@0xinsider/mcp 2.1.0 → 2.4.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.
Files changed (113) hide show
  1. package/README.md +49 -17
  2. package/dist/catalog.generated.d.ts +42 -6
  3. package/dist/catalog.generated.d.ts.map +1 -1
  4. package/dist/catalog.generated.js +51 -6
  5. package/dist/catalog.generated.js.map +1 -1
  6. package/dist/cli-lib.d.ts +8 -0
  7. package/dist/cli-lib.d.ts.map +1 -1
  8. package/dist/cli-lib.js +123 -17
  9. package/dist/cli-lib.js.map +1 -1
  10. package/dist/client.d.ts +35 -2
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/client.js +42 -4
  13. package/dist/client.js.map +1 -1
  14. package/dist/constants.d.ts +1 -1
  15. package/dist/constants.js +1 -1
  16. package/dist/product-cli/client.d.ts +3 -2
  17. package/dist/product-cli/client.d.ts.map +1 -1
  18. package/dist/product-cli/client.js.map +1 -1
  19. package/dist/product-cli/index.d.ts.map +1 -1
  20. package/dist/product-cli/index.js +3 -1
  21. package/dist/product-cli/index.js.map +1 -1
  22. package/dist/prompts/index.js +2 -2
  23. package/dist/sdk/client.d.ts +861 -136
  24. package/dist/sdk/client.d.ts.map +1 -1
  25. package/dist/sdk/client.js +924 -127
  26. package/dist/sdk/client.js.map +1 -1
  27. package/dist/sdk/errors.d.ts +48 -4
  28. package/dist/sdk/errors.d.ts.map +1 -1
  29. package/dist/sdk/errors.js +123 -3
  30. package/dist/sdk/errors.js.map +1 -1
  31. package/dist/sdk/index.d.ts +40 -9
  32. package/dist/sdk/index.d.ts.map +1 -1
  33. package/dist/sdk/index.js +6 -4
  34. package/dist/sdk/index.js.map +1 -1
  35. package/dist/sdk/pagination.d.ts +133 -15
  36. package/dist/sdk/pagination.d.ts.map +1 -1
  37. package/dist/sdk/pagination.js +132 -34
  38. package/dist/sdk/pagination.js.map +1 -1
  39. package/dist/sdk/retry.d.ts +68 -0
  40. package/dist/sdk/retry.d.ts.map +1 -0
  41. package/dist/sdk/retry.js +125 -0
  42. package/dist/sdk/retry.js.map +1 -0
  43. package/dist/sdk/schema.d.ts +2200 -688
  44. package/dist/sdk/schema.d.ts.map +1 -1
  45. package/dist/sdk/stream.d.ts +323 -8
  46. package/dist/sdk/stream.d.ts.map +1 -1
  47. package/dist/sdk/stream.js +582 -56
  48. package/dist/sdk/stream.js.map +1 -1
  49. package/dist/sdk/webhooks.d.ts +104 -12
  50. package/dist/sdk/webhooks.d.ts.map +1 -1
  51. package/dist/sdk/webhooks.js +22 -4
  52. package/dist/sdk/webhooks.js.map +1 -1
  53. package/dist/server.d.ts.map +1 -1
  54. package/dist/server.js +13 -2
  55. package/dist/server.js.map +1 -1
  56. package/dist/tools/explore-markets.d.ts.map +1 -1
  57. package/dist/tools/explore-markets.js +2 -5
  58. package/dist/tools/explore-markets.js.map +1 -1
  59. package/dist/tools/games.d.ts +5 -0
  60. package/dist/tools/games.d.ts.map +1 -0
  61. package/dist/tools/games.js +103 -0
  62. package/dist/tools/games.js.map +1 -0
  63. package/dist/tools/get-insider-radar.d.ts.map +1 -1
  64. package/dist/tools/get-insider-radar.js +11 -40
  65. package/dist/tools/get-insider-radar.js.map +1 -1
  66. package/dist/tools/get-large-positions.d.ts.map +1 -1
  67. package/dist/tools/get-large-positions.js +3 -10
  68. package/dist/tools/get-large-positions.js.map +1 -1
  69. package/dist/tools/get-leaderboard.d.ts.map +1 -1
  70. package/dist/tools/get-leaderboard.js +23 -12
  71. package/dist/tools/get-leaderboard.js.map +1 -1
  72. package/dist/tools/get-market-intel.d.ts.map +1 -1
  73. package/dist/tools/get-market-intel.js +3 -6
  74. package/dist/tools/get-market-intel.js.map +1 -1
  75. package/dist/tools/get-position-timeline.d.ts.map +1 -1
  76. package/dist/tools/get-position-timeline.js +3 -10
  77. package/dist/tools/get-position-timeline.js.map +1 -1
  78. package/dist/tools/get-positions.d.ts +2 -0
  79. package/dist/tools/get-positions.d.ts.map +1 -1
  80. package/dist/tools/get-positions.js +8 -11
  81. package/dist/tools/get-positions.js.map +1 -1
  82. package/dist/tools/get-suspicious-trades.d.ts +26 -0
  83. package/dist/tools/get-suspicious-trades.d.ts.map +1 -0
  84. package/dist/tools/get-suspicious-trades.js +91 -0
  85. package/dist/tools/get-suspicious-trades.js.map +1 -0
  86. package/dist/tools/get-trader-pnl.d.ts.map +1 -1
  87. package/dist/tools/get-trader-pnl.js +3 -6
  88. package/dist/tools/get-trader-pnl.js.map +1 -1
  89. package/dist/tools/get-trader.d.ts.map +1 -1
  90. package/dist/tools/get-trader.js +3 -6
  91. package/dist/tools/get-trader.js.map +1 -1
  92. package/dist/tools/get-trending-wallets.d.ts.map +1 -1
  93. package/dist/tools/get-trending-wallets.js +3 -10
  94. package/dist/tools/get-trending-wallets.js.map +1 -1
  95. package/dist/tools/get-whale-trades.d.ts +2 -0
  96. package/dist/tools/get-whale-trades.d.ts.map +1 -1
  97. package/dist/tools/get-whale-trades.js +21 -16
  98. package/dist/tools/get-whale-trades.js.map +1 -1
  99. package/dist/tools/pick-of-the-day.d.ts.map +1 -1
  100. package/dist/tools/pick-of-the-day.js +5 -21
  101. package/dist/tools/pick-of-the-day.js.map +1 -1
  102. package/dist/tools/read-parity.d.ts +8 -0
  103. package/dist/tools/read-parity.d.ts.map +1 -1
  104. package/dist/tools/read-parity.js +125 -77
  105. package/dist/tools/read-parity.js.map +1 -1
  106. package/dist/tools/result.d.ts +31 -0
  107. package/dist/tools/result.d.ts.map +1 -0
  108. package/dist/tools/result.js +70 -0
  109. package/dist/tools/result.js.map +1 -0
  110. package/dist/tools/search-markets.d.ts.map +1 -1
  111. package/dist/tools/search-markets.js +16 -11
  112. package/dist/tools/search-markets.js.map +1 -1
  113. package/package.json +4 -4
