feat(onebot): Stream API transport plumbing (#163 phase 1)

Foundation for NapCat-compatible Stream API actions (chunked file
upload/download): a single request answered by many response frames.

- streaming.ts: StreamSink + frame wrappers (wrapStreamFrame /
  wrapStreamTerminal) matching NapCat's wire — each frame an OB11 envelope
  with stream:'stream-action', data.type ∈ stream/response/error, and
  message mirrored into wording.
- ApiHandler: registerStreamAction / isStreamAction; handle() threads a
  sink; processStreamRequest drives WS multi-frame dispatch with optional
  liveness-based abort; dead processRequest removed.
- action-kit: defineStreamAction (sink-aware run, registers as a stream
  action) reusing defineAction's parse/describe.
- adapters: HTTP server streams chunked \r\n\r\n-delimited frames (no
  Content-Length, no false application/json), aborting on client
  disconnect; WS server/client use a backpressure-aware safeSendAsync and
  abort a stream once the socket closes.

No stream actions are registered yet — those land in the next phases.
Verified by two adversarial review passes. 480 onebot tests green.
This commit is contained in:
DrSmoothl
2026-06-30 14:40:39 +08:00
parent 62eb8fd763
commit 01ef98abb6
13 changed files with 452 additions and 38 deletions
+58
View File
@@ -21,6 +21,7 @@
import { RETCODE, failedResponse } from './types';
import type { ApiResponse, JsonObject, JsonValue } from './types';
import type { ApiActionContext, ApiHandler } from './api-handler';
import { type StreamSink, NOOP_SINK } from './streaming';
// ─────────────────────────── result currency ───────────────────────────
@@ -445,6 +446,63 @@ export function defineAction<S extends Spec>(def: ActionDef<S>): ActionSpec<S> {
};
}
// ─────────────────────────── defineStreamAction ───────────────────────────
// A Stream API action (#163): same param contract + doc machinery as
// `defineAction`, but `run` additionally receives a `StreamSink` to push
// intermediate frames, and it registers via `registerStreamAction` so the
// network adapter streams its output. The handler's return value is still the
// terminal `ApiResponse` (the final frame). Reuses `defineAction` for
// parse/describe so the two stay in lock-step.
interface StreamActionDef<S extends Spec> {
name: string | readonly [string, ...string[]];
summary?: string;
returns?: string;
returnsSchema?: JsonSchema;
readOnly?: boolean;
params: S;
rules?: (r: RuleBuilders<InferParams<S>>) => readonly CrossFieldRule[];
run: (p: InferParams<S>, ctx: ApiActionContext, raw: JsonObject, sink: StreamSink) => Promise<ApiResponse> | ApiResponse;
}
export interface StreamActionSpec<S extends Spec> {
readonly names: string[];
readonly params: S;
parse(raw: JsonObject): CoerceResult<InferParams<S>>;
describe(): ActionDoc;
register(h: ApiHandler, ctx: ApiActionContext): void;
}
export function defineStreamAction<S extends Spec>(def: StreamActionDef<S>): StreamActionSpec<S> {
// Borrow defineAction's parse + describe (run is never invoked — register
// below installs the sink-aware handler instead).
const base = defineAction<S>({
name: def.name,
summary: def.summary,
returns: def.returns,
returnsSchema: def.returnsSchema,
readOnly: def.readOnly,
params: def.params,
rules: def.rules,
run: () => failedResponse(RETCODE.INTERNAL_ERROR, 'stream action dispatched without a sink'),
});
return {
names: base.names,
params: def.params,
parse: base.parse,
describe: base.describe,
register: (h, ctx) => {
const handler = async (params: JsonObject, sink?: StreamSink): Promise<ApiResponse> => {
const r = base.parse(params);
if (!r.ok) return failedResponse(RETCODE.BAD_REQUEST, wording(r));
return def.run(r.value, ctx, params, sink ?? NOOP_SINK);
};
for (const name of base.names) h.registerStreamAction(name, handler);
},
};
}
// ─────────────────────────── presets ───────────────────────────
// `group_id` appears in 61/142 actions, `user_id` in 27 — these collapse the
// dominant shape. The injected fields are surfaced in `describe()` so docs
+63 -27
View File
@@ -14,6 +14,7 @@ import { register as registerRequest } from './actions/request';
import type { ForwardPreviewMeta } from './modules/message-actions';
import type { JsonObject, JsonValue, MessageMeta } from './types';
import { RETCODE, failedResponse } from './types';
import { type StreamSink, wrapStreamFrame, wrapStreamTerminal } from './streaming';
const moduleLog = createLogger('Bridge.Action');
@@ -80,7 +81,7 @@ export interface ApiActionContext {
fetchPttText: (messageId: number) => Promise<{ text: string }>;
}
type ActionHandler = (params: JsonObject) => Promise<import('./types').ApiResponse>;
type ActionHandler = (params: JsonObject, sink?: StreamSink) => Promise<import('./types').ApiResponse>;
/** A handled-action record handed to debug observers. */
export interface ActionRecord {
@@ -93,6 +94,9 @@ export type ActionObserver = (rec: ActionRecord) => void;
export class ApiHandler {
private readonly handlers = new Map<string, ActionHandler>();
/** Names that answer with a multi-frame Stream API response (#163). The
* network adapter consults this BEFORE dispatch to pick streaming output. */
private readonly streamActions = new Set<string>();
private readonly log: Logger;
/** Debug-stream taps — notified after every handled action. Attached
* on-demand (ref-counted) by the WebUI debug stream. */
@@ -122,7 +126,19 @@ export class ApiHandler {
this.handlers.set(action, handler);
}
async handle(action: string, params: JsonObject): Promise<import('./types').ApiResponse> {
/** Register a Stream API action — dispatched exactly like a normal action,
* but flagged so adapters stream its frames (the handler receives a sink). */
registerStreamAction(action: string, handler: ActionHandler): void {
this.handlers.set(action, handler);
this.streamActions.add(action);
}
/** Whether `action` answers with a multi-frame Stream API response. */
isStreamAction(action: string): boolean {
return this.streamActions.has(action);
}
async handle(action: string, params: JsonObject, sink?: StreamSink): Promise<import('./types').ApiResponse> {
const handler = this.handlers.get(action);
if (!handler) {
this.log.debug('unknown action %s', action);
@@ -134,15 +150,16 @@ export class ApiHandler {
// the wrap + id allocation when trace is actually live, so the default
// path stays allocation-free.
if (getLogLevel() !== 'trace') {
return this.runAction(action, handler, params);
return this.runAction(action, handler, params, sink);
}
return runWithRequestId(nextRequestId(), () => this.runAction(action, handler, params));
return runWithRequestId(nextRequestId(), () => this.runAction(action, handler, params, sink));
}
private async runAction(
action: string,
handler: ActionHandler,
params: JsonObject,
sink?: StreamSink,
): Promise<import('./types').ApiResponse> {
// Terse breadcrumb to the log file (debug, always persisted): lets the
// operator grep "what did the bot get asked to do" in post-mortems.
@@ -154,7 +171,7 @@ export class ApiHandler {
const startedAt = Date.now();
let response: import('./types').ApiResponse;
try {
response = await handler(params);
response = await handler(params, sink);
this.log.trace(() => [`${action} ⇒ ${response.status} (${Date.now() - startedAt}ms)`]);
} catch (error) {
// Action failures are almost always param-shape problems coming
@@ -185,33 +202,52 @@ export class ApiHandler {
}
}
async processRequest(rawRequest: string): Promise<string> {
if (!rawRequest.trim()) {
return JSON.stringify(failedResponse(RETCODE.BAD_REQUEST, 'bad request'));
}
/** WS dispatch supporting Stream API multi-frame responses. A normal action
* emits exactly one frame; a stream action emits each intermediate frame
* then the terminal frame — every frame carries the request's echo. `emit`
* writes one JSON string per frame; awaiting it lets the transport apply
* backpressure. `isAlive`, when supplied, is checked before each stream
* frame — returning false aborts the action (e.g. the client disconnected),
* so a dead client can't make a download keep pumping frames into the void. */
async processStreamRequest(
rawRequest: string,
emit: (json: string) => void | Promise<void>,
isAlive?: () => boolean,
): Promise<void> {
const bad = (): void => { void emit(JSON.stringify(failedResponse(RETCODE.BAD_REQUEST, 'bad request'))); };
if (!rawRequest.trim()) { bad(); return; }
let action: string;
let params: JsonObject;
let echo: JsonValue | undefined;
try {
const parsed = JSON.parse(rawRequest) as unknown;
if (!isJsonObject(parsed)) {
return JSON.stringify(failedResponse(RETCODE.BAD_REQUEST, 'bad request'));
}
const action = asString(parsed.action);
if (!action) {
return JSON.stringify(failedResponse(RETCODE.BAD_REQUEST, 'bad request'));
}
const params = isJsonObject(parsed.params) ? parsed.params : {};
const echo = parsed.echo;
const response = await this.handle(action, params);
if (echo !== undefined) {
response.echo = toJsonValue(echo);
}
return JSON.stringify(response);
if (!isJsonObject(parsed)) { bad(); return; }
const a = asString(parsed.action);
if (!a) { bad(); return; }
action = a;
params = isJsonObject(parsed.params) ? parsed.params : {};
echo = parsed.echo !== undefined ? toJsonValue(parsed.echo) : undefined;
} catch {
return JSON.stringify(failedResponse(RETCODE.BAD_REQUEST, 'bad request'));
bad();
return;
}
if (!this.isStreamAction(action)) {
const response = await this.handle(action, params);
if (echo !== undefined) response.echo = echo;
await emit(JSON.stringify(response));
return;
}
const sink: StreamSink = {
send: async (frame) => {
if (isAlive && !isAlive()) throw new Error('stream transport closed');
await emit(JSON.stringify(wrapStreamFrame(frame, echo)));
},
};
const response = await this.handle(action, params, sink);
await emit(JSON.stringify(wrapStreamTerminal(response, echo)));
}
}
@@ -5,8 +5,10 @@ import {
type Server,
type ServerResponse,
} from 'http';
import type { ApiHandler } from '../api-handler';
import type { DispatchPayload } from '../event-filter';
import type { HttpServerNetwork, JsonObject, JsonValue } from '../types';
import type { ApiResponse, HttpServerNetwork, JsonObject, JsonValue } from '../types';
import { type StreamSink, wrapStreamFrame, wrapStreamTerminal } from '../streaming';
import { IOneBotNetworkAdapter, type AdapterStatus, type NetworkAdapterContext } from './adapter';
import { isAuthorized, normalizePath } from './utils';
@@ -181,6 +183,13 @@ export class HttpServerAdapter extends IOneBotNetworkAdapter<HttpServerNetwork>
return;
}
// Stream API (#163): answer with chunked multi-frame output — each frame
// a JSON envelope delimited by `\r\n\r\n`, terminated by the final frame.
if (this.ctx.api.isStreamAction(action)) {
await streamHttpResponse(res, this.ctx.api, action, params as JsonObject, echo as JsonValue | undefined);
return;
}
const response = await this.ctx.api.handle(action, params as JsonObject);
if (echo !== undefined) {
response.echo = echo as JsonValue;
@@ -212,7 +221,41 @@ function readRequestBody(req: IncomingMessage, maxBytes = 2 * 1024 * 1024): Prom
}
function writeJson(res: ServerResponse, statusCode: number, data: unknown): void {
// A streaming response may already have flushed headers (and even ended); a
// late error must not double-send and trip ERR_HTTP_HEADERS_SENT.
if (res.headersSent || res.writableEnded) return;
res.statusCode = statusCode;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(data));
}
/** Stream a multi-frame Stream API response (#163): chunked transfer (no
* Content-Length → Node frames it), each frame a JSON envelope delimited by
* `\r\n\r\n`, the action's terminal response written last. Matches NapCat:
* the body is `\r\n\r\n`-joined JSON objects, NOT a single JSON document, so
* no `application/json` Content-Type is claimed. Each `res.write` is awaited
* (flush callback) for backpressure, and the sink aborts the action once the
* client disconnects so a big download stops pumping into a dead socket. */
async function streamHttpResponse(
res: ServerResponse,
api: ApiHandler,
action: string,
params: JsonObject,
echo: JsonValue | undefined,
): Promise<void> {
res.statusCode = 200;
const writeFrame = (frame: ApiResponse): Promise<void> =>
new Promise((resolve) => {
if (res.writableEnded || res.destroyed) { resolve(); return; }
res.write(JSON.stringify(frame) + '\r\n\r\n', () => resolve());
});
const sink: StreamSink = {
send: async (frame) => {
if (res.writableEnded || res.destroyed) throw new Error('stream client disconnected');
await writeFrame(wrapStreamFrame(frame, echo));
},
};
const response = await api.handle(action, params, sink);
await writeFrame(wrapStreamTerminal(response, echo));
if (!res.writableEnded && !res.destroyed) res.end();
}
+13
View File
@@ -31,6 +31,19 @@ export function safeSend(socket: WebSocket, payload: string, onError?: (err: Err
});
}
/** Backpressure-aware send: resolves only once `ws` has flushed `payload` to
* the socket (the `send` callback fires post-write), so a streaming producer
* that awaits this won't outrun a slow client and balloon the send buffer.
* Never rejects — a closed socket or send error resolves quietly (the stream
* sink handles liveness/abort separately so a dead client can't crash the
* per-message handler with an unhandled rejection). */
export function safeSendAsync(socket: WebSocket, payload: string): Promise<void> {
return new Promise((resolve) => {
if (socket.readyState !== 1 /* WebSocket.OPEN */) { resolve(); return; }
socket.send(payload, () => resolve());
});
}
export function safeClose(socket: WebSocket, code = 1000, reason = 'normal'): void {
if (socket.readyState === 3 /* CLOSED */ || socket.readyState === 2 /* CLOSING */) return;
socket.close(code, reason);
@@ -8,7 +8,7 @@ import {
} from '../event-filter';
import type { JsonObject, WsClientNetwork, WsRole } from '../types';
import { IOneBotNetworkAdapter, type AdapterStatus, type NetworkAdapterContext } from './adapter';
import { rawDataToString, safeClose, safeSend } from './utils';
import { rawDataToString, safeClose, safeSend, safeSendAsync } from './utils';
const moduleLog = createLogger('OneBot.WS-Client');
const DEFAULT_RECONNECT_INTERVAL_MS = 5000;
@@ -146,8 +146,13 @@ export class WsClientAdapter extends IOneBotNetworkAdapter<WsClientNetwork> {
if (this.role !== 'Api' && this.role !== 'Universal') return;
const text = rawDataToString(raw);
if (!text) return;
const response = await this.ctx.api.processRequest(text);
safeSend(socket, response);
// Stream API (#163): one frame for a normal action, N for a streaming one.
// Async send = backpressure; liveness check aborts on disconnect.
await this.ctx.api.processStreamRequest(
text,
(frame) => safeSendAsync(socket, frame),
() => socket.readyState === 1,
);
}
private sendBootstrapMetaEvents(socket: WebSocket): void {
@@ -9,7 +9,7 @@ import {
} from '../event-filter';
import type { JsonObject, WsRole, WsServerNetwork } from '../types';
import { IOneBotNetworkAdapter, type AdapterStatus, type NetworkAdapterContext } from './adapter';
import { isAuthorized, normalizePath, parseRequestPath, rawDataToString, safeClose, safeSend } from './utils';
import { isAuthorized, normalizePath, parseRequestPath, rawDataToString, safeClose, safeSend, safeSendAsync } from './utils';
const moduleLog = createLogger('OneBot.WS-Server');
@@ -119,7 +119,9 @@ export class WsServerAdapter extends IOneBotNetworkAdapter<WsServerNetwork> {
this.connections.set(socket, conn);
socket.on('message', (raw: Buffer) => {
void this.handleApiMessage(socket, role, raw);
void this.handleApiMessage(socket, role, raw).catch((err) => {
this.log.warn('[%s] handleApiMessage threw: %s', this.name, err instanceof Error ? (err.stack ?? err.message) : String(err));
});
});
socket.on('close', () => this.connections.delete(socket));
socket.on('error', (err: Error) => {
@@ -135,8 +137,15 @@ export class WsServerAdapter extends IOneBotNetworkAdapter<WsServerNetwork> {
if (role !== 'Api' && role !== 'Universal') return;
const text = rawDataToString(raw);
if (!text) return;
const response = await this.ctx.api.processRequest(text);
safeSend(socket, response);
// Stream API (#163) emits multiple frames per request; processStreamRequest
// sends one frame for a normal action and N for a streaming one. The async
// send applies backpressure (awaits flush); the liveness check aborts a
// streaming action once the client goes away.
await this.ctx.api.processStreamRequest(
text,
(frame) => safeSendAsync(socket, frame),
() => socket.readyState === 1,
);
}
private sendBootstrapMetaEvents(socket: WebSocket): void {
+75
View File
@@ -0,0 +1,75 @@
// streaming — Stream API plumbing (#163, NapCat-compatible).
//
// A handful of OneBot actions ("Stream API") answer a single request with
// MANY response frames instead of one: an upload acks each chunk, a download
// pushes the file out chunk-by-chunk. SnowLuma's normal dispatch is strictly
// one-request → one-response, so streaming needs a small seam:
//
// • a `StreamSink` the action calls to emit intermediate frames, and
// • per-adapter wiring that writes each frame on the wire (HTTP chunked /
// repeated WS messages) instead of closing after the first.
//
// Wire format mirrors NapCat exactly so its client scripts work unchanged:
// every frame is an OB11 envelope carrying `stream:'stream-action'`, and the
// frame `data` is a StreamPacket whose `type` is one of stream/response/error.
import type { ApiResponse, JsonValue } from './types';
/** Marks an OB11 envelope as one frame of a streaming response. */
export const STREAM_MARK = 'stream-action';
/** Frame `type` discriminator carried inside each frame's `data`. */
export enum StreamStatus {
/** An intermediate chunk frame (transfer in progress). */
Stream = 'stream',
/** The terminal frame — the stream finished successfully. */
Response = 'response',
/** A stream was reset / aborted by the caller. */
Reset = 'reset',
/** The terminal frame — the stream failed. */
Error = 'error',
}
/** A sink an action uses to push intermediate stream frames. Each `send`
* carries the raw frame DATA (a StreamPacket: `{ type, data_type, ... }`);
* the network adapter wraps it in an OB11 envelope + the stream marker and
* flushes it on the wire. The action's eventual return value is the terminal
* frame, so do NOT `send` the final result through the sink. */
export interface StreamSink {
send(frame: JsonValue): Promise<void>;
}
/** Fallback sink for when a stream action is invoked over a transport that
* can't stream (or by a plain client): intermediate frames are dropped, the
* terminal response is still returned normally. */
export const NOOP_SINK: StreamSink = { send: async () => { /* dropped */ } };
/** Wrap a raw stream-frame `data` payload into an OB11 envelope carrying the
* stream marker (mirrors NapCat's `OB11Response.ok(data, echo, true)`). The
* empty `message`/`wording` pair matches NapCat — clients reading either key
* on an intermediate frame get `''`, never `undefined`. */
export function wrapStreamFrame(data: JsonValue, echo: JsonValue | undefined): ApiResponse {
const frame: ApiResponse = { status: 'ok', retcode: 0, data, message: '', wording: '', stream: STREAM_MARK };
if (echo !== undefined) frame.echo = echo;
return frame;
}
/** Turn an action's terminal `ApiResponse` into the final streaming frame:
* attach the stream marker + echo, mirror `wording` into `message` (NapCat
* sets both), and — on failure — normalise `data` to an error packet
* (`{ type:'error' }`) so NapCat clients see the error `type` while the
* human-readable reason stays in `message`/`wording`.
*
* Contract: on success the action's `data` MUST carry `type:'response'` (the
* client treats that frame as the stream terminator). Stream actions are
* responsible for that, mirroring NapCat's `BaseDownloadStream`. */
export function wrapStreamTerminal(response: ApiResponse, echo: JsonValue | undefined): ApiResponse {
const frame: ApiResponse = { ...response, stream: STREAM_MARK };
if (echo !== undefined) frame.echo = echo;
if (response.status === 'failed') {
frame.data = { type: StreamStatus.Error, data_type: 'error' };
}
frame.message = frame.wording ?? '';
if (frame.wording === undefined) frame.wording = '';
return frame;
}
+7
View File
@@ -7,6 +7,13 @@ export interface ApiResponse {
data: JsonValue;
echo?: JsonValue;
wording?: string;
/** Mirror of `wording` carried on Stream API frames — NapCat puts the
* message text in BOTH `message` and `wording`, so clients reading either
* key work. Only set on stream frames. */
message?: string;
/** Only set on Stream API frames — `'stream-action'`. Marks an envelope as
* one frame of a multi-frame streaming response (NapCat Stream API wire). */
stream?: string;
}
export interface OneBotRequest {
@@ -18,6 +18,7 @@ function fakeCtx(handleFn: (action: string, params: Record<string, unknown>) =>
const data = handleFn(action, params);
return { status: 'ok', retcode: 0, data };
}),
isStreamAction: () => false,
} as any,
buildLifecycleEvent: vi.fn(() => ({})),
buildHeartbeatEvent: vi.fn(() => ({})),
@@ -68,7 +68,7 @@ import type { JsonObject, WsClientNetwork, WsServerNetwork } from '../src/types'
function ctx(): NetworkAdapterContext {
return {
uin: '10001',
api: { processRequest: async () => '' } as never,
api: { processStreamRequest: async () => {} } as never,
buildLifecycleEvent: (sub) => ({ post_type: 'meta_event', meta_event_type: 'lifecycle', sub_type: sub }),
buildHeartbeatEvent: () => ({ post_type: 'meta_event', meta_event_type: 'heartbeat', interval: 5000 }),
};
@@ -52,7 +52,7 @@ class FakeAdapter extends IOneBotNetworkAdapter<FakeNetworkConfig> {
const NULL_CTX: NetworkAdapterContext = {
uin: '10001',
api: { handle: async () => ({ status: 'failed', retcode: 0, data: null }), processRequest: async () => '' } as never,
api: { handle: async () => ({ status: 'failed', retcode: 0, data: null }), processStreamRequest: async () => {} } as never,
buildLifecycleEvent: () => ({}),
buildHeartbeatEvent: () => ({}),
};
+167
View File
@@ -0,0 +1,167 @@
// Phase 1 — Stream API plumbing (#163). Verifies the dispatch seam:
// defineStreamAction registers a streaming handler, processStreamRequest
// emits N intermediate frames + a terminal frame (all echo-tagged, marked
// `stream:'stream-action'`), normal actions stay single-frame, and failures
// terminate with an error frame. Wire format mirrors NapCat's Stream API.
import { describe, it, expect } from 'vitest';
import { ApiHandler, type ApiActionContext } from '../src/api-handler';
import { defineAction, defineStreamAction, f } from '../src/action-kit';
import { okResponse, failedResponse } from '../src/types';
import { wrapStreamFrame, wrapStreamTerminal, STREAM_MARK } from '../src/streaming';
function newHandler(): ApiHandler {
// The real register* run lazily (handlers capture ctx, never call it at
// registration), so an empty ctx is enough to exercise the dispatch seam.
return new ApiHandler({} as ApiActionContext);
}
const parse = (frames: string[]) => frames.map((f) => JSON.parse(f) as Record<string, unknown>);
describe('Stream API plumbing', () => {
it('streams N intermediate frames + a terminal frame, all echo-tagged', async () => {
const h = newHandler();
defineStreamAction({
name: 'demo_stream',
params: { n: f.uint() },
run: async (p, _ctx, _raw, sink) => {
for (let i = 0; i < p.n; i++) await sink.send({ type: 'stream', index: i });
return okResponse({ type: 'response', total: p.n });
},
}).register(h, {} as ApiActionContext);
const frames: string[] = [];
await h.processStreamRequest(
JSON.stringify({ action: 'demo_stream', params: { n: 2 }, echo: 'e1' }),
(j) => frames.push(j),
);
const got = parse(frames);
expect(got).toHaveLength(3);
expect(got[0]).toMatchObject({ status: 'ok', retcode: 0, stream: STREAM_MARK, echo: 'e1', data: { type: 'stream', index: 0 } });
expect(got[1]).toMatchObject({ stream: STREAM_MARK, echo: 'e1', data: { type: 'stream', index: 1 } });
expect(got[2]).toMatchObject({ status: 'ok', stream: STREAM_MARK, echo: 'e1', data: { type: 'response', total: 2 } });
});
it('marks the action as a stream action; normal actions are not', () => {
const h = newHandler();
defineStreamAction({ name: 'demo_stream', params: {}, run: () => okResponse({ type: 'response' }) }).register(h, {} as ApiActionContext);
defineAction({ name: 'demo_normal', params: {}, run: () => okResponse({ ok: true }) }).register(h, {} as ApiActionContext);
expect(h.isStreamAction('demo_stream')).toBe(true);
expect(h.isStreamAction('demo_normal')).toBe(false);
expect(h.isStreamAction('nope')).toBe(false);
});
it('a normal action through processStreamRequest emits exactly one un-marked frame', async () => {
const h = newHandler();
defineAction({ name: 'demo_normal', params: {}, run: () => okResponse({ ok: true }) }).register(h, {} as ApiActionContext);
const frames: string[] = [];
await h.processStreamRequest(JSON.stringify({ action: 'demo_normal', params: {}, echo: 'e2' }), (j) => frames.push(j));
expect(frames).toHaveLength(1);
const r = parse(frames)[0];
expect(r).toMatchObject({ status: 'ok', echo: 'e2', data: { ok: true } });
expect(r.stream).toBeUndefined();
});
it('a throwing stream action terminates with a single error frame', async () => {
const h = newHandler();
defineStreamAction({
name: 'demo_boom',
params: {},
run: async () => { throw new Error('boom'); },
}).register(h, {} as ApiActionContext);
const frames: string[] = [];
await h.processStreamRequest(JSON.stringify({ action: 'demo_boom', params: {}, echo: 'e3' }), (j) => frames.push(j));
expect(frames).toHaveLength(1);
expect(parse(frames)[0]).toMatchObject({
status: 'failed', stream: STREAM_MARK, echo: 'e3', wording: 'boom', data: { type: 'error', data_type: 'error' },
});
});
it('emits already-sent frames before the terminal error when a stream throws mid-way', async () => {
const h = newHandler();
defineStreamAction({
name: 'demo_partial',
params: {},
run: async (_p, _ctx, _raw, sink) => {
await sink.send({ type: 'stream', index: 0 });
throw new Error('mid');
},
}).register(h, {} as ApiActionContext);
const frames: string[] = [];
await h.processStreamRequest(JSON.stringify({ action: 'demo_partial', params: {} }), (j) => frames.push(j));
const got = parse(frames);
expect(got).toHaveLength(2);
expect(got[0]).toMatchObject({ stream: STREAM_MARK, data: { type: 'stream', index: 0 } });
expect(got[1]).toMatchObject({ status: 'failed', data: { type: 'error' } });
});
it('stream param validation fails terminate with an error frame', async () => {
const h = newHandler();
defineStreamAction({ name: 'demo_needs', params: { n: f.uint() }, run: () => okResponse({ type: 'response' }) }).register(h, {} as ApiActionContext);
const frames: string[] = [];
await h.processStreamRequest(JSON.stringify({ action: 'demo_needs', params: {} }), (j) => frames.push(j));
expect(frames).toHaveLength(1);
expect(parse(frames)[0]).toMatchObject({ status: 'failed', stream: STREAM_MARK, data: { type: 'error' } });
});
it('aborts a stream when isAlive turns false (client disconnect)', async () => {
const h = newHandler();
let sent = 0;
defineStreamAction({
name: 'demo_abort',
params: {},
run: async (_p, _ctx, _raw, sink) => {
for (let i = 0; i < 5; i++) { await sink.send({ type: 'stream', index: i }); sent++; }
return okResponse({ type: 'response' });
},
}).register(h, {} as ApiActionContext);
const frames: string[] = [];
let alive = true;
await h.processStreamRequest(
JSON.stringify({ action: 'demo_abort', params: {} }),
(j) => { frames.push(j); if (frames.length >= 2) alive = false; },
() => alive,
);
expect(sent).toBeLessThan(5); // loop aborted once the client "left"
const got = parse(frames);
expect(got[got.length - 1]).toMatchObject({ status: 'failed', data: { type: 'error' } });
});
it('wrapStreamFrame / wrapStreamTerminal shape (NapCat message+wording mirror)', () => {
expect(wrapStreamFrame({ type: 'stream', x: 1 }, 'e')).toEqual({
status: 'ok', retcode: 0, data: { type: 'stream', x: 1 }, message: '', wording: '', stream: STREAM_MARK, echo: 'e',
});
// no echo → no echo key
expect(wrapStreamFrame({ type: 'stream' }, undefined)).toEqual({
status: 'ok', retcode: 0, data: { type: 'stream' }, message: '', wording: '', stream: STREAM_MARK,
});
// failed terminal → data normalised to an error packet; reason in BOTH message + wording
expect(wrapStreamTerminal(failedResponse(1404, 'nope'), 'e')).toMatchObject({
status: 'failed', retcode: 1404, message: 'nope', wording: 'nope', stream: STREAM_MARK, echo: 'e',
data: { type: 'error', data_type: 'error' },
});
// ok terminal → data passes through, message/wording empty
expect(wrapStreamTerminal(okResponse({ type: 'response', a: 1 }), undefined)).toMatchObject({
status: 'ok', stream: STREAM_MARK, message: '', data: { type: 'response', a: 1 },
});
});
it('malformed stream requests emit one bad-request frame', async () => {
const h = newHandler();
const frames: string[] = [];
await h.processStreamRequest('not json', (j) => frames.push(j));
await h.processStreamRequest('', (j) => frames.push(j));
await h.processStreamRequest(JSON.stringify({ params: {} }), (j) => frames.push(j)); // no action
expect(frames).toHaveLength(3);
for (const f of parse(frames)) expect(f).toMatchObject({ status: 'failed' });
});
});
@@ -70,7 +70,7 @@ import type { WsClientNetwork } from '../src/types';
function ctx(): NetworkAdapterContext {
return {
uin: '10001',
api: { processRequest: async () => '' } as never,
api: { processStreamRequest: async () => {} } as never,
buildLifecycleEvent: () => ({}),
buildHeartbeatEvent: () => ({}),
};