@0xinsider/sdk 0.14.0-bootstrap.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/stream.js ADDED
@@ -0,0 +1,932 @@
1
+ /**
2
+ * SSE consumer for `GET /api/v1/stream`.
3
+ *
4
+ * The endpoint forwards the platform's live feed envelopes as Server-Sent
5
+ * Events. Source of truth: `web/public/api/v1/openapi.json` -> paths./api/v1/stream.
6
+ *
7
+ * Wire format (per the spec's response description):
8
+ * - Each data frame is `id: <seq>\ndata: <json-envelope>\n\n`, where the JSON
9
+ * envelope is `{ seq, published_at, type, ...event-specific }`. The SSE id
10
+ * equals the envelope `seq`.
11
+ * - A resync marker is `event: resync\nid: <seq>\ndata: <json>\n\n`, where the
12
+ * JSON is `{ type: "resync", completeness, from_sequence, to_sequence }`.
13
+ * It means the requested resume point is outside the retained window or a
14
+ * live sequence gap was observed; treat it as "refetch current state".
15
+ * - Idle connections emit `: keep-alive` comment lines (ignored).
16
+ *
17
+ * Resume is via the `Last-Event-ID` header set to the last `seq` you processed;
18
+ * the spec also accepts `last_event_id` / `seq` query fallbacks. This consumer
19
+ * sends the header and auto-tracks the last seen seq so a caller-driven
20
+ * reconnect resumes from the right place.
21
+ *
22
+ * Filters (per-connection, subscribe-time): `event` (comma-separated frame
23
+ * types), `condition_id`, and `min_grade` (S|A|B|C|D|F).
24
+ *
25
+ * Protocol validity (#16248). The decoder is bounded and fails visibly:
26
+ * - A successful response whose media type is not `text/event-stream` is
27
+ * `StreamProtocolError` (`unexpected_media_type`), not an empty stream.
28
+ * - A data frame whose payload is not JSON, not an object, or has no usable
29
+ * sequence (a finite `seq` in the envelope, or a finite SSE `id`) is a
30
+ * `StreamProtocolError` (`invalid_json`, `invalid_envelope`,
31
+ * `unusable_sequence`); a resync frame whose payload is not an object is
32
+ * `invalid_resync`. Before #16248 such frames were skipped or yielded with
33
+ * `NaN`, and the next valid frame moved the cursor past the gap.
34
+ * - A frame larger than `maxFrameBytes` (default 1 MiB), whether or not its
35
+ * blank-line delimiter has arrived, is `frame_too_large` before it is
36
+ * parsed (#16644); the reader is released and the connection closed.
37
+ * - The error carries `lastSeq`, the last sequence delivered before it, so a
38
+ * malformed frame never moves the cursor and a caller can decide whether
39
+ * to resume from there (which replays the frame while it is retained),
40
+ * skip past `frameId`, or refetch state. No raw payload is on the error.
41
+ * - A terminal `event: error` frame (the key was revoked, the account
42
+ * lapsed, or the credential store could not confirm the key) throws the
43
+ * typed `OxinsiderApiError` it carries and is never yielded (#16546);
44
+ * `retry: false` is permanent to the resilient consumers.
45
+ * Comment lines (`: keep-alive`), LF and CRLF framing, unknown SSE fields
46
+ * and unknown-but-valid envelope `type`s are compatible as before.
47
+ */
48
+ import { resolveApiUrl } from "./client.js";
49
+ import { errorFromResponse, OxinsiderApiError } from "./errors.js";
50
+ import { RETRY_AFTER_CEILING_MS, retryAfterSeconds, waitUnlessAborted, } from "./retry.js";
51
+ /** Default for `StreamOptions.maxFrameBytes`: 1 MiB. */
52
+ export const DEFAULT_MAX_STREAM_FRAME_BYTES = 1_048_576;
53
+ /**
54
+ * The stream broke the SSE contract (#16248). The connection is closed and
55
+ * the reader released before this is thrown. `lastSeq` is the last sequence
56
+ * delivered on this connection, or `undefined` when none was: no malformed
57
+ * frame moves the cursor. `frameId` is the SSE `id` of the offending frame
58
+ * when it carried one, `event` its SSE event name, `bytes` its size, and
59
+ * `mediaType` the response's `Content-Type` for `unexpected_media_type`. The
60
+ * raw payload is deliberately not carried: it may hold data you would not
61
+ * want in a log, and `frameId` plus `lastSeq` name the frame exactly.
62
+ *
63
+ * `streamFeedResilient` treats this as permanent: reconnecting from
64
+ * `lastSeq` would replay the same frame while the server retains it. Decide:
65
+ * resume from `lastSeq` later, resume after `frameId` to skip it, or refetch
66
+ * state and attach live.
67
+ */
68
+ export class StreamProtocolError extends Error {
69
+ reason;
70
+ lastSeq;
71
+ frameId;
72
+ event;
73
+ bytes;
74
+ mediaType;
75
+ constructor(reason, detail) {
76
+ super(streamProtocolMessage(reason, detail));
77
+ this.name = "StreamProtocolError";
78
+ this.reason = reason;
79
+ this.lastSeq = detail.lastSeq;
80
+ this.frameId = detail.frameId;
81
+ this.event = detail.event;
82
+ this.bytes = detail.bytes;
83
+ this.mediaType = detail.mediaType ?? undefined;
84
+ }
85
+ }
86
+ function streamProtocolMessage(reason, detail) {
87
+ const where = detail.frameId === undefined
88
+ ? "a frame"
89
+ : `frame id ${String(detail.frameId)}`;
90
+ const after = detail.lastSeq === undefined
91
+ ? "before any event was delivered"
92
+ : `after seq ${String(detail.lastSeq)}`;
93
+ switch (reason) {
94
+ case "unexpected_media_type":
95
+ return `0xinsider stream answered ${detail.mediaType ? `Content-Type ${detail.mediaType}` : "with no Content-Type"} instead of text/event-stream; not an SSE stream`;
96
+ case "invalid_json":
97
+ return `0xinsider stream sent ${where} whose data is not JSON (${String(detail.bytes ?? 0)} bytes) ${after}`;
98
+ case "invalid_envelope":
99
+ return `0xinsider stream sent ${where} whose data is not an envelope object ${after}`;
100
+ case "unusable_sequence":
101
+ return `0xinsider stream sent ${where} with no finite seq and no finite id ${after}`;
102
+ case "invalid_resync":
103
+ return `0xinsider stream sent a resync marker (${where}) whose data is not a resync object ${after}`;
104
+ case "frame_too_large":
105
+ return `0xinsider stream sent a frame past ${String(detail.bytes ?? 0)} bytes with no delimiter ${after}; the connection was closed`;
106
+ default:
107
+ return `0xinsider stream protocol error ${after}`;
108
+ }
109
+ }
110
+ function assertMaxFrameBytes(value) {
111
+ if (!Number.isInteger(value) || value < 1) {
112
+ throw new Error(`maxFrameBytes must be a positive integer, got ${String(value)}`);
113
+ }
114
+ return value;
115
+ }
116
+ /** `Content-Type` names an event stream: `text/event-stream`, with or without parameters. */
117
+ export function isEventStreamMediaType(contentType) {
118
+ if (contentType === null)
119
+ return false;
120
+ const essence = contentType.split(";", 1)[0]?.trim().toLowerCase();
121
+ return essence === "text/event-stream";
122
+ }
123
+ /**
124
+ * Open the stream and async-iterate its frames. Yields a discriminated
125
+ * `StreamEvent`: `kind: "event"` carries the `FeedEnvelope`, `kind: "resync"`
126
+ * carries the `ResyncMarker`.
127
+ *
128
+ * Pass `options.cursor` (a `{ seq?: number }` object) to have the last
129
+ * delivered `seq` written back as frames arrive; reconnect with
130
+ * `lastEventId: cursor.seq` to resume after a transport drop.
131
+ *
132
+ * @example
133
+ * for await (const frame of streamFeed(client, { event: ["WhaleTradesInserted"], min_grade: "S" })) {
134
+ * if (frame.kind === "event") console.log(frame.envelope.type, frame.seq);
135
+ * }
136
+ */
137
+ export async function* streamFeed(client, options = {}) {
138
+ const maxFrameBytes = assertMaxFrameBytes(options.maxFrameBytes ?? DEFAULT_MAX_STREAM_FRAME_BYTES);
139
+ const apiKey = client.getApiKey();
140
+ // The sandbox takes no credential; it answers the stream route with a 400
141
+ // saying streams are not simulated, and that answer is the server's to
142
+ // give (#16138). Production refuses a keyless stream, so say so locally.
143
+ if (!apiKey && !client.isSandbox()) {
144
+ throw new Error("getStream requires an API key (oxi_sk_*)");
145
+ }
146
+ const url = buildStreamUrl(client, options);
147
+ const headers = new Headers({ accept: "text/event-stream" });
148
+ if (apiKey)
149
+ headers.set("authorization", `Bearer ${apiKey}`);
150
+ if (options.lastEventId !== undefined) {
151
+ headers.set("last-event-id", String(options.lastEventId));
152
+ }
153
+ const fetchImpl = client.getFetch();
154
+ const response = await fetchImpl(url, {
155
+ method: "GET",
156
+ headers,
157
+ signal: options.signal,
158
+ });
159
+ if (!response.ok) {
160
+ const body = await safeText(response);
161
+ throw errorFromResponse(response.status, tryParse(body), retryAfterSeconds(response), { requestId: response.headers.get("x-request-id") });
162
+ }
163
+ // A 2xx that is not an event stream (an HTML page from a proxy, a JSON body
164
+ // from a route that moved) used to read as an empty stream that closed
165
+ // cleanly, which the reconnect loop then retried as an outage (#16248).
166
+ const mediaType = response.headers.get("content-type");
167
+ if (!isEventStreamMediaType(mediaType)) {
168
+ await response.body?.cancel().catch(() => undefined);
169
+ throw new StreamProtocolError("unexpected_media_type", {
170
+ lastSeq: undefined,
171
+ mediaType,
172
+ });
173
+ }
174
+ if (!response.body) {
175
+ throw new Error("Stream response has no readable body");
176
+ }
177
+ const reader = response.body.getReader();
178
+ const decoder = new TextDecoder();
179
+ const encoder = new TextEncoder();
180
+ let buffer = "";
181
+ // Bytes of `buffer`: exact, since every chunk adds its own byte length and
182
+ // a cut re-measures the remainder. Bounded by `maxFrameBytes`.
183
+ let bufferedBytes = 0;
184
+ let lastSeq;
185
+ try {
186
+ for (;;) {
187
+ const { value, done } = await reader.read();
188
+ if (done)
189
+ break;
190
+ buffer += decoder.decode(value, { stream: true });
191
+ bufferedBytes += value.byteLength;
192
+ // SSE frames are separated by a blank line. Handle CRLF and LF.
193
+ let sepIndex = nextFrameBoundary(buffer);
194
+ if (sepIndex.index === -1 && bufferedBytes > maxFrameBytes) {
195
+ throw new StreamProtocolError("frame_too_large", {
196
+ lastSeq,
197
+ bytes: bufferedBytes,
198
+ });
199
+ }
200
+ while (sepIndex.index !== -1) {
201
+ const rawFrame = buffer.slice(0, sepIndex.index);
202
+ // A complete frame is held to the same ceiling as an incomplete one,
203
+ // before it is parsed, advances the cursor, or is yielded: a chunk
204
+ // carrying an oversized frame and its delimiter together never reaches
205
+ // the no-delimiter check above (#16644). `lastSeq` stays the last
206
+ // delivered event.
207
+ const frameBytes = encoder.encode(rawFrame).byteLength;
208
+ if (frameBytes > maxFrameBytes) {
209
+ throw new StreamProtocolError("frame_too_large", {
210
+ lastSeq,
211
+ bytes: frameBytes,
212
+ });
213
+ }
214
+ buffer = buffer.slice(sepIndex.index + sepIndex.length);
215
+ const parsed = parseSseFrame(rawFrame);
216
+ if (parsed) {
217
+ const frame = decodeStreamFrame(parsed, lastSeq);
218
+ if (frame.kind === "resync") {
219
+ options.onResync?.(frame.marker);
220
+ }
221
+ else {
222
+ lastSeq = frame.seq;
223
+ if (options.cursor)
224
+ options.cursor.seq = frame.seq;
225
+ }
226
+ yield frame;
227
+ }
228
+ sepIndex = nextFrameBoundary(buffer);
229
+ }
230
+ bufferedBytes = buffer === "" ? 0 : encoder.encode(buffer).byteLength;
231
+ if (bufferedBytes > maxFrameBytes) {
232
+ throw new StreamProtocolError("frame_too_large", {
233
+ lastSeq,
234
+ bytes: bufferedBytes,
235
+ });
236
+ }
237
+ }
238
+ }
239
+ finally {
240
+ // `releaseLock()` detaches the reader but leaves the body -- and the
241
+ // underlying HTTP connection -- open. Any early exit from the consumer
242
+ // loop (`break`, `return`, or a throw from a handler) runs this block via
243
+ // the generator's `return()`, so without `cancel()` every early exit leaks
244
+ // one open connection to the SSE endpoint. The documented reconnect loop
245
+ // makes that one leak per reconnect, until the process runs out of sockets
246
+ // or the server's per-user SSE lease cap rejects the user's own
247
+ // reconnects. `cancel()` releases the lock as part of cancelling, and can
248
+ // reject on an already-errored stream, so the rejection is swallowed to
249
+ // keep the `finally` non-throwing (#9682).
250
+ await reader.cancel().catch(() => undefined);
251
+ }
252
+ }
253
+ /** Default for `ResilientStreamOptions.maxReconnects`. */
254
+ export const DEFAULT_MAX_STREAM_RECONNECTS = 10;
255
+ /** Default for `ResilientStreamOptions.maxRetryAfterMs`. */
256
+ export const DEFAULT_MAX_STREAM_RETRY_AFTER_MS = RETRY_AFTER_CEILING_MS;
257
+ const STREAM_RECONNECT_BASE_MS = 1_000;
258
+ const STREAM_RECONNECT_MAX_MS = 30_000;
259
+ const STREAM_RECONNECT_JITTER_MS = 250;
260
+ /**
261
+ * `streamFeedResilient` gave up after `maxReconnects` consecutive failed
262
+ * connections. `lastSeq` is the seq to resume after (pass it as `lastEventId`
263
+ * later); `cause` is the last connection's failure.
264
+ */
265
+ export class StreamReconnectsExhaustedError extends Error {
266
+ lastSeq;
267
+ attempts;
268
+ constructor(attempts, lastSeq, cause) {
269
+ super(`0xinsider stream gave up after ${String(attempts)} consecutive reconnect attempts`, { cause });
270
+ this.name = "StreamReconnectsExhaustedError";
271
+ this.attempts = attempts;
272
+ this.lastSeq = lastSeq;
273
+ }
274
+ }
275
+ /**
276
+ * `streamFeedResilient` received a server-requested wait longer than
277
+ * `maxRetryAfterMs`, so the wait is yours to schedule (#16249). `retryAt` is
278
+ * the server's not-before instant as this client read it (the header's
279
+ * seconds added to the local clock, the header's HTTP-date, or terminal
280
+ * `retry_at` when no usable header exists); `cause` is
281
+ * the refusal, whose `retryAt` (from the body's `retry_at`) is the server's
282
+ * own clock reading of the same instant. `lastSeq` is the seq to resume after:
283
+ * pass it as `lastEventId` when you reconnect at `retryAt`. `attempts` is how
284
+ * many consecutive failed connections preceded this one; the refusal itself
285
+ * spent none of `maxReconnects`.
286
+ *
287
+ * Nothing here was clamped: a wait the timer cannot hold is never shortened
288
+ * into an earlier reconnect.
289
+ */
290
+ export class StreamRetryDeferredError extends Error {
291
+ retryAt;
292
+ retryAfterMs;
293
+ lastSeq;
294
+ attempts;
295
+ constructor(retryAfterMs, lastSeq, attempts, cause) {
296
+ const retryAt = new Date(Date.now() + retryAfterMs);
297
+ super(`0xinsider stream requested a retry after ${String(Math.round(retryAfterMs / 1000))} s, past the in-process ceiling; reconnect after ${retryAt.toISOString()}${lastSeq === undefined ? "" : ` with lastEventId ${String(lastSeq)}`}`, { cause });
298
+ this.name = "StreamRetryDeferredError";
299
+ this.retryAt = retryAt;
300
+ this.retryAfterMs = retryAfterMs;
301
+ this.lastSeq = lastSeq;
302
+ this.attempts = attempts;
303
+ }
304
+ }
305
+ /**
306
+ * A 4xx other than 429 will fail the same way on every reconnect: a bad filter
307
+ * (400), a key that is invalid (401), lapsed (402), refused (403), or locked
308
+ * (423). Reconnecting would only hammer the API with a credential a person
309
+ * has to fix.
310
+ */
311
+ function isPermanentStreamError(error) {
312
+ // A protocol error is permanent too (#16248): reconnecting from `lastSeq`
313
+ // replays the same frame while the server retains it, and a wrong media
314
+ // type is the same answer on every connection.
315
+ if (error instanceof StreamProtocolError)
316
+ return true;
317
+ // A terminal frame that said `retry: false` (#16546).
318
+ if (error instanceof OxinsiderApiError && permanentTerminalErrors.has(error)) {
319
+ return true;
320
+ }
321
+ return (error instanceof OxinsiderApiError &&
322
+ error.status >= 400 &&
323
+ error.status < 500 &&
324
+ error.status !== 429);
325
+ }
326
+ /**
327
+ * The wait the server asked for, in ms, or `null` when the refusal carried
328
+ * no usable `Retry-After` or `retry_at`. Prefer the already parsed HTTP
329
+ * duration; terminal frames have no new headers, so their absolute instant
330
+ * is measured once against the local clock. Clock alignment is the caller's
331
+ * responsibility. A past instant means zero wait; invalid guidance is absent.
332
+ */
333
+ function serverRequestedWaitMs(cause, nowMs = Date.now()) {
334
+ if (!(cause instanceof OxinsiderApiError))
335
+ return null;
336
+ const seconds = cause.retryAfterSeconds;
337
+ if (typeof seconds === "number" && Number.isFinite(seconds) && seconds >= 0) {
338
+ return seconds * 1000;
339
+ }
340
+ const retryAtMs = cause.retryAt?.getTime();
341
+ return retryAtMs !== undefined && Number.isFinite(retryAtMs)
342
+ ? Math.max(0, retryAtMs - nowMs)
343
+ : null;
344
+ }
345
+ /**
346
+ * Milliseconds before reconnect `attempt` (1-based) when the server did not
347
+ * say: a jittered exponential backoff from 1 s capped at 30 s. A server-
348
+ * requested wait is handled by the loop, which checks it against
349
+ * `maxRetryAfterMs` before adding jitter.
350
+ */
351
+ function streamBackoffMs(attempt) {
352
+ const ceiling = Math.min(STREAM_RECONNECT_MAX_MS, STREAM_RECONNECT_BASE_MS * 2 ** (attempt - 1));
353
+ return ceiling / 2 + Math.random() * (ceiling / 2);
354
+ }
355
+ function assertMaxRetryAfterMs(value) {
356
+ if (Number.isNaN(value) || value < 0) {
357
+ throw new Error(`maxRetryAfterMs must be a non-negative number of milliseconds or Infinity, got ${String(value)}`);
358
+ }
359
+ return value;
360
+ }
361
+ /**
362
+ * `streamFeed` with the reconnect-and-resume loop built in (#14286).
363
+ *
364
+ * - A clean server close, a network error, a 429 or a 5xx reconnects,
365
+ * resuming after the last delivered seq (`Last-Event-ID`). Before any frame
366
+ * arrives it resumes from `lastEventId`, or attaches live when that is unset.
367
+ * - The wait is `Retry-After` (delta-seconds or an HTTP-date) plus jitter when
368
+ * the refusal carries one, otherwise a jittered backoff from 1 s capped at
369
+ * 30 s. A `Retry-After` past `maxRetryAfterMs` (60 s by default) is not
370
+ * waited out: the loop throws `StreamRetryDeferredError` with `retryAt` and
371
+ * `lastSeq`, spending no reconnect, and never clamps the wait to an earlier
372
+ * one (#16249).
373
+ * - Any other 4xx (400, 401, 402, 403, 423) is thrown at once: it cannot heal
374
+ * on its own. So is `StreamProtocolError` (#16248): a resume from
375
+ * `lastSeq` would replay the malformed frame, so the decision is yours.
376
+ * - After `maxReconnects` consecutive connections that delivered nothing, it
377
+ * throws `StreamReconnectsExhaustedError` carrying `lastSeq`.
378
+ * - `resync` markers are yielded unchanged, and `onResync` still fires. The
379
+ * marker's `id` becomes the resume cursor, as the server intends, so a
380
+ * reconnect after one does not request the aged window again.
381
+ * - Aborting `signal` ends the iterator without throwing, during a connection
382
+ * or a backoff. Each connection's body reader is cancelled when it ends, so
383
+ * reconnects do not accumulate sockets.
384
+ * - No request deadline applies, as with `streamFeed`.
385
+ *
386
+ * `options.cursor`, when passed, tracks the last delivered seq across every
387
+ * connection -- delivered, not processed (#16247). This loop resumes from it,
388
+ * so a consumer whose handler can fail should drive the stream with
389
+ * `consumeStreamCheckpointed` instead, which resumes from an acknowledged
390
+ * checkpoint and replays the event it failed on.
391
+ *
392
+ * @example
393
+ * const controller = new AbortController();
394
+ * for await (const frame of streamFeedResilient(client, {
395
+ * event: ["WhaleTradesInserted"],
396
+ * signal: controller.signal,
397
+ * onReconnect: (attempt, lastSeq, cause) => console.warn("reconnecting", attempt, lastSeq, cause),
398
+ * })) {
399
+ * if (frame.kind === "resync") await refetchState();
400
+ * else handle(frame.envelope);
401
+ * }
402
+ */
403
+ export async function* streamFeedResilient(client, options = {}) {
404
+ const { maxReconnects = DEFAULT_MAX_STREAM_RECONNECTS, maxRetryAfterMs = DEFAULT_MAX_STREAM_RETRY_AFTER_MS, onReconnect, ...streamOptions } = options;
405
+ if (!Number.isInteger(maxReconnects) || maxReconnects < 0) {
406
+ throw new Error(`maxReconnects must be a non-negative integer, got ${String(maxReconnects)}`);
407
+ }
408
+ const retryAfterCeilingMs = assertMaxRetryAfterMs(maxRetryAfterMs);
409
+ // The one configuration error `streamFeed` throws before any request; it
410
+ // would otherwise count as a transport failure and burn every reconnect.
411
+ if (!client.getApiKey() && !client.isSandbox()) {
412
+ throw new Error("streamFeedResilient requires an API key (oxi_sk_*)");
413
+ }
414
+ const signal = streamOptions.signal;
415
+ const cursor = streamOptions.cursor ?? {};
416
+ const initialLastEventId = streamOptions.lastEventId;
417
+ let attempt = 0;
418
+ for (;;) {
419
+ if (signal?.aborted)
420
+ return;
421
+ let cause;
422
+ try {
423
+ for await (const frame of streamFeed(client, {
424
+ ...streamOptions,
425
+ cursor,
426
+ lastEventId: cursor.seq ?? initialLastEventId,
427
+ })) {
428
+ attempt = 0;
429
+ if (frame.kind === "resync" && frame.seq !== null) {
430
+ cursor.seq = frame.seq;
431
+ }
432
+ yield frame;
433
+ }
434
+ }
435
+ catch (error) {
436
+ if (signal?.aborted)
437
+ return;
438
+ if (isPermanentStreamError(error))
439
+ throw error;
440
+ cause = error;
441
+ }
442
+ if (signal?.aborted)
443
+ return;
444
+ const lastSeq = cursor.seq ?? toSeq(initialLastEventId);
445
+ // A server-requested wait past the ceiling is deferred to the caller
446
+ // BEFORE it counts as a reconnect: the server said when, and burning the
447
+ // short reconnect budget on a wait we will not hold would be spending it
448
+ // on nothing.
449
+ const requestedMs = serverRequestedWaitMs(cause);
450
+ if (requestedMs !== null && requestedMs > retryAfterCeilingMs) {
451
+ throw new StreamRetryDeferredError(requestedMs, lastSeq, attempt, cause);
452
+ }
453
+ attempt += 1;
454
+ if (attempt > maxReconnects) {
455
+ throw new StreamReconnectsExhaustedError(attempt - 1, lastSeq, cause);
456
+ }
457
+ onReconnect?.(attempt, lastSeq, cause);
458
+ const delayMs = requestedMs === null
459
+ ? streamBackoffMs(attempt)
460
+ : requestedMs + Math.random() * STREAM_RECONNECT_JITTER_MS;
461
+ if (!(await waitUnlessAborted(delayMs, signal))) {
462
+ return;
463
+ }
464
+ }
465
+ }
466
+ function toSeq(value) {
467
+ if (value === undefined)
468
+ return undefined;
469
+ const seq = Number(value);
470
+ return Number.isFinite(seq) ? seq : undefined;
471
+ }
472
+ /**
473
+ * Callback-style stream consumer for environments where an async iterator is
474
+ * awkward. Returns a promise that resolves when the stream ends (or rejects on
475
+ * error / abort). Pass a `signal` to stop it.
476
+ *
477
+ * Delivery, not processing (#16247): `options.cursor` is written by
478
+ * `streamFeed` BEFORE the frame is yielded, so it has already moved past an
479
+ * event whose `onEvent` then throws. Reconnecting from it skips that event.
480
+ * For a consumer that must not lose work, use `consumeStreamCheckpointed`,
481
+ * which advances an acknowledged checkpoint only after the handler and your
482
+ * own durable write have both resolved.
483
+ */
484
+ export async function consumeStream(client, handlers, options = {}) {
485
+ for await (const frame of streamFeed(client, options)) {
486
+ if (frame.kind === "event") {
487
+ await handlers.onEvent?.(frame.envelope, frame.seq);
488
+ }
489
+ else {
490
+ await handlers.onResync?.(frame.marker, frame.seq);
491
+ }
492
+ }
493
+ }
494
+ /**
495
+ * `consumeStreamCheckpointed` gave up after `maxHandlerRetries` consecutive
496
+ * failures at the same sequence (#16247). `checkpoint` is the acknowledged
497
+ * sequence, still behind the failing one, and `replayFrom` is the point a
498
+ * later resume should pass as `lastEventId` to deliver the failed frame
499
+ * again while the server retains it. `cause` is the last rejection your
500
+ * handler produced.
501
+ */
502
+ export class StreamHandlerFailedError extends Error {
503
+ seq;
504
+ stage;
505
+ attempts;
506
+ checkpoint;
507
+ replayFrom;
508
+ constructor(detail, cause) {
509
+ super(`0xinsider stream handler failed ${String(detail.attempts)} time(s) at ${detail.seq === undefined ? "a resync marker" : `seq ${String(detail.seq)}`} (${detail.stage}); the acknowledged checkpoint is ${detail.checkpoint === undefined ? "unset" : String(detail.checkpoint)}`, { cause });
510
+ this.name = "StreamHandlerFailedError";
511
+ this.seq = detail.seq;
512
+ this.stage = detail.stage;
513
+ this.attempts = detail.attempts;
514
+ this.checkpoint = detail.checkpoint;
515
+ this.replayFrom = detail.replayFrom;
516
+ }
517
+ }
518
+ /** Default for `CheckpointedStreamOptions.maxHandlerRetries`. */
519
+ export const DEFAULT_MAX_HANDLER_RETRIES = 3;
520
+ /**
521
+ * The resume point that replays `seq` itself when nothing has been
522
+ * acknowledged yet.
523
+ *
524
+ * `Last-Event-ID` is an EXCLUSIVE lower bound on the server: the replay
525
+ * window is `latest - requested` entries taken from the tail of the retained
526
+ * history, so a resume at `S - 1` delivers `S` onward and nothing older
527
+ * (`shared_feed_history_since`, `backend/crates/app-core/src/feed_publisher.rs`;
528
+ * `backend/src/api_v1/handlers/stream.rs` builds the plan from it, read
529
+ * 2026-09-22). Sequences are a cluster-wide integer counter, so nothing sits
530
+ * between `S - 1` and `S`. A non-integer or non-positive sequence gets no
531
+ * step-back: there is no id we can name that is certainly below it.
532
+ */
533
+ function replayPointBefore(seq) {
534
+ return seq !== undefined && Number.isInteger(seq) && seq >= 1
535
+ ? seq - 1
536
+ : undefined;
537
+ }
538
+ /**
539
+ * The stream consumer for work that must not be silently dropped (#16247):
540
+ * it separates the received cursor from an acknowledged processing
541
+ * checkpoint, and only the checkpoint decides where a reconnect resumes.
542
+ *
543
+ * Ordering, per frame, for `kind: "event"`:
544
+ * 1. `onEvent` is awaited. Nothing is read from the connection while it
545
+ * runs, so a slow handler is backpressure on the socket, never a queue:
546
+ * at most one frame is ever in flight and nothing is buffered on your
547
+ * behalf.
548
+ * 2. `onCheckpoint(seq, "event")` is awaited -- your durable write.
549
+ * 3. Only then does `checkpoint.seq` become `seq`, and only then does the
550
+ * next reconnect resume after it.
551
+ * A rejection at step 1 or 2 leaves the checkpoint BEFORE the event: the
552
+ * connection is closed, a jittered backoff runs, and the consumer reconnects
553
+ * from the checkpoint, which replays the unacknowledged event while the
554
+ * server retains it. `onHandlerError` reports each failure with its attempt
555
+ * number, the replay point and whether another attempt follows. After
556
+ * `maxHandlerRetries` consecutive failures at the same sequence it throws
557
+ * `StreamHandlerFailedError`, carrying the unadvanced checkpoint.
558
+ *
559
+ * A `resync` marker is the same barrier: `onResync` is awaited, then
560
+ * `onCheckpoint(seq, "resync")`, and an interrupted refresh commits nothing,
561
+ * so the recovery is retried instead of being recorded as done. (The
562
+ * fire-and-forget `StreamOptions.onResync` notification is not awaited and is
563
+ * not a barrier -- do not use it as one.)
564
+ *
565
+ * Delivery is AT-LEAST-ONCE. A replay re-delivers every unacknowledged frame,
566
+ * and a handler that succeeded but whose `onCheckpoint` write failed sees its
567
+ * event again. Deduplicate on `seq` (it is monotonic per cluster) or make the
568
+ * side effect idempotent; nothing here can promise exactly-once side effects.
569
+ *
570
+ * `options.cursor` is untouched in meaning: it still tracks the last
571
+ * DELIVERED seq, including frames whose handler later failed, and it rewinds
572
+ * when a replay re-delivers them. Read it for transport progress; never as
573
+ * proof that the work was done.
574
+ *
575
+ * Transport recovery, `Retry-After` handling, permanent 4xx and
576
+ * `StreamProtocolError` behave exactly as in `streamFeedResilient`, which
577
+ * this drives. `StreamReconnectsExhaustedError` and
578
+ * `StreamRetryDeferredError` surface unchanged; their `lastSeq` is the
579
+ * acknowledged checkpoint, because a reconnect can only happen between
580
+ * handlers. Aborting `signal` ends the consumer without throwing: a handler
581
+ * already running is awaited (it is not cancelled for you -- pass the same
582
+ * signal into your own work if you want that), and if it succeeds its
583
+ * checkpoint is committed before the consumer returns. If an awaited event,
584
+ * resync, or checkpoint callback rejects during cancellation, the checkpoint
585
+ * stays unchanged and `onHandlerError` reports the original rejection with
586
+ * `willRetry: false` and `cancelled: true`; the consumer returns even when
587
+ * handler retries are disabled or exhausted. A coincident unrelated failure
588
+ * is still reported, without claiming cancellation caused it.
589
+ *
590
+ * @example
591
+ * const checkpoint = { seq: await loadCheckpoint() };
592
+ * await consumeStreamCheckpointed(client, {
593
+ * onEvent: async (envelope, seq) => { await applyOnce(seq, envelope); },
594
+ * onResync: async () => { await refetchCurrentState(); },
595
+ * onCheckpoint: async (seq) => { await saveCheckpoint(seq); },
596
+ * }, {
597
+ * event: ["WhaleTradesInserted"],
598
+ * lastEventId: checkpoint.seq,
599
+ * checkpoint,
600
+ * signal: controller.signal,
601
+ * onHandlerError: (error, failure) =>
602
+ * console.warn("handler failed", failure.seq, failure.attempt, failure.willRetry, error),
603
+ * });
604
+ */
605
+ export async function consumeStreamCheckpointed(client, handlers, options = {}) {
606
+ const { checkpoint = {}, maxHandlerRetries = DEFAULT_MAX_HANDLER_RETRIES, onHandlerError, ...streamOptions } = options;
607
+ if (!Number.isInteger(maxHandlerRetries) || maxHandlerRetries < 0) {
608
+ throw new Error(`maxHandlerRetries must be a non-negative integer, got ${String(maxHandlerRetries)}`);
609
+ }
610
+ const signal = streamOptions.signal;
611
+ // The caller's received cursor, if they passed one. It keeps its delivery
612
+ // meaning: this consumer mirrors every frame into it and never reads it
613
+ // back as a resume point.
614
+ const received = streamOptions.cursor;
615
+ const initialLastEventId = toSeq(streamOptions.lastEventId);
616
+ // Where the next connection resumes. It starts at the acknowledged
617
+ // checkpoint (or the caller's `lastEventId`) and only ever moves to a
618
+ // sequence whose processing completed.
619
+ let resumeAfter = checkpoint.seq ?? initialLastEventId;
620
+ let failingSeq;
621
+ let failures = 0;
622
+ for (;;) {
623
+ if (signal?.aborted)
624
+ return;
625
+ let pending;
626
+ // A cursor private to this connection: `streamFeedResilient` reads it
627
+ // back as its own resume point across transport reconnects, so the
628
+ // caller's received cursor must never be handed to it -- that cursor has
629
+ // already moved past an unacknowledged event, and lending it would make
630
+ // the replay resume after the frame it exists to redeliver.
631
+ const delivered = { seq: resumeAfter };
632
+ for await (const frame of streamFeedResilient(client, {
633
+ ...streamOptions,
634
+ cursor: delivered,
635
+ lastEventId: resumeAfter,
636
+ })) {
637
+ if (received && delivered.seq !== undefined)
638
+ received.seq = delivered.seq;
639
+ if (frame.kind === "event") {
640
+ try {
641
+ await handlers.onEvent(frame.envelope, frame.seq);
642
+ }
643
+ catch (error) {
644
+ pending = { error, stage: "event", seq: frame.seq };
645
+ break;
646
+ }
647
+ try {
648
+ await handlers.onCheckpoint?.(frame.seq, "event");
649
+ }
650
+ catch (error) {
651
+ pending = { error, stage: "checkpoint", seq: frame.seq };
652
+ break;
653
+ }
654
+ checkpoint.seq = frame.seq;
655
+ resumeAfter = frame.seq;
656
+ }
657
+ else {
658
+ try {
659
+ await handlers.onResync?.(frame.marker, frame.seq);
660
+ }
661
+ catch (error) {
662
+ pending = { error, stage: "resync", seq: frame.seq ?? undefined };
663
+ break;
664
+ }
665
+ if (frame.seq !== null) {
666
+ try {
667
+ await handlers.onCheckpoint?.(frame.seq, "resync");
668
+ }
669
+ catch (error) {
670
+ pending = { error, stage: "checkpoint", seq: frame.seq };
671
+ break;
672
+ }
673
+ checkpoint.seq = frame.seq;
674
+ resumeAfter = frame.seq;
675
+ }
676
+ }
677
+ failures = 0;
678
+ failingSeq = undefined;
679
+ if (signal?.aborted)
680
+ return;
681
+ }
682
+ // `streamFeedResilient` only ends without throwing when the signal was
683
+ // aborted, so a loop that ends with nothing pending is a clean stop.
684
+ if (!pending)
685
+ return;
686
+ const failedSeq = pending.seq;
687
+ const nextFailures = failures > 0 && failedSeq === failingSeq ? failures + 1 : 1;
688
+ resumeAfter =
689
+ checkpoint.seq ?? initialLastEventId ?? replayPointBefore(failedSeq);
690
+ // Iterator cleanup and the awaited callback have finished. Observe caller
691
+ // cancellation before committing retry accounting, but retain the failure
692
+ // diagnostic: an unrelated rejection can race with the same signal.
693
+ const cancelled = signal?.aborted === true;
694
+ const willRetry = !cancelled && nextFailures <= maxHandlerRetries;
695
+ onHandlerError?.(pending.error, {
696
+ seq: failedSeq,
697
+ stage: pending.stage,
698
+ attempt: nextFailures,
699
+ willRetry,
700
+ ...(cancelled ? { cancelled: true } : {}),
701
+ replayFrom: resumeAfter,
702
+ checkpoint: checkpoint.seq,
703
+ });
704
+ // The observer can also stop the consumer synchronously.
705
+ if (cancelled || signal?.aborted)
706
+ return;
707
+ failingSeq = failedSeq;
708
+ failures = nextFailures;
709
+ if (!willRetry) {
710
+ throw new StreamHandlerFailedError({
711
+ seq: failedSeq,
712
+ stage: pending.stage,
713
+ attempts: failures,
714
+ checkpoint: checkpoint.seq,
715
+ replayFrom: resumeAfter,
716
+ }, pending.error);
717
+ }
718
+ if (!(await waitUnlessAborted(streamBackoffMs(failures), signal))) {
719
+ return;
720
+ }
721
+ }
722
+ }
723
+ function buildStreamUrl(client, options) {
724
+ // Through the same resolver as `buildUrl`, so a path-bearing base such as
725
+ // the sandbox's `/sandbox` lands in the same place for REST and SSE.
726
+ const url = resolveApiUrl(client.getBaseUrl(), "/api/v1/stream");
727
+ if (options.event && options.event.length > 0) {
728
+ url.searchParams.set("event", options.event.join(","));
729
+ }
730
+ if (options.condition_id) {
731
+ url.searchParams.set("condition_id", options.condition_id);
732
+ }
733
+ if (options.min_grade) {
734
+ url.searchParams.set("min_grade", options.min_grade);
735
+ }
736
+ return url.toString();
737
+ }
738
+ /** Parse one SSE frame (without the trailing blank line). */
739
+ export function parseSseFrame(raw) {
740
+ const lines = raw.split(/\r?\n/);
741
+ let event;
742
+ let id;
743
+ const dataParts = [];
744
+ let sawField = false;
745
+ for (const line of lines) {
746
+ if (line === "" || line.startsWith(":")) {
747
+ // Blank or comment line (e.g. ": keep-alive"): ignore.
748
+ continue;
749
+ }
750
+ const colon = line.indexOf(":");
751
+ const field = colon === -1 ? line : line.slice(0, colon);
752
+ let value = colon === -1 ? "" : line.slice(colon + 1);
753
+ if (value.startsWith(" "))
754
+ value = value.slice(1);
755
+ switch (field) {
756
+ case "event":
757
+ event = value;
758
+ sawField = true;
759
+ break;
760
+ case "id": {
761
+ const n = Number(value);
762
+ if (Number.isFinite(n))
763
+ id = n;
764
+ sawField = true;
765
+ break;
766
+ }
767
+ case "data":
768
+ dataParts.push(value);
769
+ sawField = true;
770
+ break;
771
+ default:
772
+ // Unknown SSE field; ignore per the spec.
773
+ break;
774
+ }
775
+ }
776
+ if (!sawField && dataParts.length === 0) {
777
+ return null;
778
+ }
779
+ return { event, id, data: dataParts.join("\n") };
780
+ }
781
+ /** Find the next SSE frame boundary (blank line); handles `\n\n` and `\r\n\r\n`. */
782
+ function nextFrameBoundary(buffer) {
783
+ // Three terminator shapes, not two (#9682). A producer that ends FIELD lines
784
+ // with CRLF but the blank separator line with a bare LF yields "\r\n\n",
785
+ // which the old two-way scan cut at the "\n\n" -- one byte late, leaving a
786
+ // trailing "\r" on the last field. `parseSseFrame` splits on /\r?\n/ and so
787
+ // cannot strip a "\r" from a line with no newline after it, which turned
788
+ // `event: resync` into `event: "resync\r"` and made the exact `=== "resync"`
789
+ // routing check fail: the resume marker the cursor protocol depends on was
790
+ // silently delivered as a malformed envelope. The current Axum backend emits
791
+ // LF only, so this hardens a latent path rather than fixing a live break.
792
+ const candidates = [
793
+ { index: buffer.indexOf("\r\n\r\n"), length: 4 },
794
+ { index: buffer.indexOf("\r\n\n"), length: 3 },
795
+ { index: buffer.indexOf("\n\n"), length: 2 },
796
+ ].filter((candidate) => candidate.index !== -1);
797
+ if (candidates.length === 0)
798
+ return { index: -1, length: 0 };
799
+ // Earliest boundary wins; on a tie the LONGEST terminator wins, because
800
+ // "\r\n\r\n" and "\r\n\n" both also match "\n\n" at a later index and a
801
+ // short match would leave terminator bytes in the next frame.
802
+ return candidates.reduce((best, candidate) => candidate.index < best.index ||
803
+ (candidate.index === best.index && candidate.length > best.length)
804
+ ? candidate
805
+ : best);
806
+ }
807
+ /**
808
+ * Turn one parsed SSE frame into a `StreamEvent`, or throw
809
+ * `StreamProtocolError` (#16248). `lastSeq` is only carried onto the error.
810
+ */
811
+ export function decodeStreamFrame(parsed, lastSeq) {
812
+ const detail = {
813
+ lastSeq,
814
+ frameId: parsed.id,
815
+ event: parsed.event,
816
+ bytes: new TextEncoder().encode(parsed.data).byteLength,
817
+ };
818
+ let payload;
819
+ try {
820
+ payload = parsed.data === "" ? undefined : JSON.parse(parsed.data);
821
+ }
822
+ catch {
823
+ throw new StreamProtocolError("invalid_json", detail);
824
+ }
825
+ if (parsed.event === "error") {
826
+ throw terminalStreamError(payload, detail);
827
+ }
828
+ if (parsed.event === "resync") {
829
+ if (!isPlainObject(payload)) {
830
+ throw new StreamProtocolError("invalid_resync", detail);
831
+ }
832
+ if ("type" in payload && payload.type !== "resync") {
833
+ throw new StreamProtocolError("invalid_resync", detail);
834
+ }
835
+ return {
836
+ kind: "resync",
837
+ seq: parsed.id ?? null,
838
+ marker: payload,
839
+ };
840
+ }
841
+ if (payload === undefined) {
842
+ throw new StreamProtocolError("invalid_json", detail);
843
+ }
844
+ if (!isPlainObject(payload)) {
845
+ throw new StreamProtocolError("invalid_envelope", detail);
846
+ }
847
+ const seq = typeof payload.seq === "number" && Number.isFinite(payload.seq)
848
+ ? payload.seq
849
+ : parsed.id;
850
+ if (seq === undefined || !Number.isFinite(seq)) {
851
+ throw new StreamProtocolError("unusable_sequence", detail);
852
+ }
853
+ return { kind: "event", seq, envelope: payload };
854
+ }
855
+ /**
856
+ * The HTTP status the reconnect is answered with, for each code a terminal
857
+ * `event: error` frame carries (#16546). The frame has no status of its own;
858
+ * this mirrors `ApiError::status_code` in `backend/crates/api-core`, so the
859
+ * error thrown mid-stream is the one the next request would get.
860
+ */
861
+ const STREAM_ERROR_CODE_STATUS = {
862
+ bad_request: 400,
863
+ invalid_api_key: 401,
864
+ subscription_required: 402,
865
+ forbidden: 403,
866
+ insufficient_scope: 403,
867
+ not_found: 404,
868
+ request_timeout: 408,
869
+ account_locked: 423,
870
+ rate_limited: 429,
871
+ internal_error: 500,
872
+ rate_limit_unavailable: 503,
873
+ };
874
+ const STREAM_ERROR_UNAVAILABLE_REASONS = new Set([
875
+ "database_unavailable",
876
+ "read_model_warming",
877
+ "request_accounting_unavailable",
878
+ ]);
879
+ /**
880
+ * Errors built from a terminal frame that said `retry: false`: permanent
881
+ * whatever their status, so no consumer reconnects on them (#16546).
882
+ */
883
+ const permanentTerminalErrors = new WeakSet();
884
+ /**
885
+ * The typed error a terminal `event: error` frame stands for (#16546).
886
+ *
887
+ * The backend ends `/api/v1/stream` with `{ "type": "error", "error": {...},
888
+ * "retry": <bool> }` when the key is revoked, the account lapses, or the
889
+ * credential store cannot confirm the key. The frame is never a feed event:
890
+ * it is thrown, so it reaches no handler and moves no cursor or checkpoint.
891
+ * `retry: false` is permanent; `retry: true` goes through the resilient
892
+ * consumer's reconnect path like any 5xx.
893
+ */
894
+ function terminalStreamError(payload, detail) {
895
+ if (!isPlainObject(payload) || !isPlainObject(payload.error)) {
896
+ return new StreamProtocolError("invalid_envelope", detail);
897
+ }
898
+ const body = payload.error;
899
+ const retry = payload.retry === true;
900
+ const code = typeof body.code === "string" ? body.code : undefined;
901
+ const reason = typeof body.reason === "string" ? body.reason : undefined;
902
+ const status = reason !== undefined && STREAM_ERROR_UNAVAILABLE_REASONS.has(reason)
903
+ ? 503
904
+ : ((code !== undefined ? STREAM_ERROR_CODE_STATUS[code] : undefined) ??
905
+ (retry ? 503 : 400));
906
+ const error = errorFromResponse(status, { object: "error", error: body });
907
+ if (!retry)
908
+ permanentTerminalErrors.add(error);
909
+ return error;
910
+ }
911
+ function isPlainObject(value) {
912
+ return typeof value === "object" && value !== null && !Array.isArray(value);
913
+ }
914
+ function tryParse(text) {
915
+ if (text === null || text === "")
916
+ return null;
917
+ try {
918
+ return JSON.parse(text);
919
+ }
920
+ catch {
921
+ return null;
922
+ }
923
+ }
924
+ async function safeText(response) {
925
+ try {
926
+ return await response.text();
927
+ }
928
+ catch {
929
+ return "";
930
+ }
931
+ }
932
+ //# sourceMappingURL=stream.js.map