madeonsol-x402 2.2.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/README.md +18 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/stream.d.ts +122 -7
- package/dist/stream.d.ts.map +1 -1
- package/dist/stream.js +358 -51
- package/dist/stream.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/stream.js
CHANGED
|
@@ -40,6 +40,9 @@ async function resolveWebSocket(override) {
|
|
|
40
40
|
}
|
|
41
41
|
const OPEN = 1;
|
|
42
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);
|
|
43
46
|
function isThenable(v) {
|
|
44
47
|
return !!v && (typeof v === "object" || typeof v === "function") && typeof v.then === "function";
|
|
45
48
|
}
|
|
@@ -80,6 +83,12 @@ export class MadeOnSolStream {
|
|
|
80
83
|
ws = null;
|
|
81
84
|
listeners = new Map();
|
|
82
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 = [];
|
|
83
92
|
closedByUser = false;
|
|
84
93
|
stopped = false;
|
|
85
94
|
attempt = 0;
|
|
@@ -200,27 +209,123 @@ export class MadeOnSolStream {
|
|
|
200
209
|
}
|
|
201
210
|
}
|
|
202
211
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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);
|
|
209
236
|
if (this.ws && this.ws.readyState === OPEN)
|
|
210
|
-
this.sendSubscribe();
|
|
237
|
+
this.sendSubscribe({ only: [subId] });
|
|
211
238
|
else
|
|
212
239
|
void this.connect();
|
|
213
240
|
return this;
|
|
214
241
|
}
|
|
215
|
-
/**
|
|
216
|
-
|
|
217
|
-
|
|
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)
|
|
218
273
|
this.desired.channels.delete(c);
|
|
219
274
|
if (this.ws && this.ws.readyState === OPEN) {
|
|
220
|
-
this.ws.send(JSON.stringify({ type: "unsubscribe", channels }));
|
|
275
|
+
this.ws.send(JSON.stringify({ type: "unsubscribe", channels: arg }));
|
|
221
276
|
}
|
|
222
277
|
return this;
|
|
223
278
|
}
|
|
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
|
+
}
|
|
224
329
|
/** Open the connection (also called implicitly by subscribe). Restarts a stream that went `"fatal"`. */
|
|
225
330
|
async connect() {
|
|
226
331
|
if (this.connecting || (this.ws && this.ws.readyState === OPEN))
|
|
@@ -252,7 +357,8 @@ export class MadeOnSolStream {
|
|
|
252
357
|
// The backoff attempt is NOT reset here — only a `subscribed` ack proves
|
|
253
358
|
// the connection is usable (an auth/limit close follows a successful open).
|
|
254
359
|
this.resetHeartbeat();
|
|
255
|
-
|
|
360
|
+
this.ackExpect = [];
|
|
361
|
+
if (this.desired.channels.size > 0 || this.named.size > 0)
|
|
256
362
|
this.sendSubscribe();
|
|
257
363
|
this.emit("open", undefined);
|
|
258
364
|
};
|
|
@@ -370,29 +476,64 @@ export class MadeOnSolStream {
|
|
|
370
476
|
}
|
|
371
477
|
this.emit("fatal", { code, reason });
|
|
372
478
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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)
|
|
505
|
+
return;
|
|
506
|
+
const frames = this.subscribeFrames(only);
|
|
507
|
+
if (frames.length === 0)
|
|
376
508
|
return;
|
|
377
|
-
const msg = { type: "subscribe", channels };
|
|
378
|
-
if (Object.keys(this.desired.filters).length > 0)
|
|
379
|
-
msg.filters = this.desired.filters;
|
|
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
509
|
if ((!this.firstSubscribeSent || resumeOverride) && this.cursor && !this.recovery) {
|
|
384
510
|
const from = resumeOverride ?? { ...this.cursor };
|
|
385
|
-
|
|
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
|
+
}
|
|
386
519
|
this.recovery = {
|
|
387
|
-
protocol: "detect", from, channels, request: { resume: from }, acked: false, suppressAck: false,
|
|
520
|
+
protocol: "detect", from, channels: Array.from(channels), request: { resume: from }, acked: false, suppressAck: false,
|
|
388
521
|
instanceChanged: false, start: null, received: 0, delivered: 0, duplicates: 0, held: [], timer: null,
|
|
389
|
-
maxSeq: null, maxTs: null,
|
|
522
|
+
maxSeq: null, maxTs: null, pending, starts: new Map(), ends: new Map(),
|
|
390
523
|
};
|
|
391
524
|
}
|
|
392
525
|
this.firstSubscribeSent = true;
|
|
393
|
-
|
|
526
|
+
for (const f of frames) {
|
|
527
|
+
this.ackExpect.push(f.subId);
|
|
528
|
+
this.ws.send(JSON.stringify(f.msg));
|
|
529
|
+
}
|
|
394
530
|
}
|
|
395
|
-
/**
|
|
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
|
+
*/
|
|
396
537
|
fallbackToLegacy() {
|
|
397
538
|
const r = this.recovery;
|
|
398
539
|
if (!r || r.protocol !== "detect" || !r.from || !this.ws)
|
|
@@ -402,6 +543,7 @@ export class MadeOnSolStream {
|
|
|
402
543
|
r.timer = null;
|
|
403
544
|
}
|
|
404
545
|
r.protocol = "legacy";
|
|
546
|
+
r.pending = new Set([DEFAULT_SUB_ID]);
|
|
405
547
|
r.instanceChanged = !this.serverInstance || this.serverInstance !== r.from.instance;
|
|
406
548
|
// Same process → its ring still indexes our seq. Restarted → seq restarted, use time.
|
|
407
549
|
const legacy = r.instanceChanged ? { replay_since_ts: r.from.ts } : { replay_since_seq: r.from.seq };
|
|
@@ -424,13 +566,22 @@ export class MadeOnSolStream {
|
|
|
424
566
|
clearTimeout(this.retryTimer);
|
|
425
567
|
this.retryTimer = null;
|
|
426
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
|
+
}
|
|
427
578
|
}
|
|
428
579
|
/**
|
|
429
580
|
* A retryable gap: ask the server again on this connection after its
|
|
430
581
|
* retry_after_ms (row_cap resumes from resume_ts_hint). Bounded — the next
|
|
431
582
|
* reconnect resumes anyway.
|
|
432
583
|
*/
|
|
433
|
-
scheduleResumeRetry(retryAfterMs, hintTs) {
|
|
584
|
+
scheduleResumeRetry(retryAfterMs, hintTs, only) {
|
|
434
585
|
if (this.retryTimer || !this.cursor)
|
|
435
586
|
return;
|
|
436
587
|
if (this.resumeRetries >= this.opts.maxResumeRetries)
|
|
@@ -440,11 +591,90 @@ export class MadeOnSolStream {
|
|
|
440
591
|
const from = hintTs !== null && hintTs > this.cursor.ts ? { ...this.cursor, ts: hintTs } : { ...this.cursor };
|
|
441
592
|
this.retryTimer = setTimeout(() => {
|
|
442
593
|
this.retryTimer = null;
|
|
594
|
+
// Only the subscriptions whose replay was incomplete are asked again.
|
|
443
595
|
if (this.ws && this.ws.readyState === OPEN && !this.recovery)
|
|
444
|
-
this.sendSubscribe(from);
|
|
596
|
+
this.sendSubscribe({ resumeOverride: from, only });
|
|
445
597
|
}, delay);
|
|
446
598
|
}
|
|
447
|
-
|
|
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) {
|
|
448
678
|
const r = this.recovery;
|
|
449
679
|
if (!r)
|
|
450
680
|
return;
|
|
@@ -453,6 +683,10 @@ export class MadeOnSolStream {
|
|
|
453
683
|
r.timer = null;
|
|
454
684
|
}
|
|
455
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);
|
|
456
690
|
const reasons = [];
|
|
457
691
|
/** Reasons of the channels the server reported incomplete, with their retryability. */
|
|
458
692
|
const channelReasons = [];
|
|
@@ -462,7 +696,7 @@ export class MadeOnSolStream {
|
|
|
462
696
|
const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
|
463
697
|
// A v1 server answers with complete/sent/matched; an older one with count only.
|
|
464
698
|
const v1 = !!end && ("complete" in end || "sent" in end || "matched" in end);
|
|
465
|
-
if (r.start?.replay_truncated === true || end?.replay_truncated === true)
|
|
699
|
+
if ([...r.starts.values()].some((s) => s.replay_truncated === true) || r.start?.replay_truncated === true || end?.replay_truncated === true)
|
|
466
700
|
reasons.push("ring_truncated");
|
|
467
701
|
if (!end)
|
|
468
702
|
reasons.push("replay_timeout");
|
|
@@ -511,6 +745,8 @@ export class MadeOnSolStream {
|
|
|
511
745
|
resumeReason: typeof end?.resume_reason === "string" ? end.resume_reason : null,
|
|
512
746
|
start: r.start,
|
|
513
747
|
end,
|
|
748
|
+
subscriptions: r.ends.size ? [...r.ends.keys()] : r.pending.size ? [...r.pending] : [DEFAULT_SUB_ID],
|
|
749
|
+
ends: Object.fromEntries(r.ends),
|
|
514
750
|
};
|
|
515
751
|
// Final vs retryable. The server says which (`retryable`): true only when an
|
|
516
752
|
// incomplete channel's reason is transient (backpressure, closed,
|
|
@@ -589,7 +825,7 @@ export class MadeOnSolStream {
|
|
|
589
825
|
else if (retryable) {
|
|
590
826
|
this.unsafe = true;
|
|
591
827
|
if (serverSays)
|
|
592
|
-
this.scheduleResumeRetry(num(end?.retry_after_ms), capOnly ? num(end?.resume_ts_hint) : null);
|
|
828
|
+
this.scheduleResumeRetry(num(end?.retry_after_ms), capOnly ? num(end?.resume_ts_hint) : null, retrySubs.length ? retrySubs : undefined);
|
|
593
829
|
}
|
|
594
830
|
else {
|
|
595
831
|
// strict: stop instead of skipping what cannot be recovered.
|
|
@@ -619,7 +855,7 @@ export class MadeOnSolStream {
|
|
|
619
855
|
if (typeof msg.instance === "string")
|
|
620
856
|
this.serverInstance = msg.instance;
|
|
621
857
|
// Nothing to subscribe to → this frame is as far as a healthy connection gets.
|
|
622
|
-
if (this.desired.channels.size === 0) {
|
|
858
|
+
if (this.desired.channels.size === 0 && this.named.size === 0) {
|
|
623
859
|
this.attempt = 0;
|
|
624
860
|
this.authFailures = 0;
|
|
625
861
|
}
|
|
@@ -634,23 +870,47 @@ export class MadeOnSolStream {
|
|
|
634
870
|
r.suppressAck = false;
|
|
635
871
|
return;
|
|
636
872
|
} // ack of our own fallback subscribe
|
|
637
|
-
|
|
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);
|
|
638
893
|
if (r && r.protocol === "detect" && !r.acked) {
|
|
639
894
|
r.acked = true;
|
|
640
|
-
|
|
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)
|
|
895
|
+
if ("resume" in msg)
|
|
650
896
|
r.protocol = "resume"; // server echoed resume: it understood
|
|
651
897
|
else
|
|
652
898
|
r.timer = setTimeout(() => this.fallbackToLegacy(), this.opts.resumeDetectMs);
|
|
653
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
|
+
}
|
|
654
914
|
return;
|
|
655
915
|
}
|
|
656
916
|
case "replay_start": {
|
|
@@ -660,7 +920,7 @@ export class MadeOnSolStream {
|
|
|
660
920
|
r = this.recovery = {
|
|
661
921
|
protocol: "resume", from: null, channels: [], request: {}, acked: true, suppressAck: false,
|
|
662
922
|
instanceChanged: false, start: null, received: 0, delivered: 0, duplicates: 0, held: [], timer: null,
|
|
663
|
-
maxSeq: null, maxTs: null,
|
|
923
|
+
maxSeq: null, maxTs: null, pending: new Set([subIdOf(msg)]), starts: new Map(), ends: new Map(),
|
|
664
924
|
};
|
|
665
925
|
}
|
|
666
926
|
if (r.protocol === "detect") {
|
|
@@ -670,13 +930,48 @@ export class MadeOnSolStream {
|
|
|
670
930
|
r.timer = null;
|
|
671
931
|
}
|
|
672
932
|
}
|
|
673
|
-
r.
|
|
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);
|
|
674
949
|
return;
|
|
675
950
|
}
|
|
676
|
-
case "
|
|
677
|
-
this.
|
|
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
|
+
}
|
|
678
971
|
return;
|
|
679
|
-
|
|
972
|
+
}
|
|
973
|
+
case "warning": {
|
|
974
|
+
const sid = typeof msg.sub_id === "string" ? msg.sub_id : null;
|
|
680
975
|
if (msg.code === "channels_revoked") {
|
|
681
976
|
// The server dropped these (e.g. plan downgrade): stop re-subscribing them.
|
|
682
977
|
const names = new Set();
|
|
@@ -692,12 +987,22 @@ export class MadeOnSolStream {
|
|
|
692
987
|
names.add(x.channel);
|
|
693
988
|
}
|
|
694
989
|
}
|
|
695
|
-
|
|
696
|
-
|
|
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);
|
|
697
1001
|
}
|
|
698
1002
|
// Never swallow a server warning: a rejected/revoked channel is silent.
|
|
699
1003
|
this.emit("warning", msg);
|
|
700
1004
|
return;
|
|
1005
|
+
}
|
|
701
1006
|
default:
|
|
702
1007
|
break;
|
|
703
1008
|
}
|
|
@@ -737,7 +1042,9 @@ export class MadeOnSolStream {
|
|
|
737
1042
|
}
|
|
738
1043
|
const id = typeof msg.id === "string" || typeof msg.id === "number" ? String(msg.id) : null;
|
|
739
1044
|
if (id !== null && this.opts.dedupeSize > 0) {
|
|
740
|
-
|
|
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}`;
|
|
741
1048
|
if (this.seen.has(key)) {
|
|
742
1049
|
this.seen.delete(key);
|
|
743
1050
|
this.seen.set(key, true);
|