@0xinsider/mcp 2.1.0 → 2.1.3

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.
Files changed (92) hide show
  1. package/README.md +25 -6
  2. package/dist/cli-lib.d.ts +1 -0
  3. package/dist/cli-lib.d.ts.map +1 -1
  4. package/dist/cli-lib.js +48 -8
  5. package/dist/cli-lib.js.map +1 -1
  6. package/dist/client.d.ts +33 -2
  7. package/dist/client.d.ts.map +1 -1
  8. package/dist/client.js +42 -4
  9. package/dist/client.js.map +1 -1
  10. package/dist/constants.d.ts +1 -1
  11. package/dist/constants.js +1 -1
  12. package/dist/product-cli/client.d.ts +3 -2
  13. package/dist/product-cli/client.d.ts.map +1 -1
  14. package/dist/product-cli/client.js.map +1 -1
  15. package/dist/sdk/client.d.ts +690 -133
  16. package/dist/sdk/client.d.ts.map +1 -1
  17. package/dist/sdk/client.js +699 -125
  18. package/dist/sdk/client.js.map +1 -1
  19. package/dist/sdk/errors.d.ts +42 -4
  20. package/dist/sdk/errors.d.ts.map +1 -1
  21. package/dist/sdk/errors.js +103 -2
  22. package/dist/sdk/errors.js.map +1 -1
  23. package/dist/sdk/index.d.ts +9 -8
  24. package/dist/sdk/index.d.ts.map +1 -1
  25. package/dist/sdk/index.js +6 -4
  26. package/dist/sdk/index.js.map +1 -1
  27. package/dist/sdk/pagination.d.ts +133 -15
  28. package/dist/sdk/pagination.d.ts.map +1 -1
  29. package/dist/sdk/pagination.js +132 -34
  30. package/dist/sdk/pagination.js.map +1 -1
  31. package/dist/sdk/retry.d.ts +68 -0
  32. package/dist/sdk/retry.d.ts.map +1 -0
  33. package/dist/sdk/retry.js +125 -0
  34. package/dist/sdk/retry.js.map +1 -0
  35. package/dist/sdk/schema.d.ts +1381 -439
  36. package/dist/sdk/schema.d.ts.map +1 -1
  37. package/dist/sdk/stream.d.ts +318 -8
  38. package/dist/sdk/stream.d.ts.map +1 -1
  39. package/dist/sdk/stream.js +503 -56
  40. package/dist/sdk/stream.js.map +1 -1
  41. package/dist/sdk/webhooks.d.ts +18 -3
  42. package/dist/sdk/webhooks.d.ts.map +1 -1
  43. package/dist/sdk/webhooks.js +13 -1
  44. package/dist/sdk/webhooks.js.map +1 -1
  45. package/dist/tools/explore-markets.d.ts.map +1 -1
  46. package/dist/tools/explore-markets.js +2 -5
  47. package/dist/tools/explore-markets.js.map +1 -1
  48. package/dist/tools/get-insider-radar.d.ts.map +1 -1
  49. package/dist/tools/get-insider-radar.js +3 -10
  50. package/dist/tools/get-insider-radar.js.map +1 -1
  51. package/dist/tools/get-large-positions.d.ts.map +1 -1
  52. package/dist/tools/get-large-positions.js +3 -10
  53. package/dist/tools/get-large-positions.js.map +1 -1
  54. package/dist/tools/get-leaderboard.d.ts.map +1 -1
  55. package/dist/tools/get-leaderboard.js +3 -10
  56. package/dist/tools/get-leaderboard.js.map +1 -1
  57. package/dist/tools/get-market-intel.d.ts.map +1 -1
  58. package/dist/tools/get-market-intel.js +3 -6
  59. package/dist/tools/get-market-intel.js.map +1 -1
  60. package/dist/tools/get-position-timeline.d.ts.map +1 -1
  61. package/dist/tools/get-position-timeline.js +3 -10
  62. package/dist/tools/get-position-timeline.js.map +1 -1
  63. package/dist/tools/get-positions.d.ts +2 -0
  64. package/dist/tools/get-positions.d.ts.map +1 -1
  65. package/dist/tools/get-positions.js +8 -11
  66. package/dist/tools/get-positions.js.map +1 -1
  67. package/dist/tools/get-trader-pnl.d.ts.map +1 -1
  68. package/dist/tools/get-trader-pnl.js +3 -6
  69. package/dist/tools/get-trader-pnl.js.map +1 -1
  70. package/dist/tools/get-trader.d.ts.map +1 -1
  71. package/dist/tools/get-trader.js +3 -6
  72. package/dist/tools/get-trader.js.map +1 -1
  73. package/dist/tools/get-trending-wallets.d.ts.map +1 -1
  74. package/dist/tools/get-trending-wallets.js +3 -10
  75. package/dist/tools/get-trending-wallets.js.map +1 -1
  76. package/dist/tools/get-whale-trades.d.ts.map +1 -1
  77. package/dist/tools/get-whale-trades.js +4 -11
  78. package/dist/tools/get-whale-trades.js.map +1 -1
  79. package/dist/tools/pick-of-the-day.d.ts.map +1 -1
  80. package/dist/tools/pick-of-the-day.js +5 -21
  81. package/dist/tools/pick-of-the-day.js.map +1 -1
  82. package/dist/tools/read-parity.d.ts.map +1 -1
  83. package/dist/tools/read-parity.js +36 -44
  84. package/dist/tools/read-parity.js.map +1 -1
  85. package/dist/tools/result.d.ts +31 -0
  86. package/dist/tools/result.d.ts.map +1 -0
  87. package/dist/tools/result.js +70 -0
  88. package/dist/tools/result.js.map +1 -0
  89. package/dist/tools/search-markets.d.ts.map +1 -1
  90. package/dist/tools/search-markets.js +16 -11
  91. package/dist/tools/search-markets.js.map +1 -1
  92. package/package.json +4 -4
