madeonsol 2.27.0 → 2.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +28 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/stream.d.ts +313 -17
- package/dist/stream.d.ts.map +1 -1
- package/dist/stream.js +672 -31
- package/dist/stream.js.map +1 -1
- package/llms.txt +117 -117
- package/package.json +72 -71
package/dist/stream.js
CHANGED
|
@@ -7,9 +7,36 @@
|
|
|
7
7
|
* tokens do not expire: `getToken()` is called on every (re)connect and returns
|
|
8
8
|
* the same value until the subscription lapses or you explicitly rotate it.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* Recovery (v1 resume): the client remembers a cursor `{instance, seq, ts}` —
|
|
11
|
+
* the last frame whose handlers finished — and on every reconnect asks the
|
|
12
|
+
* server to resume after it (`subscribe {…, resume}`); against an older server
|
|
13
|
+
* it falls back to `replay_since_seq` (same process) / `replay_since_ts`
|
|
14
|
+
* (restarted). Delivery is at-least-once, de-duplicated by event `id`; a
|
|
15
|
+
* `"gap"` event says what could NOT be recovered. `seq` gaps are normal and
|
|
16
|
+
* never mean loss. Close codes: 4001 re-fetches the token (bounded, then
|
|
17
|
+
* `"fatal"`), 4002 (connection limit) waits ≥ 60 s, 4003 stops with
|
|
18
|
+
* `"fatal"`, 4008 (slow consumer) reconnects and resumes.
|
|
19
|
+
*
|
|
20
|
+
* Works in Node (uses the global `WebSocket` on Node 22+, else lazily imports
|
|
21
|
+
* the optional `ws` package) and the browser (native WebSocket). Zero required
|
|
22
|
+
* dependencies.
|
|
12
23
|
*/
|
|
24
|
+
/** Every Solana channel, in the server's order. */
|
|
25
|
+
export const STREAM_CHANNELS = [
|
|
26
|
+
"kol:trades",
|
|
27
|
+
"kol:coordination",
|
|
28
|
+
"kol:first_touches",
|
|
29
|
+
"deployer:alerts",
|
|
30
|
+
"wallet_tracker:events",
|
|
31
|
+
"copytrade:signals",
|
|
32
|
+
"price_alert:events",
|
|
33
|
+
"sniper:deploys",
|
|
34
|
+
"token:graduations",
|
|
35
|
+
"token:prices",
|
|
36
|
+
"token:locks",
|
|
37
|
+
"token:fee_claims",
|
|
38
|
+
"token:surges",
|
|
39
|
+
];
|
|
13
40
|
async function resolveWebSocket(override) {
|
|
14
41
|
if (override)
|
|
15
42
|
return override;
|
|
@@ -18,7 +45,8 @@ async function resolveWebSocket(override) {
|
|
|
18
45
|
// (undici) WebSocket on Node 22+ keeps its TLS socket open after close() —
|
|
19
46
|
// it has no terminate() and no reachable socket handle — which hangs the
|
|
20
47
|
// event loop. In the browser this import rejects and we fall back to the
|
|
21
|
-
// platform's native WebSocket.
|
|
48
|
+
// platform's native WebSocket. Cast the specifier to string so TS doesn't
|
|
49
|
+
// require the module to be installed at build time.
|
|
22
50
|
try {
|
|
23
51
|
const mod = (await import("ws"));
|
|
24
52
|
const impl = mod.default ?? mod.WebSocket;
|
|
@@ -34,25 +62,86 @@ async function resolveWebSocket(override) {
|
|
|
34
62
|
throw new Error("No WebSocket implementation available. On Node < 22, install `ws` (npm i ws) or pass { WebSocketImpl }.");
|
|
35
63
|
}
|
|
36
64
|
const OPEN = 1;
|
|
65
|
+
const HELD_LIVE_CAP = 10000;
|
|
66
|
+
function isThenable(v) {
|
|
67
|
+
return !!v && (typeof v === "object" || typeof v === "function") && typeof v.then === "function";
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Fallback classification for servers that send no `retryable` flag: reasons
|
|
71
|
+
* asking again can never fill.
|
|
72
|
+
*/
|
|
73
|
+
const PERMANENT_GAPS = new Set(["not_reconstructable", "state_stream", "window_exceeded", "ring_truncated", "instance_changed"]);
|
|
74
|
+
/** The server's transient list — a channel with one of these is worth asking again. */
|
|
75
|
+
const TRANSIENT_GAPS = new Set(["backpressure", "closed", "source_busy", "source_error", "late_ingest_possible", "row_cap"]);
|
|
76
|
+
function isPermanentGap(reason) {
|
|
77
|
+
return PERMANENT_GAPS.has(reason);
|
|
78
|
+
}
|
|
79
|
+
/** Move a cursor to `pos`: never back within one instance; seq:null frames only advance time. */
|
|
80
|
+
function stepCursor(c, pos) {
|
|
81
|
+
if (pos.seq !== null && pos.instance) {
|
|
82
|
+
if (c && c.instance === pos.instance)
|
|
83
|
+
return { instance: c.instance, seq: Math.max(c.seq, pos.seq), ts: Math.max(c.ts, pos.ts) };
|
|
84
|
+
return { instance: pos.instance, seq: pos.seq, ts: pos.ts };
|
|
85
|
+
}
|
|
86
|
+
// Unsequenced frame (durable backfill, seq:null): keep the last real seq, advance time.
|
|
87
|
+
return c ? { ...c, ts: Math.max(c.ts, pos.ts) } : null;
|
|
88
|
+
}
|
|
89
|
+
function validCursor(c) {
|
|
90
|
+
if (!c || typeof c !== "object")
|
|
91
|
+
return null;
|
|
92
|
+
const { instance, seq, ts } = c;
|
|
93
|
+
if (typeof instance !== "string" || !instance)
|
|
94
|
+
return null;
|
|
95
|
+
if (typeof seq !== "number" || !Number.isFinite(seq) || seq < 0)
|
|
96
|
+
return null;
|
|
97
|
+
if (typeof ts !== "number" || !Number.isFinite(ts) || ts < 0)
|
|
98
|
+
return null;
|
|
99
|
+
return { instance, seq, ts };
|
|
100
|
+
}
|
|
37
101
|
export class MadeOnSolStream {
|
|
38
102
|
constructor(opts) {
|
|
39
103
|
this.ws = null;
|
|
40
104
|
this.listeners = new Map();
|
|
41
105
|
this.desired = { channels: new Set(), filters: {} };
|
|
42
106
|
this.closedByUser = false;
|
|
107
|
+
this.stopped = false;
|
|
43
108
|
this.attempt = 0;
|
|
109
|
+
this.authFailures = 0;
|
|
44
110
|
this.hbTimer = null;
|
|
45
111
|
this.reconnectTimer = null;
|
|
46
112
|
this.connecting = false;
|
|
113
|
+
/** Server process id of the CURRENT connection (from connected/subscribed). */
|
|
114
|
+
this.serverInstance = null;
|
|
115
|
+
/** Whether this connection already sent its first subscribe (the only one that resumes). */
|
|
116
|
+
this.firstSubscribeSent = false;
|
|
117
|
+
/** true after an incomplete recovery: live frames are not committed until one completes. */
|
|
118
|
+
this.unsafe = false;
|
|
119
|
+
this.seen = new Map();
|
|
120
|
+
this.inflight = [];
|
|
121
|
+
this.recovery = null;
|
|
122
|
+
/** Automatic re-resume after a retryable gap. */
|
|
123
|
+
this.retryTimer = null;
|
|
124
|
+
this.resumeRetries = 0;
|
|
125
|
+
/** The last gap reported (for acceptGap()'s report). */
|
|
126
|
+
this.lastGap = null;
|
|
47
127
|
this.opts = {
|
|
48
128
|
getToken: opts.getToken,
|
|
49
129
|
autoReconnect: opts.autoReconnect ?? true,
|
|
50
130
|
maxBackoffMs: opts.maxBackoffMs ?? 30000,
|
|
51
131
|
heartbeatTimeoutMs: opts.heartbeatTimeoutMs ?? 90000,
|
|
52
132
|
WebSocketImpl: opts.WebSocketImpl,
|
|
133
|
+
dedupeSize: Math.max(0, opts.dedupeSize ?? 10000),
|
|
134
|
+
maxAuthRetries: Math.max(0, opts.maxAuthRetries ?? 3),
|
|
135
|
+
connectionLimitBackoffMs: Math.max(0, opts.connectionLimitBackoffMs ?? 60000),
|
|
136
|
+
resumeDetectMs: Math.max(0, opts.resumeDetectMs ?? 3000),
|
|
137
|
+
legacyReplayTimeoutMs: Math.max(0, opts.legacyReplayTimeoutMs ?? 15000),
|
|
138
|
+
maxResumeRetries: Math.max(0, opts.maxResumeRetries ?? 5),
|
|
139
|
+
resumeRetryDelayMs: Math.max(0, opts.resumeRetryDelayMs ?? 30000),
|
|
140
|
+
onUnrecoverableGap: opts.onUnrecoverableGap === "stop" ? "stop" : "advance",
|
|
53
141
|
};
|
|
142
|
+
this.cursor = validCursor(opts.resume);
|
|
143
|
+
this.progress = this.cursor ? { ...this.cursor } : null;
|
|
54
144
|
}
|
|
55
|
-
/** Register a handler. Use an event name, `"*"` for every event, or a lifecycle event. */
|
|
56
145
|
on(event, fn) {
|
|
57
146
|
if (!this.listeners.has(event))
|
|
58
147
|
this.listeners.set(event, new Set());
|
|
@@ -67,6 +156,44 @@ export class MadeOnSolStream {
|
|
|
67
156
|
this.listeners.get(event)?.delete(fn);
|
|
68
157
|
return this;
|
|
69
158
|
}
|
|
159
|
+
/** The COMMITTED resume cursor (last safe point) — persist this one. Null before the first. */
|
|
160
|
+
getCursor() {
|
|
161
|
+
return this.cursor ? { ...this.cursor } : null;
|
|
162
|
+
}
|
|
163
|
+
/** Received progress: the last handled frame, replayed ones included (NOT safe to resume from). */
|
|
164
|
+
getProgress() {
|
|
165
|
+
return this.progress ? { ...this.progress } : null;
|
|
166
|
+
}
|
|
167
|
+
/** true while an incomplete recovery holds the committed cursor back. */
|
|
168
|
+
isRecoveryIncomplete() {
|
|
169
|
+
return this.unsafe;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Accept the last reported gap: commit the received progress as the cursor
|
|
173
|
+
* and let live frames commit again. Call it after you backfilled the range
|
|
174
|
+
* the `"gap"` event named (or decided you do not need it). It re-reports the
|
|
175
|
+
* gap first, with `source: "manual"` and the range being skipped.
|
|
176
|
+
*/
|
|
177
|
+
acceptGap() {
|
|
178
|
+
this.unsafe = false;
|
|
179
|
+
const p = this.progress;
|
|
180
|
+
const c = this.cursor;
|
|
181
|
+
const moves = !!p && !(c && c.instance === p.instance && c.seq === p.seq && c.ts === p.ts);
|
|
182
|
+
if (this.lastGap) {
|
|
183
|
+
const g = {
|
|
184
|
+
...this.lastGap,
|
|
185
|
+
advancedPastGap: moves,
|
|
186
|
+
source: "manual",
|
|
187
|
+
skipped: { channels: this.lastGap.skipped.channels, from: c ? { ...c } : null, to: moves ? { ...p } : null },
|
|
188
|
+
};
|
|
189
|
+
this.lastGap = null;
|
|
190
|
+
this.emit("gap", g);
|
|
191
|
+
}
|
|
192
|
+
if (!moves)
|
|
193
|
+
return;
|
|
194
|
+
this.cursor = { ...p };
|
|
195
|
+
this.emit("cursor", { ...p });
|
|
196
|
+
}
|
|
70
197
|
emit(event, data, evt) {
|
|
71
198
|
const set = this.listeners.get(event);
|
|
72
199
|
if (set)
|
|
@@ -77,6 +204,20 @@ export class MadeOnSolStream {
|
|
|
77
204
|
catch { /* user handler */ }
|
|
78
205
|
}
|
|
79
206
|
}
|
|
207
|
+
/** Call every handler for a data frame; collect what they returned (for completion tracking). */
|
|
208
|
+
callHandlers(event, data, evt, out) {
|
|
209
|
+
const set = this.listeners.get(event);
|
|
210
|
+
if (!set)
|
|
211
|
+
return;
|
|
212
|
+
for (const fn of set) {
|
|
213
|
+
try {
|
|
214
|
+
out.push(fn(data, evt));
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
this.emit("error", err);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
80
221
|
/** Subscribe to one or more channels (connects on first call). Optional server-side filters. */
|
|
81
222
|
subscribe(channels, filters) {
|
|
82
223
|
for (const c of channels)
|
|
@@ -98,10 +239,15 @@ export class MadeOnSolStream {
|
|
|
98
239
|
}
|
|
99
240
|
return this;
|
|
100
241
|
}
|
|
101
|
-
/** Open the connection (also called implicitly by subscribe). */
|
|
242
|
+
/** Open the connection (also called implicitly by subscribe). Restarts a stream that went `"fatal"`. */
|
|
102
243
|
async connect() {
|
|
103
244
|
if (this.connecting || (this.ws && this.ws.readyState === OPEN))
|
|
104
245
|
return;
|
|
246
|
+
if (this.stopped) {
|
|
247
|
+
this.stopped = false;
|
|
248
|
+
this.authFailures = 0;
|
|
249
|
+
this.attempt = 0;
|
|
250
|
+
}
|
|
105
251
|
this.closedByUser = false;
|
|
106
252
|
this.connecting = true;
|
|
107
253
|
try {
|
|
@@ -109,29 +255,46 @@ export class MadeOnSolStream {
|
|
|
109
255
|
resolveWebSocket(this.opts.WebSocketImpl),
|
|
110
256
|
this.opts.getToken(),
|
|
111
257
|
]);
|
|
258
|
+
if (this.closedByUser || this.stopped)
|
|
259
|
+
return;
|
|
112
260
|
const url = `${token.ws_url}?token=${encodeURIComponent(token.token)}`;
|
|
113
261
|
const ws = new WS(url);
|
|
114
262
|
this.ws = ws;
|
|
263
|
+
this.serverInstance = null;
|
|
264
|
+
this.firstSubscribeSent = false;
|
|
265
|
+
// The automatic re-resume budget is per CONNECTION (the docs say so).
|
|
266
|
+
this.resumeRetries = 0;
|
|
115
267
|
ws.onopen = () => {
|
|
116
|
-
this.
|
|
268
|
+
if (this.ws !== ws)
|
|
269
|
+
return;
|
|
270
|
+
// The backoff attempt is NOT reset here — only a `subscribed` ack proves
|
|
271
|
+
// the connection is usable (an auth/limit close follows a successful open).
|
|
117
272
|
this.resetHeartbeat();
|
|
118
273
|
if (this.desired.channels.size > 0)
|
|
119
274
|
this.sendSubscribe();
|
|
120
275
|
this.emit("open", undefined);
|
|
121
276
|
};
|
|
122
|
-
ws.onmessage = (ev) => this.
|
|
123
|
-
|
|
277
|
+
ws.onmessage = (ev) => { if (this.ws === ws)
|
|
278
|
+
this.handleMessage(ev.data); };
|
|
279
|
+
ws.onerror = (err) => { if (this.ws === ws)
|
|
280
|
+
this.emit("error", err instanceof Error ? err : new Error("WebSocket error")); };
|
|
124
281
|
ws.onclose = (ev) => {
|
|
125
|
-
this.
|
|
126
|
-
|
|
127
|
-
this.
|
|
128
|
-
if (!this.closedByUser && this.opts.autoReconnect)
|
|
129
|
-
this.scheduleReconnect();
|
|
282
|
+
if (this.ws !== null && this.ws !== ws)
|
|
283
|
+
return; // superseded socket
|
|
284
|
+
this.handleClose(typeof ev?.code === "number" ? ev.code : null, typeof ev?.reason === "string" ? ev.reason : "");
|
|
130
285
|
};
|
|
131
286
|
}
|
|
132
287
|
catch (err) {
|
|
133
288
|
this.emit("error", err);
|
|
134
|
-
if (
|
|
289
|
+
if (this.authFailures > 0) {
|
|
290
|
+
// Token re-fetch after a 4001 failed — counts toward the bounded retries.
|
|
291
|
+
this.authFailures++;
|
|
292
|
+
if (this.authFailures > this.opts.maxAuthRetries) {
|
|
293
|
+
this.fatal(4001, "stream token refresh failed");
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (!this.closedByUser && !this.stopped && this.opts.autoReconnect)
|
|
135
298
|
this.scheduleReconnect();
|
|
136
299
|
}
|
|
137
300
|
finally {
|
|
@@ -146,6 +309,7 @@ export class MadeOnSolStream {
|
|
|
146
309
|
this.reconnectTimer = null;
|
|
147
310
|
}
|
|
148
311
|
this.clearHeartbeat();
|
|
312
|
+
this.dropRecovery();
|
|
149
313
|
const sock = this.ws;
|
|
150
314
|
this.ws = null;
|
|
151
315
|
try {
|
|
@@ -159,14 +323,300 @@ export class MadeOnSolStream {
|
|
|
159
323
|
}
|
|
160
324
|
catch { /* ignore */ }
|
|
161
325
|
}
|
|
162
|
-
|
|
326
|
+
handleClose(code, reason) {
|
|
327
|
+
this.clearHeartbeat();
|
|
328
|
+
this.ws = null;
|
|
329
|
+
this.serverInstance = null;
|
|
330
|
+
// An unfinished recovery is abandoned: its held live frames are dropped
|
|
331
|
+
// undelivered, and replayed frames never moved the cursor, so the next
|
|
332
|
+
// resume starts from the same pre-resume position.
|
|
333
|
+
this.dropRecovery();
|
|
334
|
+
this.emit("close", { code, reason });
|
|
335
|
+
if (this.closedByUser || this.stopped)
|
|
336
|
+
return;
|
|
337
|
+
if (code === 4003) {
|
|
338
|
+
this.fatal(code, reason || "authentication error");
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (code === 4001) {
|
|
342
|
+
// Token rejected (rotated / lapsed): the reconnect re-fetches it via getToken().
|
|
343
|
+
this.authFailures++;
|
|
344
|
+
if (this.authFailures > this.opts.maxAuthRetries) {
|
|
345
|
+
this.fatal(code, reason || "stream token rejected");
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (!this.opts.autoReconnect)
|
|
350
|
+
return;
|
|
351
|
+
if (code === 4002) {
|
|
352
|
+
// Connection limit: another socket holds the slot. Never retry tightly.
|
|
353
|
+
const err = new Error(`stream connection limit reached${reason ? `: ${reason}` : ""}`);
|
|
354
|
+
err.code = 4002;
|
|
355
|
+
err.reason = reason;
|
|
356
|
+
this.emit("error", err);
|
|
357
|
+
this.scheduleReconnect(code, this.opts.connectionLimitBackoffMs);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
// 4008 (slow consumer) and everything else: reconnect and resume from the cursor.
|
|
361
|
+
this.scheduleReconnect(code);
|
|
362
|
+
}
|
|
363
|
+
/** `onUnrecoverableGap: "stop"`: stop the stream and hand the decision to the caller. */
|
|
364
|
+
haltForGap(gap) {
|
|
365
|
+
this.closedByUser = true; // no reconnect; connect() restarts if the caller wants
|
|
366
|
+
if (this.retryTimer) {
|
|
367
|
+
clearTimeout(this.retryTimer);
|
|
368
|
+
this.retryTimer = null;
|
|
369
|
+
}
|
|
370
|
+
this.clearHeartbeat();
|
|
371
|
+
const sock = this.ws;
|
|
372
|
+
this.ws = null;
|
|
373
|
+
try {
|
|
374
|
+
if (typeof sock?.terminate === "function")
|
|
375
|
+
sock.terminate();
|
|
376
|
+
else
|
|
377
|
+
sock?.close(1000, "unrecoverable gap");
|
|
378
|
+
}
|
|
379
|
+
catch { /* ignore */ }
|
|
380
|
+
this.stopped = true;
|
|
381
|
+
this.emit("fatal", { code: null, reason: `unrecoverable gap: ${gap.reason}`, gap });
|
|
382
|
+
}
|
|
383
|
+
fatal(code, reason) {
|
|
384
|
+
this.stopped = true;
|
|
385
|
+
if (this.reconnectTimer) {
|
|
386
|
+
clearTimeout(this.reconnectTimer);
|
|
387
|
+
this.reconnectTimer = null;
|
|
388
|
+
}
|
|
389
|
+
this.emit("fatal", { code, reason });
|
|
390
|
+
}
|
|
391
|
+
sendSubscribe(resumeOverride) {
|
|
163
392
|
const channels = Array.from(this.desired.channels);
|
|
164
|
-
if (channels.length === 0)
|
|
393
|
+
if (channels.length === 0 || !this.ws)
|
|
165
394
|
return;
|
|
166
395
|
const msg = { type: "subscribe", channels };
|
|
167
396
|
if (Object.keys(this.desired.filters).length > 0)
|
|
168
397
|
msg.filters = this.desired.filters;
|
|
169
|
-
|
|
398
|
+
// Only the FIRST subscribe of a connection resumes (or an explicit retry
|
|
399
|
+
// after a retryable gap); a later subscribe adds channels live, and the
|
|
400
|
+
// server replays only the channels named in a subscribe.
|
|
401
|
+
if ((!this.firstSubscribeSent || resumeOverride) && this.cursor && !this.recovery) {
|
|
402
|
+
const from = resumeOverride ?? { ...this.cursor };
|
|
403
|
+
msg.resume = from;
|
|
404
|
+
this.recovery = {
|
|
405
|
+
protocol: "detect", from, channels, request: { resume: from }, acked: false, suppressAck: false,
|
|
406
|
+
instanceChanged: false, start: null, received: 0, delivered: 0, duplicates: 0, held: [], timer: null,
|
|
407
|
+
maxSeq: null, maxTs: null,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
this.firstSubscribeSent = true;
|
|
411
|
+
this.ws.send(JSON.stringify(msg));
|
|
412
|
+
}
|
|
413
|
+
/** The server did not answer `resume` (older deployment): retry with the legacy fields. */
|
|
414
|
+
fallbackToLegacy() {
|
|
415
|
+
const r = this.recovery;
|
|
416
|
+
if (!r || r.protocol !== "detect" || !r.from || !this.ws)
|
|
417
|
+
return;
|
|
418
|
+
if (r.timer) {
|
|
419
|
+
clearTimeout(r.timer);
|
|
420
|
+
r.timer = null;
|
|
421
|
+
}
|
|
422
|
+
r.protocol = "legacy";
|
|
423
|
+
r.instanceChanged = !this.serverInstance || this.serverInstance !== r.from.instance;
|
|
424
|
+
// Same process → its ring still indexes our seq. Restarted → seq restarted, use time.
|
|
425
|
+
const legacy = r.instanceChanged ? { replay_since_ts: r.from.ts } : { replay_since_seq: r.from.seq };
|
|
426
|
+
r.request = legacy;
|
|
427
|
+
r.suppressAck = true;
|
|
428
|
+
const msg = { type: "subscribe", channels: r.channels, ...legacy };
|
|
429
|
+
if (Object.keys(this.desired.filters).length > 0)
|
|
430
|
+
msg.filters = this.desired.filters;
|
|
431
|
+
try {
|
|
432
|
+
this.ws.send(JSON.stringify(msg));
|
|
433
|
+
}
|
|
434
|
+
catch { /* closing */ }
|
|
435
|
+
r.timer = setTimeout(() => this.finishRecovery(null), this.opts.legacyReplayTimeoutMs);
|
|
436
|
+
}
|
|
437
|
+
dropRecovery() {
|
|
438
|
+
if (this.recovery?.timer)
|
|
439
|
+
clearTimeout(this.recovery.timer);
|
|
440
|
+
this.recovery = null;
|
|
441
|
+
if (this.retryTimer) {
|
|
442
|
+
clearTimeout(this.retryTimer);
|
|
443
|
+
this.retryTimer = null;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* A retryable gap: ask the server again on this connection after its
|
|
448
|
+
* retry_after_ms (row_cap resumes from resume_ts_hint). Bounded — the next
|
|
449
|
+
* reconnect resumes anyway.
|
|
450
|
+
*/
|
|
451
|
+
scheduleResumeRetry(retryAfterMs, hintTs) {
|
|
452
|
+
if (this.retryTimer || !this.cursor)
|
|
453
|
+
return;
|
|
454
|
+
if (this.resumeRetries >= this.opts.maxResumeRetries)
|
|
455
|
+
return;
|
|
456
|
+
this.resumeRetries++;
|
|
457
|
+
const delay = retryAfterMs !== null && retryAfterMs >= 0 ? retryAfterMs : this.opts.resumeRetryDelayMs;
|
|
458
|
+
const from = hintTs !== null && hintTs > this.cursor.ts ? { ...this.cursor, ts: hintTs } : { ...this.cursor };
|
|
459
|
+
this.retryTimer = setTimeout(() => {
|
|
460
|
+
this.retryTimer = null;
|
|
461
|
+
if (this.ws && this.ws.readyState === OPEN && !this.recovery)
|
|
462
|
+
this.sendSubscribe(from);
|
|
463
|
+
}, delay);
|
|
464
|
+
}
|
|
465
|
+
finishRecovery(end) {
|
|
466
|
+
const r = this.recovery;
|
|
467
|
+
if (!r)
|
|
468
|
+
return;
|
|
469
|
+
if (r.timer) {
|
|
470
|
+
clearTimeout(r.timer);
|
|
471
|
+
r.timer = null;
|
|
472
|
+
}
|
|
473
|
+
this.recovery = null;
|
|
474
|
+
const reasons = [];
|
|
475
|
+
/** Reasons of the channels the server reported incomplete, with their retryability. */
|
|
476
|
+
const channelReasons = [];
|
|
477
|
+
const retryableChannelReasons = [];
|
|
478
|
+
const gapChannels = {};
|
|
479
|
+
const str = (v) => (typeof v === "string" && v ? v : null);
|
|
480
|
+
const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
|
481
|
+
// A v1 server answers with complete/sent/matched; an older one with count only.
|
|
482
|
+
const v1 = !!end && ("complete" in end || "sent" in end || "matched" in end);
|
|
483
|
+
if (r.start?.replay_truncated === true || end?.replay_truncated === true)
|
|
484
|
+
reasons.push("ring_truncated");
|
|
485
|
+
if (!end)
|
|
486
|
+
reasons.push("replay_timeout");
|
|
487
|
+
else if (v1) {
|
|
488
|
+
if (end.complete === false)
|
|
489
|
+
reasons.push(str(end.reason) ?? "incomplete");
|
|
490
|
+
const chs = end.channels;
|
|
491
|
+
if (chs && typeof chs === "object") {
|
|
492
|
+
for (const [ch, raw] of Object.entries(chs)) {
|
|
493
|
+
// token:prices is a state stream: the server re-sends a snapshot, never a log.
|
|
494
|
+
if (ch === "token:prices")
|
|
495
|
+
continue;
|
|
496
|
+
const info = (raw && typeof raw === "object" ? raw : {});
|
|
497
|
+
const gap = info.gap;
|
|
498
|
+
const late = info.late_ingest_possible === true;
|
|
499
|
+
if (info.complete === false || gap || info.mode === "none" || late) {
|
|
500
|
+
gapChannels[ch] = raw; // raw entry: mode, reason, gap, time_basis, retry_after_ms, …
|
|
501
|
+
const gr = gap && typeof gap === "object" ? gap.reason : gap;
|
|
502
|
+
const chReason = str(info.reason) ?? str(gr) ?? (info.mode === "none" ? "not_reconstructable" : late ? "late_ingest_possible" : "incomplete");
|
|
503
|
+
reasons.push(chReason);
|
|
504
|
+
channelReasons.push(chReason);
|
|
505
|
+
if (info.retryable === true || (info.retryable !== false && TRANSIENT_GAPS.has(chReason)))
|
|
506
|
+
retryableChannelReasons.push(chReason);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
else {
|
|
512
|
+
// Legacy server: `count` is what it meant to send; fewer arrived → it stopped on backpressure.
|
|
513
|
+
if (typeof end.count === "number" && r.received < end.count)
|
|
514
|
+
reasons.push("backpressure");
|
|
515
|
+
// Legacy server + restart: the old process's buffer is gone and there is no durable backfill.
|
|
516
|
+
if (r.protocol === "legacy" && r.instanceChanged)
|
|
517
|
+
reasons.push("instance_changed");
|
|
518
|
+
}
|
|
519
|
+
const uniq = Array.from(new Set(reasons));
|
|
520
|
+
const result = {
|
|
521
|
+
protocol: v1 || r.protocol === "resume" ? "resume" : "legacy",
|
|
522
|
+
from: r.from,
|
|
523
|
+
request: r.request,
|
|
524
|
+
received: r.received,
|
|
525
|
+
delivered: r.delivered,
|
|
526
|
+
duplicates: r.duplicates,
|
|
527
|
+
complete: uniq.length === 0,
|
|
528
|
+
mode: typeof end?.mode === "string" ? end.mode : null,
|
|
529
|
+
resumeReason: typeof end?.resume_reason === "string" ? end.resume_reason : null,
|
|
530
|
+
start: r.start,
|
|
531
|
+
end,
|
|
532
|
+
};
|
|
533
|
+
// Final vs retryable. The server says which (`retryable`): true only when an
|
|
534
|
+
// incomplete channel's reason is transient (backpressure, closed,
|
|
535
|
+
// source_busy, source_error, late_ingest_possible, row_cap). Older servers
|
|
536
|
+
// send no `retryable`; then the reason list decides (isPermanentGap).
|
|
537
|
+
const serverSays = !!end && typeof end.retryable === "boolean";
|
|
538
|
+
const retryable = uniq.length > 0 && (serverSays ? end.retryable === true : !uniq.every(isPermanentGap));
|
|
539
|
+
const permanent = uniq.length > 0 && !retryable;
|
|
540
|
+
// Commit point: complete, or only FINAL gaps (reported once, then treated as
|
|
541
|
+
// complete so the stream never stays stuck on something asking again cannot
|
|
542
|
+
// fill). Retryable: keep the pre-resume cursor, do not commit live frames,
|
|
543
|
+
// and resume again after retry_after_ms (row_cap: from resume_ts_hint).
|
|
544
|
+
// The position the server says is safe to continue from.
|
|
545
|
+
let pos = null;
|
|
546
|
+
if (!retryable) {
|
|
547
|
+
let seq;
|
|
548
|
+
let cts;
|
|
549
|
+
if (v1) {
|
|
550
|
+
// {seq: last_seq ?? previous, ts: last_ts ?? previous}
|
|
551
|
+
seq = num(end?.last_seq);
|
|
552
|
+
cts = num(end?.last_ts) ?? (seq !== null ? this.cursor?.ts ?? null : null);
|
|
553
|
+
}
|
|
554
|
+
else {
|
|
555
|
+
// Older servers: held live frames (not yet handled) may sit below live_from_seq.
|
|
556
|
+
const liveFrom = num(end?.live_from_seq);
|
|
557
|
+
const heldMin = r.held.reduce((m, f) => (typeof f.seq === "number" ? (m === null ? f.seq : Math.min(m, f.seq)) : m), null);
|
|
558
|
+
seq = r.maxSeq ?? (heldMin !== null ? heldMin - 1 : liveFrom !== null ? liveFrom - 1 : null);
|
|
559
|
+
cts = r.maxTs ?? this.cursor?.ts ?? null;
|
|
560
|
+
}
|
|
561
|
+
if (cts !== null)
|
|
562
|
+
pos = { instance: this.serverInstance, seq: seq !== null && seq >= 0 ? seq : null, ts: cts };
|
|
563
|
+
}
|
|
564
|
+
// Continuing past a FINAL gap is the SDK's own decision, never the user's
|
|
565
|
+
// approval: it is reported on the gap event (advancedPastGap / skipped) and
|
|
566
|
+
// `onUnrecoverableGap: "stop"` turns it off.
|
|
567
|
+
// resume_ts_hint is a row_cap device: it says "everything up to here was
|
|
568
|
+
// sent for the capped channel". If another channel is incomplete for a
|
|
569
|
+
// RETRYABLE reason (source_error, source_busy, …), resuming from the hint
|
|
570
|
+
// would step past its unread range and the next reply would claim complete.
|
|
571
|
+
// Channels with a FINAL gap are ignored here: asking again never recovers
|
|
572
|
+
// them anyway, and the gap event reports them. Same predicate as the
|
|
573
|
+
// server, checked here so the client never depends on it.
|
|
574
|
+
const capCandidates = retryableChannelReasons.length > 0
|
|
575
|
+
? retryableChannelReasons
|
|
576
|
+
: channelReasons.length > 0 ? [] : uniq.filter((x) => TRANSIENT_GAPS.has(x));
|
|
577
|
+
const capOnly = capCandidates.length > 0 && capCandidates.every((x) => x === "row_cap");
|
|
578
|
+
const exhausted = retryable && this.resumeRetries >= this.opts.maxResumeRetries;
|
|
579
|
+
const strict = uniq.length > 0 && !retryable && this.opts.onUnrecoverableGap === "stop";
|
|
580
|
+
const willAdvance = !retryable && !strict;
|
|
581
|
+
this.emit("replay", result);
|
|
582
|
+
let gap = null;
|
|
583
|
+
if (uniq.length > 0) {
|
|
584
|
+
gap = {
|
|
585
|
+
reason: uniq[0], reasons: uniq, permanent, retryable,
|
|
586
|
+
retryAfterMs: num(end?.retry_after_ms), resumeTsHint: num(end?.resume_ts_hint),
|
|
587
|
+
channels: gapChannels, from: r.from, replay: result, exhausted,
|
|
588
|
+
limits: (end?.limits && typeof end.limits === "object" ? end.limits : r.start?.limits && typeof r.start.limits === "object" ? r.start.limits : null),
|
|
589
|
+
// What the client does about it — always reported BEFORE it happens.
|
|
590
|
+
advancedPastGap: willAdvance,
|
|
591
|
+
source: "auto",
|
|
592
|
+
skipped: {
|
|
593
|
+
channels: Object.keys(gapChannels),
|
|
594
|
+
from: this.cursor ? { ...this.cursor } : null,
|
|
595
|
+
to: willAdvance && pos ? stepCursor(this.cursor, pos) : null,
|
|
596
|
+
},
|
|
597
|
+
};
|
|
598
|
+
this.lastGap = gap;
|
|
599
|
+
this.emit("gap", gap);
|
|
600
|
+
}
|
|
601
|
+
if (willAdvance) {
|
|
602
|
+
this.unsafe = false;
|
|
603
|
+
this.resumeRetries = 0;
|
|
604
|
+
if (pos)
|
|
605
|
+
this.enqueue(pos, true);
|
|
606
|
+
}
|
|
607
|
+
else if (retryable) {
|
|
608
|
+
this.unsafe = true;
|
|
609
|
+
if (serverSays)
|
|
610
|
+
this.scheduleResumeRetry(num(end?.retry_after_ms), capOnly ? num(end?.resume_ts_hint) : null);
|
|
611
|
+
}
|
|
612
|
+
else {
|
|
613
|
+
// strict: stop instead of skipping what cannot be recovered.
|
|
614
|
+
this.unsafe = true;
|
|
615
|
+
this.haltForGap(gap);
|
|
616
|
+
}
|
|
617
|
+
// Live frames that arrived during a client-side replay go out now, after it.
|
|
618
|
+
for (const f of r.held)
|
|
619
|
+
this.deliver(f);
|
|
170
620
|
}
|
|
171
621
|
handleMessage(raw) {
|
|
172
622
|
let msg;
|
|
@@ -178,31 +628,221 @@ export class MadeOnSolStream {
|
|
|
178
628
|
this.emit("error", new Error("Failed to parse stream message"));
|
|
179
629
|
return;
|
|
180
630
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
631
|
+
switch (msg.type) {
|
|
632
|
+
case "heartbeat":
|
|
633
|
+
this.resetHeartbeat();
|
|
634
|
+
this.emit("heartbeat", msg.ts);
|
|
635
|
+
return;
|
|
636
|
+
case "connected":
|
|
637
|
+
if (typeof msg.instance === "string")
|
|
638
|
+
this.serverInstance = msg.instance;
|
|
639
|
+
// Nothing to subscribe to → this frame is as far as a healthy connection gets.
|
|
640
|
+
if (this.desired.channels.size === 0) {
|
|
641
|
+
this.attempt = 0;
|
|
642
|
+
this.authFailures = 0;
|
|
643
|
+
}
|
|
644
|
+
return;
|
|
645
|
+
case "subscribed": {
|
|
646
|
+
if (typeof msg.instance === "string")
|
|
647
|
+
this.serverInstance = msg.instance;
|
|
648
|
+
this.attempt = 0;
|
|
649
|
+
this.authFailures = 0;
|
|
650
|
+
const r = this.recovery;
|
|
651
|
+
if (r && r.suppressAck) {
|
|
652
|
+
r.suppressAck = false;
|
|
653
|
+
return;
|
|
654
|
+
} // ack of our own fallback subscribe
|
|
655
|
+
this.emit("subscribed", msg.channels);
|
|
656
|
+
if (r && r.protocol === "detect" && !r.acked) {
|
|
657
|
+
r.acked = true;
|
|
658
|
+
const echo = msg.resume;
|
|
659
|
+
if (echo && typeof echo === "object" && echo.accepted === false) {
|
|
660
|
+
// Refused (e.g. replay_in_progress): no replay follows, and this is
|
|
661
|
+
// a v1 server — no waiting, no legacy fallback. The server's own
|
|
662
|
+
// warning frame explains why. Nothing was recovered, so the
|
|
663
|
+
// committed cursor must not move until a later recovery completes.
|
|
664
|
+
this.dropRecovery();
|
|
665
|
+
this.unsafe = true;
|
|
666
|
+
}
|
|
667
|
+
else if ("resume" in msg)
|
|
668
|
+
r.protocol = "resume"; // server echoed resume: it understood
|
|
669
|
+
else
|
|
670
|
+
r.timer = setTimeout(() => this.fallbackToLegacy(), this.opts.resumeDetectMs);
|
|
671
|
+
}
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
case "replay_start": {
|
|
675
|
+
let r = this.recovery;
|
|
676
|
+
if (!r) {
|
|
677
|
+
// A replay we did not ask for in this state (e.g. a late answer) — track it anyway.
|
|
678
|
+
r = this.recovery = {
|
|
679
|
+
protocol: "resume", from: null, channels: [], request: {}, acked: true, suppressAck: false,
|
|
680
|
+
instanceChanged: false, start: null, received: 0, delivered: 0, duplicates: 0, held: [], timer: null,
|
|
681
|
+
maxSeq: null, maxTs: null,
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
if (r.protocol === "detect") {
|
|
685
|
+
r.protocol = "resume";
|
|
686
|
+
if (r.timer) {
|
|
687
|
+
clearTimeout(r.timer);
|
|
688
|
+
r.timer = null;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
r.start = msg;
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
case "replay_end":
|
|
695
|
+
this.finishRecovery(msg);
|
|
696
|
+
return;
|
|
697
|
+
case "warning":
|
|
698
|
+
if (msg.code === "channels_revoked") {
|
|
699
|
+
// The server dropped these (e.g. plan downgrade): stop re-subscribing them.
|
|
700
|
+
const names = new Set();
|
|
701
|
+
if (Array.isArray(msg.channels))
|
|
702
|
+
for (const c of msg.channels)
|
|
703
|
+
if (typeof c === "string")
|
|
704
|
+
names.add(c);
|
|
705
|
+
if (Array.isArray(msg.revoked)) {
|
|
706
|
+
for (const x of msg.revoked) {
|
|
707
|
+
if (typeof x === "string")
|
|
708
|
+
names.add(x);
|
|
709
|
+
else if (x && typeof x === "object" && typeof x.channel === "string")
|
|
710
|
+
names.add(x.channel);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
for (const c of names)
|
|
714
|
+
this.desired.channels.delete(c);
|
|
715
|
+
}
|
|
716
|
+
// Never swallow a server warning: a rejected/revoked channel is silent.
|
|
717
|
+
this.emit("warning", msg);
|
|
718
|
+
return;
|
|
719
|
+
default:
|
|
720
|
+
break;
|
|
721
|
+
}
|
|
722
|
+
if (!msg.channel || !msg.event)
|
|
184
723
|
return;
|
|
724
|
+
// Bus-recovered frames (recovered:"bus") are re-sent live, not part of a replay.
|
|
725
|
+
const inReplay = msg.replayed === true && msg.recovered !== "bus";
|
|
726
|
+
const r = this.recovery;
|
|
727
|
+
if (r) {
|
|
728
|
+
if (!inReplay && r.protocol === "detect" && r.acked)
|
|
729
|
+
this.fallbackToLegacy(); // live before replay_start → old server
|
|
730
|
+
if (!inReplay && r.protocol === "legacy") {
|
|
731
|
+
if (r.held.length < HELD_LIVE_CAP) {
|
|
732
|
+
r.held.push(msg);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
// Too much live traffic to hold: stop holding, deliver in arrival order.
|
|
736
|
+
const held = r.held;
|
|
737
|
+
r.held = [];
|
|
738
|
+
for (const f of held)
|
|
739
|
+
this.deliver(f);
|
|
740
|
+
}
|
|
741
|
+
if (inReplay)
|
|
742
|
+
r.received++;
|
|
743
|
+
}
|
|
744
|
+
this.deliver(msg);
|
|
745
|
+
}
|
|
746
|
+
/** Dedupe by id, hand the frame to the handlers, track completion for the cursor. */
|
|
747
|
+
deliver(msg) {
|
|
748
|
+
const inReplay = msg.replayed === true && msg.recovered !== "bus";
|
|
749
|
+
if (inReplay && this.recovery) {
|
|
750
|
+
const r = this.recovery;
|
|
751
|
+
if (typeof msg.seq === "number" && Number.isFinite(msg.seq))
|
|
752
|
+
r.maxSeq = Math.max(r.maxSeq ?? msg.seq, msg.seq);
|
|
753
|
+
if (typeof msg.ts === "number" && Number.isFinite(msg.ts))
|
|
754
|
+
r.maxTs = Math.max(r.maxTs ?? msg.ts, msg.ts);
|
|
755
|
+
}
|
|
756
|
+
const id = typeof msg.id === "string" || typeof msg.id === "number" ? String(msg.id) : null;
|
|
757
|
+
if (id !== null && this.opts.dedupeSize > 0) {
|
|
758
|
+
const key = `${String(msg.channel)}\u0000${id}`;
|
|
759
|
+
if (this.seen.has(key)) {
|
|
760
|
+
this.seen.delete(key);
|
|
761
|
+
this.seen.set(key, true);
|
|
762
|
+
if (inReplay && this.recovery)
|
|
763
|
+
this.recovery.duplicates++;
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
this.seen.set(key, true);
|
|
767
|
+
if (this.seen.size > this.opts.dedupeSize) {
|
|
768
|
+
const oldest = this.seen.keys().next().value;
|
|
769
|
+
if (oldest !== undefined)
|
|
770
|
+
this.seen.delete(oldest);
|
|
771
|
+
}
|
|
185
772
|
}
|
|
186
|
-
if (
|
|
773
|
+
if (inReplay && this.recovery)
|
|
774
|
+
this.recovery.delivered++;
|
|
775
|
+
const data = msg.data;
|
|
776
|
+
const evt = { ...msg, replayed: msg.replayed === true || (!!data && typeof data === "object" && data.replayed === true) };
|
|
777
|
+
const seq = typeof msg.seq === "number" && Number.isFinite(msg.seq) ? msg.seq : null;
|
|
778
|
+
const ts = typeof msg.ts === "number" && Number.isFinite(msg.ts) ? msg.ts : null;
|
|
779
|
+
// Only sequenced/identified frames move the cursor (token:price ticks are
|
|
780
|
+
// state, not a log) — and none while a recovery runs: finishRecovery()
|
|
781
|
+
// commits the server's replay_end position if, and only if, it is complete.
|
|
782
|
+
const pos = (seq !== null || id !== null) && ts !== null ? { instance: this.serverInstance, seq, ts } : null;
|
|
783
|
+
// Progress always moves; the COMMITTED cursor only for live frames outside
|
|
784
|
+
// a recovery and not after an incomplete one (see finishRecovery).
|
|
785
|
+
const commit = !this.recovery && !this.unsafe;
|
|
786
|
+
const results = [];
|
|
787
|
+
this.callHandlers(evt.event, evt.data, evt, results);
|
|
788
|
+
this.callHandlers("*", evt.data, evt, results);
|
|
789
|
+
const pending = results.filter(isThenable);
|
|
790
|
+
if (pending.length === 0 && this.inflight.length === 0) {
|
|
791
|
+
if (pos)
|
|
792
|
+
this.apply(pos, commit);
|
|
187
793
|
return;
|
|
188
794
|
}
|
|
189
|
-
|
|
190
|
-
|
|
795
|
+
const entry = { pos, commit, done: pending.length === 0 };
|
|
796
|
+
this.inflight.push(entry);
|
|
797
|
+
if (entry.done) {
|
|
798
|
+
this.drainInflight();
|
|
191
799
|
return;
|
|
192
800
|
}
|
|
193
|
-
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
801
|
+
void Promise.allSettled(pending).then((settled) => {
|
|
802
|
+
for (const s of settled)
|
|
803
|
+
if (s.status === "rejected")
|
|
804
|
+
this.emit("error", s.reason);
|
|
805
|
+
entry.done = true;
|
|
806
|
+
this.drainInflight();
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
drainInflight() {
|
|
810
|
+
while (this.inflight.length > 0 && this.inflight[0].done) {
|
|
811
|
+
const e = this.inflight.shift();
|
|
812
|
+
if (e.pos)
|
|
813
|
+
this.apply(e.pos, e.commit);
|
|
197
814
|
}
|
|
198
815
|
}
|
|
199
|
-
|
|
200
|
-
|
|
816
|
+
/** Queue a position behind every frame still being handled (or apply it now). */
|
|
817
|
+
enqueue(pos, commit) {
|
|
818
|
+
if (this.inflight.length === 0)
|
|
819
|
+
this.apply(pos, commit);
|
|
820
|
+
else
|
|
821
|
+
this.inflight.push({ pos, commit, done: true });
|
|
822
|
+
}
|
|
823
|
+
/** Move `progress` (always) and the committed cursor (when `commit`) to `pos`. */
|
|
824
|
+
apply(pos, commit) {
|
|
825
|
+
const p = stepCursor(this.progress, pos);
|
|
826
|
+
if (p)
|
|
827
|
+
this.progress = p;
|
|
828
|
+
if (!commit)
|
|
829
|
+
return;
|
|
830
|
+
const c = this.cursor;
|
|
831
|
+
const next = stepCursor(c, pos);
|
|
832
|
+
if (!next || (c && c.instance === next.instance && c.seq === next.seq && c.ts === next.ts))
|
|
833
|
+
return;
|
|
834
|
+
this.cursor = next;
|
|
835
|
+
this.emit("cursor", { ...next });
|
|
836
|
+
}
|
|
837
|
+
scheduleReconnect(code = null, minDelayMs = 0) {
|
|
838
|
+
if (this.reconnectTimer || this.stopped)
|
|
201
839
|
return;
|
|
202
840
|
const base = Math.min(1000 * 2 ** this.attempt, this.opts.maxBackoffMs);
|
|
203
|
-
|
|
841
|
+
let delay = base / 2 + Math.floor((base / 2) * Math.random()); // jitter
|
|
842
|
+
if (minDelayMs > 0)
|
|
843
|
+
delay = Math.max(delay, minDelayMs + Math.floor((minDelayMs / 2) * Math.random()));
|
|
204
844
|
this.attempt++;
|
|
205
|
-
this.emit("reconnect", { attempt: this.attempt, delayMs: delay });
|
|
845
|
+
this.emit("reconnect", { attempt: this.attempt, delayMs: delay, code });
|
|
206
846
|
this.reconnectTimer = setTimeout(() => {
|
|
207
847
|
this.reconnectTimer = null;
|
|
208
848
|
void this.connect();
|
|
@@ -211,6 +851,7 @@ export class MadeOnSolStream {
|
|
|
211
851
|
resetHeartbeat() {
|
|
212
852
|
this.clearHeartbeat();
|
|
213
853
|
this.hbTimer = setTimeout(() => {
|
|
854
|
+
// Server went quiet — force a reconnect.
|
|
214
855
|
try {
|
|
215
856
|
this.ws?.close(4000, "heartbeat timeout");
|
|
216
857
|
}
|