@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.
@@ -0,0 +1,963 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ // One MoQ session to the broker (WP §2, §8.2, §10): the link that comes and goes. A `Session` is
10
+ // one admitted connection (hello received) and everything learned on it — the hello, the role the
11
+ // runtime's notices grant, the catalogs, the project state, the offsets table, the clock estimate —
12
+ // its `req` track and the calls pending on it, and its own tasks (the `res` demux, four snapshot
13
+ // watchers, the clock probe), which write only into it. A task that loses its track records why and
14
+ // closes the connection, nothing else: the node's loop (`node.ts`) sees the close and does the rest.
15
+ import * as Moq from "@moq/net";
16
+ import { ADMISSION_REFUSED_CLOSE_CODE, ClockSync, FLAGS, STREAM_GROUP_SPAN_MS, STREAM_SUBSCRIBE_MAX_AGE_MS, SynapseError, TYPES, decode as decodeBody, decodeHeader, frame, headerLength, helloPath, SID_LEN, connectPath, encodeConnectRequest, isClientType, isRole, moqPriority, normalizeProjectSnapshot, normalizeServicesSnapshot, normalizeTimeOffsets, normalizeTopicsSnapshot, nowUs, sessionPrefix, toHex, } from "@synapse-sdk/core";
17
+ import { moqLogScope } from "./moq_log.js";
18
+ import { Cell, deferred, withTimeout, TIMEOUT } from "./util.js";
19
+ // ------------------------------------------------------------------------------ moq plumbing
20
+ /** Default datagram budget (WP §6.2): Chrome reports `maxDatagramSize` 1024; native quinn hops allow about 1150. */
21
+ export function defaultMaxDatagram() { return isNodeLike() ? 1150 : 1024; }
22
+ export function isNodeLike() { return typeof globalThis.process?.versions?.node === "string"; }
23
+ /** A `stream` reader never abandons a finished group: an effectively infinite moq latency budget (`u32::MAX` ms, as the Rust client). */
24
+ export const STREAM_READER_MAX_AGE_MS = 0xffff_ffff;
25
+ export const streamSubscription = (priority) => ({ priority, maxAge: Moq.Time.Milli(STREAM_READER_MAX_AGE_MS) });
26
+ /** The latest-value subscription (snapshot tracks, `desc`, `card`): newest group only. */
27
+ export const latestSubscription = (priority, maxAgeMs = 0) => ({ priority, maxAge: Moq.Time.Milli(maxAgeMs) });
28
+ /**
29
+ * The age budget of a call track's group (Rust `CALL_GROUP_MAX_AGE`, 60 s): the retention every call
30
+ * track we write (`req`, `out`) declares, the budget its readers (`res`, `calls`) subscribe with, and how
31
+ * long a reader waits for the group due while a later group is held.
32
+ */
33
+ export const CALL_GROUP_MAX_AGE_MS = 60_000;
34
+ export const callTrackInfo = (priority) => ({ priority, maxAge: Moq.Time.Milli(CALL_GROUP_MAX_AGE_MS) });
35
+ /** A call track reader's subscription (`res`, `calls`): the call track budget. */
36
+ export const callTrackSubscription = (priority) => ({ priority, maxAge: Moq.Time.Milli(CALL_GROUP_MAX_AGE_MS) });
37
+ /**
38
+ * Retention of a one-frame track (`card`, `desc`): the frame lives as long as the track. The Rust moq
39
+ * keeps a track's latest group for the track's life; the JS one prunes it after its default 5 s, so a
40
+ * subscriber joining later would get nothing.
41
+ */
42
+ export const RETAINED_FRAME_MAX_AGE_MS = 0xffff_ffff;
43
+ /** A call track starts a new group after this many frames (moq's cap on a group). */
44
+ export const CALL_GROUP_FRAMES = 8192;
45
+ /** A `stream` topic subscriber's age budget (`STREAM_SUBSCRIBE_MAX_AGE`, 3 × the group span): a reader further behind skips whole groups. */
46
+ export const streamTopicSubscription = (priority) => ({ priority, maxAge: Moq.Time.Milli(STREAM_SUBSCRIBE_MAX_AGE_MS) });
47
+ /** moq's caps on one group. */
48
+ const MAX_GROUP_FRAMES = 8192;
49
+ const MAX_GROUP_BYTES = 32 * 1024 * 1024;
50
+ /**
51
+ * A `stream` topic's writer (WP §6, the Rust `TrackWriter`): frames go into one MoQ group at a time, in
52
+ * order; the writer finishes the open group and starts the next when the open one is older than
53
+ * `STREAM_GROUP_SPAN_MS`, or when the next frame would pass moq's caps. A late joiner reads from the
54
+ * start of the group the relay holds, so it is at most one span behind.
55
+ */
56
+ export class StreamWriter {
57
+ track;
58
+ #group;
59
+ #openedMs = 0;
60
+ #frames = 0;
61
+ #bytes = 0;
62
+ constructor(track) {
63
+ this.track = track;
64
+ }
65
+ write(header, body) {
66
+ const payload = frame(header, body);
67
+ const now = performance.now();
68
+ if (!this.#group || now - this.#openedMs >= STREAM_GROUP_SPAN_MS || this.#frames + 1 > MAX_GROUP_FRAMES || this.#bytes + payload.length > MAX_GROUP_BYTES) {
69
+ this.#group?.close();
70
+ this.#group = this.track.appendGroup();
71
+ this.#openedMs = now;
72
+ this.#frames = 0;
73
+ this.#bytes = 0;
74
+ }
75
+ this.#group.writeFrame({ timestamp: moqTimestamp(header.tUs), payload });
76
+ this.#frames++;
77
+ this.#bytes += payload.length;
78
+ }
79
+ close() { try {
80
+ this.#group?.close();
81
+ }
82
+ catch { /* closed */ } try {
83
+ this.track.close();
84
+ }
85
+ catch { /* closed */ } }
86
+ }
87
+ export function moqTimestamp(tUs) {
88
+ const n = typeof tUs === "bigint" ? Number(tUs) : tUs;
89
+ return Moq.Time.Timestamp.fromMicros(n > 0 ? n : 0);
90
+ }
91
+ /** The error for a frame refused because it is over moq's 32 MiB limit (Rust `frame_too_large`): `invalid_input`, naming the limit. */
92
+ export function frameTooLarge(what, len) {
93
+ return new SynapseError("invalid_input", { message: `${what}: ${len} bytes exceeds the frame limit`, source: "sdk" });
94
+ }
95
+ /**
96
+ * A call track writer (`req`, `out`): frames into one group after another. The open group is finished
97
+ * and the next one started when the next frame would pass `CALL_GROUP_FRAMES` frames or moq's 32 MiB of
98
+ * bytes in the group. A frame over 32 MiB on its own is refused (`frameTooLarge`) before anything is written.
99
+ */
100
+ export class CallWriter {
101
+ track;
102
+ #group;
103
+ #inGroup = 0;
104
+ /** Bytes in the open group (moq's own count: frame payloads). */
105
+ #bytes = 0;
106
+ constructor(track) {
107
+ this.track = track;
108
+ this.#group = track.appendGroup();
109
+ }
110
+ static create(bc, name) { return new CallWriter(bc.createTrack(name, callTrackInfo(moqPriority(0)))); }
111
+ write(header, body) {
112
+ // The frame's size (header + body), as moq counts it; `seq` is a fixed-width field, so it does not change it.
113
+ const size = headerLength(header) + body.length;
114
+ if (size > MAX_GROUP_BYTES)
115
+ throw frameTooLarge("frame", body.length);
116
+ if (this.#inGroup + 1 > CALL_GROUP_FRAMES || this.#bytes + size > MAX_GROUP_BYTES) {
117
+ const next = this.track.appendGroup();
118
+ const done = this.#group;
119
+ this.#group = next;
120
+ done.close();
121
+ this.#inGroup = 0;
122
+ this.#bytes = 0;
123
+ }
124
+ // The frame's `seq` is its index within its group (Rust `CallWriter::write`).
125
+ const payload = frame({ ...header, seq: this.#inGroup }, body);
126
+ this.#group.writeFrame({ timestamp: moqTimestamp(header.tUs), payload });
127
+ this.#inGroup++;
128
+ this.#bytes += payload.length;
129
+ }
130
+ close() { try {
131
+ this.#group.close();
132
+ }
133
+ catch { /* closed */ } try {
134
+ this.track.close();
135
+ }
136
+ catch { /* closed */ } }
137
+ }
138
+ /** One latest-value frame (`desc`, `card`, a snapshot): its own group, one frame, closed; the relay caches the newest. */
139
+ export function writeLatest(track, header, body) {
140
+ const g = track.appendGroup();
141
+ g.writeFrame({ timestamp: moqTimestamp(header.tUs), payload: frame(header, body) });
142
+ g.close();
143
+ }
144
+ /** Waits for a requested broadcast to become routable. */
145
+ export async function waitActive(req, timeoutMs, signal) {
146
+ const t0 = performance.now();
147
+ for (;;) {
148
+ const a = req.active.peek();
149
+ if (a)
150
+ return a;
151
+ if (signal?.aborted)
152
+ throw signal.reason instanceof Error ? signal.reason : new SynapseError("canceled", "aborted");
153
+ const left = timeoutMs - (performance.now() - t0);
154
+ if (left <= 0)
155
+ throw new SynapseError("connection_lost", `broadcast ${req.path} never appeared`);
156
+ const r = await withTimeout(Promise.race([req.active.changed(), signal ? new Promise(res => signal.addEventListener("abort", () => res(undefined), { once: true })) : new Promise(() => { })]), Math.min(left, 250));
157
+ if (r === TIMEOUT)
158
+ continue;
159
+ }
160
+ }
161
+ /**
162
+ * Reads a call track (`res`, `calls`, a FEC `report`) frame by frame, as the Rust `read_call_track`:
163
+ * groups are taken strictly in sequence from group 0, group k read to its end before group k+1. A
164
+ * group that arrives before its turn is held (a track's groups travel on streams of their own, so
165
+ * group 1 can arrive before group 0). The read ends with an error when a group older than the one due
166
+ * arrives, when a group's read fails (reset or expired), when the subscription fails, or when the
167
+ * group due has not arrived within `CALL_GROUP_MAX_AGE_MS` while a later group is held (that wait
168
+ * is armed only then; with nothing later held the reader waits for the next group with no timer).
169
+ * Returns `null` when the track ended cleanly or `signal` aborted, otherwise the error.
170
+ */
171
+ export async function readTrack(sub, onFrame, signal) {
172
+ const abort = () => sub.close();
173
+ signal?.addEventListener("abort", abort, { once: true });
174
+ const held = new Map();
175
+ let next = 0;
176
+ // When the group due must have arrived: set while a later group is held, cleared when it comes.
177
+ let deadline;
178
+ try {
179
+ for (;;) {
180
+ let g = held.get(next);
181
+ if (g)
182
+ held.delete(next);
183
+ else {
184
+ let got;
185
+ if (held.size === 0)
186
+ got = await sub.recvGroup();
187
+ else {
188
+ deadline ??= performance.now() + CALL_GROUP_MAX_AGE_MS;
189
+ const r = await withTimeout(sub.recvGroup(), Math.max(0, deadline - performance.now()));
190
+ if (r === TIMEOUT)
191
+ return new Error(`group ${next} did not arrive within the age budget while later groups did`);
192
+ got = r;
193
+ }
194
+ if (!got)
195
+ return null;
196
+ if (got.sequence > next) {
197
+ held.set(got.sequence, got);
198
+ continue;
199
+ }
200
+ if (got.sequence < next)
201
+ return new Error(`group ${got.sequence} arrived where group ${next} was due`);
202
+ g = got;
203
+ }
204
+ deadline = undefined;
205
+ for (;;) {
206
+ if (signal?.aborted)
207
+ return null;
208
+ const f = await g.readFrame();
209
+ if (!f)
210
+ break;
211
+ const r = onFrame(f.payload);
212
+ if (r)
213
+ await r;
214
+ }
215
+ next++;
216
+ }
217
+ }
218
+ catch (e) {
219
+ return e;
220
+ }
221
+ finally {
222
+ signal?.removeEventListener("abort", abort);
223
+ for (const g of held.values()) {
224
+ try {
225
+ g.close();
226
+ }
227
+ catch { /* closed */ }
228
+ }
229
+ }
230
+ }
231
+ /**
232
+ * Reads a latest-value track (a snapshot) as the Rust `watch_snapshots`: each group as it arrives,
233
+ * its first frame handed to `apply`. Returns `null` when `signal` aborted, otherwise why the track
234
+ * was lost (the subscription failed or ended).
235
+ */
236
+ async function readLatest(sub, apply, signal) {
237
+ const abort = () => sub.close();
238
+ signal.addEventListener("abort", abort, { once: true });
239
+ try {
240
+ for (;;) {
241
+ const g = await sub.recvGroup();
242
+ if (signal.aborted)
243
+ return null;
244
+ if (!g)
245
+ return new Error("track ended");
246
+ let f;
247
+ try {
248
+ f = await g.readFrame();
249
+ }
250
+ catch {
251
+ f = undefined;
252
+ }
253
+ if (f)
254
+ apply(f.payload);
255
+ }
256
+ }
257
+ catch (e) {
258
+ return e;
259
+ }
260
+ finally {
261
+ signal.removeEventListener("abort", abort);
262
+ }
263
+ }
264
+ export async function readDatagrams(sub, onDatagram, signal) {
265
+ const abort = () => sub.close();
266
+ signal?.addEventListener("abort", abort, { once: true });
267
+ try {
268
+ for (;;) {
269
+ const d = await sub.recvDatagram();
270
+ if (!d)
271
+ return null;
272
+ const r = onDatagram(d.payload);
273
+ if (r)
274
+ await r;
275
+ }
276
+ }
277
+ catch (e) {
278
+ return e;
279
+ }
280
+ finally {
281
+ signal?.removeEventListener("abort", abort);
282
+ }
283
+ }
284
+ let polyfillInstalled;
285
+ /** Installs `@moq/web-transport` in Bun/Node when the runtime has no WebTransport. Browsers skip this. */
286
+ export function ensureWebTransport() {
287
+ if (typeof globalThis.WebTransport !== "undefined")
288
+ return Promise.resolve();
289
+ if (!isNodeLike())
290
+ return Promise.resolve();
291
+ polyfillInstalled ??= (async () => {
292
+ const spec = "@moq/web-transport";
293
+ try {
294
+ const m = (await import(__rewriteRelativeImportExtension(/* @vite-ignore */ spec)));
295
+ m.install();
296
+ }
297
+ catch (e) {
298
+ throw new SynapseError("not_supported", { message: `WebTransport is unavailable and @moq/web-transport could not be loaded: ${e instanceof Error ? e.message : String(e)}`, cause: e });
299
+ }
300
+ })();
301
+ return polyfillInstalled;
302
+ }
303
+ /** A connect failure's text (`@moq/net` races WebTransport against WebSocket and rejects with an `AggregateError`). */
304
+ export function describeError(e) {
305
+ if (e instanceof AggregateError) {
306
+ const parts = e.errors.map(describeError).filter(Boolean);
307
+ return parts.length ? parts.join("; ") : e.message || e.name;
308
+ }
309
+ return e instanceof Error ? e.message || e.name : String(e);
310
+ }
311
+ /** The MoQ session-close code the broker's refusal arrives as: application codes sit at 64 + code on the wire. */
312
+ const ADMISSION_REFUSED_SESSION_CODE = 64 + ADMISSION_REFUSED_CLOSE_CODE;
313
+ const ADMISSION_REFUSED_REASON = new RegExp(`app code=${ADMISSION_REFUSED_CLOSE_CODE}(?!\\d)`);
314
+ /**
315
+ * The error of a session's close (`classify_close`): `admission_refused` travels as the application
316
+ * close code (retryable: a live session may hold this name, the project may be settling), another
317
+ * application code is a broker-side failure, `unauthorized` is the relay's own refusal, everything
318
+ * else is the transport (`connection_lost`); a clean close is a drop too (in one-shot mode a broker
319
+ * GOAWAY looks like one).
320
+ */
321
+ export function closeError(err) {
322
+ if (!err)
323
+ return new SynapseError("connection_lost", { message: "session closed by the broker", source: "transport" });
324
+ const text = `${err.message} ${err.reason ?? ""}`;
325
+ if ((err instanceof Moq.Error.Session && err.code === ADMISSION_REFUSED_SESSION_CODE) || ADMISSION_REFUSED_REASON.test(text)) {
326
+ return new SynapseError("admission_refused", { message: "the runtime refused this connection, retryable: a live session may hold this name, the project may be settling, or the runtime is overloaded; retry with backoff", source: "broker", cause: err });
327
+ }
328
+ if (err instanceof Moq.Error.Session && err.code === Moq.SessionCode.Unauthorized)
329
+ return new SynapseError("unauthorized", { message: text.trim(), source: "broker", cause: err });
330
+ const t = text.toLowerCase();
331
+ if (t.includes("unauthorized"))
332
+ return new SynapseError("unauthorized", { message: text.trim(), source: "broker", cause: err });
333
+ return new SynapseError("connection_lost", { message: text.trim() || err.name, source: "transport", cause: err });
334
+ }
335
+ /** `https://host:port/<root>` → `http://host:port/certificate.sha256`, the broker's plain-HTTP helper. */
336
+ export function brokerCertHelperUrl(brokerUrl) { const u = new URL(brokerUrl); return `http://${u.host}/certificate.sha256`; }
337
+ /**
338
+ * The runtime's URL when the caller gave none: `SYNAPSE_ENDPOINT` (Bun/Node; the runtime sets it for every task it starts).
339
+ * A browser has no environment: there, as with neither, `invalid_input`.
340
+ */
341
+ export function urlOrEndpoint(url) {
342
+ if (url !== undefined)
343
+ return url;
344
+ const env = globalThis.process?.env?.SYNAPSE_ENDPOINT?.trim();
345
+ if (env)
346
+ return env;
347
+ throw new SynapseError("invalid_input", { message: "no url given and SYNAPSE_ENDPOINT is not set", source: "sdk" });
348
+ }
349
+ const SHA256_HEX = /^[0-9a-f]{64}$/i;
350
+ /** The certificate pin when the caller gave none (SDK §13): `SYNAPSE_BROKER_CERT_SHA256` (Bun/Node), then the helper, then `undefined` = unpinned. Per attempt. */
351
+ export async function discoverBrokerCertSha256(brokerUrl, opts = {}) {
352
+ const env = globalThis.process?.env?.SYNAPSE_BROKER_CERT_SHA256?.trim();
353
+ if (env)
354
+ return env;
355
+ const signal = opts.signal ? AbortSignal.any([opts.signal, AbortSignal.timeout(opts.timeoutMs ?? 2000)]) : AbortSignal.timeout(opts.timeoutMs ?? 2000);
356
+ try {
357
+ const res = await fetch(brokerCertHelperUrl(brokerUrl), { signal });
358
+ if (!res.ok)
359
+ return undefined;
360
+ const text = (await res.text()).trim();
361
+ return SHA256_HEX.test(text) ? text : undefined;
362
+ }
363
+ catch {
364
+ return undefined;
365
+ }
366
+ }
367
+ /**
368
+ * `token_from_url`: `url` without its `jwt=` query parameters, and the last non-empty one (as the relays
369
+ * read it). A URL without `jwt=`, or one that does not parse, is returned as it is (the dial reports it).
370
+ */
371
+ export function tokenFromUrl(url) {
372
+ let u;
373
+ try {
374
+ u = new URL(url);
375
+ }
376
+ catch {
377
+ return { url, token: undefined };
378
+ }
379
+ if (!u.searchParams.has("jwt"))
380
+ return { url, token: undefined };
381
+ const token = u.searchParams.getAll("jwt").filter(v => v !== "").at(-1);
382
+ u.searchParams.delete("jwt");
383
+ return { url: u.toString(), token };
384
+ }
385
+ /**
386
+ * `Dial::new`: the one place a user's target becomes a dial. A `jwt=` in the URL is the token (the form
387
+ * the relays read), moved out of the URL here so no URL kept or printed afterwards holds it; a `token`
388
+ * given as such wins over one in the URL.
389
+ */
390
+ export function makeDial(url, identity, cert, token) {
391
+ const t = tokenFromUrl(url);
392
+ return { url: t.url, identity, cert, token: token ?? t.token };
393
+ }
394
+ /** `root_runtime_id`: a root names a runtime when it is exactly `runtimes/<runtime id>` (a canonical lowercase UUID). */
395
+ export function isRuntimeRoot(root) { return /^runtimes\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(root); }
396
+ /**
397
+ * `with_root`: the URL to dial for `url`. `url` itself when it names a path (a runtime's root, or a
398
+ * relay's `runtimes/<runtime_id>`); else `url` with the root the runtime names at its plain-HTTP helper
399
+ * (`http://<host>:<port>/root`, the port of `url`, 443 when it has none), asked at every attempt. A
400
+ * helper that cannot be reached, answers non-2xx or answers incompletely is `connection_lost` (the
401
+ * runtime may be coming back); an answer that is not a root (`runtimes/<runtime id>` exactly; a relay has
402
+ * none: it serves many runtimes) is `invalid_input`, naming the fix. `signal` abandons the ask (its reason).
403
+ */
404
+ export async function withRoot(url, signal) {
405
+ let u;
406
+ try {
407
+ u = new URL(url);
408
+ }
409
+ catch (e) {
410
+ throw new SynapseError("invalid_input", { message: `broker url ${JSON.stringify(url)}: ${describeError(e)}`, source: "sdk" });
411
+ }
412
+ if (u.pathname !== "" && u.pathname !== "/")
413
+ return url;
414
+ const addr = `${u.hostname}:${u.port || (u.protocol === "http:" ? "80" : "443")}`;
415
+ const timeout = AbortSignal.timeout(2000);
416
+ const lost = (why) => new SynapseError("connection_lost", { message: `root helper at ${addr}: ${why}`, source: "transport" });
417
+ let root;
418
+ try {
419
+ const res = await fetch(`http://${addr}/root`, { signal: signal ? AbortSignal.any([signal, timeout]) : timeout });
420
+ if (!res.ok)
421
+ throw lost(`answered ${res.status} ${res.statusText}`.trim());
422
+ root = (await res.text()).trim();
423
+ }
424
+ catch (e) {
425
+ if (signal?.aborted)
426
+ throw signal.reason;
427
+ if (e instanceof SynapseError)
428
+ throw e;
429
+ throw timeout.aborted ? new SynapseError("connection_lost", { message: `root helper at ${addr} did not answer`, source: "transport" }) : lost(describeError(e));
430
+ }
431
+ if (!isRuntimeRoot(root)) {
432
+ throw new SynapseError("invalid_input", { message: `${url} names no runtime (its /root helper returned ${JSON.stringify(root.slice(0, 80))}); give the full path, e.g. https://relay:4443/runtimes/<runtime_id>`, source: "sdk" });
433
+ }
434
+ u.pathname = `/${root}`;
435
+ return u.toString();
436
+ }
437
+ /** The key of a call in the session's pending map. */
438
+ export const callKey = (id) => toHex(id);
439
+ export class Session {
440
+ url;
441
+ conn;
442
+ pub;
443
+ sub;
444
+ hello;
445
+ maxDatagram;
446
+ log;
447
+ /** Counts *established* sessions of the node (SDK §2); set by the node when attached. */
448
+ epoch = 0;
449
+ startedMs = performance.now();
450
+ transport;
451
+ /** Datagrams travel only over WebTransport; the WebSocket fallback is stream-only. */
452
+ datagrams;
453
+ #role;
454
+ #admitting = true;
455
+ #req;
456
+ #calls = new Map();
457
+ #closing;
458
+ /** The views this session reads (latest values). */
459
+ topics = new Cell({ topics: {} });
460
+ services = new Cell({ services: {} });
461
+ timeOffsets = new Cell({});
462
+ project = new Cell(undefined);
463
+ clock;
464
+ /** Where notices go: the node's loop sets this at attach. */
465
+ onNotice;
466
+ #tasks = new AbortController();
467
+ #taskDone = [];
468
+ /** Settles with the close's error once the connection closed. */
469
+ closed;
470
+ constructor(url, conn, pub, sub, hello, maxDatagram, log, connect, reqBc, res, resSub, dom) {
471
+ this.url = url;
472
+ this.conn = conn;
473
+ this.pub = pub;
474
+ this.sub = sub;
475
+ this.hello = hello;
476
+ this.maxDatagram = maxDatagram;
477
+ this.log = log;
478
+ this.#connect = connect;
479
+ this.#reqBc = reqBc;
480
+ this.#role = hello.role;
481
+ this.transport = conn.transport;
482
+ this.datagrams = conn.transport === "webtransport";
483
+ this.#req = CallWriter.create(reqBc, "req");
484
+ this.clock = new Cell(new ClockSync(dom, hello.dom, 0).info());
485
+ this.closed = conn.closed.then(err => { log.release(); return err; });
486
+ this.#resSub = resSub;
487
+ this.#res = res;
488
+ }
489
+ /** Our connect request `client/<name>/<sid>/connect` and its track: the session lasts as long as it is announced (PLAN v0.26 §3.1); a clean close withdraws it first. */
490
+ #connect;
491
+ #reqBc;
492
+ #resSub;
493
+ #res;
494
+ get prefix() { return this.hello.prefix; }
495
+ get role() { return this.#role; }
496
+ nowUs() { return nowUs(); }
497
+ /** Whether the session still takes requests (not torn down). */
498
+ get isLive() { return this.#admitting; }
499
+ /** The broker's reason, when it closed this session with a closing notice (or a watcher lost its track). */
500
+ get closingReason() { return this.#closing; }
501
+ /** Appends a frame to our `req` track (WP §8.6). `connection_lost` once the session has ended. */
502
+ writeReq(header, body) {
503
+ if (!this.#admitting)
504
+ throw new SynapseError("connection_lost", { message: "the session has ended", source: "sdk" });
505
+ try {
506
+ this.#req.write(header, body);
507
+ }
508
+ catch (e) {
509
+ throw reqWriteError(e, "req frame", body.length);
510
+ }
511
+ }
512
+ /** Writes call `id`'s request and registers where its frames go, both while the session takes requests. */
513
+ /** `deliver(null)` = the session ended with the call pending. */
514
+ writeCall(header, body, id, deliver) {
515
+ if (!this.#admitting)
516
+ throw new SynapseError("connection_lost", { message: "the session has ended", source: "sdk" });
517
+ const k = callKey(id);
518
+ this.#calls.set(k, deliver);
519
+ try {
520
+ this.#req.write(header, body);
521
+ }
522
+ catch (e) {
523
+ this.#calls.delete(k);
524
+ throw reqWriteError(e, "call request", body.length);
525
+ }
526
+ }
527
+ forgetCall(id) { this.#calls.delete(callKey(id)); }
528
+ /** A frame for call `id`; its terminal frame forgets the call. */
529
+ #deliver(id, f) {
530
+ const k = callKey(id);
531
+ const d = this.#calls.get(k);
532
+ if (f.header.flags & FLAGS.END)
533
+ this.#calls.delete(k);
534
+ d?.(f);
535
+ }
536
+ /** Stops taking requests and fails every call pending here (`deliver(null)`: `connection_lost`). The first step of a teardown; idempotent. */
537
+ endCalls() {
538
+ this.#admitting = false;
539
+ const calls = [...this.#calls.values()];
540
+ this.#calls.clear();
541
+ for (const d of calls)
542
+ d(null);
543
+ }
544
+ /** A task of this session lost what it reads, or the broker announced the close: record `reason` (the first stands) and close the connection. The loop does the rest. */
545
+ lost(reason) {
546
+ this.#closing ??= reason;
547
+ try {
548
+ this.conn.close();
549
+ }
550
+ catch { /* closed */ }
551
+ }
552
+ /** Starts the session's tasks: the `res` demux, the four snapshot watchers, the clock probe (unless the broker shares our domain). Each writes only into this session. */
553
+ start(sync, startCall) {
554
+ const signal = this.#tasks.signal;
555
+ this.#taskDone.push(this.#readRes(signal));
556
+ this.#taskDone.push(this.#watch("runtime/topics", "catalog", signal, b => this.topics.set(normalizeTopicsSnapshot(decodeBody(b)))));
557
+ this.#taskDone.push(this.#watch("runtime/services", "catalog", signal, b => this.services.set(normalizeServicesSnapshot(decodeBody(b)))));
558
+ this.#taskDone.push(this.#watch("runtime/time", "offsets", signal, b => this.timeOffsets.set(normalizeTimeOffsets(decodeBody(b)))));
559
+ this.#taskDone.push(this.#watch("runtime/project", "state", signal, b => this.project.set(normalizeProjectSnapshot(decodeBody(b)))));
560
+ if (!sync.sameDom)
561
+ this.#taskDone.push(this.#clockTask(sync, startCall, signal));
562
+ }
563
+ /** The `res` track (WP §8.6): a call's frame to its call; a notice to the loop; a closing notice closes the session; a broken track closes it too. */
564
+ async #readRes(signal) {
565
+ const onFrame = (payload) => {
566
+ let header, hlen;
567
+ try {
568
+ ({ header, hlen } = decodeHeader(payload));
569
+ }
570
+ catch {
571
+ return;
572
+ }
573
+ const body = payload.subarray(hlen);
574
+ let decoded;
575
+ try {
576
+ decoded = decodeBody(body);
577
+ }
578
+ catch {
579
+ return;
580
+ }
581
+ const call = decoded?.call;
582
+ if (call instanceof Uint8Array && call.length === 16) {
583
+ this.#deliver(call, { header, body });
584
+ return;
585
+ }
586
+ if (header.flags & FLAGS.ERR) {
587
+ const b = decoded;
588
+ if (b && typeof b.code === "string")
589
+ this.onNotice?.({ kind: "error", body: b });
590
+ return;
591
+ }
592
+ const n = decoded;
593
+ if (!n || typeof n !== "object" || !("notice" in n))
594
+ return;
595
+ if (n.notice === "closing") {
596
+ this.lost(typeof n.reason === "string" ? n.reason : "closing");
597
+ return;
598
+ }
599
+ if ((n.notice === "promoted" || n.notice === "demoted") && isRole(n.role)) {
600
+ this.#role = n.role;
601
+ this.onNotice?.({ kind: "project", notice: { kind: n.notice, role: n.role, project: typeof n.project === "string" ? n.project : null } });
602
+ }
603
+ };
604
+ const err = await readTrack(this.#resSub, onFrame, signal);
605
+ if (signal.aborted || !this.#admitting)
606
+ return;
607
+ this.lost(`res track: ${err instanceof Error ? err.message : "ended"}`);
608
+ }
609
+ /** One snapshot watcher: follows `path`/`track` for the life of the session; a lost track closes the session (a view that froze would lie). */
610
+ async #watch(path, track, signal, apply) {
611
+ let why;
612
+ try {
613
+ const req = this.sub.request(Moq.Path.from(path));
614
+ const bc = await waitActive(req, 10_000, signal);
615
+ const sub = bc.track(track).subscribe(latestSubscription(moqPriority(1)));
616
+ const err = await readLatest(sub, payload => { try {
617
+ const { hlen } = decodeHeader(payload);
618
+ apply(payload.subarray(hlen));
619
+ }
620
+ catch { /* undecodable snapshot dropped */ } }, signal);
621
+ why = err instanceof Error ? err.message : "track ended";
622
+ }
623
+ catch (e) {
624
+ why = e instanceof Error ? e.message : String(e);
625
+ }
626
+ if (signal.aborted || !this.#admitting)
627
+ return;
628
+ this.lost(`${path} ${track} watcher: ${why}`);
629
+ }
630
+ /** The clock probe (WP §8.7): `synapse/time` calls on this session feeding the estimate, for the session's life. */
631
+ async #clockTask(sync, startCall, signal) {
632
+ while (!signal.aborted) {
633
+ const at = sync.nextProbeAt();
634
+ if (at === undefined)
635
+ return;
636
+ const wait = at - nowUs();
637
+ if (wait > 0) {
638
+ await new Promise(r => { const t = setTimeout(r, wait / 1000); signal.addEventListener("abort", () => { clearTimeout(t); r(); }, { once: true }); });
639
+ if (signal.aborted)
640
+ return;
641
+ }
642
+ const req = sync.request(nowUs());
643
+ let resp;
644
+ try {
645
+ resp = (await withTimeout(startCall("synapse/time", req), 2000));
646
+ if (resp === TIMEOUT) {
647
+ sync.probeLost();
648
+ continue;
649
+ }
650
+ }
651
+ catch (e) {
652
+ sync.probeLost();
653
+ if (e instanceof SynapseError && (e.code === "not_connected" || e.code === "connection_lost"))
654
+ return;
655
+ continue;
656
+ }
657
+ sync.response(resp, nowUs());
658
+ this.clock.set(sync.info());
659
+ }
660
+ }
661
+ /** Stops and awaits the tasks (bounded), then closes the session: track finished, MoQ close sent. */
662
+ async teardown(graceMs = 1000) {
663
+ this.endCalls();
664
+ this.#tasks.abort();
665
+ await withTimeout(Promise.allSettled(this.#taskDone), graceMs);
666
+ this.close();
667
+ await withTimeout(this.closed, graceMs);
668
+ }
669
+ /** The synchronous end: withdraw our connect request (the runtime ends the session), fail its calls, finish our track, close the connection. Idempotent. */
670
+ close() {
671
+ withdraw(this.#connect);
672
+ this.#admitting = false;
673
+ this.#tasks.abort();
674
+ this.log.closing();
675
+ try {
676
+ this.#req.close();
677
+ }
678
+ catch { /* closed */ }
679
+ try {
680
+ this.#reqBc.close();
681
+ }
682
+ catch { /* closed */ }
683
+ try {
684
+ this.#resSub.close();
685
+ }
686
+ catch { /* closed */ }
687
+ try {
688
+ this.#res.close();
689
+ }
690
+ catch { /* closed */ }
691
+ try {
692
+ this.conn.close();
693
+ }
694
+ catch { /* closed */ }
695
+ try {
696
+ this.pub.close();
697
+ }
698
+ catch { /* closed */ }
699
+ try {
700
+ this.sub.close();
701
+ }
702
+ catch { /* closed */ }
703
+ }
704
+ createBroadcast(path) { this.log.announced(path); return this.pub.createBroadcast(Moq.Path.from(path)); }
705
+ request(path) { this.log.requested(path); return this.sub.request(Moq.Path.from(path)); }
706
+ }
707
+ function certOption(hash) {
708
+ return hash ? { serverCertificateHashes: [{ algorithm: "sha-256", value: hash.trim() }] } : undefined;
709
+ }
710
+ /** What a connect attempt that got no answer reports (PLAN v0.26 §4 step 1): the request may have gone nowhere. */
711
+ export const NO_ANSWER = "no runtime answered under this root (a relay drops out-of-scope publishes silently: check the token)";
712
+ /** A session id for one connect attempt (PLAN v0.26 §5.1): 8 lowercase hex characters, 32 random bits. */
713
+ export function mintSid() { return toHex(crypto.getRandomValues(new Uint8Array(SID_LEN / 2))); }
714
+ /** Withdraws a connect request: the track finished, the broadcast un-announced and closed. Idempotent. */
715
+ function withdraw(c) {
716
+ try {
717
+ c.track.close();
718
+ }
719
+ catch { /* closed */ }
720
+ try {
721
+ c.bc.close();
722
+ }
723
+ catch { /* closed */ }
724
+ }
725
+ /**
726
+ * Asks the runtime for a session over an established connection (PLAN v0.26 §3.1): mint a `sid` for
727
+ * this attempt, write our connect request (`RPC_CALL`, `END`) on track `hello` of
728
+ * `client/<name>/<sid>/connect` and announce it on `pub`, then wait on `sub` for the answer on
729
+ * `client/<name>/<sid>/hello`: the hello (our prefix and role), or an error frame (`END|ERR`, the
730
+ * runtime's refusal, which is the attempt's error as it is: `admission_refused` when a live session
731
+ * holds the name, `invalid_input`, ...). Then our `res` track at the path the hello grants (`hello.res`,
732
+ * `runtime/rpc/@<epoch>`, PLAN v0.26 §7.2). No answer within
733
+ * `timeoutMs` is `not_connected` (`NO_ANSWER`). On any failure the request is withdrawn. `signal`
734
+ * abandons the wait (its reason is the error).
735
+ */
736
+ export async function requestSession(pub, sub, request, timeoutMs, log, signal) {
737
+ const { name } = request;
738
+ const sid = mintSid();
739
+ // Our connect request, written before it is announced (the runtime reads it once it sees it).
740
+ const path = connectPath(name, sid);
741
+ const bc = pub.createBroadcast(Moq.Path.from(path));
742
+ const connect = { bc, track: CallWriter.create(bc, "hello") };
743
+ connect.track.write({ flags: FLAGS.END, type: TYPES.RPC_CALL, seq: 0, tUs: nowUs() }, encodeConnectRequest(request));
744
+ bc.announce();
745
+ log.announced(path);
746
+ const noAnswer = () => new SynapseError("not_connected", { message: NO_ANSWER, source: "sdk" });
747
+ const stopped = (e) => { throw signal.aborted ? signal.reason : e; };
748
+ const opened = [];
749
+ try {
750
+ const hp = helloPath(name, sid);
751
+ log.requested(hp);
752
+ // Routed only: wait for the runtime to announce its answer (a blind subscription before it exists is reset as unroutable).
753
+ const helloReq = sub.request(Moq.Path.from(hp), { announced: true });
754
+ opened.push(helloReq);
755
+ const helloBc = await waitActive(helloReq, timeoutMs, signal).catch(() => stopped(noAnswer()));
756
+ const helloSub = helloBc.track("hello").subscribe(latestSubscription(moqPriority(0)));
757
+ opened.push(helloSub);
758
+ const read = async () => {
759
+ const g = await helloSub.recvGroup();
760
+ if (!g)
761
+ throw new SynapseError("connection_lost", "hello track ended");
762
+ const f = await g.readFrame();
763
+ if (!f)
764
+ throw new SynapseError("connection_lost", "empty hello");
765
+ return f.payload;
766
+ };
767
+ const abort = new Promise((_, reject) => { if (signal.aborted)
768
+ reject(signal.reason); signal.addEventListener("abort", () => reject(signal.reason), { once: true }); });
769
+ abort.catch(() => { });
770
+ const reading = read();
771
+ reading.catch(() => { });
772
+ const payload = await Promise.race([withTimeout(reading, timeoutMs), abort]).catch(e => stopped(e instanceof SynapseError ? e : new SynapseError("connection_lost", { message: `hello: ${describeError(e)}`, source: "transport", cause: e })));
773
+ if (payload === TIMEOUT)
774
+ throw noAnswer();
775
+ const hello = readAnswer(payload, name, sid);
776
+ // Our `res` track, at the path the hello grants (the runtime publishes it before it writes the hello).
777
+ const rp = hello.res;
778
+ log.requested(rp);
779
+ const resReq = sub.request(Moq.Path.from(rp), { announced: true });
780
+ opened.push(resReq);
781
+ const res = await waitActive(resReq, timeoutMs, signal).catch(() => stopped(new SynapseError("connection_lost", { message: "broker never announced our response track", source: "transport" })));
782
+ const resSub = res.track("res").subscribe(callTrackSubscription(moqPriority(0)));
783
+ helloSub.close();
784
+ helloReq.close();
785
+ return { connect, hello, res, resSub };
786
+ }
787
+ catch (e) {
788
+ for (const o of opened) {
789
+ try {
790
+ o.close();
791
+ }
792
+ catch { /* closed */ }
793
+ }
794
+ withdraw(connect);
795
+ throw e;
796
+ }
797
+ }
798
+ /** The runtime's answer frame: the hello for `client/<name>/<sid>`, or its refusal as the error. */
799
+ function readAnswer(payload, name, sid) {
800
+ let header, hlen, body;
801
+ try {
802
+ ({ header, hlen } = decodeHeader(payload));
803
+ body = decodeBody(payload.subarray(hlen));
804
+ }
805
+ catch (e) {
806
+ throw new SynapseError("invalid_input", { message: `the runtime's answer is not one this SDK understands (${describeError(e)})`, source: "sdk" });
807
+ }
808
+ if (header.type !== TYPES.RPC_RESP)
809
+ throw new SynapseError("invalid_input", { message: "hello frame is not a hello", source: "sdk" });
810
+ // The runtime's refusal: its error body is the attempt's error.
811
+ if (header.flags & FLAGS.ERR) {
812
+ const b = body;
813
+ if (!b || typeof b !== "object" || typeof b.code !== "string")
814
+ throw new SynapseError("invalid_input", { message: "the runtime's refusal is not one this SDK understands", source: "sdk" });
815
+ throw SynapseError.fromBody(b, { source: "broker" });
816
+ }
817
+ const h = body;
818
+ // A hello this SDK cannot read is a runtime/SDK version mismatch: said once, not retried.
819
+ if (!h || typeof h !== "object" || h.hello !== true || typeof h.name !== "string" || typeof h.prefix !== "string" || typeof h.rpc !== "string" || typeof h.res !== "string" || !isRole(h.role) || !isClientType(h.type) || typeof h.dom !== "string" || typeof h.broker_version !== "string") {
820
+ throw new SynapseError("invalid_input", { message: "the runtime's hello is not one this SDK understands; runtime and SDK versions differ", source: "sdk" });
821
+ }
822
+ const hello = { ...h, robot: typeof h.robot === "string" ? h.robot : "", runtime_name: typeof h.runtime_name === "string" ? h.runtime_name : "", runtime_id: typeof h.runtime_id === "string" ? h.runtime_id : "" };
823
+ if (hello.prefix !== sessionPrefix(name, sid))
824
+ throw new SynapseError("invalid_input", { message: `hello prefix ${JSON.stringify(hello.prefix)} is not client/${name}/${sid}`, source: "sdk" });
825
+ // The request path the hello grants is under our prefix: `<prefix>/rpc/@<epoch>` (PLAN v0.26 §7.1).
826
+ const granted = `${hello.prefix}/rpc/@`;
827
+ if (!hello.rpc.startsWith(granted) || hello.rpc.length === granted.length)
828
+ throw new SynapseError("invalid_input", { message: `hello rpc ${JSON.stringify(hello.rpc)} is not under ${hello.prefix}/rpc/`, source: "sdk" });
829
+ // The reply path it grants is under `runtime/rpc/` (`runtime/rpc/@<epoch>`, PLAN v0.26 §7.2).
830
+ if (!hello.res.startsWith("runtime/rpc/") || hello.res.length === "runtime/rpc/".length)
831
+ throw new SynapseError("invalid_input", { message: `hello res ${JSON.stringify(hello.res)} is not under runtime/rpc/`, source: "sdk" });
832
+ return hello;
833
+ }
834
+ /** How long a failed pre-hello step waits for the session's close to be observed (Rust `CLOSE_GRACE`). */
835
+ const CLOSE_GRACE_MS = 1000;
836
+ /**
837
+ * Opens one session to `dial` (PLAN v0.26 §3.1): the path to dial is the URL's own or, for a bare
838
+ * `https://host:port`, the root the runtime names (`withRoot`, asked at every attempt); dial with our name
839
+ * (the relay level's scope) and the token (`jwt=`, through a relay) as the only query parameters; ask the runtime for a session (`requestSession`: our identity and project
840
+ * intent go in the connect request), then announce our `req` track on the path the hello grants (`hello.rpc`).
841
+ * A relay's refusal of the connection shows up as the session closing: an observed close wins, and a
842
+ * transport failure of a step is settled against the close (`CLOSE_GRACE_MS`). `signal` abandons the
843
+ * dial: every wait stops at once and the half-open connection is closed (`canceled`); the connection
844
+ * not established within the connect timeout is `connection_lost`.
845
+ */
846
+ export async function openSession(knobs, dial, dom, signal) {
847
+ const { identity } = dial;
848
+ const name = identity.name;
849
+ if (!name)
850
+ throw new SynapseError("internal", "identity without a name");
851
+ if (knobs.transport !== "websocket")
852
+ await ensureWebTransport();
853
+ const abandoned = () => new SynapseError("canceled", { message: "the dial was abandoned", source: "sdk" });
854
+ if (signal?.aborted)
855
+ throw abandoned();
856
+ // The path to dial: the URL's own, or (none given) the runtime's root, asked of it now (a runtime started again elsewhere on this address may be another runtime).
857
+ const base = await withRoot(dial.url, signal).catch((e) => { throw signal?.aborted ? abandoned() : e; });
858
+ const pub = new Moq.Origin.Producer();
859
+ const sub = new Moq.Origin.Producer();
860
+ // Only the name and the token travel in the URL: the relay level's scope (`client/<name>/**`) and, through a relay, the token that
861
+ // grants it (`jwt=`, PLAN v0.26 §3.5). Who we are goes to the runtime in the connect request.
862
+ const url = new URL(base);
863
+ url.searchParams.append("name", name);
864
+ if (dial.token !== undefined)
865
+ url.searchParams.append("jwt", dial.token);
866
+ // One controller for the dial: the connect timeout (until the connection is established) and the caller's `signal`.
867
+ const ac = new AbortController();
868
+ const timer = setTimeout(() => ac.abort(new SynapseError("connection_lost", "connect timed out")), knobs.connectTimeoutMs);
869
+ const onAbandon = () => ac.abort(abandoned());
870
+ signal?.addEventListener("abort", onAbandon, { once: true });
871
+ const log = moqLogScope();
872
+ const abandon = () => { signal?.removeEventListener("abort", onAbandon); log.closing(); log.release(); };
873
+ let conn;
874
+ try {
875
+ const props = { url, publish: pub.consume(), consume: sub, signal: ac.signal };
876
+ const wt = certOption(dial.cert);
877
+ if (wt)
878
+ props.webtransport = wt;
879
+ if (knobs.transport === "webtransport")
880
+ props.websocket = { enabled: false };
881
+ else if (knobs.transport === "websocket")
882
+ props.websocket = { enabled: true, delay: Moq.Time.Milli(0) };
883
+ conn = await Moq.Connection.connect(props);
884
+ }
885
+ catch (e) {
886
+ clearTimeout(timer);
887
+ pub.close();
888
+ sub.close();
889
+ abandon();
890
+ // Stopped (timeout or `signal`): that reason, whatever error the connect rejected with.
891
+ if (ac.signal.aborted && ac.signal.reason instanceof SynapseError)
892
+ throw ac.signal.reason;
893
+ if (e instanceof SynapseError)
894
+ throw e;
895
+ throw closeError(e instanceof Error ? e : new Error(describeError(e)));
896
+ }
897
+ clearTimeout(timer);
898
+ const request = { type: identity.type, name, user_id: identity.user_id, credential: identity.credential, project: knobs.project, dom };
899
+ // The handshake's own controller: the caller's `signal`, or the close winning the race below.
900
+ const hs = new AbortController();
901
+ const onStop = () => hs.abort(ac.signal.reason);
902
+ if (ac.signal.aborted)
903
+ onStop();
904
+ else
905
+ ac.signal.addEventListener("abort", onStop, { once: true });
906
+ // A close is the single source of truth for why a pre-hello session ended: an observed close wins the race.
907
+ let closed = false;
908
+ const closedEarly = conn.closed.then((err) => { closed = true; throw closeError(err); });
909
+ closedEarly.catch(() => { });
910
+ let answered;
911
+ try {
912
+ answered = await Promise.race([closedEarly, requestSession(pub, sub, request, knobs.connectTimeoutMs, log, hs.signal)]);
913
+ }
914
+ catch (e) {
915
+ hs.abort(e);
916
+ let err = e instanceof SynapseError ? e : closeError(e instanceof Error ? e : new Error(describeError(e)));
917
+ // A step that tripped over a closing connection before its close was observed: the close decides.
918
+ if (!closed && err.code === "connection_lost")
919
+ err = await settlePreHello(err, conn.closed);
920
+ ac.signal.removeEventListener("abort", onStop);
921
+ abandon();
922
+ try {
923
+ conn.close();
924
+ }
925
+ catch { /* ignore */ }
926
+ pub.close();
927
+ sub.close();
928
+ throw err;
929
+ }
930
+ ac.signal.removeEventListener("abort", onStop);
931
+ signal?.removeEventListener("abort", onAbandon);
932
+ // Our request track (WP §8.2), on the path the hello grants (`<prefix>/rpc/@<epoch>`, PLAN v0.26 §7.1): the broadcast and its
933
+ // `req` track created (in the `Session`), then announced, so the runtime finds `req` as soon as it sees the broadcast.
934
+ const { hello, connect, res, resSub } = answered;
935
+ const reqBc = pub.createBroadcast(Moq.Path.from(hello.rpc));
936
+ log.announced(hello.rpc);
937
+ const session = new Session(base, conn, pub, sub, hello, knobs.maxDatagram, log, connect, reqBc, res, resSub, dom);
938
+ reqBc.announce();
939
+ return session;
940
+ }
941
+ /** `settle_pre_hello`: a pre-hello step failed with `err`; if the session closes within the grace that close is the error (a plain transport loss keeps `err`'s text alongside), otherwise `err`. */
942
+ async function settlePreHello(err, closed) {
943
+ const c = await withTimeout(closed, CLOSE_GRACE_MS);
944
+ if (c === TIMEOUT)
945
+ return err;
946
+ const e = closeError(c);
947
+ return e.code === "connection_lost" ? new SynapseError("connection_lost", { message: `${e.message} (${err.message})`, source: e.source, cause: e.cause }) : e;
948
+ }
949
+ /** The error a call pending on a session that ended reports (WP §8.4: the request was written, so it may have run). */
950
+ /** `data` is `{ outcome: "unknown" }`, with `reason` when the session's close had one (Rust `outcome_unknown_data`). */
951
+ export function outcomeUnknown(closing) {
952
+ const data = { outcome: "unknown" };
953
+ if (closing !== undefined)
954
+ data.reason = closing;
955
+ return new SynapseError("connection_lost", { message: closing ? `${closing}: the session ended with the call pending; its outcome is unknown` : "the session ended with the call pending; its outcome is unknown", source: "transport", data });
956
+ }
957
+ /** A failed write on our `req` track (Rust `write_req`): an oversized frame is `invalid_input` naming `what`; anything else `connection_lost`. */
958
+ function reqWriteError(e, what, len) {
959
+ if (e instanceof SynapseError && e.code === "invalid_input")
960
+ return frameTooLarge(what, len);
961
+ return new SynapseError("connection_lost", { message: describeError(e), source: "transport", cause: e });
962
+ }
963
+ export { deferred };