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.

124 lines (123 loc) 6.42 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; /** 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 { cause; constructor(cause) { this.cause = cause; } } /** 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) { 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; 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) { 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; while ((idx = buf.indexOf("\n\n")) >= 0) { const frame = buf.slice(0, idx); buf = buf.slice(idx + 2); let event; let data = ""; let id; 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 }; } } }