@synapse-sdk/client 0.3.3 → 0.3.4

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/calls.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type CallId, type ServiceContract } from "@synapse-sdk/core";
1
+ import { SynapseError, type CallId, type ServiceContract } from "@synapse-sdk/core";
2
2
  import type { Node } from "./node.ts";
3
3
  import { type RespFrame, type Session } from "./session.ts";
4
4
  import type { CallEvent, CallOptions, Run, RunStatus } from "./types.ts";
@@ -33,5 +33,11 @@ export declare class RunImpl<U, R> implements Run<U, R> {
33
33
  * go on the session the attempt is for (`channel.ts`). `connection_lost` once it ended.
34
34
  */
35
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
+ /**
37
+ * A call that was never sent (no session): a `Run` already `failed` with `error`. Its result is the
38
+ * error; it has no updates; `cancel()` has nothing to do. Like every Run, an unread result is no
39
+ * unhandled rejection.
40
+ */
41
+ export declare function failedRun<U, R>(error: SynapseError): Run<U, R>;
36
42
  /** `callStream`: the updates and the result as one ordered stream; `break` cancels. */
37
43
  export declare function callStream<U, R>(run: Run<U, R>): AsyncGenerator<CallEvent<U, R>, void, undefined>;
package/dist/calls.js CHANGED
@@ -178,6 +178,25 @@ export function startOn(node, session, svc, args, opts, contract) {
178
178
  run.start();
179
179
  return run;
180
180
  }
181
+ /**
182
+ * A call that was never sent (no session): a `Run` already `failed` with `error`. Its result is the
183
+ * error; it has no updates; `cancel()` has nothing to do. Like every Run, an unread result is no
184
+ * unhandled rejection.
185
+ */
186
+ export function failedRun(error) {
187
+ const result = Promise.reject(error);
188
+ result.catch(() => { });
189
+ return {
190
+ id: "",
191
+ status: "failed",
192
+ result,
193
+ then: (onFulfilled, onRejected) => result.then(onFulfilled, onRejected),
194
+ nextUpdate: async () => undefined,
195
+ updates: async function* () { },
196
+ cancel: async () => { },
197
+ [Symbol.asyncDispose]: async () => { },
198
+ };
199
+ }
181
200
  /** `callStream`: the updates and the result as one ordered stream; `break` cancels. */
182
201
  export async function* callStream(run) {
183
202
  try {
package/dist/node.d.ts CHANGED
@@ -9,14 +9,16 @@ export declare function fixedPolicy(ms: number): ReconnectPolicy;
9
9
  /** Stops after `maxAttempts` attempts in one phase. */
10
10
  export declare function withMaxAttempts(p: ReconnectPolicy, maxAttempts: number): ReconnectPolicy;
11
11
  export declare const defaultReconnectPolicy: ReconnectPolicy;
12
- /** The node's declared target: bumped `generation` per change; `dial` undefined = deliberately disconnected. */
12
+ /** The node's declared target: bumped `generation` per change; `dial` undefined = no target (the node is `idle`). */
13
13
  export interface Target {
14
14
  generation: number;
15
15
  dial: Dial | undefined;
16
16
  identity: Identity;
17
17
  }
18
- /** The one lifecycle cell: `ConnectionState` with the session on `connected`. State, not history. */
18
+ /** The one lifecycle value (held in a `Cell`): `ConnectionState` with the session on `connected`. State, not history. */
19
19
  export type Link = {
20
+ status: "idle";
21
+ } | {
20
22
  status: "connecting";
21
23
  attempt: number;
22
24
  } | {
@@ -36,6 +38,7 @@ export type Link = {
36
38
  } | {
37
39
  status: "failed";
38
40
  error: SynapseError;
41
+ generation: number;
39
42
  };
40
43
  export declare function stateOf(l: Link): ConnectionState;
41
44
  /** What a handle task is told about the node's session, in its own mailbox, in order with its other events. */
@@ -47,16 +50,20 @@ export type SessionEvent = {
47
50
  } | {
48
51
  kind: "nodeEnded";
49
52
  };
50
- /** Where the loop replies to a `connect`/`setTarget` call waiting for a target generation. */
53
+ /** How long the first pursuit of a declared target (`connect()`) keeps trying a retryable failure before `failed` (SDK §2: a hard bound; the same value in every SDK). */
54
+ export declare const FIRST_CONNECT_GIVE_UP_MS = 30000;
55
+ /** Where the loop replies to a `connect`/`disconnect` call waiting for a target generation. */
51
56
  type Reply = Deferred<void>;
52
- /** Why the loop is dialing. */
57
+ /**
58
+ * Why the loop is dialing: the first pursuit of a declared target (`connect()`), every attempt (its pause included) cut
59
+ * at `deadlineMs` (`boundMs` after the first attempt), or a dropped session (paced by the policy).
60
+ */
53
61
  type Pursuit = {
54
62
  kind: "first";
55
63
  deadlineMs: number;
64
+ boundMs: number;
56
65
  } | {
57
66
  kind: "reconnect";
58
- } | {
59
- kind: "target";
60
67
  };
61
68
  type Stand = {
62
69
  kind: "live";
@@ -76,30 +83,44 @@ type Next = {
76
83
  delayMs: number;
77
84
  } | {
78
85
  kind: "failed";
79
- } | {
80
- kind: "closed";
81
86
  };
82
- /** The rules (SDK §2, WP §10.4) for the delay before attempt `attempt`. */
87
+ /**
88
+ * The rules (SDK §2, WP §10.4) for the delay before attempt `attempt`. `unauthorized` gives up in every pursuit. The first
89
+ * connect retries only retryable errors and only until its deadline (the loop cuts the pause and the attempt at it; the
90
+ * delay is not cut here); with no policy it uses the default. Giving up is `failed` in every pursuit: the node rests
91
+ * until a new target.
92
+ */
83
93
  export declare function decide(pursuit: Pursuit, policy: ReconnectPolicy | null, err: SynapseError, attempt: number, nowMs: number): Next;
84
94
  export declare class Node {
85
95
  #private;
86
96
  readonly link: Cell<Link>;
87
97
  readonly target: Cell<Target>;
88
- readonly dom: TimeDomain;
98
+ /** The node's time domain; final once `prepared` resolved (the loop dials, and publishers describe themselves, only after). */
99
+ dom: TimeDomain;
100
+ /** Resolves once the time domain is final (the host's monotonic clock read, its domain id derived). */
101
+ readonly prepared: Promise<void>;
89
102
  readonly knobs: SessionKnobs;
90
103
  readonly policy: ReconnectPolicy | null;
104
+ /** Bun/Node: close on SIGINT/SIGTERM once a target is declared (not at construction: constructing opens nothing). */
105
+ readonly signals: boolean;
91
106
  /** Broker notices addressed to this node, across its sessions (fed by the loop from the current session's). */
92
107
  readonly notices: Set<(n: ErrorBody) => void>;
93
108
  readonly projectNotices: Set<(n: Notice) => void>;
94
- /** Resolves once the node has ended for good (`close()`, the first connect failing, the policy giving up). */
109
+ /** Resolves once the node has ended for good: `close()`, and nothing else. */
95
110
  readonly ended: Deferred<void>;
96
111
  epochs: number;
97
112
  reconnects: number;
98
113
  callsSent: number;
99
114
  callsAnswered: number;
100
- constructor(target: Target, dom: TimeDomain, knobs: SessionKnobs, policy: ReconnectPolicy | null);
115
+ /** `finalDom`: the time domain once known, when `dom` is only provisional (see `createNode`). */
116
+ constructor(target: Target, dom: TimeDomain, knobs: SessionKnobs, policy: ReconnectPolicy | null, opts?: {
117
+ signals?: boolean;
118
+ finalDom?: Promise<TimeDomain>;
119
+ });
101
120
  get isEnded(): boolean;
102
121
  get isConnected(): boolean;
122
+ /** Whether the node rests `failed` on target `generation` (a `failed` of an older target, not yet replaced by the loop, is not this target's). */
123
+ failedOn(generation: number): boolean;
103
124
  currentSession(): Session | undefined;
104
125
  state(): ConnectionState;
105
126
  /** The loop's one write. A write that names a session or withdraws one tells every monitoring handle, in the same step. */
@@ -115,13 +136,29 @@ export declare class Node {
115
136
  };
116
137
  /** The node ends for good in `link`: handles are told, feeds end. */
117
138
  end(link: Link): void;
118
- /** Builds the freshly opened session completely (its epoch, its tasks running), then names it. */
119
- attach(session: Session): Session;
139
+ /** Builds the freshly opened session completely (its epoch, the target generation it was opened for, its tasks running), then names it. */
140
+ attach(session: Session, generation: number): Session;
120
141
  /** `close()`: posts the command; resolves once the node has ended. */
121
142
  close(): Promise<void>;
122
- /** `setTarget()`: declares the target under the cell's write, posts the command with its reply, and awaits it. */
123
- setTarget(dial: Dial | undefined): Promise<void>;
124
- /** Starts the loop. `first` is the `connect()` call's reply. `signals`: close on SIGINT/SIGTERM (Bun/Node). */
143
+ /**
144
+ * The one path of every target change (`connect()`, `disconnect()`): declares `dial` (`undefined`: no target) under the
145
+ * `target` `Cell`'s write, posts the command with its reply, and awaits it (the loop answers once the target is reached, given up
146
+ * on, applied as none, or superseded by a newer one: quietly). The target already declared declares nothing (except
147
+ * from `failed` on it, where it is dialed again) and settles with the node's state (`settled`); no target again is done at
148
+ * once. `giveUpAfterMs`: the bound of the dial's first pursuit.
149
+ */
150
+ declare(dial: Dial | undefined, giveUpAfterMs?: number): Promise<void>;
151
+ /**
152
+ * Settles with target `generation` by reading the node's state: resolves once connected on a session opened for it (a
153
+ * session of an earlier target still connected does not count) or once a newer target superseded it (quietly, as the
154
+ * loop answers a superseded call); rejects once `failed` on it (its error; a `failed` of an older target keeps it waiting) or ended (`not_connected`).
155
+ */
156
+ settled(generation: number): Promise<void>;
157
+ /** `disconnect()` again while the first is still being acted on: resolves once the node rests `idle` (or a newer target superseded it); `not_connected` once it ended. */
158
+ idle(generation: number): Promise<void>;
159
+ /** Resolves once a session other than `asked` is connected; rejects with the error once the node rests `failed`, `not_connected` once it ended or rests `idle` (disconnected meanwhile: no session will follow). */
160
+ switchedFrom(asked: Session): Promise<void>;
161
+ /** Starts the loop (once the time domain is final). `first` is a `connect()` call's reply. `signals`: close on SIGINT/SIGTERM (Bun/Node). */
125
162
  run(stand: Stand, first: Reply | undefined, signals: boolean): void;
126
163
  }
127
164
  /** Resolves the identity a node declares (`resolve_identity`): a missing name is minted, a `web`/`hmi` node without a `user_id` mints one. */
@@ -133,13 +170,14 @@ export declare function resolveIdentity(o: {
133
170
  }): Identity;
134
171
  /** Resolves a `ConnectTarget` against the node's current identity (fields left out are kept). */
135
172
  export declare function resolveTarget(node: Node, t: ConnectTarget): Dial;
173
+ /** A given broker url that is not a URL is the caller's mistake: `invalid_input`. */
174
+ export declare function checkUrl(url: string): void;
136
175
  /**
137
- * Builds the node and starts its loop in the first pursuit. Resolves as soon as the loop runs, with
138
- * the node (so a `close()` can reach it during the first dial) and `connected`: the loop's reply to
139
- * the first connect, a session established or why there is none (`not_connected` after a `close()`).
176
+ * Builds the node, synchronously, with no target (`idle`), and starts its loop resting; nothing is
177
+ * dialed until `connect()` declares a target. Throws `invalid_input` for options that can never work
178
+ * (a bad name). The time domain is read in the background; the loop starts once it is final.
140
179
  */
141
- export declare function startNode(opts: ConnectOptions, giveUpAfterMs: number): Promise<{
142
- node: Node;
143
- connected: Promise<void>;
144
- }>;
180
+ export declare function createNode(opts: ConnectOptions): Node;
181
+ /** The dial `connect()` with no target declares when none is declared: `SYNAPSE_ENDPOINT` (Bun/Node; unset, or a browser: `invalid_input`) with the node's current identity. */
182
+ export declare function endpointDial(node: Node): Dial;
145
183
  export {};
package/dist/node.js CHANGED
@@ -1,9 +1,9 @@
1
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
2
+ // live on the handles' modules), its declared target, and ONE lifecycle `Cell` (`Link`) that says where
3
3
  // the node stands and, when connected, names the session. It holds no fact the broker told us: those
4
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
5
+ // down, and answers `close()` and every target change (`declare`) 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
7
  // resumable: a new session means a fresh `req` track, fresh announces, fresh subscriptions, which
8
8
  // the handles replay when told.
9
9
  import { ClockSync, SynapseError, calibrateMonotonicClock, defaultDomId, isValidClientName, mintName, mintUserId, nowUs } from "@synapse-sdk/core";
@@ -27,6 +27,7 @@ export function withMaxAttempts(p, maxAttempts) {
27
27
  export const defaultReconnectPolicy = exponentialPolicy(100, 10_000);
28
28
  export function stateOf(l) {
29
29
  switch (l.status) {
30
+ case "idle": return { status: "idle" };
30
31
  case "connected": return { status: "connected", url: l.session.url, sinceMs: l.session.startedMs };
31
32
  case "connecting": return { status: "connecting", attempt: l.attempt };
32
33
  case "reconnecting": return { status: "reconnecting", attempt: l.attempt, reason: l.reason, error: l.error, nextRetryAtMs: l.nextRetryAtMs };
@@ -35,7 +36,14 @@ export function stateOf(l) {
35
36
  case "failed": return { status: "failed", error: l.error };
36
37
  }
37
38
  }
38
- /** The rules (SDK §2, WP §10.4) for the delay before attempt `attempt`. */
39
+ /** How long the first pursuit of a declared target (`connect()`) keeps trying a retryable failure before `failed` (SDK §2: a hard bound; the same value in every SDK). */
40
+ export const FIRST_CONNECT_GIVE_UP_MS = 30_000;
41
+ /**
42
+ * The rules (SDK §2, WP §10.4) for the delay before attempt `attempt`. `unauthorized` gives up in every pursuit. The first
43
+ * connect retries only retryable errors and only until its deadline (the loop cuts the pause and the attempt at it; the
44
+ * delay is not cut here); with no policy it uses the default. Giving up is `failed` in every pursuit: the node rests
45
+ * until a new target.
46
+ */
39
47
  export function decide(pursuit, policy, err, attempt, nowMs) {
40
48
  if (err.code === "unauthorized")
41
49
  return { kind: "failed" };
@@ -43,43 +51,56 @@ export function decide(pursuit, policy, err, attempt, nowMs) {
43
51
  if (pursuit.kind === "first") {
44
52
  if (!err.retryable || nowMs >= pursuit.deadlineMs)
45
53
  return { kind: "failed" };
46
- const d = (policy ?? defaultReconnectPolicy).nextRetryDelayMs({ attempt });
47
- delay = d === null ? null : Math.min(d, pursuit.deadlineMs - nowMs);
54
+ delay = (policy ?? defaultReconnectPolicy).nextRetryDelayMs({ attempt });
48
55
  }
49
56
  else
50
57
  delay = policy ? policy.nextRetryDelayMs({ attempt }) : null;
51
58
  if (delay !== null)
52
59
  return { kind: "retry", delayMs: delay };
53
- return pursuit.kind === "reconnect" ? { kind: "closed" } : { kind: "failed" };
60
+ return { kind: "failed" };
61
+ }
62
+ /** The first connect's give-up when its bound cut an attempt: `last`, the previous attempt's error (when there was one), is its cause. */
63
+ function gaveUp(boundMs, last) {
64
+ return new SynapseError(last?.code ?? "connection_lost", { message: `the first connect gave up after ${boundMs} ms${last ? ` (last attempt: ${last.message})` : ""}`, ...(last ? { cause: last } : {}) });
54
65
  }
55
66
  // ---------------------------------------------------------------------------------------- node
56
67
  export class Node {
57
- link = new Cell({ status: "connecting", attempt: 1 });
68
+ link = new Cell({ status: "idle" });
58
69
  target;
70
+ /** The node's time domain; final once `prepared` resolved (the loop dials, and publishers describe themselves, only after). */
59
71
  dom;
72
+ /** Resolves once the time domain is final (the host's monotonic clock read, its domain id derived). */
73
+ prepared;
60
74
  knobs;
61
75
  policy;
76
+ /** Bun/Node: close on SIGINT/SIGTERM once a target is declared (not at construction: constructing opens nothing). */
77
+ signals;
62
78
  /** Broker notices addressed to this node, across its sessions (fed by the loop from the current session's). */
63
79
  notices = new Set();
64
80
  projectNotices = new Set();
65
- /** Resolves once the node has ended for good (`close()`, the first connect failing, the policy giving up). */
81
+ /** Resolves once the node has ended for good: `close()`, and nothing else. */
66
82
  ended = deferred();
67
83
  #endedFlag = false;
68
- /** The loop's mailbox: unbounded, so no `close()` or `setTarget()` is ever dropped. */
84
+ /** The loop's mailbox: unbounded, so no `close()` or target change is ever dropped. */
69
85
  #commands = new AsyncQueue(Infinity);
70
86
  #monitors = new Set();
71
87
  epochs = 0;
72
88
  reconnects = 0;
73
89
  callsSent = 0;
74
90
  callsAnswered = 0;
75
- constructor(target, dom, knobs, policy) {
91
+ /** `finalDom`: the time domain once known, when `dom` is only provisional (see `createNode`). */
92
+ constructor(target, dom, knobs, policy, opts = {}) {
76
93
  this.target = new Cell(target);
77
94
  this.dom = dom;
78
95
  this.knobs = knobs;
79
96
  this.policy = policy;
97
+ this.signals = opts.signals ?? false;
98
+ this.prepared = opts.finalDom ? opts.finalDom.then(d => { this.dom = d; }) : Promise.resolve();
80
99
  }
81
100
  get isEnded() { return this.#endedFlag; }
82
101
  get isConnected() { return this.link.get().status === "connected"; }
102
+ /** Whether the node rests `failed` on target `generation` (a `failed` of an older target, not yet replaced by the loop, is not this target's). */
103
+ failedOn(generation) { const l = this.link.get(); return l.status === "failed" && l.generation === generation; }
83
104
  currentSession() { const l = this.link.get(); return l.status === "connected" ? l.session : undefined; }
84
105
  state() { return stateOf(this.link.get()); }
85
106
  /** The loop's one write. A write that names a session or withdraws one tells every monitoring handle, in the same step. */
@@ -119,9 +140,10 @@ export class Node {
119
140
  forgetOnSignals(this.#closable);
120
141
  }
121
142
  #closable = { close: () => this.close() };
122
- /** Builds the freshly opened session completely (its epoch, its tasks running), then names it. */
123
- attach(session) {
143
+ /** Builds the freshly opened session completely (its epoch, the target generation it was opened for, its tasks running), then names it. */
144
+ attach(session, generation) {
124
145
  session.epoch = ++this.epochs;
146
+ session.generation = generation;
125
147
  const sync = new ClockSync(this.dom.id, session.hello.dom, nowUs());
126
148
  session.clock.set(sync.info());
127
149
  session.onNotice = (n) => {
@@ -141,24 +163,124 @@ export class Node {
141
163
  this.#commands.push({ kind: "close" });
142
164
  await this.ended.promise;
143
165
  }
144
- /** `setTarget()`: declares the target under the cell's write, posts the command with its reply, and awaits it. */
145
- setTarget(dial) {
166
+ /**
167
+ * The one path of every target change (`connect()`, `disconnect()`): declares `dial` (`undefined`: no target) under the
168
+ * `target` `Cell`'s write, posts the command with its reply, and awaits it (the loop answers once the target is reached, given up
169
+ * on, applied as none, or superseded by a newer one: quietly). The target already declared declares nothing (except
170
+ * from `failed` on it, where it is dialed again) and settles with the node's state (`settled`); no target again is done at
171
+ * once. `giveUpAfterMs`: the bound of the dial's first pursuit.
172
+ */
173
+ declare(dial, giveUpAfterMs = FIRST_CONNECT_GIVE_UP_MS) {
146
174
  if (this.#endedFlag)
147
- return Promise.reject(new SynapseError("not_connected", "node closed"));
175
+ return Promise.reject(notConnected());
148
176
  const t = this.target.get();
149
- if (sameDial(t.dial, dial))
150
- return Promise.resolve();
177
+ if (sameDial(t.dial, dial) && !this.failedOn(t.generation))
178
+ return dial ? this.settled(t.generation) : this.idle(t.generation);
179
+ if (dial && this.signals)
180
+ closeOnSignals(this.#closable);
151
181
  const next = { generation: t.generation + 1, dial, identity: dial ? dial.identity : t.identity };
152
182
  this.target.set(next);
153
183
  const reply = deferred();
154
- this.#commands.push({ kind: "setTarget", target: next, reply });
184
+ this.#commands.push({ kind: "declare", target: next, reply, giveUpAfterMs });
155
185
  return reply.promise;
156
186
  }
157
- /** Starts the loop. `first` is the `connect()` call's reply. `signals`: close on SIGINT/SIGTERM (Bun/Node). */
187
+ /**
188
+ * Settles with target `generation` by reading the node's state: resolves once connected on a session opened for it (a
189
+ * session of an earlier target still connected does not count) or once a newer target superseded it (quietly, as the
190
+ * loop answers a superseded call); rejects once `failed` on it (its error; a `failed` of an older target keeps it waiting) or ended (`not_connected`).
191
+ */
192
+ settled(generation) {
193
+ return new Promise((resolve, reject) => {
194
+ const check = () => {
195
+ const l = this.link.get();
196
+ if ((l.status === "connected" && l.session.generation === generation) || this.target.get().generation !== generation) {
197
+ resolve();
198
+ return true;
199
+ }
200
+ if (l.status === "failed" && l.generation === generation) {
201
+ reject(l.error);
202
+ return true;
203
+ }
204
+ if (l.status === "closed") {
205
+ reject(notConnected());
206
+ return true;
207
+ }
208
+ return false;
209
+ };
210
+ if (check())
211
+ return;
212
+ const unLink = this.link.subscribe(() => { if (check()) {
213
+ unLink();
214
+ unTarget();
215
+ } });
216
+ const unTarget = this.target.subscribe(() => { if (check()) {
217
+ unLink();
218
+ unTarget();
219
+ } });
220
+ });
221
+ }
222
+ /** `disconnect()` again while the first is still being acted on: resolves once the node rests `idle` (or a newer target superseded it); `not_connected` once it ended. */
223
+ idle(generation) {
224
+ return new Promise((resolve, reject) => {
225
+ const check = () => {
226
+ const l = this.link.get();
227
+ if (l.status === "idle" || this.target.get().generation !== generation) {
228
+ resolve();
229
+ return true;
230
+ }
231
+ if (l.status === "closed") {
232
+ reject(notConnected());
233
+ return true;
234
+ }
235
+ return false;
236
+ };
237
+ if (check())
238
+ return;
239
+ const unLink = this.link.subscribe(() => { if (check()) {
240
+ unLink();
241
+ unTarget();
242
+ } });
243
+ const unTarget = this.target.subscribe(() => { if (check()) {
244
+ unLink();
245
+ unTarget();
246
+ } });
247
+ });
248
+ }
249
+ /** Resolves once a session other than `asked` is connected; rejects with the error once the node rests `failed`, `not_connected` once it ended or rests `idle` (disconnected meanwhile: no session will follow). */
250
+ switchedFrom(asked) {
251
+ return new Promise((resolve, reject) => {
252
+ const check = () => {
253
+ const l = this.link.get();
254
+ if (l.status === "connected" && l.session !== asked) {
255
+ resolve();
256
+ return true;
257
+ }
258
+ if (l.status === "failed") {
259
+ reject(l.error);
260
+ return true;
261
+ }
262
+ if (l.status === "closed") {
263
+ reject(notConnected());
264
+ return true;
265
+ }
266
+ // A `disconnect()` during the switch: no session will follow.
267
+ if (l.status === "idle") {
268
+ reject(new SynapseError("not_connected", "disconnected before the promoted session was up"));
269
+ return true;
270
+ }
271
+ return false;
272
+ };
273
+ if (check())
274
+ return;
275
+ const un = this.link.subscribe(() => { if (check())
276
+ un(); });
277
+ });
278
+ }
279
+ /** Starts the loop (once the time domain is final). `first` is a `connect()` call's reply. `signals`: close on SIGINT/SIGTERM (Bun/Node). */
158
280
  run(stand, first, signals) {
159
281
  if (signals)
160
282
  closeOnSignals(this.#closable);
161
- void runLoop(this, this.#commands, stand, first);
283
+ void this.prepared.then(() => runLoop(this, this.#commands, stand, first));
162
284
  }
163
285
  }
164
286
  function sameDial(a, b) {
@@ -174,7 +296,7 @@ const notConnected = () => new SynapseError("not_connected", "node closed");
174
296
  */
175
297
  async function runLoop(node, commands, stand, first) {
176
298
  let target = node.target.get();
177
- /** The `setTarget`/`connect` calls waiting for the current target. */
299
+ /** The `connect`/`disconnect` calls waiting for the current target. */
178
300
  let waiting = first ? [first] : [];
179
301
  const replyAll = (r) => { for (const w of waiting)
180
302
  r ? w.reject(r) : w.resolve(); waiting = []; };
@@ -198,7 +320,7 @@ async function runLoop(node, commands, stand, first) {
198
320
  stand = { kind: "resting" };
199
321
  continue;
200
322
  }
201
- const { delayMs } = stand;
323
+ const { delayMs, pursuit } = stand;
202
324
  const ac = new AbortController();
203
325
  const opened = (async () => {
204
326
  if (delayMs > 0)
@@ -214,9 +336,18 @@ async function runLoop(node, commands, stand, first) {
214
336
  return { kind: "opened", result: e instanceof SynapseError ? e : new SynapseError("connection_lost", { message: String(e), cause: e }) };
215
337
  }
216
338
  })();
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") {
339
+ // The first connect's bound cuts the pause and the attempt at its deadline: its give-up, with the previous
340
+ // attempt's error (attempt 1 has none) as the cause.
341
+ let cutTimer;
342
+ const racers = [nextCommand(), opened];
343
+ if (pursuit.kind === "first") {
344
+ const cut = { kind: "cut", error: gaveUp(pursuit.boundMs, stand.attempt > 1 ? stand.error : undefined) };
345
+ racers.push(new Promise(r => { cutTimer = setTimeout(() => r(cut), Math.max(0, pursuit.deadlineMs - performance.now())); }));
346
+ }
347
+ wake = await Promise.race(racers);
348
+ clearTimeout(cutTimer);
349
+ // The dial is abandoned (a command, or the bound): its pause ends now; a session it still opens is closed when it arrives.
350
+ if (wake.kind === "command" || wake.kind === "cut") {
220
351
  ac.abort();
221
352
  void opened.then(w => { if (w.kind === "opened" && w.result instanceof Session)
222
353
  w.result.close(); });
@@ -243,7 +374,7 @@ async function runLoop(node, commands, stand, first) {
243
374
  const close = queued.some(c => c.kind === "close");
244
375
  let newest;
245
376
  for (const c of queued) {
246
- if (c.kind !== "setTarget")
377
+ if (c.kind !== "declare")
247
378
  continue;
248
379
  if (close) {
249
380
  waiting.push(c.reply);
@@ -252,7 +383,7 @@ async function runLoop(node, commands, stand, first) {
252
383
  if (!newest || c.target.generation > newest.target.generation) {
253
384
  if (newest)
254
385
  newest.reply.resolve();
255
- newest = { target: c.target, reply: c.reply };
386
+ newest = { target: c.target, reply: c.reply, giveUpAfterMs: c.giveUpAfterMs };
256
387
  }
257
388
  else
258
389
  c.reply.resolve(); // superseded: quietly done
@@ -272,11 +403,13 @@ async function runLoop(node, commands, stand, first) {
272
403
  target = newest.target;
273
404
  attempts = 0;
274
405
  if (target.dial) {
406
+ // Every dial is first pursued within its bound.
407
+ const pursuit = { kind: "first", deadlineMs: performance.now() + newest.giveUpAfterMs, boundMs: newest.giveUpAfterMs };
275
408
  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") };
409
+ stand = { kind: "dialing", pursuit, attempt: 1, delayMs: 0, reason: "client", error: new SynapseError("connection_lost", "target changed") };
277
410
  }
278
411
  else {
279
- node.setLink({ status: "closed", reason: "client" });
412
+ node.setLink({ status: "idle" });
280
413
  replyAll(undefined);
281
414
  stand = { kind: "resting" };
282
415
  }
@@ -302,18 +435,24 @@ async function runLoop(node, commands, stand, first) {
302
435
  stand = { kind: "dialing", pursuit: { kind: "reconnect" }, attempt: attempts, delayMs: delay, reason, error: err };
303
436
  }
304
437
  else {
305
- await teardown(node, s, { status: "closing" });
306
- node.end({ status: "closed", reason });
307
- replyAll(notConnected());
308
- return;
438
+ // The policy declined: the node rests `failed` with the drop's error until a new target (or `close()`).
439
+ await teardown(node, s, { status: "failed", error: err, generation: target.generation });
440
+ stand = { kind: "resting" };
309
441
  }
310
442
  continue;
311
443
  }
312
- // opened
313
444
  if (stand.kind !== "dialing")
314
445
  continue;
446
+ // the first connect's bound cut the attempt: given up
447
+ if (wake.kind === "cut") {
448
+ node.setLink({ status: "failed", error: wake.error, generation: target.generation });
449
+ replyAll(wake.error);
450
+ stand = { kind: "resting" };
451
+ continue;
452
+ }
453
+ // opened
315
454
  if (wake.result instanceof Session) {
316
- node.attach(wake.result);
455
+ node.attach(wake.result, target.generation);
317
456
  replyAll(undefined); // the target is reached
318
457
  stand = { kind: "live", session: wake.result };
319
458
  continue;
@@ -329,21 +468,12 @@ async function runLoop(node, commands, stand, first) {
329
468
  stand.error = e;
330
469
  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
470
  }
332
- else if (next.kind === "failed" && pursuit.kind === "target") {
333
- node.setLink({ status: "failed", error: e });
471
+ else {
472
+ // Given up (any pursuit): `failed` is a resting state; a later `connect()` dials again.
473
+ node.setLink({ status: "failed", error: e, generation: target.generation });
334
474
  replyAll(e);
335
475
  stand = { kind: "resting" };
336
476
  }
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
477
  }
348
478
  }
349
479
  /** 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). */
@@ -367,12 +497,7 @@ export function resolveIdentity(o) {
367
497
  }
368
498
  /** Resolves a `ConnectTarget` against the node's current identity (fields left out are kept). */
369
499
  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
- }
500
+ checkUrl(t.url);
376
501
  const cur = node.target.get().identity;
377
502
  if (t.name !== undefined && !isValidClientName(t.name))
378
503
  throw new SynapseError("invalid_input", `name ${JSON.stringify(t.name)} is not [a-z0-9_-]{1,64}`);
@@ -386,23 +511,37 @@ export function resolveTarget(node, t) {
386
511
  identity.credential = cred;
387
512
  return makeDial(t.url, identity, t.brokerCertSha256, t.token);
388
513
  }
514
+ /** A given broker url that is not a URL is the caller's mistake: `invalid_input`. */
515
+ export function checkUrl(url) {
516
+ try {
517
+ new URL(url);
518
+ }
519
+ catch (e) {
520
+ throw new SynapseError("invalid_input", { message: `broker url ${JSON.stringify(url)}: ${e instanceof Error ? e.message : String(e)}` });
521
+ }
522
+ }
389
523
  /**
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()`).
524
+ * Builds the node, synchronously, with no target (`idle`), and starts its loop resting; nothing is
525
+ * dialed until `connect()` declares a target. Throws `invalid_input` for options that can never work
526
+ * (a bad name). The time domain is read in the background; the loop starts once it is final.
393
527
  */
394
- export async function startNode(opts, giveUpAfterMs) {
395
- const url = urlOrEndpoint(opts.url);
528
+ export function createNode(opts) {
396
529
  const identity = resolveIdentity(opts);
397
530
  // 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)
531
+ const provisional = { id: opts.domId ?? identity.name };
532
+ const finalDom = opts.domId ? undefined : (async () => {
399
533
  await calibrateMonotonicClock();
400
- const domId = opts.domId ?? (await defaultDomId()) ?? identity.name;
534
+ return { id: (await defaultDomId()) ?? identity.name };
535
+ })();
401
536
  const knobs = { transport: opts.transport ?? "auto", connectTimeoutMs: opts.connectTimeoutMs ?? 10_000, maxDatagram: opts.maxDatagram ?? defaultMaxDatagram(), project: opts.project };
402
537
  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 };
538
+ const node = new Node({ generation: 0, dial: undefined, identity }, provisional, knobs, policy, { signals: opts.signals ?? true, finalDom });
539
+ node.run({ kind: "resting" }, undefined, false);
540
+ return node;
541
+ }
542
+ /** The dial `connect()` with no target declares when none is declared: `SYNAPSE_ENDPOINT` (Bun/Node; unset, or a browser: `invalid_input`) with the node's current identity. */
543
+ export function endpointDial(node) {
544
+ const url = urlOrEndpoint(undefined);
545
+ checkUrl(url);
546
+ return makeDial(url, node.target.get().identity, undefined, undefined);
408
547
  }