@@ -21,8 +21,105 @@
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 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.
24
47
  */
48
+ import { resolveApiUrl } from "./client.js";
25
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
+ }
26
123
  /**
27
124
  * Open the stream and async-iterate its frames. Yields a discriminated
28
125
  * `StreamEvent`: `kind: "event"` carries the `FeedEnvelope`, `kind: "resync"`
@@ -38,13 +135,18 @@ import { errorFromResponse, OxinsiderApiError } from "./errors.js";
38
135
  * }
39
136
  */
40
137
  export async function* streamFeed(client, options = {}) {
138
+ const maxFrameBytes = assertMaxFrameBytes(options.maxFrameBytes ?? DEFAULT_MAX_STREAM_FRAME_BYTES);
41
139
  const apiKey = client.getApiKey();
42
- if (!apiKey) {
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()) {
43
144
  throw new Error("getStream requires an API key (oxi_sk_*)");
44
145
  }
45
146
  const url = buildStreamUrl(client, options);
46
147
  const headers = new Headers({ accept: "text/event-stream" });
47
- headers.set("authorization", `Bearer ${apiKey}`);
148
+ if (apiKey)
149
+ headers.set("authorization", `Bearer ${apiKey}`);
48
150
  if (options.lastEventId !== undefined) {
49
151
  headers.set("last-event-id", String(options.lastEventId));
50
152
  }
@@ -58,45 +160,80 @@ export async function* streamFeed(client, options = {}) {
58
160
  const body = await safeText(response);
59
161
  throw errorFromResponse(response.status, tryParse(body), retryAfterSeconds(response));
60
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
+ }
61
174
  if (!response.body) {
62
175
  throw new Error("Stream response has no readable body");
63
176
  }
64
177
  const reader = response.body.getReader();
65
178
  const decoder = new TextDecoder();
179
+ const encoder = new TextEncoder();
66
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;
67
185
  try {
68
186
  for (;;) {
69
187
  const { value, done } = await reader.read();
70
188
  if (done)
71
189
  break;
72
190
  buffer += decoder.decode(value, { stream: true });
191
+ bufferedBytes += value.byteLength;
73
192
  // SSE frames are separated by a blank line. Handle CRLF and LF.
74
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
+ }
75
200
  while (sepIndex.index !== -1) {
76
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
+ }
77
214
  buffer = buffer.slice(sepIndex.index + sepIndex.length);
78
215
  const parsed = parseSseFrame(rawFrame);
79
216
  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 };
217
+ const frame = decodeStreamFrame(parsed, lastSeq);
218
+ if (frame.kind === "resync") {
219
+ options.onResync?.(frame.marker);
84
220
  }
85
221
  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
- }
222
+ lastSeq = frame.seq;
223
+ if (options.cursor)
224
+ options.cursor.seq = frame.seq;
96
225
  }