@@ -21,8 +21,101 @@
21
21
  *
22
22
  * Filters (per-connection, subscribe-time): `event` (comma-separated frame
23
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 that has not reached its blank-line delimiter after
35
+ * `maxFrameBytes` (default 1 MiB) is `frame_too_large`; the reader is
36
+ * 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
+ * Comment lines (`: keep-alive`), LF and CRLF framing, unknown SSE fields
42
+ * and unknown-but-valid envelope `type`s are compatible as before.
24
43
  */
44
+ import { resolveApiUrl } from "./client.js";
25
45
  import { errorFromResponse, OxinsiderApiError } from "./errors.js";
46
+ import { RETRY_AFTER_CEILING_MS, retryAfterSeconds, waitUnlessAborted, } from "./retry.js";
47
+ /** Default for `StreamOptions.maxFrameBytes`: 1 MiB. */
48
+ export const DEFAULT_MAX_STREAM_FRAME_BYTES = 1_048_576;
49
+ /**
50
+ * The stream broke the SSE contract (#16248). The connection is closed and
51
+ * the reader released before this is thrown. `lastSeq` is the last sequence
52
+ * delivered on this connection, or `undefined` when none was: no malformed
53
+ * frame moves the cursor. `frameId` is the SSE `id` of the offending frame
54
+ * when it carried one, `event` its SSE event name, `bytes` its size, and
55
+ * `mediaType` the response's `Content-Type` for `unexpected_media_type`. The
56
+ * raw payload is deliberately not carried: it may hold data you would not
57
+ * want in a log, and `frameId` plus `lastSeq` name the frame exactly.
58
+ *
59
+ * `streamFeedResilient` treats this as permanent: reconnecting from
60
+ * `lastSeq` would replay the same frame while the server retains it. Decide:
61
+ * resume from `lastSeq` later, resume after `frameId` to skip it, or refetch
62
+ * state and attach live.
63
+ */
64
+ export class StreamProtocolError extends Error {
65
+ reason;
66
+ lastSeq;
67
+ frameId;
68
+ event;
69
+ bytes;
70
+ mediaType;
71
+ constructor(reason, detail) {
72
+ super(streamProtocolMessage(reason, detail));
73
+ this.name = "StreamProtocolError";
74
+ this.reason = reason;
75
+ this.lastSeq = detail.lastSeq;
76
+ this.frameId = detail.frameId;
77
+ this.event = detail.event;
78
+ this.bytes = detail.bytes;
79
+ this.mediaType = detail.mediaType ?? undefined;
80
+ }
81
+ }
82
+ function streamProtocolMessage(reason, detail) {
83
+ const where = detail.frameId === undefined
84
+ ? "a frame"
85
+ : `frame id ${String(detail.frameId)}`;
86
+ const after = detail.lastSeq === undefined
87
+ ? "before any event was delivered"
88
+ : `after seq ${String(detail.lastSeq)}`;
89
+ switch (reason) {
90
+ case "unexpected_media_type":
91
+ return `0xinsider stream answered ${detail.mediaType ? `Content-Type ${detail.mediaType}` : "with no Content-Type"} instead of text/event-stream; not an SSE stream`;
92
+ case "invalid_json":
93
+ return `0xinsider stream sent ${where} whose data is not JSON (${String(detail.bytes ?? 0)} bytes) ${after}`;
94
+ case "invalid_envelope":
95
+ return `0xinsider stream sent ${where} whose data is not an envelope object ${after}`;
96
+ case "unusable_sequence":
97
+ return `0xinsider stream sent ${where} with no finite seq and no finite id ${after}`;
98
+ case "invalid_resync":
99
+ return `0xinsider stream sent a resync marker (${where}) whose data is not a resync object ${after}`;
100
+ case "frame_too_large":
101
+ return `0xinsider stream sent a frame past ${String(detail.bytes ?? 0)} bytes with no delimiter ${after}; the connection was closed`;
102
+ default:
103
+ return `0xinsider stream protocol error ${after}`;
104
+ }
105
+ }
106
+ function assertMaxFrameBytes(value) {
107
+ if (!Number.isInteger(value) || value < 1) {
108
+ throw new Error(`maxFrameBytes must be a positive integer, got ${String(value)}`);
109
+ }
110
+ return value;
111
+ }
112
+ /** `Content-Type` names an event stream: `text/event-stream`, with or without parameters. */
113
+ export function isEventStreamMediaType(contentType) {
114
+ if (contentType === null)
115
+ return false;
116
+ const essence = contentType.split(";", 1)[0]?.trim().toLowerCase();
117
+ return essence === "text/event-stream";
118
+ }
26
119
  /**
27
120
  * Open the stream and async-iterate its frames. Yields a discriminated
28
121
  * `StreamEvent`: `kind: "event"` carries the `FeedEnvelope`, `kind: "resync"`
@@ -38,13 +131,18 @@ import { errorFromResponse, OxinsiderApiError } from "./errors.js";
38
131
  * }
39
132
  */
40
133
  export async function* streamFeed(client, options = {}) {
134
+ const maxFrameBytes = assertMaxFrameBytes(options.maxFrameBytes ?? DEFAULT_MAX_STREAM_FRAME_BYTES);
41
135
  const apiKey = client.getApiKey();
42
- if (!apiKey) {
136
+ // The sandbox takes no credential; it answers the stream route with a 400
137
+ // saying streams are not simulated, and that answer is the server's to
138
+ // give (#16138). Production refuses a keyless stream, so say so locally.
139
+ if (!apiKey && !client.isSandbox()) {
43
140
  throw new Error("getStream requires an API key (oxi_sk_*)");
44
141
  }
45
142
  const url = buildStreamUrl(client, options);
46
143
  const headers = new Headers({ accept: "text/event-stream" });
47
- headers.set("authorization", `Bearer ${apiKey}`);
144
+ if (apiKey)
145
+ headers.set("authorization", `Bearer ${apiKey}`);
48
146
  if (options.lastEventId !== undefined) {
49
147
  headers.set("last-event-id", String(options.lastEventId));
50
148
  }
@@ -58,45 +156,68 @@ export async function* streamFeed(client, options = {}) {
58
156
  const body = await safeText(response);
59
157
  throw errorFromResponse(response.status, tryParse(body), retryAfterSeconds(response));
60
158
  }
159
+ // A 2xx that is not an event stream (an HTML page from a proxy, a JSON body
160
+ // from a route that moved) used to read as an empty stream that closed
161
+ // cleanly, which the reconnect loop then retried as an outage (#16248).
162
+ const mediaType = response.headers.get("content-type");
163
+ if (!isEventStreamMediaType(mediaType)) {
164
+ await response.body?.cancel().catch(() => undefined);
165
+ throw new StreamProtocolError("unexpected_media_type", {
166
+ lastSeq: undefined,
167
+ mediaType,
168
+ });
169
+ }
61
170
  if (!response.body) {
62
171
  throw new Error("Stream response has no readable body");
63
172
  }
64
173
  const reader = response.body.getReader();
65
174
  const decoder = new TextDecoder();
175
+ const encoder = new TextEncoder();
66
176
  let buffer = "";
177
+ // Bytes of `buffer`: exact, since every chunk adds its own byte length and
178
+ // a cut re-measures the remainder. Bounded by `maxFrameBytes`.
179
+ let bufferedBytes = 0;
180
+ let lastSeq;
67
181
  try {
68
182
  for (;;) {
69
183
  const { value, done } = await reader.read();
70
184
  if (done)
71
185
  break;
72
186
  buffer += decoder.decode(value, { stream: true });
187
+ bufferedBytes += value.byteLength;
73
188
  // SSE frames are separated by a blank line. Handle CRLF and LF.
74
189
  let sepIndex = nextFrameBoundary(buffer);
190
+ if (sepIndex.index === -1 && bufferedBytes > maxFrameBytes) {
191
+ throw new StreamProtocolError("frame_too_large", {
192
+ lastSeq,
193
+ bytes: bufferedBytes,
194
+ });
195
+ }
75
196
  while (sepIndex.index !== -1) {
76
197
  const rawFrame = buffer.slice(0, sepIndex.index);
77
198
  buffer = buffer.slice(sepIndex.index + sepIndex.length);
78
199
  const parsed = parseSseFrame(rawFrame);
79
200
  if (parsed) {
80
- if (parsed.event === "resync") {
81
- const marker = (tryParse(parsed.data) ?? {});
82
- options.onResync?.(marker);
83
- yield { kind: "resync", seq: parsed.id ?? null, marker };
201
+ const frame = decodeStreamFrame(parsed, lastSeq);
202
+ if (frame.kind === "resync") {
203
+ options.onResync?.(frame.marker);
84
204
  }
85
205
  else {
86
- const envelope = tryParse(parsed.data);
87
- if (envelope && typeof envelope === "object") {
88
- const seq = typeof envelope.seq === "number"
89
- ? envelope.seq
90
- : (parsed.id ?? NaN);
91
- if (Number.isFinite(seq) && options.cursor) {
92
- options.cursor.seq = seq;
93
- }
94
- yield { kind: "event", seq, envelope };
95
- }
206
+ lastSeq = frame.seq;
207
+ if (options.cursor)
208
+ options.cursor.seq = frame.seq;
96
209
  }
210
+ yield frame;
97
211
  }
98
212
  sepIndex = nextFrameBoundary(buffer);
99
213
  }
214
+ bufferedBytes = buffer === "" ? 0 : encoder.encode(buffer).byteLength;
215
+ if (bufferedBytes > maxFrameBytes) {
216
+ throw new StreamProtocolError("frame_too_large", {
217
+ lastSeq,
218
+ bytes: bufferedBytes,
219
+ });
220
+ }
100
221
  }
101
222
  }
102
223
  finally {
@@ -115,6 +236,8 @@ export async function* streamFeed(client, options = {}) {
115
236
  }
116
237
  /** Default for `ResilientStreamOptions.maxReconnects`. */
117
238
  export const DEFAULT_MAX_STREAM_RECONNECTS = 10;
239
+ /** Default for `ResilientStreamOptions.maxRetryAfterMs`. */
240
+ export const DEFAULT_MAX_STREAM_RETRY_AFTER_MS = RETRY_AFTER_CEILING_MS;
118
241
  const STREAM_RECONNECT_BASE_MS = 1_000;
119
242
  const STREAM_RECONNECT_MAX_MS = 30_000;
120
243
  const STREAM_RECONNECT_JITTER_MS = 250;
@@ -133,6 +256,35 @@ export class StreamReconnectsExhaustedError extends Error {
133
256
  this.lastSeq = lastSeq;
134
257
  }
135
258
  }
259
+ /**
260
+ * `streamFeedResilient` was refused with a `Retry-After` longer than
261
+ * `maxRetryAfterMs`, so the wait is yours to schedule (#16249). `retryAt` is
262
+ * the server's not-before instant as this client read it (the header's
263
+ * seconds added to the local clock, or the header's HTTP-date); `cause` is
264
+ * the refusal, whose `retryAt` (from the body's `retry_at`) is the server's
265
+ * own clock reading of the same instant. `lastSeq` is the seq to resume after:
266
+ * pass it as `lastEventId` when you reconnect at `retryAt`. `attempts` is how
267
+ * many consecutive failed connections preceded this one; the refusal itself
268
+ * spent none of `maxReconnects`.
269
+ *
270
+ * Nothing here was clamped: a wait the timer cannot hold is never shortened
271
+ * into an earlier reconnect.
272
+ */
273
+ export class StreamRetryDeferredError extends Error {
274
+ retryAt;
275
+ retryAfterMs;
276
+ lastSeq;
277
+ attempts;
278
+ constructor(retryAfterMs, lastSeq, attempts, cause) {
279
+ const retryAt = new Date(Date.now() + retryAfterMs);
280
+ super(`0xinsider stream refused with Retry-After ${String(Math.round(retryAfterMs / 1000))} s, past the in-process ceiling; reconnect after ${retryAt.toISOString()}${lastSeq === undefined ? "" : ` with lastEventId ${String(lastSeq)}`}`, { cause });
281
+ this.name = "StreamRetryDeferredError";
282
+ this.retryAt = retryAt;
283
+ this.retryAfterMs = retryAfterMs;
284
+ this.lastSeq = lastSeq;
285
+ this.attempts = attempts;
286
+ }
287
+ }
136
288
  /**
137
289
  * A 4xx other than 429 will fail the same way on every reconnect: a bad filter
138
290
  * (400), a key that is invalid (401), lapsed (402), refused (403), or locked
@@ -140,40 +292,45 @@ export class StreamReconnectsExhaustedError extends Error {
140
292
  * has to fix.
141
293
  */
142
294
  function isPermanentStreamError(error) {
295
+ // A protocol error is permanent too (#16248): reconnecting from `lastSeq`
296
+ // replays the same frame while the server retains it, and a wrong media
297
+ // type is the same answer on every connection.
298
+ if (error instanceof StreamProtocolError)
299
+ return true;
143
300
  return (error instanceof OxinsiderApiError &&
144
301
  error.status >= 400 &&
145
302
  error.status < 500 &&
146
303
  error.status !== 429);
147
304
  }
148
- function streamReconnectDelayMs(attempt, cause) {
149
- const retryAfter = cause instanceof OxinsiderApiError &&
305
+ /**
306
+ * The wait the server asked for, in ms, or `null` when the refusal carried
307
+ * no usable `Retry-After` (a network error, a clean close, a 5xx without the
308
+ * header). The header is parsed once, by `retry.ts`, when the error is built.
309
+ */
310
+ function serverRequestedWaitMs(cause) {
311
+ return cause instanceof OxinsiderApiError &&
150
312
  "retryAfterSeconds" in cause &&
151
- typeof cause.retryAfterSeconds === "number"
152
- ? cause.retryAfterSeconds
313
+ typeof cause.retryAfterSeconds === "number" &&
314
+ Number.isFinite(cause.retryAfterSeconds) &&
315
+ cause.retryAfterSeconds >= 0
316
+ ? cause.retryAfterSeconds * 1000
153
317
  : null;
154
- if (retryAfter !== null) {
155
- return Math.max(0, retryAfter * 1000) + Math.random() * STREAM_RECONNECT_JITTER_MS;
156
- }
318
+ }
319
+ /**
320
+ * Milliseconds before reconnect `attempt` (1-based) when the server did not
321
+ * say: a jittered exponential backoff from 1 s capped at 30 s. A server-
322
+ * requested wait is handled by the loop, which checks it against
323
+ * `maxRetryAfterMs` before adding jitter.
324
+ */
325
+ function streamBackoffMs(attempt) {
157
326
  const ceiling = Math.min(STREAM_RECONNECT_MAX_MS, STREAM_RECONNECT_BASE_MS * 2 ** (attempt - 1));
158
327
  return ceiling / 2 + Math.random() * (ceiling / 2);
159
328
  }
160
- /** Resolve `true` after `ms`, or `false` the moment `signal` aborts. */
161
- function waitUnlessAborted(ms, signal) {
162
- return new Promise((resolve) => {
163
- if (signal?.aborted) {
164
- resolve(false);
165
- return;
166
- }
167
- const onAbort = () => {
168
- clearTimeout(timer);
169
- resolve(false);
170
- };
171
- const timer = setTimeout(() => {
172
- signal?.removeEventListener("abort", onAbort);
173
- resolve(true);
174
- }, ms);
175
- signal?.addEventListener("abort", onAbort, { once: true });
176
- });
329
+ function assertMaxRetryAfterMs(value) {
330
+ if (Number.isNaN(value) || value < 0) {
331
+ throw new Error(`maxRetryAfterMs must be a non-negative number of milliseconds or Infinity, got ${String(value)}`);
332
+ }
333
+ return value;
177
334
  }
178
335
  /**
179
336
  * `streamFeed` with the reconnect-and-resume loop built in (#14286).
@@ -181,10 +338,15 @@ function waitUnlessAborted(ms, signal) {
181
338
  * - A clean server close, a network error, a 429 or a 5xx reconnects,
182
339
  * resuming after the last delivered seq (`Last-Event-ID`). Before any frame
183
340
  * arrives it resumes from `lastEventId`, or attaches live when that is unset.
184
- * - The wait is `Retry-After` plus jitter when the refusal carries one,
185
- * otherwise a jittered backoff from 1 s capped at 30 s.
341
+ * - The wait is `Retry-After` (delta-seconds or an HTTP-date) plus jitter when
342
+ * the refusal carries one, otherwise a jittered backoff from 1 s capped at
343
+ * 30 s. A `Retry-After` past `maxRetryAfterMs` (60 s by default) is not
344
+ * waited out: the loop throws `StreamRetryDeferredError` with `retryAt` and
345
+ * `lastSeq`, spending no reconnect, and never clamps the wait to an earlier
346
+ * one (#16249).
186
347
  * - Any other 4xx (400, 401, 402, 403, 423) is thrown at once: it cannot heal
187
- * on its own.
348
+ * on its own. So is `StreamProtocolError` (#16248): a resume from
349
+ * `lastSeq` would replay the malformed frame, so the decision is yours.
188
350
  * - After `maxReconnects` consecutive connections that delivered nothing, it
189
351
  * throws `StreamReconnectsExhaustedError` carrying `lastSeq`.
190
352
  * - `resync` markers are yielded unchanged, and `onResync` still fires. The
@@ -196,7 +358,10 @@ function waitUnlessAborted(ms, signal) {
196
358
  * - No request deadline applies, as with `streamFeed`.
197
359
  *
198
360
  * `options.cursor`, when passed, tracks the last delivered seq across every
199
- * connection.
361
+ * connection -- delivered, not processed (#16247). This loop resumes from it,
362
+ * so a consumer whose handler can fail should drive the stream with
363
+ * `consumeStreamCheckpointed` instead, which resumes from an acknowledged
364
+ * checkpoint and replays the event it failed on.
200
365
  *
201
366
  * @example
202
367
  * const controller = new AbortController();
@@ -210,13 +375,14 @@ function waitUnlessAborted(ms, signal) {
210
375
  * }
211
376
  */
212
377
  export async function* streamFeedResilient(client, options = {}) {
213
- const { maxReconnects = DEFAULT_MAX_STREAM_RECONNECTS, onReconnect, ...streamOptions } = options;
378
+ const { maxReconnects = DEFAULT_MAX_STREAM_RECONNECTS, maxRetryAfterMs = DEFAULT_MAX_STREAM_RETRY_AFTER_MS, onReconnect, ...streamOptions } = options;
214
379
  if (!Number.isInteger(maxReconnects) || maxReconnects < 0) {
215
380
  throw new Error(`maxReconnects must be a non-negative integer, got ${String(maxReconnects)}`);
216
381
  }
382
+ const retryAfterCeilingMs = assertMaxRetryAfterMs(maxRetryAfterMs);
217
383
  // The one configuration error `streamFeed` throws before any request; it
218
384
  // would otherwise count as a transport failure and burn every reconnect.
219
- if (!client.getApiKey()) {
385
+ if (!client.getApiKey() && !client.isSandbox()) {
220
386
  throw new Error("streamFeedResilient requires an API key (oxi_sk_*)");
221
387
  }
222
388
  const signal = streamOptions.signal;
@@ -249,13 +415,24 @@ export async function* streamFeedResilient(client, options = {}) {
249
415
  }
250
416
  if (signal?.aborted)
251
417
  return;
252
- attempt += 1;
253
418
  const lastSeq = cursor.seq ?? toSeq(initialLastEventId);
419
+ // A server-requested wait past the ceiling is deferred to the caller
420
+ // BEFORE it counts as a reconnect: the server said when, and burning the
421
+ // short reconnect budget on a wait we will not hold would be spending it
422
+ // on nothing.
423
+ const requestedMs = serverRequestedWaitMs(cause);
424
+ if (requestedMs !== null && requestedMs > retryAfterCeilingMs) {
425
+ throw new StreamRetryDeferredError(requestedMs, lastSeq, attempt, cause);
426
+ }
427
+ attempt += 1;
254
428
  if (attempt > maxReconnects) {
255
429
  throw new StreamReconnectsExhaustedError(attempt - 1, lastSeq, cause);
256
430
  }
257
431
  onReconnect?.(attempt, lastSeq, cause);
258
- if (!(await waitUnlessAborted(streamReconnectDelayMs(attempt, cause), signal))) {
432
+ const delayMs = requestedMs === null
433
+ ? streamBackoffMs(attempt)
434
+ : requestedMs + Math.random() * STREAM_RECONNECT_JITTER_MS;
435
+ if (!(await waitUnlessAborted(delayMs, signal))) {
259
436
  return;
260
437
  }
261
438
  }
@@ -270,6 +447,13 @@ function toSeq(value) {
270
447
  * Callback-style stream consumer for environments where an async iterator is
271
448
  * awkward. Returns a promise that resolves when the stream ends (or rejects on
272
449
  * error / abort). Pass a `signal` to stop it.
450
+ *
451
+ * Delivery, not processing (#16247): `options.cursor` is written by
452
+ * `streamFeed` BEFORE the frame is yielded, so it has already moved past an
453
+ * event whose `onEvent` then throws. Reconnecting from it skips that event.
454
+ * For a consumer that must not lose work, use `consumeStreamCheckpointed`,
455
+ * which advances an acknowledged checkpoint only after the handler and your
456
+ * own durable write have both resolved.
273
457
  */
274
458
  export async function consumeStream(client, handlers, options = {}) {
275
459
  for await (const frame of streamFeed(client, options)) {
@@ -281,8 +465,230 @@ export async function consumeStream(client, handlers, options = {}) {
281
465
  }
282
466
  }
283
467
  }
468
+ /**
469
+ * `consumeStreamCheckpointed` gave up after `maxHandlerRetries` consecutive
470
+ * failures at the same sequence (#16247). `checkpoint` is the acknowledged
471
+ * sequence, still behind the failing one, and `replayFrom` is the point a
472
+ * later resume should pass as `lastEventId` to deliver the failed frame
473
+ * again while the server retains it. `cause` is the last rejection your
474
+ * handler produced.
475
+ */
476
+ export class StreamHandlerFailedError extends Error {
477
+ seq;
478
+ stage;
479
+ attempts;
480
+ checkpoint;
481
+ replayFrom;
482
+ constructor(detail, cause) {
483
+ 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 });
484
+ this.name = "StreamHandlerFailedError";
485
+ this.seq = detail.seq;
486
+ this.stage = detail.stage;
487
+ this.attempts = detail.attempts;
488
+ this.checkpoint = detail.checkpoint;
489
+ this.replayFrom = detail.replayFrom;
490
+ }
491
+ }
492
+ /** Default for `CheckpointedStreamOptions.maxHandlerRetries`. */
493
+ export const DEFAULT_MAX_HANDLER_RETRIES = 3;
494
+ /**
495
+ * The resume point that replays `seq` itself when nothing has been
496
+ * acknowledged yet.
497
+ *
498
+ * `Last-Event-ID` is an EXCLUSIVE lower bound on the server: the replay
499
+ * window is `latest - requested` entries taken from the tail of the retained
500
+ * history, so a resume at `S - 1` delivers `S` onward and nothing older
501
+ * (`shared_feed_history_since`, `backend/crates/app-core/src/feed_publisher.rs`;
502
+ * `backend/src/api_v1/handlers/stream.rs` builds the plan from it, read
503
+ * 2026-09-22). Sequences are a cluster-wide integer counter, so nothing sits
504
+ * between `S - 1` and `S`. A non-integer or non-positive sequence gets no
505
+ * step-back: there is no id we can name that is certainly below it.
506
+ */
507
+ function replayPointBefore(seq) {
508
+ return seq !== undefined && Number.isInteger(seq) && seq >= 1
509
+ ? seq - 1
510
+ : undefined;
511
+ }
512
+ /**
513
+ * The stream consumer for work that must not be silently dropped (#16247):
514
+ * it separates the received cursor from an acknowledged processing
515
+ * checkpoint, and only the checkpoint decides where a reconnect resumes.
516
+ *
517
+ * Ordering, per frame, for `kind: "event"`:
518
+ * 1. `onEvent` is awaited. Nothing is read from the connection while it
519
+ * runs, so a slow handler is backpressure on the socket, never a queue:
520
+ * at most one frame is ever in flight and nothing is buffered on your
521
+ * behalf.
522
+ * 2. `onCheckpoint(seq, "event")` is awaited -- your durable write.
523
+ * 3. Only then does `checkpoint.seq` become `seq`, and only then does the
524
+ * next reconnect resume after it.
525
+ * A rejection at step 1 or 2 leaves the checkpoint BEFORE the event: the
526
+ * connection is closed, a jittered backoff runs, and the consumer reconnects
527
+ * from the checkpoint, which replays the unacknowledged event while the
528
+ * server retains it. `onHandlerError` reports each failure with its attempt
529
+ * number, the replay point and whether another attempt follows. After
530
+ * `maxHandlerRetries` consecutive failures at the same sequence it throws
531
+ * `StreamHandlerFailedError`, carrying the unadvanced checkpoint.
532
+ *
533
+ * A `resync` marker is the same barrier: `onResync` is awaited, then
534
+ * `onCheckpoint(seq, "resync")`, and an interrupted refresh commits nothing,
535
+ * so the recovery is retried instead of being recorded as done. (The
536
+ * fire-and-forget `StreamOptions.onResync` notification is not awaited and is
537
+ * not a barrier -- do not use it as one.)
538
+ *
539
+ * Delivery is AT-LEAST-ONCE. A replay re-delivers every unacknowledged frame,
540
+ * and a handler that succeeded but whose `onCheckpoint` write failed sees its
541
+ * event again. Deduplicate on `seq` (it is monotonic per cluster) or make the
542
+ * side effect idempotent; nothing here can promise exactly-once side effects.
543
+ *
544
+ * `options.cursor` is untouched in meaning: it still tracks the last
545
+ * DELIVERED seq, including frames whose handler later failed, and it rewinds
546
+ * when a replay re-delivers them. Read it for transport progress; never as
547
+ * proof that the work was done.
548
+ *
549
+ * Transport recovery, `Retry-After` handling, permanent 4xx and
550
+ * `StreamProtocolError` behave exactly as in `streamFeedResilient`, which
551
+ * this drives. `StreamReconnectsExhaustedError` and
552
+ * `StreamRetryDeferredError` surface unchanged; their `lastSeq` is the
553
+ * acknowledged checkpoint, because a reconnect can only happen between
554
+ * handlers. Aborting `signal` ends the consumer without throwing: a handler
555
+ * already running is awaited (it is not cancelled for you -- pass the same
556
+ * signal into your own work if you want that), and if it succeeds its
557
+ * checkpoint is committed before the consumer returns.
558
+ *
559
+ * @example
560
+ * const checkpoint = { seq: await loadCheckpoint() };
561
+ * await consumeStreamCheckpointed(client, {
562
+ * onEvent: async (envelope, seq) => { await applyOnce(seq, envelope); },
563
+ * onResync: async () => { await refetchCurrentState(); },
564
+ * onCheckpoint: async (seq) => { await saveCheckpoint(seq); },
565
+ * }, {
566
+ * event: ["WhaleTradesInserted"],
567
+ * lastEventId: checkpoint.seq,
568
+ * checkpoint,
569
+ * signal: controller.signal,
570
+ * onHandlerError: (error, failure) =>
571
+ * console.warn("replaying", failure.seq, failure.attempt, failure.willRetry, error),
572
+ * });
573
+ */
574
+ export async function consumeStreamCheckpointed(client, handlers, options = {}) {
575
+ const { checkpoint = {}, maxHandlerRetries = DEFAULT_MAX_HANDLER_RETRIES, onHandlerError, ...streamOptions } = options;
576
+ if (!Number.isInteger(maxHandlerRetries) || maxHandlerRetries < 0) {
577
+ throw new Error(`maxHandlerRetries must be a non-negative integer, got ${String(maxHandlerRetries)}`);
578
+ }
579
+ const signal = streamOptions.signal;
580
+ // The caller's received cursor, if they passed one. It keeps its delivery
581
+ // meaning: this consumer mirrors every frame into it and never reads it
582
+ // back as a resume point.
583
+ const received = streamOptions.cursor;
584
+ const initialLastEventId = toSeq(streamOptions.lastEventId);
585
+ // Where the next connection resumes. It starts at the acknowledged
586
+ // checkpoint (or the caller's `lastEventId`) and only ever moves to a
587
+ // sequence whose processing completed.
588
+ let resumeAfter = checkpoint.seq ?? initialLastEventId;
589
+ let failingSeq;
590
+ let failures = 0;
591
+ for (;;) {
592
+ if (signal?.aborted)
593
+ return;
594
+ let pending;
595
+ // A cursor private to this connection: `streamFeedResilient` reads it
596
+ // back as its own resume point across transport reconnects, so the
597
+ // caller's received cursor must never be handed to it -- that cursor has
598
+ // already moved past an unacknowledged event, and lending it would make
599
+ // the replay resume after the frame it exists to redeliver.
600
+ const delivered = { seq: resumeAfter };
601
+ for await (const frame of streamFeedResilient(client, {
602
+ ...streamOptions,
603
+ cursor: delivered,
604
+ lastEventId: resumeAfter,
605
+ })) {
606
+ if (received && delivered.seq !== undefined)
607
+ received.seq = delivered.seq;
608
+ if (frame.kind === "event") {
609
+ try {
610
+ await handlers.onEvent(frame.envelope, frame.seq);
611
+ }
612
+ catch (error) {
613
+ pending = { error, stage: "event", seq: frame.seq };
614
+ break;
615
+ }
616
+ try {
617
+ await handlers.onCheckpoint?.(frame.seq, "event");
618
+ }
619
+ catch (error) {
620
+ pending = { error, stage: "checkpoint", seq: frame.seq };
621
+ break;
622
+ }
623
+ checkpoint.seq = frame.seq;
624
+ resumeAfter = frame.seq;
625
+ }
626
+ else {
627
+ try {
628
+ await handlers.onResync?.(frame.marker, frame.seq);
629
+ }
630
+ catch (error) {
631
+ pending = { error, stage: "resync", seq: frame.seq ?? undefined };
632
+ break;
633
+ }
634
+ if (frame.seq !== null) {
635
+ try {
636
+ await handlers.onCheckpoint?.(frame.seq, "resync");
637
+ }
638
+ catch (error) {
639
+ pending = { error, stage: "checkpoint", seq: frame.seq };
640
+ break;
641
+ }
642
+ checkpoint.seq = frame.seq;
643
+ resumeAfter = frame.seq;
644
+ }
645
+ }
646
+ failures = 0;
647
+ failingSeq = undefined;
648
+ if (signal?.aborted)
649
+ return;
650
+ }
651
+ // `streamFeedResilient` only ends without throwing when the signal was
652
+ // aborted, so a loop that ends with nothing pending is a clean stop.
653
+ if (!pending)
654
+ return;
655
+ const failedSeq = pending.seq;
656
+ if (failures > 0 && failedSeq === failingSeq) {
657
+ failures += 1;
658
+ }
659
+ else {
660
+ failingSeq = failedSeq;
661
+ failures = 1;
662
+ }
663
+ resumeAfter =
664
+ checkpoint.seq ?? initialLastEventId ?? replayPointBefore(failedSeq);
665
+ const willRetry = failures <= maxHandlerRetries;
666
+ onHandlerError?.(pending.error, {
667
+ seq: failedSeq,
668
+ stage: pending.stage,
669
+ attempt: failures,
670
+ willRetry,
671
+ replayFrom: resumeAfter,
672
+ checkpoint: checkpoint.seq,
673
+ });
674
+ if (!willRetry) {
675
+ throw new StreamHandlerFailedError({
676
+ seq: failedSeq,
677
+ stage: pending.stage,
678
+ attempts: failures,
679
+ checkpoint: checkpoint.seq,
680
+ replayFrom: resumeAfter,
681
+ }, pending.error);
682
+ }
683
+ if (!(await waitUnlessAborted(streamBackoffMs(failures), signal))) {
684
+ return;
685
+ }
686
+ }
687
+ }
284
688
  function buildStreamUrl(client, options) {
285
- const url = new URL("api/v1/stream", `${client.getBaseUrl()}/`);
689
+ // Through the same resolver as `buildUrl`, so a path-bearing base such as
690
+ // the sandbox's `/sandbox` lands in the same place for REST and SSE.
691
+ const url = resolveApiUrl(client.getBaseUrl(), "/api/v1/stream");
286
692
  if (options.event && options.event.length > 0) {
287
693
  url.searchParams.set("event", options.event.join(","));
288
694
  }
@@ -363,6 +769,54 @@ function nextFrameBoundary(buffer) {
363
769
  ? candidate
364
770
  : best);
365
771
  }
772
+ /**
773
+ * Turn one parsed SSE frame into a `StreamEvent`, or throw
774
+ * `StreamProtocolError` (#16248). `lastSeq` is only carried onto the error.
775
+ */
776
+ export function decodeStreamFrame(parsed, lastSeq) {
777
+ const detail = {
778
+ lastSeq,
779
+ frameId: parsed.id,
780
+ event: parsed.event,
781
+ bytes: new TextEncoder().encode(parsed.data).byteLength,
782
+ };
783
+ let payload;
784
+ try {
785
+ payload = parsed.data === "" ? undefined : JSON.parse(parsed.data);
786
+ }
787
+ catch {
788
+ throw new StreamProtocolError("invalid_json", detail);
789
+ }
790
+ if (parsed.event === "resync") {
791
+ if (!isPlainObject(payload)) {
792
+ throw new StreamProtocolError("invalid_resync", detail);
793
+ }
794
+ if ("type" in payload && payload.type !== "resync") {
795
+ throw new StreamProtocolError("invalid_resync", detail);
796
+ }
797
+ return {
798
+ kind: "resync",
799
+ seq: parsed.id ?? null,
800
+ marker: payload,
801
+ };
802
+ }
803
+ if (payload === undefined) {
804
+ throw new StreamProtocolError("invalid_json", detail);
805
+ }
806
+ if (!isPlainObject(payload)) {
807
+ throw new StreamProtocolError("invalid_envelope", detail);
808
+ }
809
+ const seq = typeof payload.seq === "number" && Number.isFinite(payload.seq)
810
+ ? payload.seq
811
+ : parsed.id;
812
+ if (seq === undefined || !Number.isFinite(seq)) {
813
+ throw new StreamProtocolError("unusable_sequence", detail);
814
+ }
815
+ return { kind: "event", seq, envelope: payload };
816
+ }
817
+ function isPlainObject(value) {
818
+ return typeof value === "object" && value !== null && !Array.isArray(value);
819
+ }
366
820
  function tryParse(text) {
367
821
  if (text === null || text === "")
368
822
  return null;
@@ -381,11 +835,4 @@ async function safeText(response) {
381
835
  return "";
382
836
  }
383
837
  }
384
- function retryAfterSeconds(response) {
385
- const header = response.headers.get("retry-after");
386
- if (!header)
387
- return null;
388
- const seconds = Number(header);
389
- return Number.isFinite(seconds) ? seconds : null;
390
- }
391
838
  //# sourceMappingURL=stream.js.map