@synapse-sdk/client 0.1.0 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/node.js ADDED
@@ -0,0 +1,408 @@
1
+ // The node (SDK §2, WP §10): the permanent thing. It owns this node's intents (the handle registries
2
+ // live on the handles' modules), its declared target, and ONE lifecycle cell (`Link`) that says where
3
+ // the node stands and, when connected, names the session. It holds no fact the broker told us: those
4
+ // are the current `Session`'s. One loop (`run`) is the only driver: it dials, attaches, waits, tears
5
+ // down, and answers `close()` and `setTarget()` by reply. Handles are told about sessions as a
6
+ // message in their own mailbox (`monitor`), never by watching a cell. Nothing on the wire is
7
+ // resumable: a new session means a fresh `req` track, fresh announces, fresh subscriptions, which
8
+ // the handles replay when told.
9
+ import { ClockSync, SynapseError, calibrateMonotonicClock, defaultDomId, isValidClientName, mintName, mintUserId, nowUs } from "@synapse-sdk/core";
10
+ import { closeOnSignals, forgetOnSignals } from "./signals.js";
11
+ import { Session, closeError, defaultMaxDatagram, discoverBrokerCertSha256, makeDial, openSession, urlOrEndpoint } from "./session.js";
12
+ import { startOn } from "./calls.js";
13
+ import { AsyncQueue, Cell, deferred } from "./util.js";
14
+ // ------------------------------------------------------------------------------------- policy
15
+ /** Uniform in [0, 1): jitter, not a secret. */
16
+ const unitRandom = () => Math.random();
17
+ /** `initial × 2^(attempt−1)` capped at `max`, times an equal jitter U(0.5, 1); never gives up. */
18
+ export function exponentialPolicy(initialMs, maxMs) {
19
+ return { initialMs, nextRetryDelayMs: ({ attempt }) => Math.min(initialMs * 2 ** Math.min(attempt - 1, 31), maxMs) * (0.5 + 0.5 * unitRandom()) };
20
+ }
21
+ /** A fixed delay (tests). */
22
+ export function fixedPolicy(ms) { return { initialMs: ms, nextRetryDelayMs: () => ms }; }
23
+ /** Stops after `maxAttempts` attempts in one phase. */
24
+ export function withMaxAttempts(p, maxAttempts) {
25
+ return { initialMs: p.initialMs, nextRetryDelayMs: ctx => (ctx.attempt > maxAttempts ? null : p.nextRetryDelayMs(ctx)) };
26
+ }
27
+ export const defaultReconnectPolicy = exponentialPolicy(100, 10_000);
28
+ export function stateOf(l) {
29
+ switch (l.status) {
30
+ case "connected": return { status: "connected", url: l.session.url, sinceMs: l.session.startedMs };
31
+ case "connecting": return { status: "connecting", attempt: l.attempt };
32
+ case "reconnecting": return { status: "reconnecting", attempt: l.attempt, reason: l.reason, error: l.error, nextRetryAtMs: l.nextRetryAtMs };
33
+ case "closing": return { status: "closing" };
34
+ case "closed": return { status: "closed", reason: l.reason };
35
+ case "failed": return { status: "failed", error: l.error };
36
+ }
37
+ }
38
+ /** The rules (SDK §2, WP §10.4) for the delay before attempt `attempt`. */
39
+ export function decide(pursuit, policy, err, attempt, nowMs) {
40
+ if (err.code === "unauthorized")
41
+ return { kind: "failed" };
42
+ let delay;
43
+ if (pursuit.kind === "first") {
44
+ if (!err.retryable || nowMs >= pursuit.deadlineMs)
45
+ return { kind: "failed" };
46
+ const d = (policy ?? defaultReconnectPolicy).nextRetryDelayMs({ attempt });
47
+ delay = d === null ? null : Math.min(d, pursuit.deadlineMs - nowMs);
48
+ }
49
+ else
50
+ delay = policy ? policy.nextRetryDelayMs({ attempt }) : null;
51
+ if (delay !== null)
52
+ return { kind: "retry", delayMs: delay };
53
+ return pursuit.kind === "reconnect" ? { kind: "closed" } : { kind: "failed" };
54
+ }
55
+ // ---------------------------------------------------------------------------------------- node
56
+ export class Node {
57
+ link = new Cell({ status: "connecting", attempt: 1 });
58
+ target;
59
+ dom;
60
+ knobs;
61
+ policy;
62
+ /** Broker notices addressed to this node, across its sessions (fed by the loop from the current session's). */
63
+ notices = new Set();
64
+ projectNotices = new Set();
65
+ /** Resolves once the node has ended for good (`close()`, the first connect failing, the policy giving up). */
66
+ ended = deferred();
67
+ #endedFlag = false;
68
+ /** The loop's mailbox: unbounded, so no `close()` or `setTarget()` is ever dropped. */
69
+ #commands = new AsyncQueue(Infinity);
70
+ #monitors = new Set();
71
+ epochs = 0;
72
+ reconnects = 0;
73
+ callsSent = 0;
74
+ callsAnswered = 0;
75
+ constructor(target, dom, knobs, policy) {
76
+ this.target = new Cell(target);
77
+ this.dom = dom;
78
+ this.knobs = knobs;
79
+ this.policy = policy;
80
+ }
81
+ get isEnded() { return this.#endedFlag; }
82
+ get isConnected() { return this.link.get().status === "connected"; }
83
+ currentSession() { const l = this.link.get(); return l.status === "connected" ? l.session : undefined; }
84
+ state() { return stateOf(this.link.get()); }
85
+ /** The loop's one write. A write that names a session or withdraws one tells every monitoring handle, in the same step. */
86
+ setLink(next) {
87
+ const prev = this.link.get();
88
+ const event = next.status === "connected" ? { kind: "started", session: next.session } : prev.status === "connected" ? { kind: "ended" } : undefined;
89
+ this.link.set(next);
90
+ if (event)
91
+ this.#tell(event);
92
+ }
93
+ #tell(e) { for (const m of [...this.#monitors]) {
94
+ if (!m(e))
95
+ this.#monitors.delete(m);
96
+ } }
97
+ /**
98
+ * Registers a handle to be told about sessions (and the node's end) and returns the session the
99
+ * node has right now; the next event is its end. `deliver` returns `false` once the handle's
100
+ * mailbox is gone, which unregisters it; `unmonitor` unregisters it at once.
101
+ */
102
+ monitor(deliver) {
103
+ if (this.#endedFlag) {
104
+ deliver({ kind: "nodeEnded" });
105
+ return { session: undefined, unmonitor: () => { } };
106
+ }
107
+ this.#monitors.add(deliver);
108
+ return { session: this.currentSession(), unmonitor: () => { this.#monitors.delete(deliver); } };
109
+ }
110
+ /** The node ends for good in `link`: handles are told, feeds end. */
111
+ end(link) {
112
+ this.setLink(link);
113
+ this.#endedFlag = true;
114
+ const ms = [...this.#monitors];
115
+ this.#monitors.clear();
116
+ for (const m of ms)
117
+ m({ kind: "nodeEnded" });
118
+ this.ended.resolve();
119
+ forgetOnSignals(this.#closable);
120
+ }
121
+ #closable = { close: () => this.close() };
122
+ /** Builds the freshly opened session completely (its epoch, its tasks running), then names it. */
123
+ attach(session) {
124
+ session.epoch = ++this.epochs;
125
+ const sync = new ClockSync(this.dom.id, session.hello.dom, nowUs());
126
+ session.clock.set(sync.info());
127
+ session.onNotice = (n) => {
128
+ if (n.kind === "error")
129
+ for (const cb of this.notices)
130
+ cb(n.body);
131
+ else
132
+ for (const cb of this.projectNotices)
133
+ cb(n.notice);
134
+ };
135
+ session.start(sync, (svc, args) => startOn(this, session, svc, args, {}).result);
136
+ this.setLink({ status: "connected", session });
137
+ return session;
138
+ }
139
+ /** `close()`: posts the command; resolves once the node has ended. */
140
+ async close() {
141
+ this.#commands.push({ kind: "close" });
142
+ await this.ended.promise;
143
+ }
144
+ /** `setTarget()`: declares the target under the cell's write, posts the command with its reply, and awaits it. */
145
+ setTarget(dial) {
146
+ if (this.#endedFlag)
147
+ return Promise.reject(new SynapseError("not_connected", "node closed"));
148
+ const t = this.target.get();
149
+ if (sameDial(t.dial, dial))
150
+ return Promise.resolve();
151
+ const next = { generation: t.generation + 1, dial, identity: dial ? dial.identity : t.identity };
152
+ this.target.set(next);
153
+ const reply = deferred();
154
+ this.#commands.push({ kind: "setTarget", target: next, reply });
155
+ return reply.promise;
156
+ }
157
+ /** Starts the loop. `first` is the `connect()` call's reply. `signals`: close on SIGINT/SIGTERM (Bun/Node). */
158
+ run(stand, first, signals) {
159
+ if (signals)
160
+ closeOnSignals(this.#closable);
161
+ void runLoop(this, this.#commands, stand, first);
162
+ }
163
+ }
164
+ function sameDial(a, b) {
165
+ if (!a || !b)
166
+ return a === b;
167
+ return a.url === b.url && a.cert === b.cert && a.token === b.token && a.identity.type === b.identity.type && a.identity.name === b.identity.name && a.identity.user_id === b.identity.user_id && a.identity.credential === b.identity.credential;
168
+ }
169
+ const notConnected = () => new SynapseError("not_connected", "node closed");
170
+ /**
171
+ * The loop, the only driver of the node's sessions from the first connect on. One step: wait (the live
172
+ * session closed, or a command; the dial finished, or a command; a command) → take every queued command
173
+ * (`close` wins; the newest target wins, older waiting calls are superseded) → act.
174
+ */
175
+ async function runLoop(node, commands, stand, first) {
176
+ let target = node.target.get();
177
+ /** The `setTarget`/`connect` calls waiting for the current target. */
178
+ let waiting = first ? [first] : [];
179
+ const replyAll = (r) => { for (const w of waiting)
180
+ r ? w.reject(r) : w.resolve(); waiting = []; };
181
+ /** Attempts in the current pursuit, kept across a session that died young (the policy's rule). */
182
+ let attempts = 0;
183
+ // The one wait for a command, kept across steps until it is taken. A wait that lost a race is not
184
+ // dropped: the command it receives is still this wait's, and the next race (where it is listed
185
+ // first) takes it. A fresh wait is made only after the previous one's command was taken.
186
+ let commandWait;
187
+ const nextCommand = () => (commandWait ??= commands.next().then(c => ({ kind: "command", command: c })));
188
+ for (;;) {
189
+ // ---- wait
190
+ let wake;
191
+ if (stand.kind === "live") {
192
+ const s = stand.session;
193
+ wake = await Promise.race([nextCommand(), s.closed.then(err => ({ kind: "dropped", err }))]);
194
+ }
195
+ else if (stand.kind === "dialing") {
196
+ const dial = target.dial;
197
+ if (!dial) {
198
+ stand = { kind: "resting" };
199
+ continue;
200
+ }
201
+ const { delayMs } = stand;
202
+ const ac = new AbortController();
203
+ const opened = (async () => {
204
+ if (delayMs > 0)
205
+ await new Promise(r => { const t = setTimeout(r, delayMs); ac.signal.addEventListener("abort", () => { clearTimeout(t); r(); }, { once: true }); });
206
+ if (ac.signal.aborted)
207
+ return { kind: "opened", result: new SynapseError("canceled", "superseded") };
208
+ try {
209
+ // The pin: given, else discovered per attempt (a restarted dev broker has a fresh certificate), else unpinned.
210
+ const cert = dial.cert ?? await discoverBrokerCertSha256(dial.url, { signal: ac.signal });
211
+ return { kind: "opened", result: await openSession(node.knobs, { ...dial, cert }, node.dom.id, ac.signal) };
212
+ }
213
+ catch (e) {
214
+ return { kind: "opened", result: e instanceof SynapseError ? e : new SynapseError("connection_lost", { message: String(e), cause: e }) };
215
+ }
216
+ })();
217
+ wake = await Promise.race([nextCommand(), opened]);
218
+ // The dial is abandoned: its pause ends now; a session it still opens is closed when it arrives.
219
+ if (wake.kind === "command") {
220
+ ac.abort();
221
+ void opened.then(w => { if (w.kind === "opened" && w.result instanceof Session)
222
+ w.result.close(); });
223
+ }
224
+ }
225
+ else {
226
+ wake = await nextCommand();
227
+ }
228
+ // ---- commands first: everything queued; `close` wins; the newest target wins.
229
+ // Commands are taken only on a command wake. Otherwise the command wait is still out, and any
230
+ // command already queued arrived after the one that wait will receive: taking those now would
231
+ // act on newer commands before an older one. The next step's race takes the wait first.
232
+ const queued = [];
233
+ if (wake.kind === "command") {
234
+ commandWait = undefined;
235
+ queued.push(wake.command ?? { kind: "close" });
236
+ for (;;) {
237
+ const c = commands.tryNext();
238
+ if (!c)
239
+ break;
240
+ queued.push(c);
241
+ }
242
+ }
243
+ const close = queued.some(c => c.kind === "close");
244
+ let newest;
245
+ for (const c of queued) {
246
+ if (c.kind !== "setTarget")
247
+ continue;
248
+ if (close) {
249
+ waiting.push(c.reply);
250
+ continue;
251
+ }
252
+ if (!newest || c.target.generation > newest.target.generation) {
253
+ if (newest)
254
+ newest.reply.resolve();
255
+ newest = { target: c.target, reply: c.reply };
256
+ }
257
+ else
258
+ c.reply.resolve(); // superseded: quietly done
259
+ }
260
+ if (close) {
261
+ if (stand.kind === "live")
262
+ await teardown(node, stand.session, { status: "closing" });
263
+ node.end({ status: "closed", reason: "client" });
264
+ replyAll(notConnected());
265
+ return;
266
+ }
267
+ if (newest) {
268
+ if (stand.kind === "live")
269
+ await teardown(node, stand.session, { status: "closing" });
270
+ replyAll(undefined); // superseded: quietly done
271
+ waiting.push(newest.reply);
272
+ target = newest.target;
273
+ attempts = 0;
274
+ if (target.dial) {
275
+ node.setLink({ status: "connecting", attempt: 1 });
276
+ stand = { kind: "dialing", pursuit: { kind: "target" }, attempt: 1, delayMs: 0, reason: "client", error: new SynapseError("connection_lost", "target changed") };
277
+ }
278
+ else {
279
+ node.setLink({ status: "closed", reason: "client" });
280
+ replyAll(undefined);
281
+ stand = { kind: "resting" };
282
+ }
283
+ continue;
284
+ }
285
+ if (wake.kind === "command")
286
+ continue; // the command was handled above
287
+ // ---- what the wait brought
288
+ if (wake.kind === "dropped") {
289
+ if (stand.kind !== "live")
290
+ continue;
291
+ const s = stand.session;
292
+ const lived = performance.now() - s.startedMs;
293
+ const err = s.closingReason ? new SynapseError("connection_lost", { message: s.closingReason, source: "broker" }) : closeError(wake.err);
294
+ if (node.policy && lived >= node.policy.initialMs)
295
+ attempts = 0;
296
+ attempts += 1;
297
+ const reason = err.code;
298
+ const delay = node.policy ? node.policy.nextRetryDelayMs({ attempt: attempts }) : null;
299
+ if (delay !== null) {
300
+ node.reconnects++;
301
+ await teardown(node, s, { status: "reconnecting", attempt: attempts, reason, error: err, nextRetryAtMs: performance.now() + delay });
302
+ stand = { kind: "dialing", pursuit: { kind: "reconnect" }, attempt: attempts, delayMs: delay, reason, error: err };
303
+ }
304
+ else {
305
+ await teardown(node, s, { status: "closing" });
306
+ node.end({ status: "closed", reason });
307
+ replyAll(notConnected());
308
+ return;
309
+ }
310
+ continue;
311
+ }
312
+ // opened
313
+ if (stand.kind !== "dialing")
314
+ continue;
315
+ if (wake.result instanceof Session) {
316
+ node.attach(wake.result);
317
+ replyAll(undefined); // the target is reached
318
+ stand = { kind: "live", session: wake.result };
319
+ continue;
320
+ }
321
+ const e = wake.result;
322
+ const { pursuit } = stand;
323
+ stand.attempt += 1;
324
+ if (pursuit.kind === "reconnect")
325
+ attempts = stand.attempt;
326
+ const next = decide(pursuit, node.policy, e, stand.attempt, performance.now());
327
+ if (next.kind === "retry") {
328
+ stand.delayMs = next.delayMs;
329
+ stand.error = e;
330
+ node.setLink(pursuit.kind === "first" ? { status: "connecting", attempt: stand.attempt } : { status: "reconnecting", attempt: stand.attempt, reason: stand.reason, error: e, nextRetryAtMs: performance.now() + next.delayMs });
331
+ }
332
+ else if (next.kind === "failed" && pursuit.kind === "target") {
333
+ node.setLink({ status: "failed", error: e });
334
+ replyAll(e);
335
+ stand = { kind: "resting" };
336
+ }
337
+ else if (next.kind === "failed") {
338
+ node.end({ status: "failed", error: e });
339
+ replyAll(e);
340
+ return;
341
+ }
342
+ else {
343
+ node.end({ status: "closed", reason: stand.reason });
344
+ replyAll(notConnected());
345
+ return;
346
+ }
347
+ }
348
+ }
349
+ /** Ends session `s`: the node stops naming it (handles are told, the status is true from that instant), then the session is torn down (calls failed, tasks awaited, close sent). */
350
+ async function teardown(node, s, next) {
351
+ node.setLink(next);
352
+ await s.teardown();
353
+ }
354
+ // ------------------------------------------------------------------------------------ connect
355
+ /** Resolves the identity a node declares (`resolve_identity`): a missing name is minted, a `web`/`hmi` node without a `user_id` mints one. */
356
+ export function resolveIdentity(o) {
357
+ const name = o.name ?? mintName(o.type);
358
+ if (!isValidClientName(name))
359
+ throw new SynapseError("invalid_input", `name ${JSON.stringify(name)} is not [a-z0-9_-]{1,64}`);
360
+ const id = { type: o.type, name };
361
+ const userId = o.userId ?? ((o.type === "web" || o.type === "hmi") ? mintUserId() : undefined);
362
+ if (userId)
363
+ id.user_id = userId;
364
+ if (o.credential)
365
+ id.credential = o.credential;
366
+ return id;
367
+ }
368
+ /** Resolves a `ConnectTarget` against the node's current identity (fields left out are kept). */
369
+ export function resolveTarget(node, t) {
370
+ try {
371
+ new URL(t.url);
372
+ }
373
+ catch (e) {
374
+ throw new SynapseError("invalid_input", { message: `broker url ${JSON.stringify(t.url)}: ${e instanceof Error ? e.message : String(e)}` });
375
+ }
376
+ const cur = node.target.get().identity;
377
+ if (t.name !== undefined && !isValidClientName(t.name))
378
+ throw new SynapseError("invalid_input", `name ${JSON.stringify(t.name)} is not [a-z0-9_-]{1,64}`);
379
+ const type = t.type ?? cur.type;
380
+ const identity = { type, name: t.name ?? cur.name };
381
+ const userId = t.userId ?? cur.user_id ?? ((type === "web" || type === "hmi") ? mintUserId() : undefined);
382
+ if (userId)
383
+ identity.user_id = userId;
384
+ const cred = t.credential ?? cur.credential;
385
+ if (cred)
386
+ identity.credential = cred;
387
+ return makeDial(t.url, identity, t.brokerCertSha256, t.token);
388
+ }
389
+ /**
390
+ * Builds the node and starts its loop in the first pursuit. Resolves as soon as the loop runs, with
391
+ * the node (so a `close()` can reach it during the first dial) and `connected`: the loop's reply to
392
+ * the first connect, a session established or why there is none (`not_connected` after a `close()`).
393
+ */
394
+ export async function startNode(opts, giveUpAfterMs) {
395
+ const url = urlOrEndpoint(opts.url);
396
+ const identity = resolveIdentity(opts);
397
+ // In the host's domain (Bun/Node) `nowUs()` must be the kernel's monotonic clock, like every other process on the host (WP §7.3).
398
+ if (!opts.domId)
399
+ await calibrateMonotonicClock();
400
+ const domId = opts.domId ?? (await defaultDomId()) ?? identity.name;
401
+ const knobs = { transport: opts.transport ?? "auto", connectTimeoutMs: opts.connectTimeoutMs ?? 10_000, maxDatagram: opts.maxDatagram ?? defaultMaxDatagram(), project: opts.project };
402
+ const policy = opts.reconnect === undefined ? defaultReconnectPolicy : opts.reconnect;
403
+ const dial = makeDial(url, identity, opts.brokerCertSha256, opts.token);
404
+ const node = new Node({ generation: 0, dial, identity }, { id: domId }, knobs, policy);
405
+ const first = deferred();
406
+ node.run({ kind: "dialing", pursuit: { kind: "first", deadlineMs: performance.now() + giveUpAfterMs }, attempt: 1, delayMs: 0, reason: "", error: new SynapseError("connection_lost") }, first, opts.signals ?? true);
407
+ return { node, connected: first.promise };
408
+ }