226
+ yield frame;
97
227
  }
98
228
  sepIndex = nextFrameBoundary(buffer);
99
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
+ }
100
237
  }
101
238
  }
102
239
  finally {
@@ -115,6 +252,8 @@ export async function* streamFeed(client, options = {}) {
115
252
  }
116
253
  /** Default for `ResilientStreamOptions.maxReconnects`. */
117
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;
118
257
  const STREAM_RECONNECT_BASE_MS = 1_000;
119
258
  const STREAM_RECONNECT_MAX_MS = 30_000;
120
259
  const STREAM_RECONNECT_JITTER_MS = 250;
@@ -133,6 +272,35 @@ export class StreamReconnectsExhaustedError extends Error {
133
272
  this.lastSeq = lastSeq;
134
273
  }
135
274
  }
275
+ /**
276
+ * `streamFeedResilient` was refused with a `Retry-After` 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, or the header's HTTP-date); `cause` is
280
+ * the refusal, whose `retryAt` (from the body's `retry_at`) is the server's
281
+ * own clock reading of the same instant. `lastSeq` is the seq to resume after:
282
+ * pass it as `lastEventId` when you reconnect at `retryAt`. `attempts` is how
283
+ * many consecutive failed connections preceded this one; the refusal itself
284
+ * spent none of `maxReconnects`.
285
+ *
286
+ * Nothing here was clamped: a wait the timer cannot hold is never shortened
287
+ * into an earlier reconnect.
288
+ */
289
+ export class StreamRetryDeferredError extends Error {
290
+ retryAt;
291
+ retryAfterMs;
292
+ lastSeq;
293
+ attempts;
294
+ constructor(retryAfterMs, lastSeq, attempts, cause) {
295
+ const retryAt = new Date(Date.now() + retryAfterMs);
296
+ 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 });
297
+ this.name = "StreamRetryDeferredError";
298
+ this.retryAt = retryAt;
299
+ this.retryAfterMs = retryAfterMs;
300
+ this.lastSeq = lastSeq;
301
+ this.attempts = attempts;
302
+ }
303
+ }
136
304
  /**
137
305
  * A 4xx other than 429 will fail the same way on every reconnect: a bad filter
138
306
  * (400), a key that is invalid (401), lapsed (402), refused (403), or locked
@@ -140,40 +308,49 @@ export class StreamReconnectsExhaustedError extends Error {
140
308
  * has to fix.
141
309
  */
142
310
  function isPermanentStreamError(error) {
311
+ // A protocol error is permanent too (#16248): reconnecting from `lastSeq`
312
+ // replays the same frame while the server retains it, and a wrong media
313
+ // type is the same answer on every connection.
314
+ if (error instanceof StreamProtocolError)
315
+ return true;
316
+ // A terminal frame that said `retry: false` (#16546).
317
+ if (error instanceof OxinsiderApiError && permanentTerminalErrors.has(error)) {
318
+ return true;
319
+ }
143
320
  return (error instanceof OxinsiderApiError &&
144
321
  error.status >= 400 &&
145
322
  error.status < 500 &&
146
323
  error.status !== 429);
147
324
  }
