@synapse-sdk/client 0.1.0 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -111
- package/dist/calls.d.ts +37 -0
- package/dist/calls.js +192 -0
- package/dist/channel.d.ts +84 -0
- package/dist/channel.js +215 -0
- package/dist/fec.d.ts +85 -0
- package/dist/fec.js +281 -0
- package/dist/index.d.ts +11 -74
- package/dist/index.js +11 -15
- package/dist/moq_log.d.ts +23 -0
- package/dist/moq_log.js +176 -0
- package/dist/node.d.ts +145 -0
- package/dist/node.js +408 -0
- package/dist/scene.d.ts +299 -0
- package/dist/scene.js +1531 -0
- package/dist/scene_frames.d.ts +51 -0
- package/dist/scene_frames.js +215 -0
- package/dist/serve.d.ts +46 -0
- package/dist/serve.js +281 -0
- package/dist/session.d.ts +250 -0
- package/dist/session.js +963 -0
- package/dist/signals.d.ts +10 -0
- package/dist/signals.js +63 -0
- package/dist/store.d.ts +155 -0
- package/dist/store.js +924 -0
- package/dist/synapse.d.ts +93 -0
- package/dist/synapse.js +268 -0
- package/dist/tasks.d.ts +37 -0
- package/dist/tasks.js +132 -0
- package/dist/topics.d.ts +151 -0
- package/dist/topics.js +959 -0
- package/dist/types.d.ts +820 -0
- package/dist/types.js +1 -0
- package/dist/util.d.ts +67 -0
- package/dist/util.js +227 -0
- package/dist/view.d.ts +19 -0
- package/dist/view.js +145 -0
- package/package.json +18 -39
- package/dist/node-CSSIXG4k.js +0 -1501
- package/dist/node-DbD7G0Nx.d.ts +0 -466
- package/dist/react.d.ts +0 -63
- package/dist/react.js +0 -141
package/dist/node-CSSIXG4k.js
DELETED
|
@@ -1,1501 +0,0 @@
|
|
|
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 };
|