@synapse-sdk/client 0.3.0 → 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/node.js CHANGED
@@ -1,14 +1,14 @@
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";
10
10
  import { closeOnSignals, forgetOnSignals } from "./signals.js";
11
- import { Session, closeError, defaultMaxDatagram, discoverBrokerCertSha256, openSession } from "./session.js";
11
+ import { Session, closeError, defaultMaxDatagram, discoverBrokerCertSha256, makeDial, openSession, urlOrEndpoint } from "./session.js";
12
12
  import { startOn } from "./calls.js";
13
13
  import { AsyncQueue, Cell, deferred } from "./util.js";
14
14
  // ------------------------------------------------------------------------------------- policy
@@ -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,30 +163,130 @@ 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) {
165
287
  if (!a || !b)
166
288
  return a === b;
167
- return a.url === b.url && a.cert === b.cert && 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;
289
+ 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
290
  }
169
291
  const notConnected = () => new SynapseError("not_connected", "node closed");
170
292
  /**
@@ -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}`);
@@ -384,24 +509,39 @@ export function resolveTarget(node, t) {
384
509
  const cred = t.credential ?? cur.credential;
385
510
  if (cred)
386
511
  identity.credential = cred;
387
- return { url: t.url, identity, cert: t.brokerCertSha256 };
512
+ return makeDial(t.url, identity, t.brokerCertSha256, t.token);
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
+ }
388
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) {
528
+ export function createNode(opts) {
395
529
  const identity = resolveIdentity(opts);
396
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).
397
- if (!opts.domId)
531
+ const provisional = { id: opts.domId ?? identity.name };
532
+ const finalDom = opts.domId ? undefined : (async () => {
398
533
  await calibrateMonotonicClock();
399
- const domId = opts.domId ?? (await defaultDomId()) ?? identity.name;
534
+ return { id: (await defaultDomId()) ?? identity.name };
535
+ })();
400
536
  const knobs = { transport: opts.transport ?? "auto", connectTimeoutMs: opts.connectTimeoutMs ?? 10_000, maxDatagram: opts.maxDatagram ?? defaultMaxDatagram(), project: opts.project };
401
537
  const policy = opts.reconnect === undefined ? defaultReconnectPolicy : opts.reconnect;
402
- const dial = { url: opts.url, identity, cert: opts.brokerCertSha256 };
403
- const node = new Node({ generation: 0, dial, identity }, { id: domId }, knobs, policy);
404
- const first = deferred();
405
- 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);
406
- 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);
407
547
  }