148
- function streamReconnectDelayMs(attempt, cause) {
149
- const retryAfter = cause instanceof OxinsiderApiError &&
325
+ /**
326
+ * The wait the server asked for, in ms, or `null` when the refusal carried
327
+ * no usable `Retry-After` (a network error, a clean close, a 5xx without the
328
+ * header). The header is parsed once, by `retry.ts`, when the error is built.
329
+ */
330
+ function serverRequestedWaitMs(cause) {
331
+ return cause instanceof OxinsiderApiError &&
150
332
  "retryAfterSeconds" in cause &&
151
- typeof cause.retryAfterSeconds === "number"
152
- ? cause.retryAfterSeconds
333
+ typeof cause.retryAfterSeconds === "number" &&
334
+ Number.isFinite(cause.retryAfterSeconds) &&
335
+ cause.retryAfterSeconds >= 0
336
+ ? cause.retryAfterSeconds * 1000
153
337
  : null;
154
- if (retryAfter !== null) {
155
- return Math.max(0, retryAfter * 1000) + Math.random() * STREAM_RECONNECT_JITTER_MS;
156
- }
338
+ }
339
+ /**
340
+ * Milliseconds before reconnect `attempt` (1-based) when the server did not
341
+ * say: a jittered exponential backoff from 1 s capped at 30 s. A server-
342
+ * requested wait is handled by the loop, which checks it against
343
+ * `maxRetryAfterMs` before adding jitter.
344
+ */
345
+ function streamBackoffMs(attempt) {
157
346
  const ceiling = Math.min(STREAM_RECONNECT_MAX_MS, STREAM_RECONNECT_BASE_MS * 2 ** (attempt - 1));
158
347
  return ceiling / 2 + Math.random() * (ceiling / 2);
159
348
  }
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
- });
349
+ function assertMaxRetryAfterMs(value) {
350
+ if (Number.isNaN(value) || value < 0) {
351
+ throw new Error(`maxRetryAfterMs must be a non-negative number of milliseconds or Infinity, got ${String(value)}`);
352
+ }
353
+ return value;
177
354
  }
178
355
  /**
179
356
  * `streamFeed` with the reconnect-and-resume loop built in (#14286).
@@ -181,10 +358,15 @@ function waitUnlessAborted(ms, signal) {
181
358
  * - A clean server close, a network error, a 429 or a 5xx reconnects,
182
359
  * resuming after the last delivered seq (`Last-Event-ID`). Before any frame
183
360
  * 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.
361
+ * - The wait is `Retry-After` (delta-seconds or an HTTP-date) plus jitter when
362
+ * the refusal carries one, otherwise a jittered backoff from 1 s capped at
363
+ * 30 s. A `Retry-After` past `maxRetryAfterMs` (60 s by default) is not
364
+ * waited out: the loop throws `StreamRetryDeferredError` with `retryAt` and
365
+ * `lastSeq`, spending no reconnect, and never clamps the wait to an earlier
366
+ * one (#16249).
186
367
  * - Any other 4xx (400, 401, 402, 403, 423) is thrown at once: it cannot heal
187
- * on its own.
368
+ * on its own. So is `StreamProtocolError` (#16248): a resume from
369
+ * `lastSeq` would replay the malformed frame, so the decision is yours.
188
370
  * - After `maxReconnects` consecutive connections that delivered nothing, it
189
371
  * throws `StreamReconnectsExhaustedError` carrying `lastSeq`.
190
372
  * - `resync` markers are yielded unchanged, and `onResync` still fires. The
@@ -196,7 +378,10 @@ function waitUnlessAborted(ms, signal) {
196
378
  * - No request deadline applies, as with `streamFeed`.
197
379
  *
198
380
  * `options.cursor`, when passed, tracks the last delivered seq across every
199
- * connection.
381
+ * connection -- delivered, not processed (#16247). This loop resumes from it,
382
+ * so a consumer whose handler can fail should drive the stream with
383
+ * `consumeStreamCheckpointed` instead, which resumes from an acknowledged
384
+ * checkpoint and replays the event it failed on.
200
385
  *
201
386
  * @example
202
387
  * const controller = new AbortController();
@@ -210,13 +395,14 @@ function waitUnlessAborted(ms, signal) {
210
395
  * }
211
396
  */
