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