@synapse-sdk/client 0.1.0 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/topics.js ADDED
@@ -0,0 +1,959 @@
1
+ // Topics (SDK §4, WP §6–§7), the JS spelling of the Rust client's `topics.rs`.
2
+ //
3
+ // A publisher is an intent (`channel.ts`): its handle task asks the runtime `synapse/topic/register`
4
+ // on every session and, admitted, installs the broadcast `<prefix>/topic/<bind>/<topic>` — `desc`
5
+ // (one retained frame: the descriptor with our time domain) and `data` — which `publish()` writes to
6
+ // on the fast lane, synchronously; while nothing is installed frames are dropped and counted.
7
+ //
8
+ // A subscription follows its *topic*: a task (`followTopic`) subscribes to each publisher the
9
+ // catalog lists for the topic but ourselves and hands its `data` track to the read; a publisher that
10
+ // leaves the catalog is removed; a new session resets the set. The catalog is the runtime's statement
11
+ // (listed = alive): a publisher whose subscribe fails is left out until the catalog next changes,
12
+ // when every listed publisher not held is tried again; no timer of our own. A datagram publisher
13
+ // whose own `desc` has `fec` gets a follower (the video path: `FecReader` per publisher).
14
+ import * as Moq from "@moq/net";
15
+ import { FLAGS, MSGPACK, SynapseError, TYPES, auParamSets, auSyncPoints, decode as decodeBody, decodeHeader, encode as encodeBody, fecSymbolSizeFor, frame, isVideo, mediaFlags, moqPriority, nowUs, stampSentUs, hasPendingSentUs, mediaHeader, topicPath, validateSync, } from "@synapse-sdk/core";
16
+ import { Handle, start as startHandle } from "./channel.js";
17
+ import { FecReader, FecWriter, ReportWriter, feedbackListener, requireFec } from "./fec.js";
18
+ import { RETAINED_FRAME_MAX_AGE_MS, StreamWriter, latestSubscription, moqTimestamp, streamTopicSubscription, waitActive, writeLatest } from "./session.js";
19
+ import { AsyncQueue, deferred, withTimeout, TIMEOUT } from "./util.js";
20
+ /** moq's cap on one `stream` frame (a group's cache). */
21
+ const STREAM_FRAME_MAX = 32 * 1024 * 1024;
22
+ /** The bound on adding one publisher (its broadcast, its `desc`, its `data` subscription). */
23
+ const ADD_WITHIN_MS = 2000;
24
+ /** The most messages one read takes from one publisher: a publisher that stays readable cannot keep a read from returning. */
25
+ const DRAIN_PER_READ = 256;
26
+ /** The cap of the every-message `fec` queue (frames): moq's own bound on a track's unread datagrams, as in Rust (`FEC_QUEUE_CAP` 64). */
27
+ const FEC_QUEUE_CAP = 64;
28
+ /** The publisher shared by every `publisher(topic)` handle of one topic on this node. */
29
+ export class TopicPublisher {
30
+ node;
31
+ contract;
32
+ wasm;
33
+ warn;
34
+ name;
35
+ registerSvc = "synapse/topic/register";
36
+ unregisterSvc = "synapse/topic/unregister";
37
+ desc;
38
+ fecParams;
39
+ fecStats;
40
+ #bound;
41
+ #verdict = { status: "unbound" };
42
+ #seq = 0;
43
+ published = 0;
44
+ droppedDisconnected = 0;
45
+ handles = 0;
46
+ handle;
47
+ constructor(node, contract, maxDatagram, wasm, warn) {
48
+ this.node = node;
49
+ this.contract = contract;
50
+ this.wasm = wasm;
51
+ this.warn = warn;
52
+ this.name = contract.name;
53
+ let fec = contract.delivery === "datagram" ? contract.fec : undefined;
54
+ if (fec) {
55
+ fec = { ...fec, symbol_size: fec.symbol_size === 0 ? fecSymbolSizeFor(maxDatagram) : fec.symbol_size };
56
+ if (fec.symbol_size < 8 || fec.symbol_size % 8 !== 0)
57
+ throw new SynapseError("invalid_input", `fec symbol_size ${fec.symbol_size} must be a multiple of 8`);
58
+ this.fecStats = { blocks: 0, plain: 0, superseded: 0, symbols: 0, reports: 0, repairPct: fec.repair_pct };
59
+ }
60
+ this.fecParams = fec;
61
+ this.desc = contract.desc(node.dom);
62
+ if (fec)
63
+ this.desc.fec = { ...fec };
64
+ this.handle = startHandle(node, this);
65
+ }
66
+ get verdict() { return this.#verdict; }
67
+ get isBound() { return this.#bound !== undefined; }
68
+ registerArgs(bind) { return { topic: this.name, desc: this.desc, bind }; }
69
+ unregisterArgs(bind) { return { topic: this.name, bind }; }
70
+ /** Admitted: the broadcast is created, the descriptor written, the data track made, then announced (WP §7.1). */
71
+ install(s, bind, _mailbox) {
72
+ const bc = s.createBroadcast(topicPath(s.prefix, bind, this.name));
73
+ // The one-frame descriptor is kept for the publisher's life: a subscriber joining later reads it (see `RETAINED_FRAME_MAX_AGE_MS`).
74
+ const desc = bc.createTrack("desc", { priority: moqPriority(1), maxAge: Moq.Time.Milli(RETAINED_FRAME_MAX_AGE_MS) });
75
+ writeLatest(desc, { flags: FLAGS.KEY, type: TYPES.PUB, seq: 1, tUs: nowUs() }, encodeBody(this.desc));
76
+ // Stream frames carry the publish stamp at microsecond timescale (newest among one millisecond's messages is by stamp, not poll order).
77
+ const dataTrack = bc.createTrack("data", this.contract.delivery === "stream" ? { priority: moqPriority(3), timescale: Moq.Time.Timescale.MICRO } : { priority: moqPriority(3) });
78
+ let data;
79
+ if (this.contract.delivery === "stream")
80
+ data = { kind: "stream", w: new StreamWriter(dataTrack) };
81
+ else if (!this.fecParams)
82
+ data = { kind: "datagram", track: dataTrack };
83
+ else {
84
+ const params = { ...this.fecParams, repair_pct: this.fecStats.repairPct };
85
+ data = { kind: "fec", w: new FecWriter(this.name, dataTrack, this.wasm, params, this.fecStats, () => { }) };
86
+ }
87
+ bc.announce();
88
+ let feedback;
89
+ if (data.kind === "fec" && this.fecParams.repair_pct > 0) {
90
+ feedback = new AbortController();
91
+ const w = data.w;
92
+ void feedbackListener(s, this.name, this.fecParams.repair_pct, pct => w.setRepairPct(pct), () => { this.fecStats.reports++; }, feedback.signal, this.warn);
93
+ }
94
+ const old = this.#bound;
95
+ this.#bound = { bc, desc, data, feedback };
96
+ if (old)
97
+ finish(old);
98
+ }
99
+ retract() { const b = this.#bound; this.#bound = undefined; if (b)
100
+ finish(b); }
101
+ setVerdict(v) { this.#verdict = v; }
102
+ /**
103
+ * Publishes one frame (SDK §13: synchronous, never awaits the network). While nothing is installed
104
+ * (between sessions, a refused register being asked again) the frame is dropped and counted. A
105
+ * `stream` frame over moq's cap, a plain datagram over the session's datagram size, or a `fec`
106
+ * frame the codec refuses is refused (`invalid_input`) without taking a `seq`.
107
+ */
108
+ publishFrame(type, flags, tUs, tlvs, body) {
109
+ // A `MEDIA` frame on a msgpack topic contradicts the descriptor every subscriber decodes by (WP §7.2).
110
+ if (type === TYPES.MEDIA && !this.contract.raw)
111
+ throw new SynapseError("invalid_input", `topic ${this.name}: a MEDIA frame needs a raw topic (a contentType other than ${MSGPACK})`);
112
+ const b = this.#bound;
113
+ if (!b) {
114
+ this.droppedDisconnected++;
115
+ return;
116
+ }
117
+ const header = { flags, type: type, seq: 0, tUs };
118
+ if (tlvs.length)
119
+ header.tlvs = tlvs;
120
+ const len = 16 + body.length;
121
+ if (b.data.kind === "stream" && len > STREAM_FRAME_MAX)
122
+ throw new SynapseError("invalid_input", `stream topic ${this.name}: ${len} bytes exceeds moq's ${STREAM_FRAME_MAX}-byte frame cap`);
123
+ if (b.data.kind === "datagram" && len > this.node.knobs.maxDatagram)
124
+ throw new SynapseError("invalid_input", `datagram topic ${this.name}: ${len} bytes exceeds the session's datagram size of ${this.node.knobs.maxDatagram} bytes; use a stream topic, or a datagram topic with fec`);
125
+ header.seq = this.#seq;
126
+ let written = true;
127
+ try {
128
+ switch (b.data.kind) {
129
+ case "stream":
130
+ if (hasPendingSentUs(header))
131
+ stampSentUs(header, nowUs());
132
+ b.data.w.write(header, body);
133
+ break;
134
+ case "datagram":
135
+ if (hasPendingSentUs(header))
136
+ stampSentUs(header, nowUs());
137
+ b.data.track.appendDatagram(moqTimestamp(tUs), frame(header, body));
138
+ break;
139
+ case "fec":
140
+ b.data.w.publish(header, body);
141
+ break;
142
+ }
143
+ }
144
+ catch (e) {
145
+ if (e instanceof SynapseError && e.code === "invalid_input")
146
+ throw e;
147
+ written = false; // the session is going down under us: the unbind is on its way
148
+ }
149
+ this.#seq = (this.#seq + 1) >>> 0;
150
+ if (written)
151
+ this.published++;
152
+ else
153
+ this.droppedDisconnected++;
154
+ }
155
+ /**
156
+ * The typed form: validates against the contract's schema and encodes msgpack; a raw topic's bytes
157
+ * go as given, and on a `video/*` topic as a `MEDIA` frame whose `KEY`/`CONFIG`/`RECOVERY` flags
158
+ * are read off the bitstream (Rust `Publisher<RawBody>::publish`).
159
+ */
160
+ publish(value, opts = {}) {
161
+ const tUs = opts.tUs ?? nowUs();
162
+ if (this.contract.raw) {
163
+ if (!(value instanceof Uint8Array))
164
+ throw new SynapseError("invalid_input", `topic ${this.name} (${this.contract.contentType}) takes bytes`);
165
+ if (!isVideo(this.contract.contentType)) {
166
+ this.publishFrame(TYPES.PUB, opts.flags ?? 0, tUs, [], value);
167
+ return;
168
+ }
169
+ const { idr, recovery } = auSyncPoints(value);
170
+ this.publishFrame(TYPES.MEDIA, mediaFlags({ key: idr, config: auParamSets(value) !== undefined, recovery }) | (opts.flags ?? 0), tUs, [], value);
171
+ return;
172
+ }
173
+ let v = value;
174
+ if (this.contract.schema) {
175
+ const r = validateSync(this.contract.schema, value);
176
+ if (r.issues)
177
+ throw new SynapseError("invalid_input", { message: r.issues[0]?.message ?? "invalid value", data: { issues: r.issues } });
178
+ v = r.value;
179
+ }
180
+ this.publishFrame(TYPES.PUB, opts.flags ?? 0, tUs, [], encodeBody(v));
181
+ }
182
+ /** An encoder's frame with the flags it already carries and its `STAGES` stamps (no re-parse); `tUs` 0 = now. The video helper's path (Rust `publish_media_frame`), not a user verb. */
183
+ publishMediaFrame(f) { const h = mediaHeader(f, 0); this.publishFrame(h.type, h.flags, f.tUs || nowUs(), h.tlvs ?? [], f.data); }
184
+ stats() {
185
+ const s = { published: this.published, droppedDisconnected: this.droppedDisconnected };
186
+ if (this.fecStats)
187
+ s.fec = { blocks: this.fecStats.blocks, plain: this.fecStats.plain, superseded: this.fecStats.superseded };
188
+ return s;
189
+ }
190
+ }
191
+ function finish(b) {
192
+ b.feedback?.abort();
193
+ try {
194
+ if (b.data.kind === "fec")
195
+ b.data.w.close();
196
+ else if (b.data.kind === "stream")
197
+ b.data.w.close();
198
+ else
199
+ b.data.track.close();
200
+ }
201
+ catch { /* closed */ }
202
+ try {
203
+ b.desc.close();
204
+ }
205
+ catch { /* closed */ }
206
+ try {
207
+ b.bc.close();
208
+ }
209
+ catch { /* closed */ }
210
+ }
211
+ /**
212
+ * The key of a publisher handle's encoder-frame path, the JS spelling of Rust's `pub(crate)
213
+ * publish_media_frame`: `@synapse-sdk/media`'s `videoPublisher` reaches it by the same
214
+ * `Symbol.for` key (the two packages share no module), so it is on no public type.
215
+ */
216
+ export const PUBLISH_MEDIA_FRAME = Symbol.for("@synapse-sdk/client:publishMediaFrame");
217
+ /** The application's handle: the last one closed ends the publisher's task (`unregister`). */
218
+ export class PublisherHandle {
219
+ p;
220
+ onLast;
221
+ #closed = false;
222
+ constructor(p, onLast) {
223
+ this.p = p;
224
+ this.onLast = onLast;
225
+ p.handles++;
226
+ }
227
+ get topic() { return this.p.name; }
228
+ get verdict() { return this.p.verdict; }
229
+ ready() { return this.p.handle.ready(); }
230
+ /** A closed handle publishes nothing (the Rust handle is gone once dropped): `not_connected`. */
231
+ #open() { if (this.#closed)
232
+ throw new SynapseError("not_connected", "publisher closed"); }
233
+ publish(value, opts) { this.#open(); this.p.publish(value, opts); }
234
+ /** `@synapse-sdk/media`'s video publisher: an encoder's frame with its flags and `STAGES` (see `PUBLISH_MEDIA_FRAME`). */
235
+ [PUBLISH_MEDIA_FRAME](f) { this.#open(); this.p.publishMediaFrame(f); }
236
+ stats() { return this.p.stats(); }
237
+ close() {
238
+ if (this.#closed)
239
+ return;
240
+ this.#closed = true;
241
+ if (--this.p.handles === 0) {
242
+ this.p.handle.unregister();
243
+ this.onLast();
244
+ }
245
+ }
246
+ async [Symbol.asyncDispose]() { this.close(); }
247
+ }
248
+ /** Resolves the codec a `fec` topic needs (once per process). */
249
+ export async function wasmFor(contract) {
250
+ return contract.delivery === "datagram" && contract.fec ? requireFec(`topic ${contract.name}`) : undefined;
251
+ }
252
+ // ------------------------------------------------------------------------------ subscription
253
+ /** What a source gives when its track ended. */
254
+ const END = Symbol("end");
255
+ /**
256
+ * The bound on the datagrams one source holds for the read: the ring moq itself keeps of a track's
257
+ * unread datagrams (`MAX_DATAGRAMS = 64` in moq-net; Rust `FEC_QUEUE_CAP` is the same 64).
258
+ * `@moq/net` 0.4.2 has no synchronous datagram read, so a datagram source's reader takes them into
259
+ * this holder as they arrive and the read drains it: what the Rust read sees in moq's ring.
260
+ */
261
+ const DATAGRAM_RING = 64;
262
+ /** `seq` wraps at u32: "newer" is a positive signed distance (RFC 1982). */
263
+ const newer = (seq, than) => (((seq - than) | 0) > 0);
264
+ export class TopicSubscription {
265
+ node;
266
+ contract;
267
+ opts;
268
+ warn;
269
+ topic;
270
+ stats = { read: 0, superseded: 0, staleDrops: 0, mismatch: 0, fecGiveup: 0, repaired: 0, coalesced: 0 };
271
+ #sources = [];
272
+ /** Frames the `fec` followers reassembled; latest mode keeps one per publisher. */
273
+ #fec = [];
274
+ #wake;
275
+ #ended = false;
276
+ #stop = new AbortController();
277
+ latest;
278
+ #deadlineUs;
279
+ #throttleUntil = 0;
280
+ #throttled;
281
+ #throttleTimer;
282
+ constructor(node, contract, opts, warn) {
283
+ this.node = node;
284
+ this.contract = contract;
285
+ this.opts = opts;
286
+ this.warn = warn;
287
+ this.topic = contract.name;
288
+ this.latest = (opts.queueDepth ?? 1) !== 0;
289
+ this.#deadlineUs = opts.deadlineMs !== undefined ? opts.deadlineMs * 1000 : undefined;
290
+ void followTopic(this);
291
+ }
292
+ get signal() { return this.#stop.signal; }
293
+ get ownDom() { return this.node.dom.id; }
294
+ /** The follow task hands a plain publisher here (a datagram source's reader starts); the waiting `next()` is woken. */
295
+ addSource(src) {
296
+ if (this.#ended) {
297
+ closeSource(src);
298
+ return;
299
+ }
300
+ this.#sources.push(src);
301
+ if (!src.stream)
302
+ void this.#readDatagrams(src);
303
+ this.#signal();
304
+ }
305
+ /** A datagram source's reader: one `recvDatagram` at a time into `held`, until the track or the source ends. */
306
+ async #readDatagrams(src) {
307
+ try {
308
+ for (;;) {
309
+ const d = await src.sub.recvDatagram();
310
+ if (!d || src.ended)
311
+ break;
312
+ this.#hold(src, d.payload);
313
+ this.#signal();
314
+ }
315
+ }
316
+ catch { /* the track broke: its end */ }
317
+ src.done = true;
318
+ this.#signal();
319
+ }
320
+ /** Into a datagram source's holder: latest mode keeps the newest `seq`, every-message mode the last `DATAGRAM_RING`; what is displaced counts `superseded`. */
321
+ #hold(src, payload) {
322
+ if (!this.latest) {
323
+ src.held.push(payload);
324
+ if (src.held.length > DATAGRAM_RING) {
325
+ src.held.shift();
326
+ this.stats.superseded++;
327
+ }
328
+ return;
329
+ }
330
+ let seq;
331
+ try {
332
+ seq = decodeHeader(payload).header.seq;
333
+ }
334
+ catch {
335
+ this.stats.mismatch++;
336
+ return;
337
+ }
338
+ if (src.held.length) {
339
+ this.stats.superseded++;
340
+ if (!newer(seq, src.heldSeq))
341
+ return; // an older or equal `seq` arriving later
342
+ }
343
+ src.held[0] = payload;
344
+ src.heldSeq = seq;
345
+ }
346
+ /**
347
+ * The next payload ready on `src` without waiting (Rust `Source::poll_payload`), `END` once its
348
+ * track ended, `undefined` when nothing is ready: a stream source then holds one pending read
349
+ * (`recvGroup` / `readFrame`) that wakes the read when it settles.
350
+ */
351
+ #poll(src) {
352
+ if (!src.stream)
353
+ return src.held.length ? src.held.shift() : src.done ? END : undefined;
354
+ if (src.ready !== undefined) {
355
+ const r = src.ready;
356
+ src.ready = undefined;
357
+ return r;
358
+ }
359
+ if (src.wait)
360
+ return undefined;
361
+ for (;;) {
362
+ const g = src.group;
363
+ if (g) {
364
+ const f = g.tryReadFrame();
365
+ if (f)
366
+ return f.payload;
367
+ // The group ended, or moq expired it (the read fell behind its budget): the next one.
368
+ if (g.done || g.closed.peek() instanceof Error) {
369
+ src.group = undefined;
370
+ continue;
371
+ }
372
+ src.wait = g.readFrame().then(f => { if (f)
373
+ src.ready = f.payload;
374
+ else if (src.group === g)
375
+ src.group = undefined; }, () => { if (src.group === g)
376
+ src.group = undefined; }).finally(() => { src.wait = undefined; this.#signal(); });
377
+ return undefined;
378
+ }
379
+ let next;
380
+ try {
381
+ next = src.sub.tryRecvGroup();
382
+ }
383
+ catch {
384
+ return END;
385
+ }
386
+ if (next) {
387
+ src.group = next;
388
+ continue;
389
+ }
390
+ src.wait = src.sub.recvGroup().then(r => { if (r)
391
+ src.group = r;
392
+ else
393
+ src.ready = END; }, () => { src.ready = END; }).finally(() => { src.wait = undefined; this.#signal(); });
394
+ return undefined;
395
+ }
396
+ }
397
+ /** The publisher leaves the read: its subscription and request are closed, a payload it gave is never returned. */
398
+ removeSource(src) {
399
+ const i = this.#sources.indexOf(src);
400
+ if (i >= 0)
401
+ this.#sources.splice(i, 1);
402
+ closeSource(src);
403
+ this.#signal();
404
+ }
405
+ /** A `fec` follower's frame: latest mode replaces the publisher's pending one. */
406
+ pushFec(env) {
407
+ if (this.latest) {
408
+ const i = this.#fec.findIndex(e => e.publisher === env.publisher);
409
+ if (i >= 0) {
410
+ this.#fec[i] = env;
411
+ this.stats.superseded++;
412
+ }
413
+ else
414
+ this.#fec.push(env);
415
+ }
416
+ else {
417
+ this.#fec.push(env);
418
+ if (this.#fec.length > FEC_QUEUE_CAP) {
419
+ this.#fec.shift();
420
+ this.stats.superseded++;
421
+ }
422
+ }
423
+ this.#signal();
424
+ }
425
+ dropFecOf(publisher) { for (let i = this.#fec.length - 1; i >= 0; i--)
426
+ if (this.#fec[i].publisher === publisher)
427
+ this.#fec.splice(i, 1); }
428
+ /** A new session (or none): the `fec` frames and a batch held by `throttleMs` are the previous session's and go. */
429
+ clearFec() { this.#fec.length = 0; this.#throttled = undefined; }
430
+ #signal() { const w = this.#wake; this.#wake = undefined; w?.resolve(); }
431
+ /**
432
+ * The established descriptor's `content_type` (the catalog's, the runtime's statement), set by the
433
+ * follow task from each snapshot: bodies decode by it, not by what this subscription declared.
434
+ */
435
+ contentType;
436
+ /** The decoding step: header facts plus the body by the established `content_type` (msgpack → a value, validated when the contract has a schema; anything else → bytes), or `mismatch` and dropped. */
437
+ envelope(payload, publisher, dom) {
438
+ let header, hlen;
439
+ try {
440
+ ({ header, hlen } = decodeHeader(payload));
441
+ }
442
+ catch {
443
+ this.stats.mismatch++;
444
+ return undefined;
445
+ }
446
+ const body = payload.subarray(hlen);
447
+ let data;
448
+ if ((this.contentType ?? this.contract.contentType) !== MSGPACK)
449
+ data = body;
450
+ else {
451
+ let v;
452
+ try {
453
+ v = decodeBody(body);
454
+ }
455
+ catch {
456
+ this.stats.mismatch++;
457
+ return undefined;
458
+ }
459
+ if (this.contract.schema) {
460
+ const r = validateSync(this.contract.schema, v);
461
+ if (r.issues) {
462
+ this.stats.mismatch++;
463
+ return undefined;
464
+ }
465
+ v = r.value;
466
+ }
467
+ data = v;
468
+ }
469
+ const f = header.flags;
470
+ return { data, tUs: Number(header.tUs), seq: header.seq, publisher, dom, flags: f, type: header.type, tlvs: header.tlvs ?? [], key: (f & FLAGS.KEY) !== 0, config: (f & FLAGS.CONFIG) !== 0, recovery: (f & FLAGS.RECOVERY) !== 0 };
471
+ }
472
+ /** One payload taken from `src` (Rust `drain_source`'s step): every-message mode keeps it, latest mode keeps the newest `seq`. */
473
+ #take(src, payload, t, nowUs_) {
474
+ if (!this.latest && this.#deadlineUs === undefined) {
475
+ const e = this.envelope(payload, src.publisher, src.dom);
476
+ if (e)
477
+ t.out.push(e);
478
+ return;
479
+ }
480
+ // The header only: a superseded message's body is never decoded.
481
+ let h;
482
+ try {
483
+ h = decodeHeader(payload).header;
484
+ }
485
+ catch {
486
+ this.stats.mismatch++;
487
+ return;
488
+ }
489
+ if (this.#deadlineUs !== undefined && src.dom === this.ownDom && nowUs_ - Number(h.tUs) > this.#deadlineUs) {
490
+ this.stats.staleDrops++;
491
+ return;
492
+ }
493
+ if (!this.latest) {
494
+ const e = this.envelope(payload, src.publisher, src.dom);
495
+ if (e)
496
+ t.out.push(e);
497
+ return;
498
+ }
499
+ if (src.last !== undefined && !newer(h.seq, src.last)) {
500
+ this.stats.superseded++;
501
+ return;
502
+ }
503
+ if (t.newest && !newer(h.seq, t.newest.seq)) {
504
+ this.stats.superseded++;
505
+ return;
506
+ }
507
+ if (t.newest)
508
+ this.stats.superseded++;
509
+ t.newest = { seq: h.seq, payload };
510
+ }
511
+ /**
512
+ * The next batch, never empty; `undefined` once the subscription or the node ended. Drains what
513
+ * every source has ready, synchronously (at most `DRAIN_PER_READ` per source), and the `fec`
514
+ * frames; with nothing to return it waits for a source's pending read or reader, a `fec` frame,
515
+ * a change of the set or the end of a `throttleMs` interval, and drains again.
516
+ */
517
+ async next() {
518
+ for (;;) {
519
+ if (this.#ended)
520
+ return undefined;
521
+ const out = [];
522
+ let more = false;
523
+ const now = nowUs();
524
+ for (const src of [...this.#sources]) {
525
+ const t = { newest: undefined, out, count: 0 };
526
+ for (;;) {
527
+ if (t.count === DRAIN_PER_READ) {
528
+ more = true;
529
+ break;
530
+ } // what is left is taken by the next read
531
+ const p = this.#poll(src);
532
+ if (p === undefined)
533
+ break;
534
+ if (p === END) {
535
+ src.ended = true;
536
+ break;
537
+ }
538
+ t.count++;
539
+ this.#take(src, p, t, now);
540
+ }
541
+ if (t.newest) {
542
+ const e = this.envelope(t.newest.payload, src.publisher, src.dom);
543
+ if (e) {
544
+ src.last = t.newest.seq;
545
+ out.push(e);
546
+ }
547
+ }
548
+ // A source whose track ended leaves the read.
549
+ if (src.ended) {
550
+ this.#sources.splice(this.#sources.indexOf(src), 1);
551
+ closeSource(src);
552
+ }
553
+ }
554
+ // The `fec` frames: already one per publisher in latest mode (see `pushFec`); the drop-late rule here, at the read.
555
+ for (const env of this.#fec.splice(0)) {
556
+ if (this.#deadlineUs !== undefined && env.dom === this.ownDom && now - env.tUs > this.#deadlineUs) {
557
+ this.stats.staleDrops++;
558
+ continue;
559
+ }
560
+ out.push(env);
561
+ }
562
+ const batch = this.#throttle(out);
563
+ if (batch) {
564
+ this.stats.read += batch.length;
565
+ return batch;
566
+ }
567
+ // Everything taken was dropped while a source is at its per-read bound: drain again at once.
568
+ if (more)
569
+ continue;
570
+ // Woken by a source's pending read or reader, a `fec` frame, a change of the set, the throttle's end, the end.
571
+ this.#wake ??= deferred();
572
+ await this.#wake.promise;
573
+ }
574
+ }
575
+ /**
576
+ * `throttleMs`: at most one batch per interval; a batch inside the interval is held (newest per
577
+ * publisher) and merged into the next; when the interval ends a waiting `next()` returns it.
578
+ */
579
+ #throttle(out) {
580
+ const t = this.opts.throttleMs;
581
+ if (t === undefined)
582
+ return out.length ? out : undefined;
583
+ const now = performance.now();
584
+ const held = this.#throttled ?? [];
585
+ for (const e of out) {
586
+ const i = held.findIndex(h => h.publisher === e.publisher);
587
+ if (i >= 0) {
588
+ held[i] = e;
589
+ this.stats.coalesced++;
590
+ }
591
+ else
592
+ held.push(e);
593
+ }
594
+ if (!held.length)
595
+ return undefined;
596
+ if (now < this.#throttleUntil) {
597
+ this.#throttled = held;
598
+ this.#throttleTimer ??= setTimeout(() => { this.#throttleTimer = undefined; this.#signal(); }, this.#throttleUntil - now);
599
+ return undefined;
600
+ }
601
+ this.#throttled = undefined;
602
+ this.#throttleUntil = now + t;
603
+ return held;
604
+ }
605
+ async *[Symbol.asyncIterator]() {
606
+ for (;;) {
607
+ const b = await this.next();
608
+ if (!b)
609
+ return;
610
+ yield b;
611
+ }
612
+ }
613
+ /** Ends the subscription: the follow task stops, every track is closed, a pending `next()` returns `undefined`. */
614
+ unsubscribe() {
615
+ if (this.#ended)
616
+ return;
617
+ this.#ended = true;
618
+ this.#stop.abort();
619
+ for (const s of this.#sources.splice(0))
620
+ closeSource(s);
621
+ this.#fec.length = 0;
622
+ this.#throttled = undefined;
623
+ if (this.#throttleTimer !== undefined) {
624
+ clearTimeout(this.#throttleTimer);
625
+ this.#throttleTimer = undefined;
626
+ }
627
+ this.#signal();
628
+ }
629
+ async [Symbol.asyncDispose]() { this.unsubscribe(); }
630
+ }
631
+ /** A source is done: its subscription and its request are closed (its pending read settles as the end). */
632
+ function closeSource(src) {
633
+ src.ended = true;
634
+ try {
635
+ src.sub.close();
636
+ }
637
+ catch { /* closed */ }
638
+ try {
639
+ src.req.close();
640
+ }
641
+ catch { /* closed */ }
642
+ }
643
+ /**
644
+ * The task behind one subscription (control path only, never a frame): follows the topic as the
645
+ * module docs say. Interrupted by a session change, a catalog change or the node's end.
646
+ */
647
+ async function followTopic(sub) {
648
+ const { node, contract, opts } = sub;
649
+ // Unbounded, as the Rust task's event channel: no session event is ever dropped.
650
+ const events = new AsyncQueue(Infinity);
651
+ const { session: first, unmonitor } = node.monitor(e => events.push(e));
652
+ let session = first;
653
+ let epoch;
654
+ let snapshot;
655
+ const have = new Map();
656
+ /** The publishers tried on this snapshot (held or failed): each listed one is tried once per snapshot. */
657
+ let tried = new Set();
658
+ const stopAll = async () => { for (const [, f] of have)
659
+ if (f.following.kind === "fec") {
660
+ f.following.stop.abort();
661
+ await f.following.done;
662
+ } have.clear(); };
663
+ const ended = (f) => f.following.kind === "plain" ? f.following.src.ended : f.following.finished;
664
+ let pending;
665
+ const onStop = () => events.push({ kind: "nodeEnded" });
666
+ sub.signal.addEventListener("abort", onStop, { once: true });
667
+ try {
668
+ for (;;) {
669
+ if (sub.signal.aborted)
670
+ return;
671
+ if (pending) {
672
+ if (pending.kind === "started")
673
+ session = pending.session;
674
+ else if (pending.kind === "ended")
675
+ session = undefined;
676
+ else
677
+ return;
678
+ pending = undefined;
679
+ }
680
+ if (session?.epoch !== epoch) {
681
+ epoch = session?.epoch;
682
+ snapshot = session?.topics.get();
683
+ // Everything followed is the previous session's (Rust `Change::Reset`): the read's sources
684
+ // leave first, then the `fec` followers stop, then what they handed to the read is cleared.
685
+ for (const [, f] of have)
686
+ if (f.following.kind === "plain")
687
+ sub.removeSource(f.following.src);
688
+ await stopAll();
689
+ tried = new Set();
690
+ sub.clearFec();
691
+ }
692
+ const todo = [];
693
+ // The topic's delivery is the catalog's (the established descriptor): a bare-name subscription has no contract to say.
694
+ let delivery = contract.delivery;
695
+ if (session && snapshot) {
696
+ const entry = snapshot.topics[contract.name];
697
+ delivery = entry?.desc?.delivery ?? contract.delivery;
698
+ // ... and so is the body's `content_type` (what the read decodes by).
699
+ sub.contentType = entry?.desc?.content_type;
700
+ const wanted = new Map();
701
+ if (entry)
702
+ for (const [prefix, l] of Object.entries(entry.publishers))
703
+ if (prefix !== session.prefix)
704
+ wanted.set(topicPath(prefix, l.bind, contract.name), { prefix, name: l.name });
705
+ for (const [path, f] of [...have]) {
706
+ if (wanted.has(path) && !ended(f))
707
+ continue;
708
+ have.delete(path);
709
+ if (f.following.kind === "fec") {
710
+ f.following.stop.abort();
711
+ await f.following.done;
712
+ sub.dropFecOf(f.publisher);
713
+ }
714
+ else
715
+ sub.removeSource(f.following.src);
716
+ }
717
+ for (const p of [...tried])
718
+ if (!wanted.has(p))
719
+ tried.delete(p);
720
+ for (const [path, w] of wanted)
721
+ if (!have.has(path) && !tried.has(path))
722
+ todo.push({ path, ...w });
723
+ }
724
+ const s = session;
725
+ const nextEvent = events.next();
726
+ const catalogChanged = s ? s.topics.changed() : new Promise(() => { });
727
+ const passAbort = new AbortController();
728
+ const pass = (async () => {
729
+ if (!s)
730
+ return;
731
+ for (const t of todo) {
732
+ if (passAbort.signal.aborted)
733
+ return;
734
+ tried.add(t.path);
735
+ // One addition, stopped by the pass's end or by its bound: a stopped addition closes what it opened.
736
+ const one = new AbortController();
737
+ const stopOne = () => one.abort();
738
+ passAbort.signal.addEventListener("abort", stopOne, { once: true });
739
+ try {
740
+ const adding = add(s, t.path, delivery, opts, one.signal);
741
+ const added = await withTimeout(adding, ADD_WITHIN_MS);
742
+ if (added === TIMEOUT) {
743
+ one.abort();
744
+ adding.then(closeAdded, () => { });
745
+ sub.warn(`topic ${contract.name}: listed publisher ${t.prefix} not subscribed within ${ADD_WITHIN_MS} ms; tried again at the next catalog change`);
746
+ continue;
747
+ }
748
+ if (passAbort.signal.aborted) {
749
+ closeAdded(added);
750
+ continue;
751
+ }
752
+ if (added.kind === "plain") {
753
+ const src = { path: t.path, prefix: t.prefix, publisher: t.name, dom: added.dom, req: added.req, sub: added.sub, stream: added.stream, group: undefined, wait: undefined, ready: undefined, held: [], heldSeq: undefined, done: false, last: undefined, ended: false };
754
+ sub.addSource(src);
755
+ have.set(t.path, { prefix: t.prefix, publisher: t.name, following: { kind: "plain", src } });
756
+ }
757
+ else {
758
+ const stop = new AbortController();
759
+ const following = { kind: "fec", stop, done: Promise.resolve(), finished: false };
760
+ following.done = followFec(sub, s, t.prefix, t.name, added.req, added.bc, added.desc, stop.signal).finally(() => { following.finished = true; });
761
+ have.set(t.path, { prefix: t.prefix, publisher: t.name, following });
762
+ }
763
+ }
764
+ catch (e) {
765
+ sub.warn(`topic ${contract.name}: listed publisher ${t.prefix} not subscribed (${e instanceof Error ? e.message : String(e)}); tried again at the next catalog change`);
766
+ }
767
+ finally {
768
+ passAbort.signal.removeEventListener("abort", stopOne);
769
+ }
770
+ }
771
+ })();
772
+ const woke = await Promise.race([nextEvent.then(e => ({ kind: "event", e })), catalogChanged.then(() => ({ kind: "catalog" })), pass.then(() => ({ kind: "pass" }))]);
773
+ if (woke.kind === "event") {
774
+ passAbort.abort();
775
+ pending = woke.e ?? { kind: "nodeEnded" };
776
+ }
777
+ else if (woke.kind === "catalog") {
778
+ passAbort.abort();
779
+ snapshot = s.topics.get();
780
+ tried = new Set();
781
+ }
782
+ else if (!todo.length) {
783
+ // Nothing to do: wait for the next event or catalog change.
784
+ const w = await Promise.race([nextEvent.then(e => ({ kind: "event", e })), catalogChanged.then(() => ({ kind: "catalog" }))]);
785
+ if (w.kind === "event")
786
+ pending = w.e ?? { kind: "nodeEnded" };
787
+ else {
788
+ snapshot = s.topics.get();
789
+ tried = new Set();
790
+ }
791
+ }
792
+ }
793
+ }
794
+ finally {
795
+ sub.signal.removeEventListener("abort", onStop);
796
+ unmonitor();
797
+ await stopAll();
798
+ void events.close(true);
799
+ sub.unsubscribe();
800
+ }
801
+ }
802
+ /** Closes an addition nobody took (its pass ended, or it came in after its bound). */
803
+ function closeAdded(a) {
804
+ if (a.kind === "plain") {
805
+ try {
806
+ a.sub.close();
807
+ }
808
+ catch { /* closed */ }
809
+ }
810
+ else {
811
+ try {
812
+ a.bc.close();
813
+ }
814
+ catch { /* closed */ }
815
+ }
816
+ try {
817
+ a.req.close();
818
+ }
819
+ catch { /* closed */ }
820
+ }
821
+ /**
822
+ * Subscribes to publisher `path`'s `data` track on `s`; its own `desc` is read first (its time
823
+ * domain and its `fec`). A failed or stopped (`signal`) addition closes its request.
824
+ */
825
+ async function add(s, path, delivery, opts, signal) {
826
+ const req = s.request(path);
827
+ try {
828
+ const bc = await waitActive(req, ADD_WITHIN_MS, signal);
829
+ const desc = await ownDesc(bc, signal);
830
+ if (signal.aborted)
831
+ throw new SynapseError("canceled", "aborted");
832
+ if (!desc)
833
+ throw new Error("no readable descriptor");
834
+ const dom = desc.dom.id;
835
+ if (delivery === "datagram" && desc.fec)
836
+ return { kind: "fec", req, bc, desc };
837
+ const priority = moqPriority(opts.priority ?? 3);
838
+ const sub = bc.track("data").subscribe(delivery === "stream" ? streamTopicSubscription(priority) : latestSubscription(priority, opts.maxAgeMs ?? 0));
839
+ return { kind: "plain", req, sub, dom, stream: delivery === "stream" };
840
+ }
841
+ catch (e) {
842
+ try {
843
+ req.close();
844
+ }
845
+ catch { /* closed */ }
846
+ throw e;
847
+ }
848
+ }
849
+ /** The publisher's own descriptor from its `desc` track; a stopped `signal` closes the track and reads as none. */
850
+ async function ownDesc(bc, signal) {
851
+ if (signal.aborted)
852
+ return undefined;
853
+ const sub = bc.track("desc").subscribe(latestSubscription(moqPriority(0)));
854
+ const stop = () => sub.close();
855
+ signal.addEventListener("abort", stop, { once: true });
856
+ try {
857
+ const g = await sub.recvGroup();
858
+ if (!g)
859
+ return undefined;
860
+ const f = await g.readFrame();
861
+ if (!f)
862
+ return undefined;
863
+ const { hlen } = decodeHeader(f.payload);
864
+ const d = decodeBody(f.payload.subarray(hlen));
865
+ return d && typeof d === "object" && d.dom && typeof d.dom.id === "string" ? d : undefined;
866
+ }
867
+ catch {
868
+ return undefined;
869
+ }
870
+ finally {
871
+ signal.removeEventListener("abort", stop);
872
+ sub.close();
873
+ }
874
+ }
875
+ /**
876
+ * The publisher's frames on a `fec` topic: every datagram through its `FecReader`, polled after each
877
+ * and every 10 ms; frames into the subscription's `fec` queue; one `REPORT` a second while datagrams
878
+ * arrive. Ends when stopped, when the broadcast ends or fails, or when the codec is missing (warned;
879
+ * tried again at the next catalog change like every listed publisher).
880
+ */
881
+ async function followFec(sub, s, prefix, publisher, req, bc, desc, signal) {
882
+ let reader;
883
+ let reports;
884
+ let tick;
885
+ let reportTick;
886
+ let track;
887
+ try {
888
+ const wasm = await requireFec(`topic ${sub.topic}`);
889
+ if (signal.aborted)
890
+ return;
891
+ const params = sub.opts.fecDeadlineMs !== undefined ? { ...(desc.fec), deadline_ms: sub.opts.fecDeadlineMs } : desc.fec;
892
+ reader = new FecReader(wasm, params, sub.opts.fecDeadlineMs);
893
+ const r = reader;
894
+ track = bc.track("data").subscribe(latestSubscription(moqPriority(sub.opts.priority ?? 3), sub.opts.maxAgeMs ?? 0));
895
+ const t = track;
896
+ signal.addEventListener("abort", () => t.close(), { once: true });
897
+ let prev = r.stats();
898
+ const poll = () => {
899
+ for (const f of r.poll(nowUs())) {
900
+ const env = sub.envelope(f.frame, publisher, desc.dom.id);
901
+ if (env)
902
+ sub.pushFec(env);
903
+ }
904
+ const st = r.stats();
905
+ sub.stats.fecGiveup += st.lost - prev.lost;
906
+ sub.stats.repaired += st.repaired - prev.repaired;
907
+ prev = st;
908
+ };
909
+ tick = setInterval(poll, 10);
910
+ let received = false;
911
+ if (sub.opts.fecFeedback ?? true) {
912
+ reports = new ReportWriter(s, prefix, sub.topic);
913
+ const w = reports;
914
+ reportTick = setInterval(() => { if (!received)
915
+ return; try {
916
+ w.write(r.takeReport());
917
+ }
918
+ catch {
919
+ w.close();
920
+ reports = undefined;
921
+ if (reportTick !== undefined)
922
+ clearInterval(reportTick);
923
+ } }, 1000);
924
+ }
925
+ for (;;) {
926
+ const d = await t.recvDatagram();
927
+ if (!d)
928
+ break;
929
+ received = true;
930
+ if (!r.push(d.payload, nowUs()))
931
+ sub.stats.mismatch++;
932
+ poll();
933
+ }
934
+ }
935
+ catch (e) {
936
+ if (!signal.aborted)
937
+ sub.warn(`topic ${sub.topic}: follower of ${prefix} ended (${e instanceof Error ? e.message : String(e)}); tried again at the next catalog change`);
938
+ }
939
+ finally {
940
+ if (tick !== undefined)
941
+ clearInterval(tick);
942
+ if (reportTick !== undefined)
943
+ clearInterval(reportTick);
944
+ reports?.close();
945
+ try {
946
+ track?.close();
947
+ }
948
+ catch { /* closed */ }
949
+ reader?.close();
950
+ try {
951
+ bc.close();
952
+ }
953
+ catch { /* closed */ }
954
+ try {
955
+ req.close();
956
+ }
957
+ catch { /* closed */ }
958
+ }
959
+ }