212
397
  export async function* streamFeedResilient(client, options = {}) {
213
- const { maxReconnects = DEFAULT_MAX_STREAM_RECONNECTS, onReconnect, ...streamOptions } = options;
398
+ const { maxReconnects = DEFAULT_MAX_STREAM_RECONNECTS, maxRetryAfterMs = DEFAULT_MAX_STREAM_RETRY_AFTER_MS, onReconnect, ...streamOptions } = options;
214
399
  if (!Number.isInteger(maxReconnects) || maxReconnects < 0) {
215
400
  throw new Error(`maxReconnects must be a non-negative integer, got ${String(maxReconnects)}`);
216
401
  }
402
+ const retryAfterCeilingMs = assertMaxRetryAfterMs(maxRetryAfterMs);
217
403
  // The one configuration error `streamFeed` throws before any request; it
218
404
  // would otherwise count as a transport failure and burn every reconnect.
219
- if (!client.getApiKey()) {
405
+ if (!client.getApiKey() && !client.isSandbox()) {
220
406
  throw new Error("streamFeedResilient requires an API key (oxi_sk_*)");
221
407
  }
222
408
  const signal = streamOptions.signal;
@@ -249,13 +435,24 @@ export async function* streamFeedResilient(client, options = {}) {
249
435
  }
250
436
  if (signal?.aborted)
251
437
  return;
252
- attempt += 1;
253
438
  const lastSeq = cursor.seq ?? toSeq(initialLastEventId);
439
+ // A server-requested wait past the ceiling is deferred to the caller
440
+ // BEFORE it counts as a reconnect: the server said when, and burning the
441
+ // short reconnect budget on a wait we will not hold would be spending it
442
+ // on nothing.
443
+ const requestedMs = serverRequestedWaitMs(cause);
444
+ if (requestedMs !== null && requestedMs > retryAfterCeilingMs) {
445
+ throw new StreamRetryDeferredError(requestedMs, lastSeq, attempt, cause);
446
+ }
447
+ attempt += 1;
254
448
  if (attempt > maxReconnects) {
255
449
  throw new StreamReconnectsExhaustedError(attempt - 1, lastSeq, cause);
256
450
  }
257
451
  onReconnect?.(attempt, lastSeq, cause);
258
- if (!(await waitUnlessAborted(streamReconnectDelayMs(attempt, cause), signal))) {
452
+ const delayMs = requestedMs === null
453
+ ? streamBackoffMs(attempt)
454
+ : requestedMs + Math.random() * STREAM_RECONNECT_JITTER_MS;
455
+ if (!(await waitUnlessAborted(delayMs, signal))) {
259
456
  return;
260
457
  }
261
458
  }
@@ -270,6 +467,13 @@ function toSeq(value) {
270
467
  * Callback-style stream consumer for environments where an async iterator is
271
468
  * awkward. Returns a promise that resolves when the stream ends (or rejects on
272
469
  * error / abort). Pass a `signal` to stop it.
470
+ *
471
+ * Delivery, not processing (#16247): `options.cursor` is written by
472
+ * `streamFeed` BEFORE the frame is yielded, so it has already moved past an
473
+ * event whose `onEvent` then throws. Reconnecting from it skips that event.
474
+ * For a consumer that must not lose work, use `consumeStreamCheckpointed`,
475
+ * which advances an acknowledged checkpoint only after the handler and your
476
+ * own durable write have both resolved.
273
477
  */
274
478
  export async function consumeStream(client, handlers, options = {}) {
275
479
  for await (const frame of streamFeed(client, options)) {
@@ -281,8 +485,230 @@ export async function consumeStream(client, handlers, options = {}) {
281
485
  }
282
486
  }
283
487
  }
488
+ /**
489
+ * `consumeStreamCheckpointed` gave up after `maxHandlerRetries` consecutive
490
+ * failures at the same sequence (#16247). `checkpoint` is the acknowledged
491
+ * sequence, still behind the failing one, and `replayFrom` is the point a
492
+ * later resume should pass as `lastEventId` to deliver the failed frame
493
+ * again while the server retains it. `cause` is the last rejection your
494
+ * handler produced.
495
+ */
496
+ export class StreamHandlerFailedError extends Error {
497
+ seq;
498
+ stage;
499
+ attempts;
500
+ checkpoint;
501
+ replayFrom;
502
+ constructor(detail, cause) {
503
+ 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 });
504
+ this.name = "StreamHandlerFailedError";
505
+ this.seq = detail.seq;
506
+ this.stage = detail.stage;
507
+ this.attempts = detail.attempts;
508
+ this.checkpoint = detail.checkpoint;
509
+ this.replayFrom = detail.replayFrom;
510
+ }
511
+ }
512
+ /** Default for `CheckpointedStreamOptions.maxHandlerRetries`. */
513
+ export const DEFAULT_MAX_HANDLER_RETRIES = 3;
514
+ /**
515
+ * The resume point that replays `seq` itself when nothing has been
516
+ * acknowledged yet.
517
+ *
518
+ * `Last-Event-ID` is an EXCLUSIVE lower bound on the server: the replay
519
+ * window is `latest - requested` entries taken from the tail of the retained
520
+ * history, so a resume at `S - 1` delivers `S` onward and nothing older
521
+ * (`shared_feed_history_since`, `backend/crates/app-core/src/feed_publisher.rs`;
522
+ * `backend/src/api_v1/handlers/stream.rs` builds the plan from it, read
523
+ * 2026-09-22). Sequences are a cluster-wide integer counter, so nothing sits
524
+ * between `S - 1` and `S`. A non-integer or non-positive sequence gets no
525
+ * step-back: there is no id we can name that is certainly below it.
526
+ */
527
+ function replayPointBefore(seq) {
528
+ return seq !== undefined && Number.isInteger(seq) && seq >= 1
529
+ ? seq - 1
530
+ : undefined;
531
+ }
532
+ /**
533
+ * The stream consumer for work that must not be silently dropped (#16247):
534
+ * it separates the received cursor from an acknowledged processing
535
+ * checkpoint, and only the checkpoint decides where a reconnect resumes.
536
+ *
537
+ * Ordering, per frame, for `kind: "event"`:
538
+ * 1. `onEvent` is awaited. Nothing is read from the connection while it
539
+ * runs, so a slow handler is backpressure on the socket, never a queue:
540
+ * at most one frame is ever in flight and nothing is buffered on your
541
+ * behalf.
542
+ * 2. `onCheckpoint(seq, "event")` is awaited -- your durable write.
543
+ * 3. Only then does `checkpoint.seq` become `seq`, and only then does the
544
+ * next reconnect resume after it.
545
+ * A rejection at step 1 or 2 leaves the checkpoint BEFORE the event: the
546
+ * connection is closed, a jittered backoff runs, and the consumer reconnects
547
+ * from the checkpoint, which replays the unacknowledged event while the
548
+ * server retains it. `onHandlerError` reports each failure with its attempt
549
+ * number, the replay point and whether another attempt follows. After
550
+ * `maxHandlerRetries` consecutive failures at the same sequence it throws
551
+ * `StreamHandlerFailedError`, carrying the unadvanced checkpoint.
552
+ *
553
+ * A `resync` marker is the same barrier: `onResync` is awaited, then
554
+ * `onCheckpoint(seq, "resync")`, and an interrupted refresh commits nothing,
555
+ * so the recovery is retried instead of being recorded as done. (The
556
+ * fire-and-forget `StreamOptions.onResync` notification is not awaited and is
557
+ * not a barrier -- do not use it as one.)
558
+ *
559
+ * Delivery is AT-LEAST-ONCE. A replay re-delivers every unacknowledged frame,
560
+ * and a handler that succeeded but whose `onCheckpoint` write failed sees its
561
+ * event again. Deduplicate on `seq` (it is monotonic per cluster) or make the
562
+ * side effect idempotent; nothing here can promise exactly-once side effects.
563
+ *
564
+ * `options.cursor` is untouched in meaning: it still tracks the last
565
+ * DELIVERED seq, including frames whose handler later failed, and it rewinds
566
+ * when a replay re-delivers them. Read it for transport progress; never as
567
+ * proof that the work was done.
568
+ *
569
+ * Transport recovery, `Retry-After` handling, permanent 4xx and
570
+ * `StreamProtocolError` behave exactly as in `streamFeedResilient`, which
571
+ * this drives. `StreamReconnectsExhaustedError` and
572
+ * `StreamRetryDeferredError` surface unchanged; their `lastSeq` is the
573
+ * acknowledged checkpoint, because a reconnect can only happen between
574
+ * handlers. Aborting `signal` ends the consumer without throwing: a handler
575
+ * already running is awaited (it is not cancelled for you -- pass the same
576
+ * signal into your own work if you want that), and if it succeeds its
577
+ * checkpoint is committed before the consumer returns.
578
+ *
579
+ * @example
580
+ * const checkpoint = { seq: await loadCheckpoint() };
581
+ * await consumeStreamCheckpointed(client, {
582
+ * onEvent: async (envelope, seq) => { await applyOnce(seq, envelope); },
583
+ * onResync: async () => { await refetchCurrentState(); },
584
+ * onCheckpoint: async (seq) => { await saveCheckpoint(seq); },
585
+ * }, {
586
+ * event: ["WhaleTradesInserted"],
587
+ * lastEventId: checkpoint.seq,
588
+ * checkpoint,
589
+ * signal: controller.signal,
590
+ * onHandlerError: (error, failure) =>
591
+ * console.warn("replaying", failure.seq, failure.attempt, failure.willRetry, error),
592
+ * });
593
+ */
594
+ export async function consumeStreamCheckpointed(client, handlers, options = {}) {
595
+ const { checkpoint = {}, maxHandlerRetries = DEFAULT_MAX_HANDLER_RETRIES, onHandlerError, ...streamOptions } = options;
596
+ if (!Number.isInteger(maxHandlerRetries) || maxHandlerRetries < 0) {
597
+ throw new Error(`maxHandlerRetries must be a non-negative integer, got ${String(maxHandlerRetries)}`);
598
+ }
599
+ const signal = streamOptions.signal;
600
+ // The caller's received cursor, if they passed one. It keeps its delivery
601
+ // meaning: this consumer mirrors every frame into it and never reads it
602
+ // back as a resume point.
603
+ const received = streamOptions.cursor;
604
+ const initialLastEventId = toSeq(streamOptions.lastEventId);
605
+ // Where the next connection resumes. It starts at the acknowledged
606
+ // checkpoint (or the caller's `lastEventId`) and only ever moves to a
607
+ // sequence whose processing completed.
608
+ let resumeAfter = checkpoint.seq ?? initialLastEventId;
609
+ let failingSeq;
610
+ let failures = 0;
611
+ for (;;) {
612
+ if (signal?.aborted)
613
+ return;
614
+ let pending;
615
+ // A cursor private to this connection: `streamFeedResilient` reads it
616
+ // back as its own resume point across transport reconnects, so the
617
+ // caller's received cursor must never be handed to it -- that cursor has
618
+ // already moved past an unacknowledged event, and lending it would make
619
+ // the replay resume after the frame it exists to redeliver.
620
+ const delivered = { seq: resumeAfter };
621
+ for await (const frame of streamFeedResilient(client, {
622
+ ...streamOptions,
623
+ cursor: delivered,
624
+ lastEventId: resumeAfter,
625
+ })) {
626
+ if (received && delivered.seq !== undefined)
627
+ received.seq = delivered.seq;
628
+ if (frame.kind === "event") {
629
+ try {
630
+ await handlers.onEvent(frame.envelope, frame.seq);
631
+ }
632
+ catch (error) {
633
+ pending = { error, stage: "event", seq: frame.seq };
634
+ break;
635
+ }
636
+ try {
637
+ await handlers.onCheckpoint?.(frame.seq, "event");
638
+ }
639
+ catch (error) {
640
+ pending = { error, stage: "checkpoint", seq: frame.seq };
641
+ break;
642
+ }
643
+ checkpoint.seq = frame.seq;
644
+ resumeAfter = frame.seq;
645
+ }
646
+ else {
647
+ try {
648
+ await handlers.onResync?.(frame.marker, frame.seq);
649
+ }
650
+ catch (error) {
651
+ pending = { error, stage: "resync", seq: frame.seq ?? undefined };
652
+ break;
653
+ }
654
+ if (frame.seq !== null) {
655
+ try {
656
+ await handlers.onCheckpoint?.(frame.seq, "resync");
657
+ }
658
+ catch (error) {
659
+ pending = { error, stage: "checkpoint", seq: frame.seq };
660
+ break;
661
+ }
662
+ checkpoint.seq = frame.seq;
663
+ resumeAfter = frame.seq;
664
+ }
665
+ }
666
+ failures = 0;
667
+ failingSeq = undefined;
668
+ if (signal?.aborted)
669
+ return;
670
+ }
671
+ // `streamFeedResilient` only ends without throwing when the signal was
672
+ // aborted, so a loop that ends with nothing pending is a clean stop.
673
+ if (!pending)
674
+ return;
675
+ const failedSeq = pending.seq;
676
+ if (failures > 0 && failedSeq === failingSeq) {
677
+ failures += 1;
678
+ }
679
+ else {
680
+ failingSeq = failedSeq;
681
+ failures = 1;
682
+ }
683
+ resumeAfter =
684
+ checkpoint.seq ?? initialLastEventId ?? replayPointBefore(failedSeq);
685
+ const willRetry = failures <= maxHandlerRetries;
686
+ onHandlerError?.(pending.error, {
687
+ seq: failedSeq,
688
+ stage: pending.stage,
689
+ attempt: failures,
690
+ willRetry,
691
+ replayFrom: resumeAfter,
692
+ checkpoint: checkpoint.seq,
693
+ });
694
+ if (!willRetry) {
695
+ throw new StreamHandlerFailedError({
696
+ seq: failedSeq,
697
+ stage: pending.stage,
698
+ attempts: failures,
699
+ checkpoint: checkpoint.seq,
700
+ replayFrom: resumeAfter,
701
+ }, pending.error);
702
+ }
703
+ if (!(await waitUnlessAborted(streamBackoffMs(failures), signal))) {
704
+ return;
705
+ }
706
+ }
707
+ }
284
708
  function buildStreamUrl(client, options) {
285
- const url = new URL("api/v1/stream", `${client.getBaseUrl()}/`);
709
+ // Through the same resolver as `buildUrl`, so a path-bearing base such as
710
+ // the sandbox's `/sandbox` lands in the same place for REST and SSE.
711
+ const url = resolveApiUrl(client.getBaseUrl(), "/api/v1/stream");
286
712
  if (options.event && options.event.length > 0) {
287
713
  url.searchParams.set("event", options.event.join(","));
288
714
  }
@@ -363,6 +789,113 @@ function nextFrameBoundary(buffer) {
363
789
  ? candidate
364
790
  : best);
365
791
  }
792
+ /**
793
+ * Turn one parsed SSE frame into a `StreamEvent`, or throw
794
+ * `StreamProtocolError` (#16248). `lastSeq` is only carried onto the error.
795
+ */
796
+ export function decodeStreamFrame(parsed, lastSeq) {
797
+ const detail = {
798
+ lastSeq,
799
+ frameId: parsed.id,
800
+ event: parsed.event,
801
+ bytes: new TextEncoder().encode(parsed.data).byteLength,
802
+ };
803
+ let payload;
804
+ try {
805
+ payload = parsed.data === "" ? undefined : JSON.parse(parsed.data);
806
+ }
807
+ catch {
808
+ throw new StreamProtocolError("invalid_json", detail);
809
+ }
810
+ if (parsed.event === "error") {
811
+ throw terminalStreamError(payload, detail);
812
+ }
813
+ if (parsed.event === "resync") {
814
+ if (!isPlainObject(payload)) {
815
+ throw new StreamProtocolError("invalid_resync", detail);
816
+ }
817
+ if ("type" in payload && payload.type !== "resync") {
818
+ throw new StreamProtocolError("invalid_resync", detail);
819
+ }
820
+ return {
821
+ kind: "resync",
822
+ seq: parsed.id ?? null,
823
+ marker: payload,
824
+ };
825
+ }
826
+ if (payload === undefined) {
827
+ throw new StreamProtocolError("invalid_json", detail);
828
+ }
829
+ if (!isPlainObject(payload)) {
830
+ throw new StreamProtocolError("invalid_envelope", detail);
831
+ }
832
+ const seq = typeof payload.seq === "number" && Number.isFinite(payload.seq)
833
+ ? payload.seq
834
+ : parsed.id;
835
+ if (seq === undefined || !Number.isFinite(seq)) {
836
+ throw new StreamProtocolError("unusable_sequence", detail);
837
+ }
838
+ return { kind: "event", seq, envelope: payload };
839
+ }
840
+ /**
841
+ * The HTTP status the reconnect is answered with, for each code a terminal
842
+ * `event: error` frame carries (#16546). The frame has no status of its own;
843
+ * this mirrors `ApiError::status_code` in `backend/crates/api-core`, so the
844
+ * error thrown mid-stream is the one the next request would get.
845
+ */
846
+ const STREAM_ERROR_CODE_STATUS = {
847
+ bad_request: 400,
848
+ invalid_api_key: 401,
849
+ subscription_required: 402,
850
+ forbidden: 403,
851
+ insufficient_scope: 403,
852
+ not_found: 404,
853
+ request_timeout: 408,
854
+ account_locked: 423,
855
+ rate_limited: 429,
856
+ internal_error: 500,
857
+ rate_limit_unavailable: 503,
858
+ };
859
+ const STREAM_ERROR_UNAVAILABLE_REASONS = new Set([
860
+ "database_unavailable",
861
+ "read_model_warming",
862
+ "request_accounting_unavailable",
863
+ ]);
864
+ /**
865
+ * Errors built from a terminal frame that said `retry: false`: permanent
866
+ * whatever their status, so no consumer reconnects on them (#16546).
867
+ */
868
+ const permanentTerminalErrors = new WeakSet();
869
+ /**
870
+ * The typed error a terminal `event: error` frame stands for (#16546).
871
+ *
872
+ * The backend ends `/api/v1/stream` with `{ "type": "error", "error": {...},
873
+ * "retry": <bool> }` when the key is revoked, the account lapses, or the
874
+ * credential store cannot confirm the key. The frame is never a feed event:
875
+ * it is thrown, so it reaches no handler and moves no cursor or checkpoint.
876
+ * `retry: false` is permanent; `retry: true` goes through the resilient
877
+ * consumer's reconnect path like any 5xx.
878
+ */
879
+ function terminalStreamError(payload, detail) {
880
+ if (!isPlainObject(payload) || !isPlainObject(payload.error)) {
881
+ return new StreamProtocolError("invalid_envelope", detail);
882
+ }
883
+ const body = payload.error;
884
+ const retry = payload.retry === true;
885
+ const code = typeof body.code === "string" ? body.code : undefined;
886
+ const reason = typeof body.reason === "string" ? body.reason : undefined;
887
+ const status = reason !== undefined && STREAM_ERROR_UNAVAILABLE_REASONS.has(reason)
888
+ ? 503
889
+ : ((code !== undefined ? STREAM_ERROR_CODE_STATUS[code] : undefined) ??
890
+ (retry ? 503 : 400));
891
+ const error = errorFromResponse(status, { object: "error", error: body });
892
+ if (!retry)
893
+ permanentTerminalErrors.add(error);
894
+ return error;
895
+ }
896
+ function isPlainObject(value) {
897
+ return typeof value === "object" && value !== null && !Array.isArray(value);
898
+ }
366
899
  function tryParse(text) {
367
900
  if (text === null || text === "")
368
901
  return null;
@@ -381,11 +914,4 @@ async function safeText(response) {
381
914
  return "";
382
915
  }
383
916
  }
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
917
  //# sourceMappingURL=stream.js.map