UNPKG

kestrel.markets

Version:

A typed, token-efficient language + runtime for agentic trading: agents author bounded plans, the runtime fires them at the tick. CLI + typed library + MCP server.

143 lines (135 loc) 7.79 kB
/** * # client/sse-transport — the LIBRARY-SAFE SSE transport leaf (kestrel-bsn8). * * ONE SSE framing + opaque-cursor resume loop for every streamed Operation, shared by the * library-safe {@link ./index.ts KestrelClient} AND the CLI's {@link ../cli/backend/sse.ts} * (→ RemoteBackend, the oversight client): extract and reuse, never re-implement. The leaf knows * the WIRE (framing, the opaque cursor, resume, the reconnect budget) and NOTHING about any * caller's event vocabulary — the closed enum arrives as the caller's `isEventType` guard, and the * decoded body stays the caller's business. * * - Framing: `\n\n`-delimited frames; `event:` name, concatenated `data:`, and the raw `id:` * line as an OPAQUE STRING (never JSON-parsed). Comment lines (leading `:`) are SSE * heartbeats and are ignored. * - Resume: `?cursor=` and `Last-Event-ID` carry the SAME opaque value; a dropped connection * re-opens from the LAST cursor — never from the beginning. * - Fail closed: an `event:` outside the caller's vocabulary is a protocol violation, and a * handler throw is a hard error — both are re-thrown past the reconnect loop (a drift is not a * drop). A transient stream error reconnects, bounded by {@link MAX_RECONNECTS}; exhausting the * budget without a terminal event is a hard 504, never a silent empty result. * * LIBRARY-SAFE: this module imports NOTHING from `src/cli`, holds no error type of its own, and * uses only web-standard globals (`fetch`/`ReadableStream`/`TextDecoder`/`URLSearchParams`) — so it * stays inside the `types:[]` light-build closure (tsconfig.build.json) that `./client` publishes. * It is ERROR-AGNOSTIC: the caller INJECTS the fail-closed error factories (`httpError` / * `unknownEventError`), so the SDK throws `KestrelClientError` and the CLI throws `CliError` over * this ONE loop, and this leaf depends on neither. Hard vs. transient is discriminated * STRUCTURALLY (a protocol violation / a handler throw is fail-closed; only a stream drop * reconnects), never by the error's class. node ≥18 + bun + Workers. */ /** Bounded SSE reconnect budget (network resilience; a dropped connection re-opens * from the last opaque cursor — never restarts at the beginning). */ export const MAX_RECONNECTS = 5; export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>; /** What the caller's frame handler tells the loop: whether the stream reached the caller's * terminal event, and an optional cursor override carried inside the decoded body. */ export interface SseFrameOutcome { readonly done?: boolean; readonly cursor?: string; } export interface SseStreamOptions { readonly fetch: FetchLike; /** The events endpoint WITHOUT a cursor query — the loop owns `?cursor=`/`Last-Event-ID`. */ readonly endpoint: string; /** The initial opaque cursor, handed back verbatim (never `.token` of a JSON object). */ readonly cursor: string; /** Caller headers (e.g. `Authorization`); the SSE `accept` + resume headers are added here. */ readonly headers?: Record<string, string>; /** The caller's CLOSED event vocabulary. Anything else → {@link SseStreamOptions.unknownEventError}. */ readonly isEventType: (type: string) => boolean; /** Decode/accumulate one in-vocabulary frame. `cursor` is the opaque resume bookmark AT this * frame (post `id:` advance); throw (any error) to fail closed. */ readonly onFrame: (type: string, data: string, cursor: string) => SseFrameOutcome; readonly maxReconnects?: number; /** Build the fail-closed transport error for a bad HTTP status (non-ok stream, no body, budget * exhaustion). REQUIRED — the leaf holds no error type of its own, so the caller supplies its * own (e.g. `CliError` for the CLI, `KestrelClientError` for the SDK). */ readonly httpError: (status: number, message: string) => Error; /** Build the fail-closed error for an off-vocabulary `event:`. REQUIRED. */ readonly unknownEventError: (type: string) => Error; } /** A hard, fail-closed error the reconnect loop must re-throw (a protocol violation or a handler * throw), wrapping the CALLER's original error. Distinguishing hard from transient STRUCTURALLY — * not by the error's class — is what lets the transport stay error-agnostic (kestrel-bsn8). A bare * stream drop is never wrapped, so it reconnects; anything wrapped here re-throws its `cause`. */ class FailClosed { constructor(readonly cause: unknown) {} } /** Read an event stream to the caller's terminal frame, resuming across drops from the last * opaque cursor. Returns once `onFrame` reports `done`; otherwise fails closed. */ export async function consumeSseStream(opts: SseStreamOptions): Promise<void> { let cursor = opts.cursor; const budget = opts.maxReconnects ?? MAX_RECONNECTS; for (let attempt = 0; attempt < budget; attempt++) { const q = new URLSearchParams({ cursor }); const res = await opts.fetch(`${opts.endpoint}?${q.toString()}`, { // `?cursor=` and `Last-Event-ID` must agree — we send the same value on both. headers: { ...opts.headers, accept: "text/event-stream", "last-event-id": cursor }, }); if (!res.ok) throw opts.httpError(res.status, "event stream failed"); const stream = res.body; if (!stream) throw opts.httpError(500, "event stream had no body"); try { for await (const f of parseSSE(stream)) { if (f.id !== undefined) cursor = f.id; // advance the opaque resume bookmark if (f.data === "" && f.event === undefined) continue; // heartbeat/comment-only frame const type = f.event ?? ""; if (!opts.isEventType(type)) throw new FailClosed(opts.unknownEventError(type)); let out: SseFrameOutcome; try { out = opts.onFrame(type, f.data, cursor); } catch (e) { throw new FailClosed(e); // a handler throw is a hard error, never a reconnect } if (out.cursor) cursor = out.cursor; if (out.done) return; } } catch (e) { if (e instanceof FailClosed) throw e.cause; // protocol violation / handler throw → fail closed continue; // transient stream drop → reconnect from `cursor` } // clean end without a terminal event → reconnect from the last cursor } throw opts.httpError(504, "event stream did not reach a terminal state after retries"); } /** Minimal SSE frame parser over a web ReadableStream<Uint8Array> (node + bun + workers). Yields * one record per `\n\n`-delimited frame: the `event:` name, the concatenated `data:` payload, and * the raw `id:` line as an OPAQUE STRING (never JSON-parsed). Comment lines (leading `:`) are * ignored (SSE heartbeats). */ export async function* parseSSE( body: ReadableStream<Uint8Array>, ): AsyncGenerator<{ event: string | undefined; data: string; id: string | undefined }> { const reader = body.getReader(); const dec = new TextDecoder(); let buf = ""; for (;;) { const { value, done } = await reader.read(); if (done) break; buf += dec.decode(value, { stream: true }); let idx: number; while ((idx = buf.indexOf("\n\n")) >= 0) { const frame = buf.slice(0, idx); buf = buf.slice(idx + 2); let event: string | undefined; let data = ""; let id: string | undefined; for (const line of frame.split("\n")) { if (line.startsWith(":")) continue; // comment / heartbeat if (line.startsWith("event:")) event = line.slice(6).trim(); else if (line.startsWith("data:")) data += (data ? "\n" : "") + line.slice(5).replace(/^ /, ""); else if (line.startsWith("id:")) id = line.slice(3).trim(); // OPAQUE string cursor } yield { event, data, id }; } } }