@0xinsider/mcp 2.0.1 → 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.
- package/README.md +27 -7
- package/dist/catalog.generated.d.ts +4 -0
- package/dist/catalog.generated.d.ts.map +1 -1
- package/dist/catalog.generated.js +5 -0
- package/dist/catalog.generated.js.map +1 -1
- package/dist/cli-lib.d.ts +1 -0
- package/dist/cli-lib.d.ts.map +1 -1
- package/dist/cli-lib.js +48 -8
- package/dist/cli-lib.js.map +1 -1
- package/dist/client.d.ts +36 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +42 -4
- package/dist/client.js.map +1 -1
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/product-cli/client.d.ts +3 -2
- package/dist/product-cli/client.d.ts.map +1 -1
- package/dist/product-cli/client.js.map +1 -1
- package/dist/sdk/client.d.ts +705 -131
- package/dist/sdk/client.d.ts.map +1 -1
- package/dist/sdk/client.js +719 -125
- package/dist/sdk/client.js.map +1 -1
- package/dist/sdk/errors.d.ts +42 -4
- package/dist/sdk/errors.d.ts.map +1 -1
- package/dist/sdk/errors.js +103 -2
- package/dist/sdk/errors.js.map +1 -1
- package/dist/sdk/index.d.ts +9 -8
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/index.js +6 -4
- package/dist/sdk/index.js.map +1 -1
- package/dist/sdk/pagination.d.ts +133 -15
- package/dist/sdk/pagination.d.ts.map +1 -1
- package/dist/sdk/pagination.js +132 -34
- package/dist/sdk/pagination.js.map +1 -1
- package/dist/sdk/retry.d.ts +68 -0
- package/dist/sdk/retry.d.ts.map +1 -0
- package/dist/sdk/retry.js +125 -0
- package/dist/sdk/retry.js.map +1 -0
- package/dist/sdk/schema.d.ts +1536 -430
- package/dist/sdk/schema.d.ts.map +1 -1
- package/dist/sdk/stream.d.ts +318 -8
- package/dist/sdk/stream.d.ts.map +1 -1
- package/dist/sdk/stream.js +503 -56
- package/dist/sdk/stream.js.map +1 -1
- package/dist/sdk/webhooks.d.ts +18 -3
- package/dist/sdk/webhooks.d.ts.map +1 -1
- package/dist/sdk/webhooks.js +13 -1
- package/dist/sdk/webhooks.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +2 -1
- package/dist/server.js.map +1 -1
- package/dist/tools/explore-markets.d.ts.map +1 -1
- package/dist/tools/explore-markets.js +2 -5
- package/dist/tools/explore-markets.js.map +1 -1
- package/dist/tools/get-insider-radar.d.ts.map +1 -1
- package/dist/tools/get-insider-radar.js +3 -10
- package/dist/tools/get-insider-radar.js.map +1 -1
- package/dist/tools/get-large-positions.d.ts.map +1 -1
- package/dist/tools/get-large-positions.js +3 -10
- package/dist/tools/get-large-positions.js.map +1 -1
- package/dist/tools/get-leaderboard.d.ts.map +1 -1
- package/dist/tools/get-leaderboard.js +3 -10
- package/dist/tools/get-leaderboard.js.map +1 -1
- package/dist/tools/get-market-intel.d.ts.map +1 -1
- package/dist/tools/get-market-intel.js +3 -6
- package/dist/tools/get-market-intel.js.map +1 -1
- package/dist/tools/get-position-timeline.d.ts.map +1 -1
- package/dist/tools/get-position-timeline.js +3 -10
- package/dist/tools/get-position-timeline.js.map +1 -1
- package/dist/tools/get-positions.d.ts +2 -0
- package/dist/tools/get-positions.d.ts.map +1 -1
- package/dist/tools/get-positions.js +8 -11
- package/dist/tools/get-positions.js.map +1 -1
- package/dist/tools/get-trader-pnl.d.ts.map +1 -1
- package/dist/tools/get-trader-pnl.js +3 -6
- package/dist/tools/get-trader-pnl.js.map +1 -1
- package/dist/tools/get-trader.d.ts.map +1 -1
- package/dist/tools/get-trader.js +3 -6
- package/dist/tools/get-trader.js.map +1 -1
- package/dist/tools/get-trending-wallets.d.ts.map +1 -1
- package/dist/tools/get-trending-wallets.js +3 -10
- package/dist/tools/get-trending-wallets.js.map +1 -1
- package/dist/tools/get-whale-trades.d.ts.map +1 -1
- package/dist/tools/get-whale-trades.js +4 -11
- package/dist/tools/get-whale-trades.js.map +1 -1
- package/dist/tools/pick-of-the-day.d.ts.map +1 -1
- package/dist/tools/pick-of-the-day.js +5 -21
- package/dist/tools/pick-of-the-day.js.map +1 -1
- package/dist/tools/read-parity.d.ts +1 -0
- package/dist/tools/read-parity.d.ts.map +1 -1
- package/dist/tools/read-parity.js +69 -44
- package/dist/tools/read-parity.js.map +1 -1
- package/dist/tools/result.d.ts +31 -0
- package/dist/tools/result.d.ts.map +1 -0
- package/dist/tools/result.js +70 -0
- package/dist/tools/result.js.map +1 -0
- package/dist/tools/search-markets.d.ts.map +1 -1
- package/dist/tools/search-markets.js +16 -11
- package/dist/tools/search-markets.js.map +1 -1
- package/package.json +4 -4
package/dist/sdk/stream.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
87
|
-
if (
|
|
88
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
|
|
313
|
+
typeof cause.retryAfterSeconds === "number" &&
|
|
314
|
+
Number.isFinite(cause.retryAfterSeconds) &&
|
|
315
|
+
cause.retryAfterSeconds >= 0
|
|
316
|
+
? cause.retryAfterSeconds * 1000
|
|
153
317
|
: null;
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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`
|
|
185
|
-
* otherwise a jittered backoff from 1 s capped at
|
|
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
|
-
|
|
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
|
-
|
|
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
|