@synapse-sdk/client 0.1.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.
@@ -0,0 +1,1501 @@
1
+ import * as Phoenix from "phoenix";
2
+ //#region src/errors.ts
3
+ var ConnectionError = class extends Error {
4
+ constructor(message, options) {
5
+ super(message);
6
+ this.name = "ConnectionError";
7
+ if (options && options.cause !== void 0) this.cause = options.cause;
8
+ }
9
+ };
10
+ var TimeoutError = class extends ConnectionError {
11
+ constructor(message) {
12
+ super(message);
13
+ this.name = "TimeoutError";
14
+ }
15
+ };
16
+ /** A newer `connect(...)` (or `disconnect()`) replaced the target this promise was awaiting. */
17
+ var SupersededError = class extends ConnectionError {
18
+ constructor() {
19
+ super("connection target was superseded");
20
+ this.name = "SupersededError";
21
+ }
22
+ };
23
+ var ChannelError = class extends Error {
24
+ topic;
25
+ response;
26
+ constructor(topic, message, response) {
27
+ super(`channel ${topic}: ${message}`);
28
+ this.name = "ChannelError";
29
+ this.topic = topic;
30
+ this.response = response;
31
+ }
32
+ };
33
+ /**
34
+ * Known bus error codes — server-emitted (`unauthorized`, `service_unavailable`,
35
+ * `provider_error`, `invalid_input`, `unknown_event`, `ros_error`) plus client-side
36
+ * (`bad_ack`, `not_connected`, `timeout`, `cancelled`). Match on these to avoid magic
37
+ * strings; treat the set as **non-exhaustive** (the server may add codes).
38
+ */
39
+ const BusErrorCode = {
40
+ Unauthorized: "unauthorized",
41
+ ServiceUnavailable: "service_unavailable",
42
+ ProviderError: "provider_error",
43
+ InvalidInput: "invalid_input",
44
+ UnknownEvent: "unknown_event",
45
+ RosError: "ros_error",
46
+ BadAck: "bad_ack",
47
+ NotConnected: "not_connected",
48
+ Timeout: "timeout",
49
+ Cancelled: "cancelled"
50
+ };
51
+ /** Raised when a service call fails — at accept time or mid-flight (see the bus error model). */
52
+ var BusError = class extends Error {
53
+ code;
54
+ detail;
55
+ constructor(error) {
56
+ const e = error ?? {};
57
+ super(typeof e.message === "string" ? e.message : "bus error");
58
+ this.name = "BusError";
59
+ this.code = typeof e.code === "string" ? e.code : "error";
60
+ this.detail = error;
61
+ }
62
+ };
63
+ //#endregion
64
+ //#region src/channel.ts
65
+ /**
66
+ * One topic on the connection. Owned and reference-counted by `Connection` (one Channel
67
+ * per topic). Membership is independent of any UI component lifecycle: listeners attach
68
+ * and detach freely via `on`/`off` without re-joining, and the channel auto-rejoins when
69
+ * the socket reconnects.
70
+ */
71
+ var Channel = class {
72
+ topic;
73
+ state = "closed";
74
+ host;
75
+ params;
76
+ joinRef = null;
77
+ joinPromise = null;
78
+ joinResponse = void 0;
79
+ bindings = /* @__PURE__ */ new Map();
80
+ constructor(host, topic, params) {
81
+ this.host = host;
82
+ this.topic = topic;
83
+ this.params = params;
84
+ }
85
+ /** Idempotent: a join in flight or already joined returns the existing result. */
86
+ join(timeoutMs) {
87
+ if (this.state === "joined") return Promise.resolve(this.joinResponse);
88
+ if (this.state === "joining" && this.joinPromise) return this.joinPromise;
89
+ return this.doJoin(timeoutMs);
90
+ }
91
+ doJoin(timeoutMs) {
92
+ if (!this.host._isOpen()) {
93
+ this.state = "errored";
94
+ return Promise.reject(new ChannelError(this.topic, "socket not open"));
95
+ }
96
+ this.state = "joining";
97
+ const ref = this.host._makeRef();
98
+ this.joinRef = ref;
99
+ this.joinPromise = this.host._request({
100
+ join_ref: ref,
101
+ ref,
102
+ topic: this.topic,
103
+ event: "phx_join",
104
+ payload: this.params
105
+ }, timeoutMs).then((reply) => {
106
+ if (reply.status === "ok") {
107
+ this.state = "joined";
108
+ this.joinResponse = reply.response;
109
+ return reply.response;
110
+ }
111
+ this.state = "errored";
112
+ throw new ChannelError(this.topic, `join ${reply.status}`, reply.response);
113
+ }).catch((e) => {
114
+ this.state = "errored";
115
+ throw e;
116
+ });
117
+ return this.joinPromise;
118
+ }
119
+ async leave(timeoutMs) {
120
+ const wasActive = this.state === "joined" || this.state === "joining";
121
+ this.state = "leaving";
122
+ this.host._removeChannel(this.topic);
123
+ if (wasActive && this.joinRef && this.host._isOpen()) try {
124
+ await this.host._request({
125
+ join_ref: this.joinRef,
126
+ ref: this.host._makeRef(),
127
+ topic: this.topic,
128
+ event: "phx_leave",
129
+ payload: {}
130
+ }, timeoutMs);
131
+ } catch {}
132
+ this.state = "closed";
133
+ this.joinRef = null;
134
+ this.joinPromise = null;
135
+ this.bindings.clear();
136
+ }
137
+ /** Fire-and-forget; for handlers that reply with `{:noreply, ...}` (e.g. pub/sub publish). */
138
+ push(event, payload) {
139
+ this.host._send({
140
+ join_ref: this.joinRef,
141
+ ref: this.host._makeRef(),
142
+ topic: this.topic,
143
+ event,
144
+ payload: payload ?? {}
145
+ });
146
+ }
147
+ /** Send and await the reply; for handlers that reply with `{:reply, ...}`. */
148
+ request(event, payload, timeoutMs) {
149
+ return this.host._request({
150
+ join_ref: this.joinRef,
151
+ ref: this.host._makeRef(),
152
+ topic: this.topic,
153
+ event,
154
+ payload: payload ?? {}
155
+ }, timeoutMs);
156
+ }
157
+ on(event, cb) {
158
+ let set = this.bindings.get(event);
159
+ if (!set) {
160
+ set = /* @__PURE__ */ new Set();
161
+ this.bindings.set(event, set);
162
+ }
163
+ set.add(cb);
164
+ return cb;
165
+ }
166
+ off(event, cb) {
167
+ this.bindings.get(event)?.delete(cb);
168
+ }
169
+ _trigger(event, payload, ref) {
170
+ const set = this.bindings.get(event);
171
+ if (!set) return;
172
+ for (const cb of set) try {
173
+ cb(payload, ref);
174
+ } catch (e) {
175
+ this.host._log("error", `channel handler threw for ${this.topic}/${event}`, e);
176
+ }
177
+ }
178
+ /** Called when a (new) socket is open. Re-establishes membership if needed. */
179
+ _rejoin() {
180
+ if (this.state === "joined" || this.state === "joining" || this.state === "leaving") return;
181
+ this.doJoin().catch(() => {});
182
+ }
183
+ /** Called when the socket goes down: the join is no longer valid; we'll rejoin on reconnect. */
184
+ _onSocketDown() {
185
+ if (this.state === "leaving" || this.state === "closed") return;
186
+ this.state = "closed";
187
+ this.joinRef = null;
188
+ this.joinPromise = null;
189
+ }
190
+ };
191
+ //#endregion
192
+ //#region src/serializer.ts
193
+ const phx = Phoenix.Serializer;
194
+ /**
195
+ * The wire serializer — the **installed** Phoenix `Serializer` (v2 text + binary frames),
196
+ * wrapped to our `Serializer` interface so wire-compat tracks the `phoenix` package. Its
197
+ * `encode`/`decode` are callback-style but synchronous, so we capture the result inline.
198
+ */
199
+ const serializer = {
200
+ encode(msg) {
201
+ let out = "";
202
+ phx.encode(msg, (result) => {
203
+ out = result;
204
+ });
205
+ return out;
206
+ },
207
+ decode(raw) {
208
+ let out;
209
+ phx.decode(raw, (msg) => {
210
+ out = msg;
211
+ });
212
+ return out;
213
+ }
214
+ };
215
+ //#endregion
216
+ //#region src/connection.ts
217
+ const WS_OPEN = 1;
218
+ const WS_CLOSE_NORMAL = 1e3;
219
+ const DEFAULT_VSN = "2.0.0";
220
+ const AUTH_TOKEN_PREFIX = "base64url.bearer.phx.";
221
+ const DEFAULT_BACKOFF = [
222
+ 10,
223
+ 50,
224
+ 100,
225
+ 250,
226
+ 500,
227
+ 1e3,
228
+ 2e3
229
+ ];
230
+ const defaultReconnectAfterMs = (tries) => DEFAULT_BACKOFF[tries - 1] ?? 5e3;
231
+ /**
232
+ * Single-socket connection supervisor.
233
+ *
234
+ * Invariant (the whole point): **at most one socket is ever acted upon.** A new socket is
235
+ * never created until the previous one's `close` is confirmed (healthy switch) or it has
236
+ * been fenced and abandoned (dead socket). Three mechanics enforce it:
237
+ *
238
+ * 1. **Serialized reconcile** — every transition runs through one queue; the latest
239
+ * desired target always wins (rapid `connect`/`disconnect` coalesce).
240
+ * 2. **Generation + live-identity guards** — every socket event checks it belongs to the
241
+ * current desired-epoch *and* the current socket; late events from a superseded or
242
+ * replaced socket are inert.
243
+ * 3. **Verified teardown** — switching/closing awaits the real `close` event (with a
244
+ * force-fence timeout) before any new socket opens.
245
+ *
246
+ * No global/`window` coupling, so discarding a socket is clean — nothing to leak or
247
+ * resurrect on tab visibility.
248
+ */
249
+ var Connection = class {
250
+ WS;
251
+ serializer;
252
+ heartbeatIntervalMs;
253
+ requestTimeoutMs;
254
+ closeTimeoutMs;
255
+ reconnectAfterMs;
256
+ vsn;
257
+ _log;
258
+ desired = null;
259
+ live = null;
260
+ generation = 0;
261
+ state = {
262
+ status: "idle",
263
+ endpoint: null,
264
+ error: null,
265
+ since: now()
266
+ };
267
+ listeners = /* @__PURE__ */ new Set();
268
+ channels = /* @__PURE__ */ new Map();
269
+ pending = /* @__PURE__ */ new Map();
270
+ refCounter = 0;
271
+ reconciling = false;
272
+ dirty = false;
273
+ reconnectAttempt = 0;
274
+ reconnectTimer = null;
275
+ closed = false;
276
+ constructor(options = {}) {
277
+ const WS = options.WebSocket ?? globalThis.WebSocket;
278
+ if (!WS) throw new ConnectionError("no WebSocket available — pass options.WebSocket (e.g. the `ws` package on older Node)");
279
+ this.WS = WS;
280
+ this.serializer = options.serializer ?? serializer;
281
+ this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? 15e3;
282
+ this.requestTimeoutMs = options.requestTimeoutMs ?? 1e4;
283
+ this.closeTimeoutMs = options.closeTimeoutMs ?? 2e3;
284
+ this.reconnectAfterMs = options.reconnectAfterMs ?? defaultReconnectAfterMs;
285
+ this.vsn = options.vsn ?? DEFAULT_VSN;
286
+ this._log = options.logger ?? (() => {});
287
+ }
288
+ getState() {
289
+ return this.state;
290
+ }
291
+ /** Observe connection state. Returns an unsubscribe fn. `getState`/state are stable
292
+ * references between changes, so this is `useSyncExternalStore`-friendly. */
293
+ subscribe(listener) {
294
+ this.listeners.add(listener);
295
+ return () => this.listeners.delete(listener);
296
+ }
297
+ /** Declarative + idempotent. Sets the desired target and converges to it. Resolves when
298
+ * this target reaches `connected`; rejects if a different target supersedes it. */
299
+ connect(target) {
300
+ if (this.closed) return Promise.reject(new ConnectionError("connection disposed"));
301
+ const key = targetKey(target);
302
+ if (this.desired && targetKey(this.desired) === key) return this.state.status === "connected" ? Promise.resolve() : this.waitForTarget(key);
303
+ this.desired = target;
304
+ this.bumpGeneration();
305
+ this.cancelReconnect();
306
+ this.reconcile();
307
+ return this.waitForTarget(key);
308
+ }
309
+ /** Tear down to `idle`. Resolves once fully closed. */
310
+ disconnect() {
311
+ if (this.closed || this.desired === null && this.live === null) return Promise.resolve();
312
+ this.desired = null;
313
+ this.bumpGeneration();
314
+ this.cancelReconnect();
315
+ this.reconcile();
316
+ if (this.state.status === "idle") return Promise.resolve();
317
+ return new Promise((resolve) => {
318
+ const off = this.subscribe((s) => {
319
+ if (s.status === "idle") {
320
+ off();
321
+ resolve();
322
+ }
323
+ });
324
+ });
325
+ }
326
+ /** Permanent dispose — closes the socket and stops everything. */
327
+ close() {
328
+ this.closed = true;
329
+ this.desired = null;
330
+ this.bumpGeneration();
331
+ this.cancelReconnect();
332
+ if (this.live) {
333
+ this.fence(this.live);
334
+ this.live = null;
335
+ }
336
+ this.flushPending(new ConnectionError("connection disposed"));
337
+ this.setState({
338
+ status: "idle",
339
+ endpoint: null,
340
+ error: null
341
+ });
342
+ }
343
+ /** Get-or-create the (single) channel for a topic. Joined now if the socket is open,
344
+ * otherwise it joins automatically once a socket comes up. */
345
+ channel(topic, params = {}) {
346
+ let ch = this.channels.get(topic);
347
+ if (ch) return ch;
348
+ ch = new Channel(this, topic, params);
349
+ this.channels.set(topic, ch);
350
+ if (this._isOpen()) ch._rejoin();
351
+ return ch;
352
+ }
353
+ _makeRef() {
354
+ this.refCounter += 1;
355
+ return String(this.refCounter);
356
+ }
357
+ _isOpen() {
358
+ return !!this.live && this.live.opened && this.live.ws.readyState === WS_OPEN;
359
+ }
360
+ _send(msg) {
361
+ const live = this.live;
362
+ if (!live || !this._isOpen()) {
363
+ this._log("warn", `dropping ${msg.event} on ${msg.topic} — socket not open`);
364
+ return;
365
+ }
366
+ this.sendRaw(live, msg);
367
+ }
368
+ _request(msg, timeoutMs = this.requestTimeoutMs) {
369
+ return new Promise((resolve, reject) => {
370
+ const live = this.live;
371
+ if (!live || !this._isOpen()) {
372
+ reject(new ConnectionError("not connected"));
373
+ return;
374
+ }
375
+ const ref = msg.ref ?? this._makeRef();
376
+ const timer = setTimeout(() => {
377
+ this.pending.delete(ref);
378
+ resolve({
379
+ status: "timeout",
380
+ response: void 0
381
+ });
382
+ }, timeoutMs);
383
+ this.pending.set(ref, {
384
+ resolve,
385
+ reject,
386
+ timer
387
+ });
388
+ try {
389
+ this.sendRaw(live, {
390
+ ...msg,
391
+ ref
392
+ });
393
+ } catch (e) {
394
+ clearTimeout(timer);
395
+ this.pending.delete(ref);
396
+ reject(e);
397
+ }
398
+ });
399
+ }
400
+ _removeChannel(topic) {
401
+ this.channels.delete(topic);
402
+ }
403
+ reconcile() {
404
+ this.dirty = true;
405
+ if (this.reconciling) return;
406
+ this.reconciling = true;
407
+ this.runReconcile();
408
+ }
409
+ async runReconcile() {
410
+ try {
411
+ while (this.dirty && !this.closed) {
412
+ this.dirty = false;
413
+ await this.step();
414
+ }
415
+ } finally {
416
+ this.reconciling = false;
417
+ }
418
+ }
419
+ async step() {
420
+ const desired = this.desired;
421
+ const live = this.live;
422
+ if (!desired && !live) {
423
+ this.setState({
424
+ status: "idle",
425
+ endpoint: null,
426
+ error: null
427
+ });
428
+ return;
429
+ }
430
+ if (!desired && live) {
431
+ await this.teardown(live);
432
+ this.dirty = true;
433
+ return;
434
+ }
435
+ if (desired && !live) {
436
+ await this.bringUp(desired);
437
+ return;
438
+ }
439
+ if (desired && live) {
440
+ if (targetKey(live.target) === targetKey(desired)) return;
441
+ await this.teardown(live);
442
+ this.dirty = true;
443
+ }
444
+ }
445
+ async bringUp(target) {
446
+ const gen = this.generation;
447
+ if (this.state.status !== "reconnecting") this.setState({
448
+ status: "connecting",
449
+ endpoint: target.url,
450
+ error: null
451
+ });
452
+ let token;
453
+ let params;
454
+ try {
455
+ token = target.token ? await target.token() : void 0;
456
+ if (gen !== this.generation) return;
457
+ params = await resolveParams(target.params);
458
+ if (gen !== this.generation) return;
459
+ } catch (e) {
460
+ if (gen === this.generation) this.failTarget(e);
461
+ return;
462
+ }
463
+ const url = buildUrl(target.url, params, this.vsn);
464
+ let ws;
465
+ try {
466
+ ws = token ? new this.WS(url, ["phoenix", AUTH_TOKEN_PREFIX + base64NoPad(token)]) : new this.WS(url);
467
+ } catch (e) {
468
+ if (gen === this.generation) this.failTarget(e);
469
+ return;
470
+ }
471
+ const live = {
472
+ ws,
473
+ generation: gen,
474
+ target,
475
+ heartbeatTimer: null,
476
+ pendingHeartbeatRef: null,
477
+ opened: false
478
+ };
479
+ this.live = live;
480
+ ws.onopen = () => {
481
+ if (this.generation === gen && this.live === live) this.onOpen(live);
482
+ };
483
+ ws.onmessage = (ev) => {
484
+ if (this.generation === gen && this.live === live) this.onMessage(live, ev.data);
485
+ };
486
+ ws.onerror = (ev) => {
487
+ if (this.generation === gen && this.live === live) this._log("warn", "socket error", ev);
488
+ };
489
+ ws.onclose = () => {
490
+ if (this.generation === gen && this.live === live) this.onUnexpectedClose(live);
491
+ };
492
+ }
493
+ onOpen(live) {
494
+ live.opened = true;
495
+ this.reconnectAttempt = 0;
496
+ this.startHeartbeat(live);
497
+ for (const ch of this.channels.values()) ch._rejoin();
498
+ const verify = live.target.verify;
499
+ if (!verify) {
500
+ this.setConnected(live);
501
+ return;
502
+ }
503
+ const gen = live.generation;
504
+ verify(this.verifyHandle()).then(() => {
505
+ if (this.generation === gen && this.live === live) this.setConnected(live);
506
+ }).catch((e) => {
507
+ if (this.generation === gen && this.live === live) {
508
+ this._log("warn", "verify failed", e);
509
+ this.replaceDead(live, asError$1(e));
510
+ }
511
+ });
512
+ }
513
+ verifyHandle() {
514
+ return { channel: (topic, params) => this.channel(topic, params ?? {}) };
515
+ }
516
+ setConnected(live) {
517
+ this.setState({
518
+ status: "connected",
519
+ endpoint: live.target.url,
520
+ error: null
521
+ });
522
+ }
523
+ async teardown(live) {
524
+ this.setState({ status: "closing" });
525
+ this.clearHeartbeat(live);
526
+ const ws = live.ws;
527
+ await new Promise((resolve) => {
528
+ let done = false;
529
+ const finish = () => {
530
+ if (done) return;
531
+ done = true;
532
+ clearTimeout(forceTimer);
533
+ resolve();
534
+ };
535
+ const forceTimer = setTimeout(() => {
536
+ detach(ws);
537
+ finish();
538
+ }, this.closeTimeoutMs);
539
+ ws.onopen = null;
540
+ ws.onmessage = null;
541
+ ws.onerror = null;
542
+ ws.onclose = () => finish();
543
+ try {
544
+ ws.close(WS_CLOSE_NORMAL);
545
+ } catch {
546
+ finish();
547
+ }
548
+ });
549
+ if (this.live === live) this.live = null;
550
+ this.afterSocketGone(new ConnectionError("connection closed"));
551
+ }
552
+ replaceDead(live, err) {
553
+ this._log("warn", "socket dead — fencing", err);
554
+ this.fence(live);
555
+ if (this.live === live) this.live = null;
556
+ this.afterSocketGone(err);
557
+ if (this.desired && targetKey(this.desired) === targetKey(live.target)) this.scheduleReconnect();
558
+ else this.reconcile();
559
+ }
560
+ onUnexpectedClose(live) {
561
+ this.clearHeartbeat(live);
562
+ if (this.live === live) this.live = null;
563
+ this.afterSocketGone(new ConnectionError("connection lost"));
564
+ if (this.desired && targetKey(this.desired) === targetKey(live.target)) this.scheduleReconnect();
565
+ else this.reconcile();
566
+ }
567
+ /** Detach handlers and best-effort close, without awaiting (the socket may never close). */
568
+ fence(live) {
569
+ this.clearHeartbeat(live);
570
+ detach(live.ws);
571
+ try {
572
+ live.ws.close(WS_CLOSE_NORMAL);
573
+ } catch {}
574
+ }
575
+ afterSocketGone(err) {
576
+ this.flushPending(err);
577
+ for (const ch of this.channels.values()) ch._onSocketDown();
578
+ }
579
+ scheduleReconnect() {
580
+ if (this.closed || !this.desired) return;
581
+ this.reconnectAttempt += 1;
582
+ const delay = this.reconnectAfterMs(this.reconnectAttempt);
583
+ this.setState({ status: "reconnecting" });
584
+ this.cancelReconnect();
585
+ this.reconnectTimer = setTimeout(() => {
586
+ this.reconnectTimer = null;
587
+ this.reconcile();
588
+ }, delay);
589
+ }
590
+ cancelReconnect() {
591
+ if (this.reconnectTimer) {
592
+ clearTimeout(this.reconnectTimer);
593
+ this.reconnectTimer = null;
594
+ }
595
+ }
596
+ startHeartbeat(live) {
597
+ this.clearHeartbeat(live);
598
+ live.heartbeatTimer = setTimeout(() => this.sendHeartbeat(live), this.heartbeatIntervalMs);
599
+ }
600
+ sendHeartbeat(live) {
601
+ if (this.live !== live || !this._isOpen()) return;
602
+ if (live.pendingHeartbeatRef) {
603
+ this.replaceDead(live, new ConnectionError("heartbeat timeout"));
604
+ return;
605
+ }
606
+ live.pendingHeartbeatRef = this._makeRef();
607
+ this.sendRaw(live, {
608
+ join_ref: null,
609
+ ref: live.pendingHeartbeatRef,
610
+ topic: "phoenix",
611
+ event: "heartbeat",
612
+ payload: {}
613
+ });
614
+ live.heartbeatTimer = setTimeout(() => this.sendHeartbeat(live), this.heartbeatIntervalMs);
615
+ }
616
+ clearHeartbeat(live) {
617
+ if (live.heartbeatTimer) {
618
+ clearTimeout(live.heartbeatTimer);
619
+ live.heartbeatTimer = null;
620
+ }
621
+ live.pendingHeartbeatRef = null;
622
+ }
623
+ onMessage(live, data) {
624
+ let msg;
625
+ try {
626
+ msg = this.serializer.decode(data);
627
+ } catch (e) {
628
+ this._log("error", "decode failed", e);
629
+ return;
630
+ }
631
+ if (msg.ref && msg.ref === live.pendingHeartbeatRef && msg.topic === "phoenix") {
632
+ live.pendingHeartbeatRef = null;
633
+ return;
634
+ }
635
+ if (msg.event === "phx_reply" && msg.ref) {
636
+ const p = this.pending.get(msg.ref);
637
+ if (p) {
638
+ this.pending.delete(msg.ref);
639
+ clearTimeout(p.timer);
640
+ const payload = msg.payload;
641
+ p.resolve({
642
+ status: payload?.status ?? "ok",
643
+ response: payload?.response
644
+ });
645
+ return;
646
+ }
647
+ }
648
+ const ch = this.channels.get(msg.topic);
649
+ if (ch) ch._trigger(msg.event, msg.payload, msg.ref);
650
+ }
651
+ sendRaw(live, msg) {
652
+ live.ws.send(this.serializer.encode(msg));
653
+ }
654
+ flushPending(err) {
655
+ for (const p of this.pending.values()) {
656
+ clearTimeout(p.timer);
657
+ p.reject(err);
658
+ }
659
+ this.pending.clear();
660
+ }
661
+ setState(partial) {
662
+ const merged = {
663
+ ...this.state,
664
+ ...partial
665
+ };
666
+ if (!(merged.status !== this.state.status || merged.endpoint !== this.state.endpoint || merged.error !== this.state.error)) return;
667
+ this.state = {
668
+ ...merged,
669
+ since: now()
670
+ };
671
+ for (const l of this.listeners) try {
672
+ l(this.state);
673
+ } catch (e) {
674
+ this._log("error", "state listener threw", e);
675
+ }
676
+ }
677
+ failTarget(e) {
678
+ this.setState({
679
+ status: "error",
680
+ error: asError$1(e)
681
+ });
682
+ }
683
+ desiredKey() {
684
+ return this.desired ? targetKey(this.desired) : "\0idle";
685
+ }
686
+ waitForTarget(key) {
687
+ return new Promise((resolve, reject) => {
688
+ const check = (s) => {
689
+ if (this.desiredKey() !== key) {
690
+ reject(new SupersededError());
691
+ return true;
692
+ }
693
+ if (s.status === "connected" && this.live && targetKey(this.live.target) === key) {
694
+ resolve();
695
+ return true;
696
+ }
697
+ if (s.status === "error" && s.error) {
698
+ reject(s.error);
699
+ return true;
700
+ }
701
+ return false;
702
+ };
703
+ if (check(this.state)) return;
704
+ const off = this.subscribe((s) => {
705
+ if (check(s)) off();
706
+ });
707
+ });
708
+ }
709
+ bumpGeneration() {
710
+ this.generation += 1;
711
+ }
712
+ };
713
+ function now() {
714
+ return Date.now();
715
+ }
716
+ function asError$1(e) {
717
+ return e instanceof Error ? e : new ConnectionError(String(e));
718
+ }
719
+ /** Detach all handlers so an abandoned socket can never call back into us. */
720
+ function detach(ws) {
721
+ ws.onopen = null;
722
+ ws.onmessage = null;
723
+ ws.onerror = null;
724
+ ws.onclose = null;
725
+ }
726
+ /** Target identity = url + plain params only. `token`/`verify` (functions, often fresh per
727
+ * render) are *how* to connect, never *what* — so they don't trigger spurious reconnects. */
728
+ function targetKey(t) {
729
+ const p = t.params && typeof t.params !== "function" ? stableStringify(t.params) : "";
730
+ return t.url + "|" + p;
731
+ }
732
+ function stableStringify(o) {
733
+ const keys = Object.keys(o).sort();
734
+ const out = {};
735
+ for (const k of keys) out[k] = o[k];
736
+ return JSON.stringify(out);
737
+ }
738
+ async function resolveParams(p) {
739
+ if (!p) return {};
740
+ return typeof p === "function" ? await p() : p;
741
+ }
742
+ function buildUrl(base, params, vsn) {
743
+ let url = base.replace(/\/$/, "");
744
+ if (!url.endsWith("/websocket")) url += "/websocket";
745
+ const q = new URLSearchParams();
746
+ q.set("vsn", vsn);
747
+ for (const [k, v] of Object.entries(params)) {
748
+ if (v === void 0 || v === null) continue;
749
+ q.set(k, typeof v === "string" ? v : JSON.stringify(v));
750
+ }
751
+ return `${url}?${q.toString()}`;
752
+ }
753
+ function base64NoPad(token) {
754
+ return (typeof btoa === "function" ? btoa(token) : globalThis.Buffer.from(token, "utf-8").toString("base64")).replace(/=/g, "");
755
+ }
756
+ //#endregion
757
+ //#region src/address.ts
758
+ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
759
+ /**
760
+ * Split a `[realm:]name` address into `{ realm, name }`; default realm `synapse`.
761
+ * Only a bare-identifier prefix counts as a realm, so a synapse name with `/` and no realm
762
+ * passes through unchanged. Mirrors the Python client.
763
+ *
764
+ * "camera/capture" -> { realm: "synapse", name: "camera/capture" }
765
+ * "rosbridge:/add_two_ints" -> { realm: "rosbridge", name: "/add_two_ints" }
766
+ */
767
+ function address(addr) {
768
+ const i = addr.indexOf(":");
769
+ if (i > 0) {
770
+ const realm = addr.slice(0, i);
771
+ if (IDENTIFIER.test(realm)) return {
772
+ realm,
773
+ name: addr.slice(i + 1)
774
+ };
775
+ }
776
+ return {
777
+ realm: "synapse",
778
+ name: addr
779
+ };
780
+ }
781
+ //#endregion
782
+ //#region src/node.ts
783
+ /**
784
+ * A Synapse bus node. Wraps the single-socket {@link Connection} and adds the bus verbs —
785
+ * `call`, `callStream`, `subscribe`, `publish`, `service` — mirroring the Python `Node`.
786
+ * Any node can both consume and provide (a browser can publish joystick state *and* expose
787
+ * a service the robot calls into).
788
+ */
789
+ var Synapse = class {
790
+ connection;
791
+ identity;
792
+ tasks = /* @__PURE__ */ new Map();
793
+ consumerChannels = /* @__PURE__ */ new Map();
794
+ pubsubChannels = /* @__PURE__ */ new Map();
795
+ pubsubSubs = /* @__PURE__ */ new Map();
796
+ stateHandles = /* @__PURE__ */ new Map();
797
+ catalogChannelP = null;
798
+ catalogWatchers = /* @__PURE__ */ new Set();
799
+ systemEnabled;
800
+ systemChannel = null;
801
+ role = null;
802
+ projectId = null;
803
+ systemError = null;
804
+ systemRequest = {};
805
+ systemState;
806
+ systemListeners = /* @__PURE__ */ new Set();
807
+ systemGen = 0;
808
+ constructor(options = {}) {
809
+ this.connection = new Connection(options);
810
+ this.identity = {
811
+ user_id: options.userId ?? randomId(),
812
+ type: options.role ?? "agent"
813
+ };
814
+ if (options.name) this.identity.user_name = options.name;
815
+ this.systemEnabled = options.system !== false;
816
+ this.systemState = this.computeSystem();
817
+ this.connection.subscribe((cs) => this.onTransport(cs));
818
+ }
819
+ /**
820
+ * Connect (and negotiate the system session). Identity params are merged in. Resolves when
821
+ * **ready** — transport connected *and* a server role assigned (unless `system: false`,
822
+ * where ready just means connected). Declarative + idempotent. To request control, pass
823
+ * `{ project, controller: true }`.
824
+ */
825
+ async connect(target) {
826
+ const req = {
827
+ project: target.project,
828
+ controller: target.controller
829
+ };
830
+ if (this.systemChannel && (req.project !== this.systemRequest.project || req.controller !== this.systemRequest.controller)) {
831
+ this.systemChannel.leave();
832
+ this.systemChannel = null;
833
+ }
834
+ this.systemRequest = req;
835
+ await this.connection.connect({
836
+ url: target.url,
837
+ params: this.withIdentity(target.params),
838
+ token: target.token,
839
+ verify: target.verify
840
+ });
841
+ if (this.systemEnabled) await this.whenReady();
842
+ }
843
+ disconnect() {
844
+ return this.connection.disconnect();
845
+ }
846
+ close() {
847
+ this.connection.close();
848
+ }
849
+ getState() {
850
+ return this.connection.getState();
851
+ }
852
+ /** Observe connection state — `useSyncExternalStore`-friendly. */
853
+ subscribeState(listener) {
854
+ return this.connection.subscribe(listener);
855
+ }
856
+ /** The full readiness — `{ status, role, projectId, ready, error }`. */
857
+ getSystem() {
858
+ return this.systemState;
859
+ }
860
+ /** Observe readiness — `useSyncExternalStore`-friendly. The connection indicator binds
861
+ * to `{ status, role }` (transport status + server role) and `ready` is the gate. */
862
+ subscribeSystem(listener) {
863
+ this.systemListeners.add(listener);
864
+ return () => this.systemListeners.delete(listener);
865
+ }
866
+ /** Resolves once **ready** (negotiated + role assigned), or rejects after `timeoutMs`. */
867
+ whenReady(timeoutMs = 1e4) {
868
+ if (this.systemState.ready) return Promise.resolve();
869
+ return new Promise((resolve, reject) => {
870
+ const timer = setTimeout(() => {
871
+ off();
872
+ reject(new BusError({
873
+ code: BusErrorCode.NotConnected,
874
+ message: "timed out waiting for ready"
875
+ }));
876
+ }, timeoutMs);
877
+ const off = this.subscribeSystem((s) => {
878
+ if (s.ready) {
879
+ clearTimeout(timer);
880
+ off();
881
+ resolve();
882
+ }
883
+ });
884
+ });
885
+ }
886
+ /** Activate a project (request control of it). Role updates arrive live via the session. */
887
+ async activateProject(projectId) {
888
+ const reply = await this.requireSystem().request("activate_project", { project_id: projectId });
889
+ if (reply.status !== "ok") throw new BusError(reply.response);
890
+ }
891
+ /** Step back to viewer for the active project. */
892
+ async deactivateProject() {
893
+ const reply = await this.requireSystem().request("deactivate_project", {});
894
+ if (reply.status !== "ok") throw new BusError(reply.response);
895
+ }
896
+ /** Resolves once the transport is connected — used internally before channel ops (channels
897
+ * need the socket, not a role). */
898
+ awaitConnected(timeoutMs = 1e4) {
899
+ if (this.connection.getState().status === "connected") return Promise.resolve();
900
+ return new Promise((resolve, reject) => {
901
+ const timer = setTimeout(() => {
902
+ off();
903
+ reject(new BusError({
904
+ code: BusErrorCode.NotConnected,
905
+ message: "timed out waiting for connection"
906
+ }));
907
+ }, timeoutMs);
908
+ const off = this.connection.subscribe((s) => {
909
+ if (s.status === "connected") {
910
+ clearTimeout(timer);
911
+ off();
912
+ resolve();
913
+ }
914
+ });
915
+ });
916
+ }
917
+ onTransport(cs) {
918
+ if (cs.status === "connected") {
919
+ if (this.systemEnabled) this.enterSystem();
920
+ } else {
921
+ this.role = null;
922
+ this.projectId = null;
923
+ if (cs.status !== "error") this.systemError = null;
924
+ this.systemGen++;
925
+ }
926
+ this.emitSystem();
927
+ }
928
+ async enterSystem() {
929
+ const gen = ++this.systemGen;
930
+ let ch = this.systemChannel;
931
+ if (!ch) {
932
+ ch = this.connection.channel("synapse:system", this.systemPayload());
933
+ ch.on("session_state", (p) => this.onSessionState(p));
934
+ ch.on("promoted", () => this.setRole("controller"));
935
+ ch.on("demoted", () => this.setRole("viewer"));
936
+ this.systemChannel = ch;
937
+ }
938
+ try {
939
+ const reply = await ch.join();
940
+ if (gen !== this.systemGen) return;
941
+ this.role = normalizeRole(reply?.role) ?? this.role;
942
+ if (typeof reply?.project_id === "string") this.projectId = reply.project_id;
943
+ this.systemError = null;
944
+ this.emitSystem();
945
+ } catch (e) {
946
+ if (gen !== this.systemGen) return;
947
+ this.role = null;
948
+ this.systemError = asError(e);
949
+ this.emitSystem();
950
+ }
951
+ }
952
+ onSessionState(p) {
953
+ const r = normalizeRole(p.role);
954
+ if (r) this.role = r;
955
+ if (typeof p.project_id === "string") this.projectId = p.project_id;
956
+ this.emitSystem();
957
+ }
958
+ setRole(role) {
959
+ this.role = role;
960
+ this.emitSystem();
961
+ }
962
+ requireSystem() {
963
+ if (!this.systemChannel) throw new BusError({
964
+ code: BusErrorCode.NotConnected,
965
+ message: "system channel not joined"
966
+ });
967
+ return this.systemChannel;
968
+ }
969
+ systemPayload() {
970
+ const p = {};
971
+ if (this.systemRequest.project) p.project_id = this.systemRequest.project;
972
+ if (this.systemRequest.controller) p.role = "controller";
973
+ return p;
974
+ }
975
+ computeSystem() {
976
+ const cs = this.connection.getState();
977
+ const ready = cs.status === "connected" && (!this.systemEnabled || this.role !== null);
978
+ return {
979
+ status: cs.status,
980
+ role: this.role,
981
+ projectId: this.projectId,
982
+ ready,
983
+ error: this.systemError ?? cs.error
984
+ };
985
+ }
986
+ emitSystem() {
987
+ const next = this.computeSystem();
988
+ const prev = this.systemState;
989
+ if (next.status === prev.status && next.role === prev.role && next.projectId === prev.projectId && next.ready === prev.ready && next.error === prev.error) return;
990
+ this.systemState = next;
991
+ for (const l of this.systemListeners) try {
992
+ l(next);
993
+ } catch (e) {
994
+ this.connection._log("error", "system listener threw", e);
995
+ }
996
+ }
997
+ async call(service, input = {}, opts = {}) {
998
+ const { realm, name } = address(service);
999
+ const ch = await this.consumerChannel(realm, name);
1000
+ const ack = await ch.request("call", input, opts.timeout);
1001
+ if (ack.status !== "ok") throw new BusError(ack.response);
1002
+ const taskId = ack.response?.task_id;
1003
+ if (!taskId) throw new BusError({
1004
+ code: BusErrorCode.BadAck,
1005
+ message: "service did not return a task_id"
1006
+ });
1007
+ return await new Promise((resolve, reject) => {
1008
+ let idle;
1009
+ const settle = () => {
1010
+ this.tasks.delete(taskId);
1011
+ if (idle) clearTimeout(idle);
1012
+ };
1013
+ const armIdle = () => {
1014
+ if (!opts.idleTimeout) return;
1015
+ if (idle) clearTimeout(idle);
1016
+ idle = setTimeout(() => {
1017
+ settle();
1018
+ ch.push("cancel", { task_id: taskId });
1019
+ reject(new BusError({
1020
+ code: BusErrorCode.Timeout,
1021
+ message: `no progress for ${opts.idleTimeout}ms`
1022
+ }));
1023
+ }, opts.idleTimeout);
1024
+ };
1025
+ this.tasks.set(taskId, {
1026
+ onUpdate: (f) => {
1027
+ armIdle();
1028
+ opts.onUpdate?.(f);
1029
+ },
1030
+ onResult: (f) => {
1031
+ settle();
1032
+ resolve(f.result ?? f);
1033
+ },
1034
+ onError: (f) => {
1035
+ settle();
1036
+ reject(new BusError(f.error ?? f));
1037
+ }
1038
+ });
1039
+ armIdle();
1040
+ const signal = opts.signal;
1041
+ if (signal) {
1042
+ const onAbort = () => {
1043
+ settle();
1044
+ ch.push("cancel", { task_id: taskId });
1045
+ reject(abortError(signal));
1046
+ };
1047
+ if (signal.aborted) onAbort();
1048
+ else signal.addEventListener("abort", onAbort, { once: true });
1049
+ }
1050
+ });
1051
+ }
1052
+ /** Call as a loop: yields each `update` then the terminal `result`. Breaking cancels. */
1053
+ async *callStream(service, input = {}, opts = {}) {
1054
+ const { realm, name } = address(service);
1055
+ const ch = await this.consumerChannel(realm, name);
1056
+ const ack = await ch.request("call", input, opts.timeout);
1057
+ if (ack.status !== "ok") throw new BusError(ack.response);
1058
+ const taskId = ack.response?.task_id;
1059
+ if (!taskId) throw new BusError({
1060
+ code: BusErrorCode.BadAck,
1061
+ message: "service did not return a task_id"
1062
+ });
1063
+ const queue = [];
1064
+ let wake = null;
1065
+ let done = false;
1066
+ let err = null;
1067
+ let idle;
1068
+ const bump = () => {
1069
+ wake?.();
1070
+ wake = null;
1071
+ };
1072
+ const clearIdle = () => {
1073
+ if (idle) clearTimeout(idle);
1074
+ };
1075
+ const armIdle = () => {
1076
+ if (!opts.idleTimeout) return;
1077
+ clearIdle();
1078
+ idle = setTimeout(() => {
1079
+ err = new BusError({
1080
+ code: BusErrorCode.Timeout,
1081
+ message: `no progress for ${opts.idleTimeout}ms`
1082
+ });
1083
+ done = true;
1084
+ bump();
1085
+ }, opts.idleTimeout);
1086
+ };
1087
+ this.tasks.set(taskId, {
1088
+ onUpdate: (f) => {
1089
+ armIdle();
1090
+ queue.push({
1091
+ type: "update",
1092
+ data: f
1093
+ });
1094
+ bump();
1095
+ },
1096
+ onResult: (f) => {
1097
+ clearIdle();
1098
+ queue.push({
1099
+ type: "result",
1100
+ data: f
1101
+ });
1102
+ done = true;
1103
+ bump();
1104
+ },
1105
+ onError: (f) => {
1106
+ clearIdle();
1107
+ err = new BusError(f.error ?? f);
1108
+ done = true;
1109
+ bump();
1110
+ }
1111
+ });
1112
+ armIdle();
1113
+ try {
1114
+ while (true) {
1115
+ if (queue.length === 0 && !done && !err) await new Promise((r) => wake = r);
1116
+ while (queue.length) {
1117
+ const ev = queue.shift();
1118
+ yield ev;
1119
+ if (ev.type === "result") return;
1120
+ }
1121
+ if (err) throw err;
1122
+ if (done) return;
1123
+ }
1124
+ } finally {
1125
+ clearIdle();
1126
+ this.tasks.delete(taskId);
1127
+ ch.push("cancel", { task_id: taskId });
1128
+ }
1129
+ }
1130
+ async publish(resource, data = {}, opts = {}) {
1131
+ const { realm, name } = address(resource);
1132
+ (await this.ensurePubsub(realm, name, opts.opts)).push("publish", data);
1133
+ }
1134
+ subscribe(resource, cbOrOpts, maybeOpts) {
1135
+ if (typeof cbOrOpts === "function") return this.subscribePush(resource, cbOrOpts, maybeOpts);
1136
+ return this.subscribePull(resource, cbOrOpts);
1137
+ }
1138
+ /** A render-loop-friendly latest-value holder for high-rate topics (three.js / `useFrame`). */
1139
+ latest(resource, opts = {}) {
1140
+ return new LatestValue((cb) => this.subscribePush(resource, cb, opts));
1141
+ }
1142
+ /**
1143
+ * Open a live, always-current local mirror of a `synapse:state:<name>` resource — a
1144
+ * shared key→value map that converges across nodes. `handle.value` is kept up to date
1145
+ * from the initial sync and each diff; write with `handle.put`/`handle.delete`. Cached
1146
+ * per resource (repeated calls share one mirror).
1147
+ */
1148
+ openState(resource) {
1149
+ const { realm, name } = address(resource);
1150
+ const topic = `${realm}:state:${name}`;
1151
+ let p = this.stateHandles.get(topic);
1152
+ if (!p) {
1153
+ p = this.doOpenState(resource, topic);
1154
+ this.stateHandles.set(topic, p);
1155
+ }
1156
+ return p;
1157
+ }
1158
+ async doOpenState(resource, topic) {
1159
+ await this.awaitConnected();
1160
+ const ch = this.connection.channel(topic, {});
1161
+ const handle = new StateHandle(resource, ch);
1162
+ ch.on("state_diff", (d) => handle._onDiff(d));
1163
+ const sync = await ch.join();
1164
+ handle._applySync(sync);
1165
+ return handle;
1166
+ }
1167
+ /** Capability cards of every registered service. */
1168
+ async listServices() {
1169
+ const reply = await (await this.catalog()).request("list", {});
1170
+ if (reply.status !== "ok") throw new BusError(reply.response);
1171
+ return reply.response.services ?? [];
1172
+ }
1173
+ /** One service's capability card (throws `BusError` if unknown). */
1174
+ async describe(service) {
1175
+ const reply = await (await this.catalog()).request("info", { service });
1176
+ if (reply.status !== "ok") throw new BusError(reply.response);
1177
+ return reply.response;
1178
+ }
1179
+ /** State resources currently registered on the bus. */
1180
+ async listResources() {
1181
+ const reply = await (await this.catalog()).request("list", {});
1182
+ if (reply.status !== "ok") throw new BusError(reply.response);
1183
+ return reply.response.resources ?? [];
1184
+ }
1185
+ watchCatalog(callback) {
1186
+ if (callback) {
1187
+ this.catalogWatchers.add(callback);
1188
+ this.catalog().catch((e) => this.connection._log("warn", "catalog join failed", e));
1189
+ return { unsubscribe: () => void this.catalogWatchers.delete(callback) };
1190
+ }
1191
+ return this.watchCatalogIterator();
1192
+ }
1193
+ watchCatalogIterator() {
1194
+ const self = this;
1195
+ return { async *[Symbol.asyncIterator]() {
1196
+ const queue = [];
1197
+ let wake = null;
1198
+ const cb = (c) => {
1199
+ queue.push(c);
1200
+ wake?.();
1201
+ wake = null;
1202
+ };
1203
+ self.catalogWatchers.add(cb);
1204
+ try {
1205
+ await self.catalog();
1206
+ while (true) {
1207
+ if (queue.length === 0) await new Promise((r) => wake = r);
1208
+ while (queue.length) yield queue.shift();
1209
+ }
1210
+ } finally {
1211
+ self.catalogWatchers.delete(cb);
1212
+ }
1213
+ } };
1214
+ }
1215
+ catalog() {
1216
+ if (!this.catalogChannelP) this.catalogChannelP = this.openCatalog();
1217
+ return this.catalogChannelP;
1218
+ }
1219
+ async openCatalog() {
1220
+ await this.awaitConnected();
1221
+ const ch = this.connection.channel("synapse:catalog", {});
1222
+ ch.on("change", (c) => {
1223
+ for (const w of this.catalogWatchers) w(c);
1224
+ });
1225
+ await ch.join();
1226
+ return ch;
1227
+ }
1228
+ service(name, handler, card = {}) {
1229
+ const { realm, name: svc } = address(name);
1230
+ const topic = `${realm}:service:${svc}`;
1231
+ const ch = this.connection.channel(topic, {
1232
+ role: "provider",
1233
+ card
1234
+ });
1235
+ const cancels = /* @__PURE__ */ new Map();
1236
+ ch.on("call", (frame) => void this.runProvider(ch, handler, frame, cancels));
1237
+ ch.on("cancel", (frame) => {
1238
+ const id = frame.task_id;
1239
+ if (id) cancels.get(id)?.abort();
1240
+ });
1241
+ this.awaitConnected().then(() => ch.join()).catch((e) => this.connection._log("warn", `provider join failed for ${topic}`, e));
1242
+ return { unregister: () => void ch.leave() };
1243
+ }
1244
+ async runProvider(ch, handler, frame, cancels) {
1245
+ const taskId = frame.task_id;
1246
+ const ac = new AbortController();
1247
+ cancels.set(taskId, ac);
1248
+ const ctx = {
1249
+ update: (data) => ch.push("update", {
1250
+ task_id: taskId,
1251
+ ...data
1252
+ }),
1253
+ signal: ac.signal,
1254
+ taskId
1255
+ };
1256
+ try {
1257
+ const result = await handler(frame.input ?? {}, ctx);
1258
+ ch.push("result", {
1259
+ task_id: taskId,
1260
+ result
1261
+ });
1262
+ } catch (e) {
1263
+ ch.push("error", {
1264
+ task_id: taskId,
1265
+ error: {
1266
+ code: BusErrorCode.ProviderError,
1267
+ message: errMessage(e)
1268
+ }
1269
+ });
1270
+ } finally {
1271
+ cancels.delete(taskId);
1272
+ }
1273
+ }
1274
+ withIdentity(p) {
1275
+ const id = this.identity;
1276
+ if (typeof p === "function") {
1277
+ const fn = p;
1278
+ return async () => ({
1279
+ ...id,
1280
+ ...await fn()
1281
+ });
1282
+ }
1283
+ return {
1284
+ ...id,
1285
+ ...p ?? {}
1286
+ };
1287
+ }
1288
+ async consumerChannel(realm, name) {
1289
+ const topic = `${realm}:service:${name}`;
1290
+ let ch = this.consumerChannels.get(topic);
1291
+ if (!ch) {
1292
+ ch = this.connection.channel(topic, { role: "consumer" });
1293
+ ch.on("result", (f) => this.routeTask("result", f));
1294
+ ch.on("update", (f) => this.routeTask("update", f));
1295
+ ch.on("error", (f) => this.routeTask("error", f));
1296
+ this.consumerChannels.set(topic, ch);
1297
+ }
1298
+ await this.awaitConnected();
1299
+ await ch.join();
1300
+ return ch;
1301
+ }
1302
+ routeTask(kind, frame) {
1303
+ const taskId = frame.task_id;
1304
+ if (!taskId) return;
1305
+ const w = this.tasks.get(taskId);
1306
+ if (!w) return;
1307
+ if (kind === "update") w.onUpdate?.(frame);
1308
+ else if (kind === "result") w.onResult(frame);
1309
+ else w.onError(frame);
1310
+ }
1311
+ subsFor(topic) {
1312
+ let s = this.pubsubSubs.get(topic);
1313
+ if (!s) {
1314
+ s = /* @__PURE__ */ new Set();
1315
+ this.pubsubSubs.set(topic, s);
1316
+ }
1317
+ return s;
1318
+ }
1319
+ async ensurePubsub(realm, name, joinOpts) {
1320
+ const topic = `${realm}:pubsub:${name}`;
1321
+ let ch = this.pubsubChannels.get(topic);
1322
+ if (!ch) {
1323
+ ch = this.connection.channel(topic, joinOpts ?? {});
1324
+ const subs = this.subsFor(topic);
1325
+ ch.on("message", (m) => {
1326
+ for (const f of subs) f(m);
1327
+ });
1328
+ this.pubsubChannels.set(topic, ch);
1329
+ }
1330
+ await this.awaitConnected();
1331
+ await ch.join();
1332
+ return ch;
1333
+ }
1334
+ subscribePush(resource, cb, opts) {
1335
+ const { realm, name } = address(resource);
1336
+ const topic = `${realm}:pubsub:${name}`;
1337
+ const subs = this.subsFor(topic);
1338
+ subs.add(cb);
1339
+ this.ensurePubsub(realm, name, opts?.opts).catch((e) => this.connection._log("warn", `subscribe failed for ${topic}`, e));
1340
+ return { unsubscribe: () => void subs.delete(cb) };
1341
+ }
1342
+ subscribePull(resource, opts) {
1343
+ const self = this;
1344
+ const { realm, name } = address(resource);
1345
+ const topic = `${realm}:pubsub:${name}`;
1346
+ return { async *[Symbol.asyncIterator]() {
1347
+ const queue = [];
1348
+ let wake = null;
1349
+ const cb = (m) => {
1350
+ queue.push(m);
1351
+ wake?.();
1352
+ wake = null;
1353
+ };
1354
+ const subs = self.subsFor(topic);
1355
+ subs.add(cb);
1356
+ try {
1357
+ await self.ensurePubsub(realm, name, opts?.opts);
1358
+ while (true) {
1359
+ if (queue.length === 0) await new Promise((r) => wake = r);
1360
+ while (queue.length) yield queue.shift();
1361
+ }
1362
+ } finally {
1363
+ subs.delete(cb);
1364
+ }
1365
+ } };
1366
+ }
1367
+ };
1368
+ /**
1369
+ * Latest-value holder for high-frequency topics. Read `.get()` inside a three.js / r3f
1370
+ * `useFrame` loop (non-reactive) and mutate the scene directly — React never re-renders.
1371
+ */
1372
+ var LatestValue = class {
1373
+ value = void 0;
1374
+ listeners = /* @__PURE__ */ new Set();
1375
+ sub;
1376
+ constructor(subscribe) {
1377
+ this.sub = subscribe((m) => {
1378
+ this.value = m;
1379
+ for (const l of this.listeners) l(m);
1380
+ });
1381
+ }
1382
+ get() {
1383
+ return this.value;
1384
+ }
1385
+ onChange(cb) {
1386
+ this.listeners.add(cb);
1387
+ return () => void this.listeners.delete(cb);
1388
+ }
1389
+ close() {
1390
+ this.sub.unsubscribe();
1391
+ this.listeners.clear();
1392
+ }
1393
+ };
1394
+ /**
1395
+ * A live local mirror of a `synapse:state:<name>` resource. `value` is kept current from the
1396
+ * initial `state_sync` and each `state_diff` (absolute, version-ordered); a version gap
1397
+ * triggers an automatic `resync`. Writes (`put`/`delete`) are server-confirmed and come back
1398
+ * as a diff (self-included). `value` is replaced (new reference) on each change, so it binds
1399
+ * cleanly to `useSyncExternalStore` / reads cleanly in a `useFrame` loop.
1400
+ */
1401
+ var StateHandle = class {
1402
+ resource;
1403
+ ch;
1404
+ _value = {};
1405
+ _version = 0;
1406
+ listeners = /* @__PURE__ */ new Set();
1407
+ resyncing = false;
1408
+ constructor(resource, ch) {
1409
+ this.resource = resource;
1410
+ this.ch = ch;
1411
+ }
1412
+ /** The current converged map (new reference on every change). */
1413
+ get value() {
1414
+ return this._value;
1415
+ }
1416
+ /** The version of the last applied op. */
1417
+ get version() {
1418
+ return this._version;
1419
+ }
1420
+ async put(key, value) {
1421
+ const reply = await this.ch.request("put", {
1422
+ key,
1423
+ value
1424
+ });
1425
+ if (reply.status !== "ok") throw new BusError(reply.response);
1426
+ }
1427
+ async delete(key) {
1428
+ const reply = await this.ch.request("delete", { key });
1429
+ if (reply.status !== "ok") throw new BusError(reply.response);
1430
+ }
1431
+ /** Observe each applied change: `(value, diff) => …`. Returns unsubscribe. */
1432
+ subscribe(listener) {
1433
+ this.listeners.add(listener);
1434
+ return () => void this.listeners.delete(listener);
1435
+ }
1436
+ /** Force a full re-sync from the server. */
1437
+ async resync() {
1438
+ if (this.resyncing) return;
1439
+ this.resyncing = true;
1440
+ try {
1441
+ const reply = await this.ch.request("resync", {});
1442
+ if (reply.status === "ok") this._applySync(reply.response);
1443
+ } finally {
1444
+ this.resyncing = false;
1445
+ }
1446
+ }
1447
+ close() {
1448
+ this.listeners.clear();
1449
+ this.ch.leave();
1450
+ }
1451
+ _applySync(sync) {
1452
+ this._value = { ...sync.state ?? {} };
1453
+ this._version = sync.version ?? 0;
1454
+ this.emit({
1455
+ changed: this._value,
1456
+ removed: [],
1457
+ version: this._version
1458
+ });
1459
+ }
1460
+ _onDiff(diff) {
1461
+ const v = diff.version;
1462
+ if (typeof v === "number" && v !== this._version + 1) {
1463
+ this.resync();
1464
+ return;
1465
+ }
1466
+ const next = { ...this._value };
1467
+ for (const [k, val] of Object.entries(diff.changed ?? {})) next[k] = val;
1468
+ for (const k of diff.removed ?? []) delete next[k];
1469
+ this._value = next;
1470
+ if (typeof v === "number") this._version = v;
1471
+ this.emit(diff);
1472
+ }
1473
+ emit(diff) {
1474
+ for (const l of this.listeners) try {
1475
+ l(this._value, diff);
1476
+ } catch {}
1477
+ }
1478
+ };
1479
+ function randomId() {
1480
+ const c = globalThis.crypto;
1481
+ return c?.randomUUID ? c.randomUUID() : `node-${Math.random().toString(36).slice(2, 10)}`;
1482
+ }
1483
+ function errMessage(e) {
1484
+ return e instanceof Error ? e.message : String(e);
1485
+ }
1486
+ function abortError(signal) {
1487
+ const reason = signal.reason;
1488
+ return reason instanceof Error ? reason : new BusError({
1489
+ code: BusErrorCode.Cancelled,
1490
+ message: "call aborted"
1491
+ });
1492
+ }
1493
+ function asError(e) {
1494
+ return e instanceof Error ? e : new BusError({ message: String(e) });
1495
+ }
1496
+ /** Accept any non-empty string role (roles are open-ended), else null. */
1497
+ function normalizeRole(r) {
1498
+ return typeof r === "string" && r.length > 0 ? r : null;
1499
+ }
1500
+ //#endregion
1501
+ export { Connection as a, BusError as c, ConnectionError as d, SupersededError as f, address as i, BusErrorCode as l, StateHandle as n, serializer as o, TimeoutError as p, Synapse as r, Channel as s, LatestValue as t, ChannelError as u };