@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 CHANGED
@@ -1,113 +1,3 @@
1
1
  # @synapse-sdk/client
2
2
 
3
- Isomorphic TypeScript SDK for the **Synapse** robotics bus (Phoenix Channels). Browser,
4
- Bun, Node, Deno, edge. The companion to `python_client` — same model (call / stream /
5
- subscribe / publish / provide, realm-prefixed addresses), idiomatic TypeScript.
6
-
7
- ```sh
8
- # internal (git) for now:
9
- bun add github:<org>/<repo> # or: pnpm add github:<org>/<repo>
10
- ```
11
-
12
- Imports: `@synapse-sdk/client` (core) and `@synapse-sdk/client/react` (hooks; React is an
13
- optional peer dependency).
14
-
15
- > Built for robot control and HMIs, where a stale or still-open socket is a safety hazard.
16
- > So we **own the connection lifecycle** (a single, verified socket) and reuse the installed
17
- > `phoenix` package only for the wire serializer.
18
-
19
- ## The safety invariant
20
-
21
- > **At most one socket is ever acted upon.** A new socket is never created until the previous
22
- > one's `close` is confirmed (healthy switch) or it's been fenced and abandoned (dead socket).
23
-
24
- Three mechanics enforce it (`src/connection.ts`):
25
-
26
- 1. **Serialized reconcile** — `connect`/`disconnect` set a *desired target*; one queue
27
- converges to it, latest wins. Mashing the connect button coalesces.
28
- 2. **Generation + live-identity guards** — every socket event checks it belongs to the
29
- current desired-epoch *and* the current socket, so a late message/close from a superseded
30
- or replaced socket is inert.
31
- 3. **Verified teardown** — switching awaits the real `close` (with a force-fence timeout)
32
- before any new socket opens. Dead sockets (heartbeat timeout) are fenced and abandoned —
33
- never awaited, never read from or written to again.
34
-
35
- No `window` coupling, so discarding a socket is clean. The fuzz test runs 100 random
36
- connect/disconnect/retarget ops and asserts simultaneously-open sockets never exceeds **1**.
37
-
38
- ## Quick start
39
-
40
- ```ts
41
- import { Synapse } from "@synapse-sdk/client"
42
-
43
- const synapse = new Synapse() // browser/Bun: uses the built-in WebSocket
44
-
45
- // declarative, idempotent target — the "connect button" calls this
46
- await synapse.connect({
47
- url: "wss://robot-7.tail.ts.net/runtime/api/v1/socket",
48
- params: { type: "web" },
49
- token: () => auth.getToken(), // → Phoenix auth subprotocol, fresh on every (re)connect
50
- })
51
- await synapse.connect({ url: "wss://robot-9/…", token }) // retarget: same verb, safe switch
52
- await synapse.disconnect()
53
-
54
- synapse.subscribeState((s) => render(s.status)) // idle|connecting|connected|reconnecting|closing|error
55
-
56
- // consume — mirrors the Python node
57
- const sum = await synapse.call("rosbridge:/add_two_ints", { a: 2, b: 3 }, { onUpdate: show, signal })
58
- for await (const ev of synapse.callStream("planner/plan", req)) { /* ev.type: update|result */ }
59
- synapse.subscribe("telemetry/pose", (m) => render(m))
60
- await synapse.publish("teleop/cmd_vel", axes)
61
-
62
- // provide — from any node, including the browser (the robot calls in)
63
- synapse.service("teleop/joystick", (input, ctx) => drive(input))
64
- ```
65
-
66
- ### three.js / react-three-fiber (no React re-renders)
67
-
68
- High-frequency data bypasses React entirely via `latest()` — read `.get()` in the render loop:
69
-
70
- ```ts
71
- const joints = synapse.latest("rosbridge:/joint_states", { opts: { type: "sensor_msgs/JointState" } })
72
- useFrame(() => {
73
- const s = joints.get() // non-reactive, latest-wins (no queue growth)
74
- if (s) robotRef.current?.setJointValues(toMap(s.name, s.position))
75
- })
76
- ```
77
-
78
- Connection *status* (low-frequency) drives UI through a selector hook; robot *data*
79
- (high-frequency) drives the scene imperatively. React stays quiet at 60 Hz.
80
-
81
- ## Layout (single package)
82
-
83
- ```
84
- src/
85
- connection.ts single-socket state machine — the safety core
86
- channel.ts one topic on the connection (join/push/request/on, auto-rejoin)
87
- serializer.ts wraps the installed `phoenix` Serializer (v2 text + binary frames)
88
- node.ts Synapse — the bus verbs (call/callStream/subscribe/publish/service/latest)
89
- address.ts realm-prefix parsing (`rosbridge:/x` → { realm, name })
90
- errors.ts ConnectionError / SupersededError / ChannelError / BusError
91
- types.ts public types
92
- index.ts barrel
93
- test/ bun:test — connection (fuzz/invariant), channel, node
94
- ```
95
-
96
- ## Develop
97
-
98
- ```bash
99
- bun install
100
- bun test # invariant fuzz, channel, call/subscribe/publish/provider
101
- bun examples/live_demo.ts # end-to-end against a running runtime on :9001
102
- bunx tsc --noEmit # type-check
103
- ```
104
-
105
- ## Status & roadmap
106
-
107
- - [x] Single-socket connection core — fuzz-proven invariant
108
- - [x] Bus verbs: `call` / `callStream` / `subscribe` / `publish` / `service` / `latest`
109
- - [x] Wire frames reconciled against the live Elixir `ServiceChannel` / `PubSubChannel`
110
- - [ ] React adapter — `useConnectionStatus` / `useSubscription` / `useService` + zustand
111
- - [ ] System-channel handshake as the default `verify` (server role → verified `connected`)
112
- - [ ] State mirrors (`synapse:state:*`) + discovery (`synapse:catalog`)
113
- ```
3
+ Part of the Synapse SDK for JavaScript. Documentation: the repository.
@@ -0,0 +1,37 @@
1
+ import { type CallId, type ServiceContract } from "@synapse-sdk/core";
2
+ import type { Node } from "./node.ts";
3
+ import { type RespFrame, type Session } from "./session.ts";
4
+ import type { CallEvent, CallOptions, Run, RunStatus } from "./types.ts";
5
+ /** A fresh call id (WP §8.2): 16 random bytes. */
6
+ export declare function newCallId(): CallId;
7
+ export declare class RunImpl<U, R> implements Run<U, R> {
8
+ #private;
9
+ private readonly node;
10
+ readonly session: Session;
11
+ readonly callId: CallId;
12
+ private readonly svc;
13
+ private readonly opts;
14
+ private readonly contract;
15
+ readonly id: string;
16
+ constructor(node: Node, session: Session, callId: CallId, svc: string, opts: CallOptions<U>, contract: ServiceContract<unknown, unknown, U, R> | undefined);
17
+ get status(): RunStatus;
18
+ get result(): Promise<R>;
19
+ then<A = R, B = never>(onFulfilled?: ((v: R) => A | PromiseLike<A>) | null, onRejected?: ((e: unknown) => B | PromiseLike<B>) | null): Promise<A | B>;
20
+ /** The session's demux hands frames here; `null` = the session ended with the call pending. */
21
+ deliver(f: RespFrame | null): void;
22
+ /** Starts the per-frame reader once the request is written (frames can arrive before `writeCall` returns). */
23
+ start(): void;
24
+ nextUpdate(): Promise<U | undefined>;
25
+ updates(): AsyncIterable<U>;
26
+ /** Cooperative cancel (WP §8.4): tells the broker, then waits for its `canceled` terminal frame. `connection_lost` without sending once the session ended. */
27
+ cancel(reason?: string): Promise<void>;
28
+ /** Dropped with the call in flight: the runtime is told (best effort). */
29
+ [Symbol.asyncDispose](): Promise<void>;
30
+ }
31
+ /**
32
+ * Starts a call on `session` — a specific session, not whichever is current: an attempt's requests
33
+ * go on the session the attempt is for (`channel.ts`). `connection_lost` once it ended.
34
+ */
35
+ export declare function startOn<U, R>(node: Node, session: Session, svc: string, args: unknown, opts: CallOptions<U>, contract?: ServiceContract<unknown, unknown, U, R>): RunImpl<U, R>;
36
+ /** `callStream`: the updates and the result as one ordered stream; `break` cancels. */
37
+ export declare function callStream<U, R>(run: Run<U, R>): AsyncGenerator<CallEvent<U, R>, void, undefined>;
package/dist/calls.js ADDED
@@ -0,0 +1,192 @@
1
+ // Calls (SDK §5.2, WP §8.3): a `Run` is one call on one session. It holds the session it was sent on
2
+ // and acts only through it: its frames come from that session's `res` demux, a cancel goes on that
3
+ // session's `req` track, and the session's end fails it (`connection_lost`, outcome unknown) — never
4
+ // a frame on a later session (WP §8.4: ownership is checked, never inferred).
5
+ import { FLAGS, SynapseError, TYPES, decode as decodeBody, encode as encodeBody, nowUs, toHex, validateSync } from "@synapse-sdk/core";
6
+ import { outcomeUnknown } from "./session.js";
7
+ import { AsyncQueue, deferred } from "./util.js";
8
+ /** A fresh call id (WP §8.2): 16 random bytes. */
9
+ export function newCallId() { const b = new Uint8Array(16); crypto.getRandomValues(b); return b; }
10
+ export class RunImpl {
11
+ node;
12
+ session;
13
+ callId;
14
+ svc;
15
+ opts;
16
+ contract;
17
+ id;
18
+ #status = "pending";
19
+ #frames = new AsyncQueue(4096);
20
+ #result = deferred();
21
+ #done = false;
22
+ #idle;
23
+ #onAbort;
24
+ constructor(node, session, callId, svc, opts, contract) {
25
+ this.node = node;
26
+ this.session = session;
27
+ this.callId = callId;
28
+ this.svc = svc;
29
+ this.opts = opts;
30
+ this.contract = contract;
31
+ this.id = toHex(callId);
32
+ this.#result.promise.catch(() => { });
33
+ if (opts.signal) {
34
+ // The cancel's own failure (e.g. the session ended first) is already the call's result.
35
+ this.#onAbort = () => { this.cancel("aborted").catch(() => { }); };
36
+ if (opts.signal.aborted)
37
+ queueMicrotask(this.#onAbort);
38
+ else
39
+ opts.signal.addEventListener("abort", this.#onAbort, { once: true });
40
+ }
41
+ }
42
+ get status() { return this.#status; }
43
+ get result() { return this.#result.promise; }
44
+ then(onFulfilled, onRejected) { return this.#result.promise.then(onFulfilled, onRejected); }
45
+ /** The session's demux hands frames here; `null` = the session ended with the call pending. */
46
+ deliver(f) { this.#frames.push(f); }
47
+ /** Starts the per-frame reader once the request is written (frames can arrive before `writeCall` returns). */
48
+ start() { void this.#read(); }
49
+ async #read() {
50
+ this.#armIdle();
51
+ for (;;) {
52
+ const f = await this.#frames.next();
53
+ if (f === undefined)
54
+ return;
55
+ if (f === null) {
56
+ this.#finish({ error: outcomeUnknown(this.session.closingReason) });
57
+ return;
58
+ }
59
+ this.#armIdle();
60
+ if (f.header.type !== TYPES.RPC_RESP)
61
+ continue;
62
+ let reply;
63
+ try {
64
+ reply = decodeBody(f.body);
65
+ }
66
+ catch {
67
+ continue;
68
+ }
69
+ if (f.header.flags & FLAGS.END) {
70
+ if (f.header.flags & FLAGS.ERR) {
71
+ const b = reply.body;
72
+ this.#finish({ error: SynapseError.fromBody(b, { source: b.source ?? "broker" }) });
73
+ }
74
+ else {
75
+ let out = reply.body;
76
+ if (this.contract?.output) {
77
+ const r = validateSync(this.contract.output, out);
78
+ if (r.issues) {
79
+ this.#finish({ error: new SynapseError("invalid_output", { message: r.issues[0]?.message ?? "invalid output", data: { issues: r.issues } }) });
80
+ return;
81
+ }
82
+ out = r.value;
83
+ }
84
+ this.#finish({ value: out });
85
+ }
86
+ return;
87
+ }
88
+ this.#status = "running";
89
+ let u = reply.body;
90
+ if (this.contract?.update) {
91
+ const r = validateSync(this.contract.update, u);
92
+ if (r.issues)
93
+ continue;
94
+ u = r.value;
95
+ }
96
+ this.#updates.push(u);
97
+ try {
98
+ this.opts.onUpdate?.(u);
99
+ }
100
+ catch { /* the caller's */ }
101
+ }
102
+ }
103
+ #updates = new AsyncQueue(4096);
104
+ #armIdle() {
105
+ if (this.opts.idleTimeoutMs === undefined)
106
+ return;
107
+ if (this.#idle !== undefined)
108
+ clearTimeout(this.#idle);
109
+ this.#idle = setTimeout(() => { void this.#sendCancel("idle_timeout"); this.#finish({ error: new SynapseError("idle_timeout", `no frame from ${this.svc} for ${this.opts.idleTimeoutMs} ms`) }); }, this.opts.idleTimeoutMs);
110
+ }
111
+ #finish(r) {
112
+ if (this.#done)
113
+ return;
114
+ this.#done = true;
115
+ if (this.#idle !== undefined)
116
+ clearTimeout(this.#idle);
117
+ if (this.#onAbort && this.opts.signal)
118
+ this.opts.signal.removeEventListener("abort", this.#onAbort);
119
+ this.session.forgetCall(this.callId);
120
+ this.node.callsAnswered++;
121
+ void this.#updates.close();
122
+ void this.#frames.close(true);
123
+ if ("error" in r) {
124
+ this.#status = r.error.code === "canceled" ? "canceled" : "failed";
125
+ this.#result.reject(r.error);
126
+ }
127
+ else {
128
+ this.#status = "succeeded";
129
+ this.#result.resolve(r.value);
130
+ }
131
+ }
132
+ async nextUpdate() { return this.#updates.next(); }
133
+ updates() { return this.#updates; }
134
+ #sendCancel(reason) {
135
+ if (!this.session.isLive)
136
+ return;
137
+ const body = { call: this.callId, reason };
138
+ try {
139
+ this.session.writeReq({ flags: 0, type: TYPES.RPC_CANCEL, seq: 0, tUs: nowUs() }, encodeBody(body));
140
+ }
141
+ catch { /* the session is going */ }
142
+ }
143
+ /** Cooperative cancel (WP §8.4): tells the broker, then waits for its `canceled` terminal frame. `connection_lost` without sending once the session ended. */
144
+ async cancel(reason = "caller") {
145
+ if (this.#done)
146
+ return;
147
+ if (!this.session.isLive) {
148
+ this.#finish({ error: new SynapseError("connection_lost", "cancel while disconnected") });
149
+ return;
150
+ }
151
+ this.#sendCancel(reason);
152
+ try {
153
+ await this.#result.promise;
154
+ }
155
+ catch (e) {
156
+ if (!(e instanceof SynapseError && e.code === "canceled"))
157
+ throw e;
158
+ }
159
+ }
160
+ /** Dropped with the call in flight: the runtime is told (best effort). */
161
+ async [Symbol.asyncDispose]() { if (!this.#done) {
162
+ this.#sendCancel("dropped");
163
+ this.#finish({ error: new SynapseError("canceled", "dropped") });
164
+ } }
165
+ }
166
+ /**
167
+ * Starts a call on `session` — a specific session, not whichever is current: an attempt's requests
168
+ * go on the session the attempt is for (`channel.ts`). `connection_lost` once it ended.
169
+ */
170
+ export function startOn(node, session, svc, args, opts, contract) {
171
+ const id = newCallId();
172
+ const run = new RunImpl(node, session, id, svc, opts, contract);
173
+ const body = { call: id, svc, args };
174
+ if (opts.deadlineMs !== undefined)
175
+ body.deadline_ms = Math.max(1, Math.round(opts.deadlineMs));
176
+ session.writeCall({ flags: 0, type: TYPES.RPC_CALL, seq: 0, tUs: nowUs() }, encodeBody(body), id, f => run.deliver(f));
177
+ node.callsSent++;
178
+ run.start();
179
+ return run;
180
+ }
181
+ /** `callStream`: the updates and the result as one ordered stream; `break` cancels. */
182
+ export async function* callStream(run) {
183
+ try {
184
+ for await (const u of run.updates())
185
+ yield { type: "update", data: u };
186
+ yield { type: "result", data: await run.result };
187
+ }
188
+ finally {
189
+ if (run.status === "pending" || run.status === "running")
190
+ run.cancel("caller").catch(() => { });
191
+ }
192
+ }
@@ -0,0 +1,84 @@
1
+ import { SynapseError, encode as encodeBody, type ErrorBody } from "@synapse-sdk/core";
2
+ import type { Node } from "./node.ts";
3
+ import type { Session } from "./session.ts";
4
+ import { AsyncQueue, type Deferred } from "./util.ts";
5
+ /** A fresh binding id (WP §8.1): 6 hex digits, the identity of one attempt to the runtime. */
6
+ export declare function newBind(): string;
7
+ /** The pace of the first re-ask of a refused or transient request. */
8
+ export declare const REFOLLOW_PACE_MS = 100;
9
+ /** The longest pause between two re-asks of a refused intent. */
10
+ export declare const REFUSAL_PACE_MAX_MS = 5000;
11
+ /** The pause before re-ask number `n` (1-based): doubled per re-ask, capped. */
12
+ export declare function refusalPaceMs(n: number): number;
13
+ /** The runtime is mid-change and says so: asked again shortly, silently. */
14
+ export declare function isTransient(e: SynapseError): boolean;
15
+ /** A refusal that is asked again, never a verdict: every code but the session going. */
16
+ export declare function isReasked(e: SynapseError): boolean;
17
+ /** The holder a refusal names (`data.by`), if it does. */
18
+ export declare function holderOf(e: SynapseError): string | undefined;
19
+ export type Event = {
20
+ kind: "session";
21
+ session: Session;
22
+ } | {
23
+ kind: "sessionEnded";
24
+ } | {
25
+ kind: "nodeEnded";
26
+ } | {
27
+ kind: "reply";
28
+ attempt: number;
29
+ result: SynapseError | undefined;
30
+ } | {
31
+ kind: "ended";
32
+ bind: string;
33
+ } | {
34
+ kind: "ready";
35
+ reply: Deferred<void>;
36
+ } | {
37
+ kind: "retry";
38
+ attempt: number;
39
+ } | {
40
+ kind: "unregister";
41
+ };
42
+ /** One intent kind: what the task installs and retracts, and how it asks the runtime. */
43
+ export interface Intent {
44
+ readonly name: string;
45
+ readonly registerSvc: string;
46
+ readonly unregisterSvc: string;
47
+ registerArgs(bind: string): unknown;
48
+ unregisterArgs(bind: string): unknown;
49
+ /** The runtime admitted attempt `bind` on `s`: announce and install what the fast lane uses. Throws when the announce failed (asked again like a refusal). */
50
+ install(s: Session, bind: string, mailbox: (e: Event) => void): void;
51
+ /** Retracts what `install` installed, if anything is. */
52
+ retract(): void;
53
+ /** Where the intent stands on the current session. */
54
+ setVerdict(v: {
55
+ status: "unbound";
56
+ } | {
57
+ status: "pending";
58
+ epoch: number;
59
+ } | {
60
+ status: "admitted";
61
+ epoch: number;
62
+ }): void;
63
+ /** The task is exiting (after the last retract). */
64
+ exited?(): void;
65
+ warn(msg: string): void;
66
+ }
67
+ /** The application's side of a handle: its mailbox, and `ready()` as a request to the task. */
68
+ export declare class Handle {
69
+ readonly node: Node;
70
+ readonly intent: Intent;
71
+ /** Unbounded, as the Rust task's control channel: no lifecycle message is ever dropped. */
72
+ readonly mailbox: AsyncQueue<Event>;
73
+ readonly exited: Deferred<void>;
74
+ constructor(node: Node, intent: Intent);
75
+ /** Resolves once the current attempt is admitted and announced; `not_connected` once the task is gone. */
76
+ ready(): Promise<void>;
77
+ /** The intent is over: the task writes the `unregister` and exits. */
78
+ unregister(): void;
79
+ }
80
+ /** Starts the task for `intent`; returns the handle. */
81
+ export declare function start(node: Node, intent: Intent): Handle;
82
+ /** Encodes a `register`'s args for the wire (msgpack map). */
83
+ export declare const encodeArgs: typeof encodeBody;
84
+ export type { ErrorBody };
@@ -0,0 +1,215 @@
1
+ // One task per handle (SDK §2.1, PLAN v0.22 §5): Phoenix's `Channel` on the client, for the two
2
+ // intents asked of the runtime by `register` — a publisher (`topics.ts`) and a service registration
3
+ // (`serve.ts`). A handle is one task owning all of the handle's lifecycle state, driven by one
4
+ // mailbox; its events, taken strictly in order:
5
+ // - `session` / `sessionEnded` (told by the node's loop, `Node.monitor`): the attempt on the previous
6
+ // session ends (its `unregister` written if the session still takes it), a new one begins on the
7
+ // new session — a fresh bind, `register`, then the announce; `nodeEnded`: the task exits;
8
+ // - `reply`: the answer to the `register` the task wrote (by attempt: an older one is dropped);
9
+ // - `ended`: the installed binding's reader ended (a provider's `calls` track): a renewal on the same session;
10
+ // - `ready`: the application waits for the admission ([`Handle.ready`]), answered when it lands;
11
+ // - `retry`: a paced re-ask is due; `unregister`: the application dropped the handle.
12
+ // A transient reply (the runtime mid-change) is asked again on the same bind after `REFOLLOW_PACE`;
13
+ // every refusal too (D 2026-10-06, R6, "phoenix style"), paced `refusalPace`, with a warning each
14
+ // time. A refusal is no verdict: the verdict stays `pending`. Fast-lane invariant: the task never
15
+ // sits between the application and a data track; it only installs and retracts what the fast lane uses.
16
+ import { SynapseError, encode as encodeBody } from "@synapse-sdk/core";
17
+ import { startOn } from "./calls.js";
18
+ import { AsyncQueue, deferred } from "./util.js";
19
+ /** A fresh binding id (WP §8.1): 6 hex digits, the identity of one attempt to the runtime. */
20
+ export function newBind() { const b = new Uint8Array(3); crypto.getRandomValues(b); return Array.from(b, x => x.toString(16).padStart(2, "0")).join(""); }
21
+ /** The pace of the first re-ask of a refused or transient request. */
22
+ export const REFOLLOW_PACE_MS = 100;
23
+ /** The longest pause between two re-asks of a refused intent. */
24
+ export const REFUSAL_PACE_MAX_MS = 5000;
25
+ /** The pause before re-ask number `n` (1-based): doubled per re-ask, capped. */
26
+ export function refusalPaceMs(n) { return Math.min(REFOLLOW_PACE_MS * 2 ** Math.min(n - 1, 31), REFUSAL_PACE_MAX_MS); }
27
+ /** The runtime is mid-change and says so: asked again shortly, silently. */
28
+ export function isTransient(e) { return ["service_unavailable", "provider_unavailable", "overloaded", "project_settling", "timeout"].includes(e.code); }
29
+ /** A refusal that is asked again, never a verdict: every code but the session going. */
30
+ export function isReasked(e) { return e.code !== "not_connected" && e.code !== "connection_lost"; }
31
+ /** The holder a refusal names (`data.by`), if it does. */
32
+ export function holderOf(e) { const d = e.data; return d && typeof d.by === "string" ? d.by : undefined; }
33
+ /** The application's side of a handle: its mailbox, and `ready()` as a request to the task. */
34
+ export class Handle {
35
+ node;
36
+ intent;
37
+ /** Unbounded, as the Rust task's control channel: no lifecycle message is ever dropped. */
38
+ mailbox = new AsyncQueue(Infinity);
39
+ exited = deferred();
40
+ constructor(node, intent) {
41
+ this.node = node;
42
+ this.intent = intent;
43
+ }
44
+ /** Resolves once the current attempt is admitted and announced; `not_connected` once the task is gone. */
45
+ ready() {
46
+ const reply = deferred();
47
+ if (!this.mailbox.push({ kind: "ready", reply }))
48
+ return Promise.reject(new SynapseError("not_connected"));
49
+ return Promise.race([reply.promise, this.exited.promise.then(() => { throw new SynapseError("not_connected"); })]);
50
+ }
51
+ /** The intent is over: the task writes the `unregister` and exits. */
52
+ unregister() { this.mailbox.push({ kind: "unregister" }); }
53
+ }
54
+ /** Starts the task for `intent`; returns the handle. */
55
+ export function start(node, intent) {
56
+ const h = new Handle(node, intent);
57
+ void run(h);
58
+ return h;
59
+ }
60
+ async function run(h) {
61
+ const { node, intent, mailbox } = h;
62
+ const post = (e) => { mailbox.push(e); };
63
+ let session;
64
+ let attempt = 0;
65
+ let current;
66
+ let admitted = false;
67
+ let refusals = 0;
68
+ let retryTimer;
69
+ const ready = [];
70
+ const clearRetry = () => { if (retryTimer !== undefined) {
71
+ clearTimeout(retryTimer);
72
+ retryTimer = undefined;
73
+ } };
74
+ const scheduleRetry = (ms) => { clearRetry(); const a = attempt; retryTimer = setTimeout(() => { retryTimer = undefined; post({ kind: "retry", attempt: a }); }, ms); };
75
+ /** Writes the current attempt's `register` (again, on a re-ask: the same bind); the reply comes back as an event. */
76
+ const ask = () => {
77
+ const a = current;
78
+ if (!a)
79
+ return;
80
+ const n = attempt;
81
+ try {
82
+ const run = startOn(node, a.session, intent.registerSvc, intent.registerArgs(a.bind), {});
83
+ void run.result.then(() => post({ kind: "reply", attempt: n, result: undefined }), (e) => post({ kind: "reply", attempt: n, result: e instanceof SynapseError ? e : new SynapseError("internal", String(e)) }));
84
+ }
85
+ catch (e) {
86
+ post({ kind: "reply", attempt: n, result: e instanceof SynapseError ? e : new SynapseError("internal", String(e)) });
87
+ }
88
+ };
89
+ /** `unregister {bind}` on the attempt's session; re-asked while transient, off the task. */
90
+ const unregister = (a) => {
91
+ const args = intent.unregisterArgs(a.bind);
92
+ void (async () => {
93
+ for (;;) {
94
+ try {
95
+ await startOn(node, a.session, intent.unregisterSvc, args, {}).result;
96
+ return;
97
+ }
98
+ catch (e) {
99
+ if (e instanceof SynapseError && isTransient(e) && a.session.isLive) {
100
+ await new Promise(r => setTimeout(r, REFOLLOW_PACE_MS));
101
+ continue;
102
+ }
103
+ return;
104
+ }
105
+ }
106
+ })();
107
+ };
108
+ /** The current attempt is over: its `unregister` on its own session while that lives, then the retract. */
109
+ const endAttempt = () => {
110
+ attempt += 1;
111
+ clearRetry();
112
+ admitted = false;
113
+ const a = current;
114
+ current = undefined;
115
+ if (!a)
116
+ return;
117
+ if (a.session.isLive)
118
+ unregister(a);
119
+ intent.retract();
120
+ };
121
+ /** A new attempt on `s`: a fresh bind, `pending`, its `register`. */
122
+ const begin = (s) => {
123
+ attempt += 1;
124
+ refusals = 0;
125
+ current = { bind: newBind(), session: s };
126
+ intent.setVerdict({ status: "pending", epoch: s.epoch });
127
+ ask();
128
+ };
129
+ const sessionChanged = (s) => {
130
+ session = s;
131
+ endAttempt();
132
+ if (s)
133
+ begin(s);
134
+ else
135
+ intent.setVerdict({ status: "unbound" });
136
+ };
137
+ /** A refusal (R6): asked again on the same attempt and bind after `refusalPaceMs`, with a warning. */
138
+ const refused = (e, what) => {
139
+ refusals += 1;
140
+ const pace = refusalPaceMs(refusals);
141
+ intent.warn(`${intent.registerSvc} ${intent.name}: ${what} (${e.code}${e.message ? `: ${e.message}` : ""}; holder ${holderOf(e) ?? "-"}; attempt ${refusals}; again in ${pace} ms)`);
142
+ scheduleRetry(pace);
143
+ };
144
+ const { session: first, unmonitor } = node.monitor(e => mailbox.push(e.kind === "started" ? { kind: "session", session: e.session } : e.kind === "ended" ? { kind: "sessionEnded" } : { kind: "nodeEnded" }));
145
+ sessionChanged(first);
146
+ try {
147
+ for (;;) {
148
+ const ev = await mailbox.next();
149
+ if (!ev || ev.kind === "unregister" || ev.kind === "nodeEnded")
150
+ break;
151
+ switch (ev.kind) {
152
+ case "session":
153
+ sessionChanged(ev.session);
154
+ break;
155
+ case "sessionEnded":
156
+ sessionChanged(undefined);
157
+ break;
158
+ case "reply": {
159
+ if (ev.attempt !== attempt || !current)
160
+ break; // an attempt no longer current
161
+ const a = current;
162
+ if (!ev.result) {
163
+ try {
164
+ intent.install(a.session, a.bind, post);
165
+ admitted = true;
166
+ intent.setVerdict({ status: "admitted", epoch: a.session.epoch });
167
+ for (const r of ready.splice(0))
168
+ r.resolve();
169
+ }
170
+ catch (e) {
171
+ refused(e instanceof SynapseError ? e : new SynapseError("internal", String(e)), "announce failed; asking again");
172
+ }
173
+ }
174
+ else if (isTransient(ev.result))
175
+ scheduleRetry(REFOLLOW_PACE_MS);
176
+ else if (isReasked(ev.result))
177
+ refused(ev.result, "refused by the runtime; asking again");
178
+ // else: the session went; its end (a message on its way) begins the next attempt
179
+ break;
180
+ }
181
+ case "ended": {
182
+ // The installed binding's reader ended: a renewal on the same session.
183
+ if (!current || current.bind !== ev.bind)
184
+ break;
185
+ const s = current.session;
186
+ endAttempt();
187
+ begin(s);
188
+ break;
189
+ }
190
+ case "ready":
191
+ if (admitted)
192
+ ev.reply.resolve();
193
+ else
194
+ ready.push(ev.reply);
195
+ break;
196
+ case "retry":
197
+ if (ev.attempt === attempt)
198
+ ask();
199
+ break;
200
+ }
201
+ }
202
+ }
203
+ finally {
204
+ unmonitor();
205
+ endAttempt();
206
+ intent.setVerdict({ status: "unbound" });
207
+ intent.exited?.();
208
+ void mailbox.close(true);
209
+ h.exited.resolve();
210
+ for (const r of ready.splice(0))
211
+ r.reject(new SynapseError("not_connected"));
212
+ }
213
+ }
214
+ /** Encodes a `register`'s args for the wire (msgpack map). */
215
+ export const encodeArgs = encodeBody;