madeonsol-x402 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/stream.js CHANGED
@@ -1,3 +1,19 @@
1
+ /** Every Solana channel, in the server's order. */
2
+ export const STREAM_CHANNELS = [
3
+ "kol:trades",
4
+ "kol:coordination",
5
+ "kol:first_touches",
6
+ "deployer:alerts",
7
+ "wallet_tracker:events",
8
+ "copytrade:signals",
9
+ "price_alert:events",
10
+ "sniper:deploys",
11
+ "token:graduations",
12
+ "token:prices",
13
+ "token:locks",
14
+ "token:fee_claims",
15
+ "token:surges",
16
+ ];
1
17
  async function resolveWebSocket(override) {
2
18
  if (override)
3
19
  return override;
@@ -23,16 +39,81 @@ async function resolveWebSocket(override) {
23
39
  throw new Error("No WebSocket implementation available. On Node < 22, install `ws` (npm i ws) or pass { WebSocketImpl }.");
24
40
  }
25
41
  const OPEN = 1;
42
+ const HELD_LIVE_CAP = 10_000;
43
+ const DEFAULT_SUB_ID = "default";
44
+ const SUB_ID_RE = /^[A-Za-z0-9_.-]{1,64}$/;
45
+ const subIdOf = (f) => (typeof f.sub_id === "string" && f.sub_id ? f.sub_id : DEFAULT_SUB_ID);
46
+ function isThenable(v) {
47
+ return !!v && (typeof v === "object" || typeof v === "function") && typeof v.then === "function";
48
+ }
49
+ /**
50
+ * Fallback classification for servers that send no `retryable` flag: reasons
51
+ * asking again can never fill.
52
+ */
53
+ const PERMANENT_GAPS = new Set(["not_reconstructable", "state_stream", "window_exceeded", "ring_truncated", "instance_changed"]);
54
+ /** The server's transient list — a channel with one of these is worth asking again. */
55
+ const TRANSIENT_GAPS = new Set(["backpressure", "closed", "source_busy", "source_error", "late_ingest_possible", "row_cap"]);
56
+ function isPermanentGap(reason) {
57
+ return PERMANENT_GAPS.has(reason);
58
+ }
59
+ /** Move a cursor to `pos`: never back within one instance; seq:null frames only advance time. */
60
+ function stepCursor(c, pos) {
61
+ if (pos.seq !== null && pos.instance) {
62
+ if (c && c.instance === pos.instance)
63
+ return { instance: c.instance, seq: Math.max(c.seq, pos.seq), ts: Math.max(c.ts, pos.ts) };
64
+ return { instance: pos.instance, seq: pos.seq, ts: pos.ts };
65
+ }
66
+ // Unsequenced frame (durable backfill, seq:null): keep the last real seq, advance time.
67
+ return c ? { ...c, ts: Math.max(c.ts, pos.ts) } : null;
68
+ }
69
+ function validCursor(c) {
70
+ if (!c || typeof c !== "object")
71
+ return null;
72
+ const { instance, seq, ts } = c;
73
+ if (typeof instance !== "string" || !instance)
74
+ return null;
75
+ if (typeof seq !== "number" || !Number.isFinite(seq) || seq < 0)
76
+ return null;
77
+ if (typeof ts !== "number" || !Number.isFinite(ts) || ts < 0)
78
+ return null;
79
+ return { instance, seq, ts };
80
+ }
26
81
  export class MadeOnSolStream {
27
82
  opts;
28
83
  ws = null;
29
84
  listeners = new Map();
30
85
  desired = { channels: new Set(), filters: {} };
86
+ /** Named subscriptions (Phase 2), in creation order; the default one is `desired`. */
87
+ named = new Map();
88
+ /** sub_ids of the subscribes sent on this connection whose `subscribed` ack is still due (acks arrive in order). */
89
+ ackExpect = [];
90
+ namedUnsupportedWarned = false;
91
+ listWaiters = [];
31
92
  closedByUser = false;
93
+ stopped = false;
32
94
  attempt = 0;
95
+ authFailures = 0;
33
96
  hbTimer = null;
34
97
  reconnectTimer = null;
35
98
  connecting = false;
99
+ /** Server process id of the CURRENT connection (from connected/subscribed). */
100
+ serverInstance = null;
101
+ /** Whether this connection already sent its first subscribe (the only one that resumes). */
102
+ firstSubscribeSent = false;
103
+ /** COMMITTED (safe) cursor — the one to persist and resume from. */
104
+ cursor;
105
+ /** Received progress — every handled frame, including replayed ones. */
106
+ progress;
107
+ /** true after an incomplete recovery: live frames are not committed until one completes. */
108
+ unsafe = false;
109
+ seen = new Map();
110
+ inflight = [];
111
+ recovery = null;
112
+ /** Automatic re-resume after a retryable gap. */
113
+ retryTimer = null;
114
+ resumeRetries = 0;
115
+ /** The last gap reported (for acceptGap()'s report). */
116
+ lastGap = null;
36
117
  constructor(opts) {
37
118
  this.opts = {
38
119
  getToken: opts.getToken,
@@ -40,9 +121,18 @@ export class MadeOnSolStream {
40
121
  maxBackoffMs: opts.maxBackoffMs ?? 30_000,
41
122
  heartbeatTimeoutMs: opts.heartbeatTimeoutMs ?? 90_000,
42
123
  WebSocketImpl: opts.WebSocketImpl,
124
+ dedupeSize: Math.max(0, opts.dedupeSize ?? 10_000),
125
+ maxAuthRetries: Math.max(0, opts.maxAuthRetries ?? 3),
126
+ connectionLimitBackoffMs: Math.max(0, opts.connectionLimitBackoffMs ?? 60_000),
127
+ resumeDetectMs: Math.max(0, opts.resumeDetectMs ?? 3_000),
128
+ legacyReplayTimeoutMs: Math.max(0, opts.legacyReplayTimeoutMs ?? 15_000),
129
+ maxResumeRetries: Math.max(0, opts.maxResumeRetries ?? 5),
130
+ resumeRetryDelayMs: Math.max(0, opts.resumeRetryDelayMs ?? 30_000),
131
+ onUnrecoverableGap: opts.onUnrecoverableGap === "stop" ? "stop" : "advance",
43
132
  };
133
+ this.cursor = validCursor(opts.resume);
134
+ this.progress = this.cursor ? { ...this.cursor } : null;
44
135
  }
45
- /** Register a handler. Use an event name, `"*"` for every event, or a lifecycle event. */
46
136
  on(event, fn) {
47
137
  if (!this.listeners.has(event))
48
138
  this.listeners.set(event, new Set());
@@ -57,6 +147,44 @@ export class MadeOnSolStream {
57
147
  this.listeners.get(event)?.delete(fn);
58
148
  return this;
59
149
  }
150
+ /** The COMMITTED resume cursor (last safe point) — persist this one. Null before the first. */
151
+ getCursor() {
152
+ return this.cursor ? { ...this.cursor } : null;
153
+ }
154
+ /** Received progress: the last handled frame, replayed ones included (NOT safe to resume from). */
155
+ getProgress() {
156
+ return this.progress ? { ...this.progress } : null;
157
+ }
158
+ /** true while an incomplete recovery holds the committed cursor back. */
159
+ isRecoveryIncomplete() {
160
+ return this.unsafe;
161
+ }
162
+ /**
163
+ * Accept the last reported gap: commit the received progress as the cursor
164
+ * and let live frames commit again. Call it after you backfilled the range
165
+ * the `"gap"` event named (or decided you do not need it). It re-reports the
166
+ * gap first, with `source: "manual"` and the range being skipped.
167
+ */
168
+ acceptGap() {
169
+ this.unsafe = false;
170
+ const p = this.progress;
171
+ const c = this.cursor;
172
+ const moves = !!p && !(c && c.instance === p.instance && c.seq === p.seq && c.ts === p.ts);
173
+ if (this.lastGap) {
174
+ const g = {
175
+ ...this.lastGap,
176
+ advancedPastGap: moves,
177
+ source: "manual",
178
+ skipped: { channels: this.lastGap.skipped.channels, from: c ? { ...c } : null, to: moves ? { ...p } : null },
179
+ };
180
+ this.lastGap = null;
181
+ this.emit("gap", g);
182
+ }
183
+ if (!moves)
184
+ return;
185
+ this.cursor = { ...p };
186
+ this.emit("cursor", { ...p });
187
+ }
60
188
  emit(event, data, evt) {
61
189
  const set = this.listeners.get(event);
62
190
  if (set)
@@ -67,31 +195,146 @@ export class MadeOnSolStream {
67
195
  catch { /* user handler */ }
68
196
  }
69
197
  }
70
- /** Subscribe to one or more channels (connects on first call). Optional server-side filters. */
71
- subscribe(channels, filters) {
72
- for (const c of channels)
73
- this.desired.channels.add(c);
74
- if (filters)
75
- this.desired.filters = { ...this.desired.filters, ...filters };
198
+ /** Call every handler for a data frame; collect what they returned (for completion tracking). */
199
+ callHandlers(event, data, evt, out) {
200
+ const set = this.listeners.get(event);
201
+ if (!set)
202
+ return;
203
+ for (const fn of set) {
204
+ try {
205
+ out.push(fn(data, evt));
206
+ }
207
+ catch (err) {
208
+ this.emit("error", err);
209
+ }
210
+ }
211
+ }
212
+ subscribe(arg, filters) {
213
+ if (Array.isArray(arg)) {
214
+ for (const c of arg)
215
+ this.desired.channels.add(c);
216
+ if (filters)
217
+ this.desired.filters = { ...this.desired.filters, ...filters };
218
+ if (this.ws && this.ws.readyState === OPEN)
219
+ this.sendSubscribe({ only: [DEFAULT_SUB_ID] });
220
+ else
221
+ void this.connect();
222
+ return this;
223
+ }
224
+ const subId = arg.subId;
225
+ if (typeof subId !== "string" || !SUB_ID_RE.test(subId))
226
+ throw new Error("subId must be 1-64 characters of A-Z a-z 0-9 _ . -");
227
+ if (subId === DEFAULT_SUB_ID)
228
+ return this.subscribe(arg.channels, arg.filters);
229
+ const entry = this.named.get(subId) ?? { channels: new Set(), filters: {} };
230
+ for (const c of arg.channels)
231
+ entry.channels.add(c);
232
+ // A named subscription's filters are REPLACED when given (the server does the same).
233
+ if (arg.filters)
234
+ entry.filters = { ...arg.filters };
235
+ this.named.set(subId, entry);
76
236
  if (this.ws && this.ws.readyState === OPEN)
77
- this.sendSubscribe();
237
+ this.sendSubscribe({ only: [subId] });
78
238
  else
79
239
  void this.connect();
80
240
  return this;
81
241
  }
82
- /** Stop receiving the given channels. */
83
- unsubscribe(channels) {
84
- for (const c of channels)
242
+ /**
243
+ * Replace the filters of a subscription (`"default"` for the plain one).
244
+ * The server acks with an `updated` frame; a refused update (for example a
245
+ * `token:prices` subscription without valid `mints`) arrives as a `warning`
246
+ * with code `invalid_filters` and the previous filters stay.
247
+ */
248
+ updateSubscription(subId, filters) {
249
+ if (subId === DEFAULT_SUB_ID)
250
+ this.desired.filters = { ...filters };
251
+ else {
252
+ const entry = this.named.get(subId);
253
+ if (!entry)
254
+ throw new Error(`unknown subscription ${subId}`);
255
+ entry.filters = { ...filters };
256
+ }
257
+ if (this.ws && this.ws.readyState === OPEN) {
258
+ this.ws.send(JSON.stringify({ type: "update", ...(subId === DEFAULT_SUB_ID ? {} : { sub_id: subId }), filters }));
259
+ }
260
+ return this;
261
+ }
262
+ unsubscribe(arg) {
263
+ if (typeof arg === "string") {
264
+ if (arg === DEFAULT_SUB_ID)
265
+ return this.unsubscribe(Array.from(this.desired.channels));
266
+ this.named.delete(arg);
267
+ this.forgetPending(arg);
268
+ if (this.ws && this.ws.readyState === OPEN)
269
+ this.ws.send(JSON.stringify({ type: "unsubscribe", sub_id: arg }));
270
+ return this;
271
+ }
272
+ for (const c of arg)
85
273
  this.desired.channels.delete(c);
86
274
  if (this.ws && this.ws.readyState === OPEN) {
87
- this.ws.send(JSON.stringify({ type: "unsubscribe", channels }));
275
+ this.ws.send(JSON.stringify({ type: "unsubscribe", channels: arg }));
88
276
  }
89
277
  return this;
90
278
  }
91
- /** Open the connection (also called implicitly by subscribe). */
279
+ /** Every subscription this client asks for (local view, no round trip). */
280
+ getSubscriptions() {
281
+ const out = [];
282
+ if (this.desired.channels.size > 0)
283
+ out.push({ subId: DEFAULT_SUB_ID, channels: Array.from(this.desired.channels), filters: { ...this.desired.filters } });
284
+ for (const [subId, e] of this.named)
285
+ out.push({ subId, channels: Array.from(e.channels), filters: { ...e.filters } });
286
+ return out;
287
+ }
288
+ /**
289
+ * Ask the server what this connection holds (`list` → `subscriptions`).
290
+ * Resolves with the local view when not connected or when the server does
291
+ * not answer within `timeoutMs`.
292
+ */
293
+ listSubscriptions(timeoutMs = 5_000) {
294
+ if (!this.ws || this.ws.readyState !== OPEN)
295
+ return Promise.resolve(this.getSubscriptions());
296
+ return new Promise((resolve) => {
297
+ const w = { resolve, timer: setTimeout(() => { this.listWaiters = this.listWaiters.filter((x) => x !== w); resolve(this.getSubscriptions()); }, timeoutMs) };
298
+ this.listWaiters.push(w);
299
+ try {
300
+ this.ws.send(JSON.stringify({ type: "list" }));
301
+ }
302
+ catch { /* closing: the timer answers */ }
303
+ });
304
+ }
305
+ /**
306
+ * A pre-Phase-2 server (ignores sub_id) answers every resume with ONE
307
+ * replay for the whole connection, reported without sub_id: only "default"
308
+ * can still be awaited. Finishes the recovery at once when that one has
309
+ * already ended.
310
+ */
311
+ collapsePending(r) {
312
+ if (r.pending.size === 1 && r.pending.has(DEFAULT_SUB_ID))
313
+ return;
314
+ r.pending.clear();
315
+ if (!r.ends.has(DEFAULT_SUB_ID))
316
+ r.pending.add(DEFAULT_SUB_ID);
317
+ if (r.pending.size === 0 && r.protocol !== "detect")
318
+ this.finishRecovery(r.ends.get(DEFAULT_SUB_ID) ?? null);
319
+ }
320
+ /** A subscription removed while its replay was still awaited: stop waiting for it. */
321
+ forgetPending(subId) {
322
+ const r = this.recovery;
323
+ if (!r || !r.pending.has(subId))
324
+ return;
325
+ r.pending.delete(subId);
326
+ if (r.pending.size === 0 && r.protocol !== "detect")
327
+ this.finishRecovery(r.ends.size ? [...r.ends.values()].pop() : null);
328
+ }
329
+ /** Open the connection (also called implicitly by subscribe). Restarts a stream that went `"fatal"`. */
92
330
  async connect() {
93
331
  if (this.connecting || (this.ws && this.ws.readyState === OPEN))
94
332
  return;
333
+ if (this.stopped) {
334
+ this.stopped = false;
335
+ this.authFailures = 0;
336
+ this.attempt = 0;
337
+ }
95
338
  this.closedByUser = false;
96
339
  this.connecting = true;
97
340
  try {
@@ -99,29 +342,47 @@ export class MadeOnSolStream {
99
342
  resolveWebSocket(this.opts.WebSocketImpl),
100
343
  this.opts.getToken(),
101
344
  ]);
345
+ if (this.closedByUser || this.stopped)
346
+ return;
102
347
  const url = `${token.ws_url}?token=${encodeURIComponent(token.token)}`;
103
348
  const ws = new WS(url);
104
349
  this.ws = ws;
350
+ this.serverInstance = null;
351
+ this.firstSubscribeSent = false;
352
+ // The automatic re-resume budget is per CONNECTION (the docs say so).
353
+ this.resumeRetries = 0;
105
354
  ws.onopen = () => {
106
- this.attempt = 0;
355
+ if (this.ws !== ws)
356
+ return;
357
+ // The backoff attempt is NOT reset here — only a `subscribed` ack proves
358
+ // the connection is usable (an auth/limit close follows a successful open).
107
359
  this.resetHeartbeat();
108
- if (this.desired.channels.size > 0)
360
+ this.ackExpect = [];
361
+ if (this.desired.channels.size > 0 || this.named.size > 0)
109
362
  this.sendSubscribe();
110
363
  this.emit("open", undefined);
111
364
  };
112
- ws.onmessage = (ev) => this.handleMessage(ev.data);
113
- ws.onerror = (err) => this.emit("error", err instanceof Error ? err : new Error("WebSocket error"));
365
+ ws.onmessage = (ev) => { if (this.ws === ws)
366
+ this.handleMessage(ev.data); };
367
+ ws.onerror = (err) => { if (this.ws === ws)
368
+ this.emit("error", err instanceof Error ? err : new Error("WebSocket error")); };
114
369
  ws.onclose = (ev) => {
115
- this.clearHeartbeat();
116
- this.ws = null;
117
- this.emit("close", { code: ev?.code, reason: ev?.reason });
118
- if (!this.closedByUser && this.opts.autoReconnect)
119
- this.scheduleReconnect();
370
+ if (this.ws !== null && this.ws !== ws)
371
+ return; // superseded socket
372
+ this.handleClose(typeof ev?.code === "number" ? ev.code : null, typeof ev?.reason === "string" ? ev.reason : "");
120
373
  };
121
374
  }
122
375
  catch (err) {
123
376
  this.emit("error", err);
124
- if (!this.closedByUser && this.opts.autoReconnect)
377
+ if (this.authFailures > 0) {
378
+ // Token re-fetch after a 4001 failed — counts toward the bounded retries.
379
+ this.authFailures++;
380
+ if (this.authFailures > this.opts.maxAuthRetries) {
381
+ this.fatal(4001, "stream token refresh failed");
382
+ return;
383
+ }
384
+ }
385
+ if (!this.closedByUser && !this.stopped && this.opts.autoReconnect)
125
386
  this.scheduleReconnect();
126
387
  }
127
388
  finally {
@@ -136,6 +397,7 @@ export class MadeOnSolStream {
136
397
  this.reconnectTimer = null;
137
398
  }
138
399
  this.clearHeartbeat();
400
+ this.dropRecovery();
139
401
  const sock = this.ws;
140
402
  this.ws = null;
141
403
  try {
@@ -149,14 +411,430 @@ export class MadeOnSolStream {
149
411
  }
150
412
  catch { /* ignore */ }
151
413
  }
152
- sendSubscribe() {
153
- const channels = Array.from(this.desired.channels);
154
- if (channels.length === 0)
414
+ handleClose(code, reason) {
415
+ this.clearHeartbeat();
416
+ this.ws = null;
417
+ this.serverInstance = null;
418
+ // An unfinished recovery is abandoned: its held live frames are dropped
419
+ // undelivered, and replayed frames never moved the cursor, so the next
420
+ // resume starts from the same pre-resume position.
421
+ this.dropRecovery();
422
+ this.emit("close", { code, reason });
423
+ if (this.closedByUser || this.stopped)
424
+ return;
425
+ if (code === 4003) {
426
+ this.fatal(code, reason || "authentication error");
427
+ return;
428
+ }
429
+ if (code === 4001) {
430
+ // Token rejected (rotated / lapsed): the reconnect re-fetches it via getToken().
431
+ this.authFailures++;
432
+ if (this.authFailures > this.opts.maxAuthRetries) {
433
+ this.fatal(code, reason || "stream token rejected");
434
+ return;
435
+ }
436
+ }
437
+ if (!this.opts.autoReconnect)
438
+ return;
439
+ if (code === 4002) {
440
+ // Connection limit: another socket holds the slot. Never retry tightly.
441
+ const err = new Error(`stream connection limit reached${reason ? `: ${reason}` : ""}`);
442
+ err.code = 4002;
443
+ err.reason = reason;
444
+ this.emit("error", err);
445
+ this.scheduleReconnect(code, this.opts.connectionLimitBackoffMs);
446
+ return;
447
+ }
448
+ // 4008 (slow consumer) and everything else: reconnect and resume from the cursor.
449
+ this.scheduleReconnect(code);
450
+ }
451
+ /** `onUnrecoverableGap: "stop"`: stop the stream and hand the decision to the caller. */
452
+ haltForGap(gap) {
453
+ this.closedByUser = true; // no reconnect; connect() restarts if the caller wants
454
+ if (this.retryTimer) {
455
+ clearTimeout(this.retryTimer);
456
+ this.retryTimer = null;
457
+ }
458
+ this.clearHeartbeat();
459
+ const sock = this.ws;
460
+ this.ws = null;
461
+ try {
462
+ if (typeof sock?.terminate === "function")
463
+ sock.terminate();
464
+ else
465
+ sock?.close(1000, "unrecoverable gap");
466
+ }
467
+ catch { /* ignore */ }
468
+ this.stopped = true;
469
+ this.emit("fatal", { code: null, reason: `unrecoverable gap: ${gap.reason}`, gap });
470
+ }
471
+ fatal(code, reason) {
472
+ this.stopped = true;
473
+ if (this.reconnectTimer) {
474
+ clearTimeout(this.reconnectTimer);
475
+ this.reconnectTimer = null;
476
+ }
477
+ this.emit("fatal", { code, reason });
478
+ }
479
+ /** The subscribe frames for the given subscriptions (default first, then named in creation order). */
480
+ subscribeFrames(only) {
481
+ const out = [];
482
+ const want = (id) => !only || only.includes(id);
483
+ if (want(DEFAULT_SUB_ID) && this.desired.channels.size > 0) {
484
+ const msg = { type: "subscribe", channels: Array.from(this.desired.channels) };
485
+ if (Object.keys(this.desired.filters).length > 0)
486
+ msg.filters = this.desired.filters;
487
+ out.push({ subId: DEFAULT_SUB_ID, msg });
488
+ }
489
+ for (const [subId, e] of this.named) {
490
+ if (!want(subId) || e.channels.size === 0)
491
+ continue;
492
+ out.push({ subId, msg: { type: "subscribe", sub_id: subId, channels: Array.from(e.channels), filters: e.filters } });
493
+ }
494
+ return out;
495
+ }
496
+ /**
497
+ * Send the subscribe(s). On a connection's FIRST subscribe (or an explicit
498
+ * retry after a retryable gap) every subscription is sent with the SAME
499
+ * resume cursor: the server serves one replay per subscription, one after
500
+ * another, and holds live frames until the last replay_end. A later
501
+ * subscribe adds channels live (no resume).
502
+ */
503
+ sendSubscribe({ resumeOverride, only } = {}) {
504
+ if (!this.ws)
155
505
  return;
156
- const msg = { type: "subscribe", channels };
506
+ const frames = this.subscribeFrames(only);
507
+ if (frames.length === 0)
508
+ return;
509
+ if ((!this.firstSubscribeSent || resumeOverride) && this.cursor && !this.recovery) {
510
+ const from = resumeOverride ?? { ...this.cursor };
511
+ const pending = new Set();
512
+ const channels = new Set();
513
+ for (const f of frames) {
514
+ f.msg.resume = from;
515
+ pending.add(f.subId);
516
+ for (const c of f.msg.channels)
517
+ channels.add(c);
518
+ }
519
+ this.recovery = {
520
+ protocol: "detect", from, channels: Array.from(channels), request: { resume: from }, acked: false, suppressAck: false,
521
+ instanceChanged: false, start: null, received: 0, delivered: 0, duplicates: 0, held: [], timer: null,
522
+ maxSeq: null, maxTs: null, pending, starts: new Map(), ends: new Map(),
523
+ };
524
+ }
525
+ this.firstSubscribeSent = true;
526
+ for (const f of frames) {
527
+ this.ackExpect.push(f.subId);
528
+ this.ws.send(JSON.stringify(f.msg));
529
+ }
530
+ }
531
+ /**
532
+ * The server did not answer `resume` (older deployment): retry with the
533
+ * legacy fields. Such a server has no named subscriptions either, so the
534
+ * one legacy replay covers the union of channels and resolves every pending
535
+ * subscription at once.
536
+ */
537
+ fallbackToLegacy() {
538
+ const r = this.recovery;
539
+ if (!r || r.protocol !== "detect" || !r.from || !this.ws)
540
+ return;
541
+ if (r.timer) {
542
+ clearTimeout(r.timer);
543
+ r.timer = null;
544
+ }
545
+ r.protocol = "legacy";
546
+ r.pending = new Set([DEFAULT_SUB_ID]);
547
+ r.instanceChanged = !this.serverInstance || this.serverInstance !== r.from.instance;
548
+ // Same process → its ring still indexes our seq. Restarted → seq restarted, use time.
549
+ const legacy = r.instanceChanged ? { replay_since_ts: r.from.ts } : { replay_since_seq: r.from.seq };
550
+ r.request = legacy;
551
+ r.suppressAck = true;
552
+ const msg = { type: "subscribe", channels: r.channels, ...legacy };
157
553
  if (Object.keys(this.desired.filters).length > 0)
158
554
  msg.filters = this.desired.filters;
159
- this.ws?.send(JSON.stringify(msg));
555
+ try {
556
+ this.ws.send(JSON.stringify(msg));
557
+ }
558
+ catch { /* closing */ }
559
+ r.timer = setTimeout(() => this.finishRecovery(null), this.opts.legacyReplayTimeoutMs);
560
+ }
561
+ dropRecovery() {
562
+ if (this.recovery?.timer)
563
+ clearTimeout(this.recovery.timer);
564
+ this.recovery = null;
565
+ if (this.retryTimer) {
566
+ clearTimeout(this.retryTimer);
567
+ this.retryTimer = null;
568
+ }
569
+ // A `list` still awaiting its answer resolves with the local view; the
570
+ // ack order of the dead connection means nothing on the next one.
571
+ this.ackExpect = [];
572
+ const waiters = this.listWaiters;
573
+ this.listWaiters = [];
574
+ for (const w of waiters) {
575
+ clearTimeout(w.timer);
576
+ w.resolve(this.getSubscriptions());
577
+ }
578
+ }
579
+ /**
580
+ * A retryable gap: ask the server again on this connection after its
581
+ * retry_after_ms (row_cap resumes from resume_ts_hint). Bounded — the next
582
+ * reconnect resumes anyway.
583
+ */
584
+ scheduleResumeRetry(retryAfterMs, hintTs, only) {
585
+ if (this.retryTimer || !this.cursor)
586
+ return;
587
+ if (this.resumeRetries >= this.opts.maxResumeRetries)
588
+ return;
589
+ this.resumeRetries++;
590
+ const delay = retryAfterMs !== null && retryAfterMs >= 0 ? retryAfterMs : this.opts.resumeRetryDelayMs;
591
+ const from = hintTs !== null && hintTs > this.cursor.ts ? { ...this.cursor, ts: hintTs } : { ...this.cursor };
592
+ this.retryTimer = setTimeout(() => {
593
+ this.retryTimer = null;
594
+ // Only the subscriptions whose replay was incomplete are asked again.
595
+ if (this.ws && this.ws.readyState === OPEN && !this.recovery)
596
+ this.sendSubscribe({ resumeOverride: from, only });
597
+ }, delay);
598
+ }
599
+ /**
600
+ * One `replay_end` per subscription → one aggregate the single-replay logic
601
+ * can run on unchanged: complete only when every subscription is; the
602
+ * commit position is the SMALLEST last_seq / last_ts across them (a later
603
+ * subscription's replay covered more, but the earlier one's live frames
604
+ * from that point on are still only in the live flush); channel entries
605
+ * keyed `"<sub_id>/<channel>"` for named subscriptions; `retryable` when
606
+ * any subscription says so.
607
+ */
608
+ aggregateEnds(r, lastEnd) {
609
+ const ends = [...r.ends.entries()];
610
+ if (ends.length === 0)
611
+ return lastEnd;
612
+ if (ends.length === 1 && ends[0][0] === DEFAULT_SUB_ID)
613
+ return ends[0][1];
614
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
615
+ const agg = { ...(lastEnd ?? ends[ends.length - 1][1]) };
616
+ const channels = {};
617
+ let complete = true, retryableKnown = true, retryable = false, truncated = false;
618
+ let lastSeq = null, lastTs = null, liveFrom = null, retryAfter = null, hint = null;
619
+ let count = 0, sent = 0, matched = 0, reason = null, limits = null;
620
+ const min = (a, b) => (a === null ? b : b === null ? a : Math.min(a, b));
621
+ for (const [subId, e] of ends) {
622
+ if (e.complete === false) {
623
+ complete = false;
624
+ if (!reason && typeof e.reason === "string")
625
+ reason = e.reason;
626
+ }
627
+ if (typeof e.retryable !== "boolean")
628
+ retryableKnown = false;
629
+ else if (e.retryable)
630
+ retryable = true;
631
+ if (e.replay_truncated === true)
632
+ truncated = true;
633
+ const chs = e.channels;
634
+ if (chs && typeof chs === "object")
635
+ for (const [ch, raw] of Object.entries(chs))
636
+ channels[subId === DEFAULT_SUB_ID ? ch : `${subId}/${ch}`] = raw;
637
+ lastSeq = min(lastSeq, num(e.last_seq));
638
+ lastTs = min(lastTs, num(e.last_ts));
639
+ liveFrom = min(liveFrom, num(e.live_from_seq));
640
+ const ra = num(e.retry_after_ms);
641
+ if (ra !== null)
642
+ retryAfter = retryAfter === null ? ra : Math.max(retryAfter, ra);
643
+ hint = min(hint, num(e.resume_ts_hint));
644
+ count += num(e.count) ?? 0;
645
+ sent += num(e.sent) ?? 0;
646
+ matched += num(e.matched) ?? 0;
647
+ if (!limits && e.limits && typeof e.limits === "object")
648
+ limits = e.limits;
649
+ }
650
+ agg.complete = complete;
651
+ agg.reason = complete ? null : reason ?? "incomplete";
652
+ agg.channels = channels;
653
+ if (retryableKnown)
654
+ agg.retryable = retryable;
655
+ else
656
+ delete agg.retryable;
657
+ if (truncated)
658
+ agg.replay_truncated = true;
659
+ agg.last_seq = lastSeq;
660
+ agg.last_ts = lastTs;
661
+ agg.live_from_seq = liveFrom;
662
+ if (retryAfter !== null)
663
+ agg.retry_after_ms = retryAfter;
664
+ else
665
+ delete agg.retry_after_ms;
666
+ if (hint !== null)
667
+ agg.resume_ts_hint = hint;
668
+ else
669
+ delete agg.resume_ts_hint;
670
+ agg.count = count;
671
+ agg.sent = sent;
672
+ agg.matched = matched;
673
+ if (limits)
674
+ agg.limits = limits;
675
+ return agg;
676
+ }
677
+ finishRecovery(lastEnd) {
678
+ const r = this.recovery;
679
+ if (!r)
680
+ return;
681
+ if (r.timer) {
682
+ clearTimeout(r.timer);
683
+ r.timer = null;
684
+ }
685
+ this.recovery = null;
686
+ const end = this.aggregateEnds(r, lastEnd);
687
+ // Subscriptions whose own replay_end was incomplete and retryable (or, on a
688
+ // server that does not say, incomplete): the automatic retry asks only for them.
689
+ const retrySubs = [...r.ends.entries()].filter(([, e]) => e.complete === false && e.retryable !== false).map(([id]) => id);
690
+ const reasons = [];
691
+ /** Reasons of the channels the server reported incomplete, with their retryability. */
692
+ const channelReasons = [];
693
+ const retryableChannelReasons = [];
694
+ const gapChannels = {};
695
+ const str = (v) => (typeof v === "string" && v ? v : null);
696
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
697
+ // A v1 server answers with complete/sent/matched; an older one with count only.
698
+ const v1 = !!end && ("complete" in end || "sent" in end || "matched" in end);
699
+ if ([...r.starts.values()].some((s) => s.replay_truncated === true) || r.start?.replay_truncated === true || end?.replay_truncated === true)
700
+ reasons.push("ring_truncated");
701
+ if (!end)
702
+ reasons.push("replay_timeout");
703
+ else if (v1) {
704
+ if (end.complete === false)
705
+ reasons.push(str(end.reason) ?? "incomplete");
706
+ const chs = end.channels;
707
+ if (chs && typeof chs === "object") {
708
+ for (const [ch, raw] of Object.entries(chs)) {
709
+ // token:prices is a state stream: the server re-sends a snapshot, never a log.
710
+ if (ch === "token:prices")
711
+ continue;
712
+ const info = (raw && typeof raw === "object" ? raw : {});
713
+ const gap = info.gap;
714
+ const late = info.late_ingest_possible === true;
715
+ if (info.complete === false || gap || info.mode === "none" || late) {
716
+ gapChannels[ch] = raw; // raw entry: mode, reason, gap, time_basis, retry_after_ms, …
717
+ const gr = gap && typeof gap === "object" ? gap.reason : gap;
718
+ const chReason = str(info.reason) ?? str(gr) ?? (info.mode === "none" ? "not_reconstructable" : late ? "late_ingest_possible" : "incomplete");
719
+ reasons.push(chReason);
720
+ channelReasons.push(chReason);
721
+ if (info.retryable === true || (info.retryable !== false && TRANSIENT_GAPS.has(chReason)))
722
+ retryableChannelReasons.push(chReason);
723
+ }
724
+ }
725
+ }
726
+ }
727
+ else {
728
+ // Legacy server: `count` is what it meant to send; fewer arrived → it stopped on backpressure.
729
+ if (typeof end.count === "number" && r.received < end.count)
730
+ reasons.push("backpressure");
731
+ // Legacy server + restart: the old process's buffer is gone and there is no durable backfill.
732
+ if (r.protocol === "legacy" && r.instanceChanged)
733
+ reasons.push("instance_changed");
734
+ }
735
+ const uniq = Array.from(new Set(reasons));
736
+ const result = {
737
+ protocol: v1 || r.protocol === "resume" ? "resume" : "legacy",
738
+ from: r.from,
739
+ request: r.request,
740
+ received: r.received,
741
+ delivered: r.delivered,
742
+ duplicates: r.duplicates,
743
+ complete: uniq.length === 0,
744
+ mode: typeof end?.mode === "string" ? end.mode : null,
745
+ resumeReason: typeof end?.resume_reason === "string" ? end.resume_reason : null,
746
+ start: r.start,
747
+ end,
748
+ subscriptions: r.ends.size ? [...r.ends.keys()] : r.pending.size ? [...r.pending] : [DEFAULT_SUB_ID],
749
+ ends: Object.fromEntries(r.ends),
750
+ };
751
+ // Final vs retryable. The server says which (`retryable`): true only when an
752
+ // incomplete channel's reason is transient (backpressure, closed,
753
+ // source_busy, source_error, late_ingest_possible, row_cap). Older servers
754
+ // send no `retryable`; then the reason list decides (isPermanentGap).
755
+ const serverSays = !!end && typeof end.retryable === "boolean";
756
+ const retryable = uniq.length > 0 && (serverSays ? end.retryable === true : !uniq.every(isPermanentGap));
757
+ const permanent = uniq.length > 0 && !retryable;
758
+ // Commit point: complete, or only FINAL gaps (reported once, then treated as
759
+ // complete so the stream never stays stuck on something asking again cannot
760
+ // fill). Retryable: keep the pre-resume cursor, do not commit live frames,
761
+ // and resume again after retry_after_ms (row_cap: from resume_ts_hint).
762
+ // The position the server says is safe to continue from.
763
+ let pos = null;
764
+ if (!retryable) {
765
+ let seq;
766
+ let cts;
767
+ if (v1) {
768
+ // {seq: last_seq ?? previous, ts: last_ts ?? previous}
769
+ seq = num(end?.last_seq);
770
+ cts = num(end?.last_ts) ?? (seq !== null ? this.cursor?.ts ?? null : null);
771
+ }
772
+ else {
773
+ // Older servers: held live frames (not yet handled) may sit below live_from_seq.
774
+ const liveFrom = num(end?.live_from_seq);
775
+ const heldMin = r.held.reduce((m, f) => (typeof f.seq === "number" ? (m === null ? f.seq : Math.min(m, f.seq)) : m), null);
776
+ seq = r.maxSeq ?? (heldMin !== null ? heldMin - 1 : liveFrom !== null ? liveFrom - 1 : null);
777
+ cts = r.maxTs ?? this.cursor?.ts ?? null;
778
+ }
779
+ if (cts !== null)
780
+ pos = { instance: this.serverInstance, seq: seq !== null && seq >= 0 ? seq : null, ts: cts };
781
+ }
782
+ // Continuing past a FINAL gap is the SDK's own decision, never the user's
783
+ // approval: it is reported on the gap event (advancedPastGap / skipped) and
784
+ // `onUnrecoverableGap: "stop"` turns it off.
785
+ // resume_ts_hint is a row_cap device: it says "everything up to here was
786
+ // sent for the capped channel". If another channel is incomplete for a
787
+ // RETRYABLE reason (source_error, source_busy, …), resuming from the hint
788
+ // would step past its unread range and the next reply would claim complete.
789
+ // Channels with a FINAL gap are ignored here: asking again never recovers
790
+ // them anyway, and the gap event reports them. Same predicate as the
791
+ // server, checked here so the client never depends on it.
792
+ const capCandidates = retryableChannelReasons.length > 0
793
+ ? retryableChannelReasons
794
+ : channelReasons.length > 0 ? [] : uniq.filter((x) => TRANSIENT_GAPS.has(x));
795
+ const capOnly = capCandidates.length > 0 && capCandidates.every((x) => x === "row_cap");
796
+ const exhausted = retryable && this.resumeRetries >= this.opts.maxResumeRetries;
797
+ const strict = uniq.length > 0 && !retryable && this.opts.onUnrecoverableGap === "stop";
798
+ const willAdvance = !retryable && !strict;
799
+ this.emit("replay", result);
800
+ let gap = null;
801
+ if (uniq.length > 0) {
802
+ gap = {
803
+ reason: uniq[0], reasons: uniq, permanent, retryable,
804
+ retryAfterMs: num(end?.retry_after_ms), resumeTsHint: num(end?.resume_ts_hint),
805
+ channels: gapChannels, from: r.from, replay: result, exhausted,
806
+ limits: (end?.limits && typeof end.limits === "object" ? end.limits : r.start?.limits && typeof r.start.limits === "object" ? r.start.limits : null),
807
+ // What the client does about it — always reported BEFORE it happens.
808
+ advancedPastGap: willAdvance,
809
+ source: "auto",
810
+ skipped: {
811
+ channels: Object.keys(gapChannels),
812
+ from: this.cursor ? { ...this.cursor } : null,
813
+ to: willAdvance && pos ? stepCursor(this.cursor, pos) : null,
814
+ },
815
+ };
816
+ this.lastGap = gap;
817
+ this.emit("gap", gap);
818
+ }
819
+ if (willAdvance) {
820
+ this.unsafe = false;
821
+ this.resumeRetries = 0;
822
+ if (pos)
823
+ this.enqueue(pos, true);
824
+ }
825
+ else if (retryable) {
826
+ this.unsafe = true;
827
+ if (serverSays)
828
+ this.scheduleResumeRetry(num(end?.retry_after_ms), capOnly ? num(end?.resume_ts_hint) : null, retrySubs.length ? retrySubs : undefined);
829
+ }
830
+ else {
831
+ // strict: stop instead of skipping what cannot be recovered.
832
+ this.unsafe = true;
833
+ this.haltForGap(gap);
834
+ }
835
+ // Live frames that arrived during a client-side replay go out now, after it.
836
+ for (const f of r.held)
837
+ this.deliver(f);
160
838
  }
161
839
  handleMessage(raw) {
162
840
  let msg;
@@ -168,31 +846,292 @@ export class MadeOnSolStream {
168
846
  this.emit("error", new Error("Failed to parse stream message"));
169
847
  return;
170
848
  }
171
- if (msg.type === "heartbeat") {
172
- this.resetHeartbeat();
173
- this.emit("heartbeat", msg.ts);
849
+ switch (msg.type) {
850
+ case "heartbeat":
851
+ this.resetHeartbeat();
852
+ this.emit("heartbeat", msg.ts);
853
+ return;
854
+ case "connected":
855
+ if (typeof msg.instance === "string")
856
+ this.serverInstance = msg.instance;
857
+ // Nothing to subscribe to → this frame is as far as a healthy connection gets.
858
+ if (this.desired.channels.size === 0 && this.named.size === 0) {
859
+ this.attempt = 0;
860
+ this.authFailures = 0;
861
+ }
862
+ return;
863
+ case "subscribed": {
864
+ if (typeof msg.instance === "string")
865
+ this.serverInstance = msg.instance;
866
+ this.attempt = 0;
867
+ this.authFailures = 0;
868
+ const r = this.recovery;
869
+ if (r && r.suppressAck) {
870
+ r.suppressAck = false;
871
+ return;
872
+ } // ack of our own fallback subscribe
873
+ // Acks arrive in the order the subscribes were sent: a named subscribe
874
+ // answered WITHOUT sub_id means the server ignores sub_id (older
875
+ // deployment) — every subscription then collapsed into one on the
876
+ // server. Said once, never silently.
877
+ const expected = this.ackExpect.shift() ?? DEFAULT_SUB_ID;
878
+ // The subscription this ack is about: the server's sub_id, else the
879
+ // one we sent in this position (an older server echoes none).
880
+ const ackedId = typeof msg.sub_id === "string" && msg.sub_id ? msg.sub_id : expected;
881
+ if (expected !== DEFAULT_SUB_ID && typeof msg.sub_id !== "string") {
882
+ if (!this.namedUnsupportedWarned) {
883
+ this.namedUnsupportedWarned = true;
884
+ this.emit("warning", { code: "named_subscriptions_unsupported", sub_id: expected, message: "The server ignored sub_id: it predates named subscriptions, so every subscription on this connection shares one channel set and one filter object." });
885
+ }
886
+ // Such a server runs ONE replay for the whole connection and reports
887
+ // it without sub_id ("default"): every named id must leave `pending`
888
+ // or the recovery would never finish and the cursor would freeze.
889
+ if (r)
890
+ this.collapsePending(r);
891
+ }
892
+ this.emit("subscribed", msg.channels, msg);
893
+ if (r && r.protocol === "detect" && !r.acked) {
894
+ r.acked = true;
895
+ if ("resume" in msg)
896
+ r.protocol = "resume"; // server echoed resume: it understood
897
+ else
898
+ r.timer = setTimeout(() => this.fallbackToLegacy(), this.opts.resumeDetectMs);
899
+ }
900
+ const echo = msg.resume;
901
+ if (r && echo && typeof echo === "object" && echo.accepted === false) {
902
+ // Refused for THIS subscription (replay_in_progress: it already has a
903
+ // replay running or queued): no replay_end will come for it. When
904
+ // nothing at all was accepted, nothing was recovered, so the
905
+ // committed cursor must not move until a later recovery completes.
906
+ r.pending.delete(ackedId);
907
+ if (r.pending.size === 0 && r.ends.size === 0) {
908
+ this.dropRecovery();
909
+ this.unsafe = true;
910
+ }
911
+ else if (r.pending.size === 0 && r.protocol !== "detect")
912
+ this.finishRecovery([...r.ends.values()].pop() ?? null);
913
+ }
914
+ return;
915
+ }
916
+ case "replay_start": {
917
+ let r = this.recovery;
918
+ if (!r) {
919
+ // A replay we did not ask for in this state (e.g. a late answer) — track it anyway.
920
+ r = this.recovery = {
921
+ protocol: "resume", from: null, channels: [], request: {}, acked: true, suppressAck: false,
922
+ instanceChanged: false, start: null, received: 0, delivered: 0, duplicates: 0, held: [], timer: null,
923
+ maxSeq: null, maxTs: null, pending: new Set([subIdOf(msg)]), starts: new Map(), ends: new Map(),
924
+ };
925
+ }
926
+ if (r.protocol === "detect") {
927
+ r.protocol = "resume";
928
+ if (r.timer) {
929
+ clearTimeout(r.timer);
930
+ r.timer = null;
931
+ }
932
+ }
933
+ r.starts.set(subIdOf(msg), msg);
934
+ if (!r.start)
935
+ r.start = msg;
936
+ return;
937
+ }
938
+ case "replay_end": {
939
+ const r = this.recovery;
940
+ if (!r)
941
+ return;
942
+ const sid = subIdOf(msg);
943
+ r.ends.set(sid, msg);
944
+ r.pending.delete(sid);
945
+ // Every subscription's replay has ended (a legacy server answers once,
946
+ // for the whole connection) → aggregate and commit.
947
+ if (r.pending.size === 0 || r.protocol === "legacy")
948
+ this.finishRecovery(msg);
949
+ return;
950
+ }
951
+ case "updated":
952
+ this.emit("updated", msg);
953
+ return;
954
+ case "unsubscribed":
955
+ this.emit("unsubscribed", msg);
956
+ return;
957
+ case "subscriptions": {
958
+ const list = Array.isArray(msg.list)
959
+ ? msg.list.map((s) => ({
960
+ subId: typeof s.sub_id === "string" ? s.sub_id : DEFAULT_SUB_ID,
961
+ channels: (Array.isArray(s.channels) ? s.channels : []),
962
+ filters: (s.filters && typeof s.filters === "object" ? s.filters : {}),
963
+ }))
964
+ : [];
965
+ const waiters = this.listWaiters;
966
+ this.listWaiters = [];
967
+ for (const w of waiters) {
968
+ clearTimeout(w.timer);
969
+ w.resolve(list);
970
+ }
971
+ return;
972
+ }
973
+ case "warning": {
974
+ const sid = typeof msg.sub_id === "string" ? msg.sub_id : null;
975
+ if (msg.code === "channels_revoked") {
976
+ // The server dropped these (e.g. plan downgrade): stop re-subscribing them.
977
+ const names = new Set();
978
+ if (Array.isArray(msg.channels))
979
+ for (const c of msg.channels)
980
+ if (typeof c === "string")
981
+ names.add(c);
982
+ if (Array.isArray(msg.revoked)) {
983
+ for (const x of msg.revoked) {
984
+ if (typeof x === "string")
985
+ names.add(x);
986
+ else if (x && typeof x === "object" && typeof x.channel === "string")
987
+ names.add(x.channel);
988
+ }
989
+ }
990
+ const target = sid && sid !== DEFAULT_SUB_ID ? this.named.get(sid)?.channels : this.desired.channels;
991
+ if (target)
992
+ for (const c of names)
993
+ target.delete(c);
994
+ if (sid && sid !== DEFAULT_SUB_ID && this.named.get(sid)?.channels.size === 0)
995
+ this.named.delete(sid);
996
+ }
997
+ else if ((msg.code === "too_many_subscriptions" || msg.code === "invalid_sub_id") && sid && sid !== DEFAULT_SUB_ID) {
998
+ // The server will refuse it on every reconnect too: forget it, and do not wait for its replay.
999
+ this.named.delete(sid);
1000
+ this.forgetPending(sid);
1001
+ }
1002
+ // Never swallow a server warning: a rejected/revoked channel is silent.
1003
+ this.emit("warning", msg);
1004
+ return;
1005
+ }
1006
+ default:
1007
+ break;
1008
+ }
1009
+ if (!msg.channel || !msg.event)
174
1010
  return;
1011
+ // Bus-recovered frames (recovered:"bus") are re-sent live, not part of a replay.
1012
+ const inReplay = msg.replayed === true && msg.recovered !== "bus";
1013
+ const r = this.recovery;
1014
+ if (r) {
1015
+ if (!inReplay && r.protocol === "detect" && r.acked)
1016
+ this.fallbackToLegacy(); // live before replay_start → old server
1017
+ if (!inReplay && r.protocol === "legacy") {
1018
+ if (r.held.length < HELD_LIVE_CAP) {
1019
+ r.held.push(msg);
1020
+ return;
1021
+ }
1022
+ // Too much live traffic to hold: stop holding, deliver in arrival order.
1023
+ const held = r.held;
1024
+ r.held = [];
1025
+ for (const f of held)
1026
+ this.deliver(f);
1027
+ }
1028
+ if (inReplay)
1029
+ r.received++;
175
1030
  }
176
- if (msg.type === "connected") {
1031
+ this.deliver(msg);
1032
+ }
1033
+ /** Dedupe by id, hand the frame to the handlers, track completion for the cursor. */
1034
+ deliver(msg) {
1035
+ const inReplay = msg.replayed === true && msg.recovered !== "bus";
1036
+ if (inReplay && this.recovery) {
1037
+ const r = this.recovery;
1038
+ if (typeof msg.seq === "number" && Number.isFinite(msg.seq))
1039
+ r.maxSeq = Math.max(r.maxSeq ?? msg.seq, msg.seq);
1040
+ if (typeof msg.ts === "number" && Number.isFinite(msg.ts))
1041
+ r.maxTs = Math.max(r.maxTs ?? msg.ts, msg.ts);
1042
+ }
1043
+ const id = typeof msg.id === "string" || typeof msg.id === "number" ? String(msg.id) : null;
1044
+ if (id !== null && this.opts.dedupeSize > 0) {
1045
+ // Dedupe is per (sub_id, channel, id): the same event delivered under two
1046
+ // named subscriptions is two legitimate deliveries.
1047
+ const key = `${subIdOf(msg)}\u0000${String(msg.channel)}\u0000${id}`;
1048
+ if (this.seen.has(key)) {
1049
+ this.seen.delete(key);
1050
+ this.seen.set(key, true);
1051
+ if (inReplay && this.recovery)
1052
+ this.recovery.duplicates++;
1053
+ return;
1054
+ }
1055
+ this.seen.set(key, true);
1056
+ if (this.seen.size > this.opts.dedupeSize) {
1057
+ const oldest = this.seen.keys().next().value;
1058
+ if (oldest !== undefined)
1059
+ this.seen.delete(oldest);
1060
+ }
1061
+ }
1062
+ if (inReplay && this.recovery)
1063
+ this.recovery.delivered++;
1064
+ const data = msg.data;
1065
+ const evt = { ...msg, replayed: msg.replayed === true || (!!data && typeof data === "object" && data.replayed === true) };
1066
+ const seq = typeof msg.seq === "number" && Number.isFinite(msg.seq) ? msg.seq : null;
1067
+ const ts = typeof msg.ts === "number" && Number.isFinite(msg.ts) ? msg.ts : null;
1068
+ // Only sequenced/identified frames move the cursor (token:price ticks are
1069
+ // state, not a log) — and none while a recovery runs: finishRecovery()
1070
+ // commits the server's replay_end position if, and only if, it is complete.
1071
+ const pos = (seq !== null || id !== null) && ts !== null ? { instance: this.serverInstance, seq, ts } : null;
1072
+ // Progress always moves; the COMMITTED cursor only for live frames outside
1073
+ // a recovery and not after an incomplete one (see finishRecovery).
1074
+ const commit = !this.recovery && !this.unsafe;
1075
+ const results = [];
1076
+ this.callHandlers(evt.event, evt.data, evt, results);
1077
+ this.callHandlers("*", evt.data, evt, results);
1078
+ const pending = results.filter(isThenable);
1079
+ if (pending.length === 0 && this.inflight.length === 0) {
1080
+ if (pos)
1081
+ this.apply(pos, commit);
177
1082
  return;
178
- } // 'open' already emitted on socket open
179
- if (msg.type === "subscribed") {
180
- this.emit("subscribed", msg.channels);
1083
+ }
1084
+ const entry = { pos, commit, done: pending.length === 0 };
1085
+ this.inflight.push(entry);
1086
+ if (entry.done) {
1087
+ this.drainInflight();
181
1088
  return;
182
1089
  }
183
- if (msg.channel && msg.event) {
184
- const evt = msg;
185
- this.emit(evt.event, evt.data, evt);
186
- this.emit("*", evt.data, evt);
1090
+ void Promise.allSettled(pending).then((settled) => {
1091
+ for (const s of settled)
1092
+ if (s.status === "rejected")
1093
+ this.emit("error", s.reason);
1094
+ entry.done = true;
1095
+ this.drainInflight();
1096
+ });
1097
+ }
1098
+ drainInflight() {
1099
+ while (this.inflight.length > 0 && this.inflight[0].done) {
1100
+ const e = this.inflight.shift();
1101
+ if (e.pos)
1102
+ this.apply(e.pos, e.commit);
187
1103
  }
188
1104
  }
189
- scheduleReconnect() {
190
- if (this.reconnectTimer)
1105
+ /** Queue a position behind every frame still being handled (or apply it now). */
1106
+ enqueue(pos, commit) {
1107
+ if (this.inflight.length === 0)
1108
+ this.apply(pos, commit);
1109
+ else
1110
+ this.inflight.push({ pos, commit, done: true });
1111
+ }
1112
+ /** Move `progress` (always) and the committed cursor (when `commit`) to `pos`. */
1113
+ apply(pos, commit) {
1114
+ const p = stepCursor(this.progress, pos);
1115
+ if (p)
1116
+ this.progress = p;
1117
+ if (!commit)
1118
+ return;
1119
+ const c = this.cursor;
1120
+ const next = stepCursor(c, pos);
1121
+ if (!next || (c && c.instance === next.instance && c.seq === next.seq && c.ts === next.ts))
1122
+ return;
1123
+ this.cursor = next;
1124
+ this.emit("cursor", { ...next });
1125
+ }
1126
+ scheduleReconnect(code = null, minDelayMs = 0) {
1127
+ if (this.reconnectTimer || this.stopped)
191
1128
  return;
192
1129
  const base = Math.min(1000 * 2 ** this.attempt, this.opts.maxBackoffMs);
193
- const delay = base / 2 + Math.floor((base / 2) * Math.random()); // jitter
1130
+ let delay = base / 2 + Math.floor((base / 2) * Math.random()); // jitter
1131
+ if (minDelayMs > 0)
1132
+ delay = Math.max(delay, minDelayMs + Math.floor((minDelayMs / 2) * Math.random()));
194
1133
  this.attempt++;
195
- this.emit("reconnect", { attempt: this.attempt, delayMs: delay });
1134
+ this.emit("reconnect", { attempt: this.attempt, delayMs: delay, code });
196
1135
  this.reconnectTimer = setTimeout(() => {
197
1136
  this.reconnectTimer = null;
198
1137
  void this.connect();