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
JavaScript
/**
* # 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 };
}
}
}