@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.
@@ -1,7 +1,7 @@
1
1
  import type { SceneEntity, SceneTxn, Xform } from "./types.ts";
2
2
  /** The ops the materializers queue. */
3
3
  type FrameTxn = Pick<SceneTxn, "spawn" | "patch" | "rename" | "despawn">;
4
- /** The entities to reconcile against: `scene.entities` (id → entity) or a list of entities. */
4
+ /** The entities to reconcile against: `scene.get()` (id → entity) or a list of entities. */
5
5
  type Entities = Iterable<SceneEntity> | Readonly<Record<string, SceneEntity>>;
6
6
  /** The presentation name of a materialized derived frame: `<parent name>_<frame>` (e.g. `ur5e_tool0`). */
7
7
  export declare function derivedFrameName(parentName: string, frame: string): string;
@@ -28,7 +28,7 @@ export interface MaterializeReport {
28
28
  /**
29
29
  * Reconciles `robot`'s `robot.frames` with its derived frames (those with `props.from` equal to
30
30
  * `provenance`, default `"urdf"`; a frame without `from` counts as `"urdf"`), queueing the ops on
31
- * `txn`. `entities` is the scene now (`scene.entities`). `restPoses` maps declared names to poses
31
+ * `txn`. `entities` is the scene now (`scene.get()`). `restPoses` maps declared names to poses
32
32
  * (FK at the authored joints, the caller's work); a name with no frame yet and no pose is an error,
33
33
  * never a spawn, so a call with no poses only renames, backfills `from` and retracts.
34
34
  *
@@ -29,7 +29,7 @@ export function validateDerivedFrame(frame, parentId) {
29
29
  /**
30
30
  * Reconciles `robot`'s `robot.frames` with its derived frames (those with `props.from` equal to
31
31
  * `provenance`, default `"urdf"`; a frame without `from` counts as `"urdf"`), queueing the ops on
32
- * `txn`. `entities` is the scene now (`scene.entities`). `restPoses` maps declared names to poses
32
+ * `txn`. `entities` is the scene now (`scene.get()`). `restPoses` maps declared names to poses
33
33
  * (FK at the authored joints, the caller's work); a name with no frame yet and no pose is an error,
34
34
  * never a spawn, so a call with no poses only renames, backfills `from` and retracts.
35
35
  *
package/dist/session.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as Moq from "@moq/net";
2
- import { ClockSync, SynapseError, type CallId, type ClockInfo, type ErrorBody, type Header, type Hello, type Identity, type ProjectSnapshot, type Role, type ServicesSnapshot, type TimeOffsets, type TopicsSnapshot } from "@synapse-sdk/core";
2
+ import { ClockSync, SynapseError, type CallId, type ClockInfo, type ConnectRequest, type ErrorBody, type Header, type Hello, type Identity, type ProjectSnapshot, type Role, type ServicesSnapshot, type TimeOffsets, type TopicsSnapshot } from "@synapse-sdk/core";
3
3
  import { type MoqLogScope } from "./moq_log.ts";
4
4
  import { Cell, deferred } from "./util.ts";
5
5
  import type { Notice } from "./types.ts";
@@ -83,7 +83,7 @@ export declare function ensureWebTransport(): Promise<void>;
83
83
  export declare function describeError(e: unknown): string;
84
84
  /**
85
85
  * The error of a session's close (`classify_close`): `admission_refused` travels as the application
86
- * close code (retryable: a live session may hold this name, the project may be settling), another
86
+ * close code (retryable: a live session holds this name; a crashed one is released after the idle timeout), another
87
87
  * application code is a broker-side failure, `unauthorized` is the relay's own refusal, everything
88
88
  * else is the transport (`connection_lost`); a clean close is a drop too (in one-shot mode a broker
89
89
  * GOAWAY looks like one).
@@ -91,20 +91,51 @@ export declare function describeError(e: unknown): string;
91
91
  export declare function closeError(err: Error | null): SynapseError;
92
92
  /** `https://host:port/<root>` → `http://host:port/certificate.sha256`, the broker's plain-HTTP helper. */
93
93
  export declare function brokerCertHelperUrl(brokerUrl: string): string;
94
+ /**
95
+ * The runtime's URL when the caller gave none: `SYNAPSE_ENDPOINT` (Bun/Node; the runtime sets it for every task it starts).
96
+ * A browser has no environment: there, as with neither, `invalid_input`.
97
+ */
98
+ export declare function urlOrEndpoint(url: string | undefined): string;
94
99
  /** The certificate pin when the caller gave none (SDK §13): `SYNAPSE_BROKER_CERT_SHA256` (Bun/Node), then the helper, then `undefined` = unpinned. Per attempt. */
95
100
  export declare function discoverBrokerCertSha256(brokerUrl: string, opts?: {
96
101
  timeoutMs?: number;
97
102
  signal?: AbortSignal;
98
103
  }): Promise<string | undefined>;
104
+ /**
105
+ * `token_from_url`: `url` without its `jwt=` query parameters, and the last non-empty one (as the relays
106
+ * read it). A URL without `jwt=`, or one that does not parse, is returned as it is (the dial reports it).
107
+ */
108
+ export declare function tokenFromUrl(url: string): {
109
+ url: string;
110
+ token: string | undefined;
111
+ };
112
+ /**
113
+ * `Dial::new`: the one place a user's target becomes a dial. A `jwt=` in the URL is the token (the form
114
+ * the relays read), moved out of the URL here so no URL kept or printed afterwards holds it; a `token`
115
+ * given as such wins over one in the URL.
116
+ */
117
+ export declare function makeDial(url: string, identity: Identity, cert: string | undefined, token: string | undefined): Dial;
118
+ /** `root_runtime_id`: a root names a runtime when it is exactly `runtimes/<runtime id>` (a canonical lowercase UUID). */
119
+ export declare function isRuntimeRoot(root: string): boolean;
120
+ /**
121
+ * `with_root`: the URL to dial for `url`. `url` itself when it names a path (a runtime's root, or a
122
+ * relay's `runtimes/<runtime_id>`); else `url` with the root the runtime names at its plain-HTTP helper
123
+ * (`http://<host>:<port>/root`, the port of `url`, 443 when it has none), asked at every attempt. A
124
+ * helper that cannot be reached, answers non-2xx or answers incompletely is `connection_lost` (the
125
+ * runtime may be coming back); an answer that is not a root (`runtimes/<runtime id>` exactly; a relay has
126
+ * none: it serves many runtimes) is `invalid_input`, naming the fix. `signal` abandons the ask (its reason).
127
+ */
128
+ export declare function withRoot(url: string, signal?: AbortSignal): Promise<string>;
99
129
  export interface RespFrame {
100
130
  header: Header;
101
131
  body: Uint8Array;
102
132
  }
103
- /** What one attempt dials (`Dial`). */
133
+ /** What one attempt dials (`Dial`): built by `makeDial` only, so `url` never holds the token. */
104
134
  export interface Dial {
105
135
  url: string;
106
136
  identity: Identity;
107
137
  cert: string | undefined;
138
+ token: string | undefined;
108
139
  }
109
140
  export interface SessionKnobs {
110
141
  transport: "auto" | "webtransport" | "websocket";
@@ -133,6 +164,8 @@ export declare class Session {
133
164
  readonly log: MoqLogScope;
134
165
  /** Counts *established* sessions of the node (SDK §2); set by the node when attached. */
135
166
  epoch: number;
167
+ /** The target generation this session was opened for (`Target.generation`); set by the node's loop when attached. */
168
+ generation: number;
136
169
  readonly startedMs: number;
137
170
  readonly transport: "webtransport" | "websocket";
138
171
  /** Datagrams travel only over WebTransport; the WebSocket fallback is stream-only. */
@@ -147,7 +180,7 @@ export declare class Session {
147
180
  onNotice: ((n: BrokerNotice) => void) | undefined;
148
181
  /** Settles with the close's error once the connection closed. */
149
182
  readonly closed: Promise<Error | null>;
150
- constructor(url: string, conn: Moq.Connection.Established, pub: Moq.Origin.Producer, sub: Moq.Origin.Producer, hello: Hello, maxDatagram: number, log: MoqLogScope, reqBc: Moq.Broadcast.Producer, res: Moq.Broadcast.Consumer, resSub: Moq.Track.Subscriber, dom: string);
183
+ constructor(url: string, conn: Moq.Connection.Established, pub: Moq.Origin.Producer, sub: Moq.Origin.Producer, hello: Hello, maxDatagram: number, log: MoqLogScope, connect: ConnectTrack, reqBc: Moq.Broadcast.Producer, res: Moq.Broadcast.Consumer, resSub: Moq.Track.Subscriber, dom: string);
151
184
  get prefix(): string;
152
185
  get role(): Role;
153
186
  nowUs(): number;
@@ -169,18 +202,48 @@ export declare class Session {
169
202
  start(sync: ClockSync, startCall: (svc: string, args: unknown) => Promise<unknown>): void;
170
203
  /** Stops and awaits the tasks (bounded), then closes the session: track finished, MoQ close sent. */
171
204
  teardown(graceMs?: number): Promise<void>;
172
- /** The synchronous end: fail its calls, finish our track, close the connection. Idempotent. */
205
+ /** The synchronous end: withdraw our connect request (the runtime ends the session), fail its calls, finish our track, close the connection. Idempotent. */
173
206
  close(): void;
174
207
  createBroadcast(path: string): Moq.Broadcast.Producer;
175
208
  request(path: string): Moq.Origin.Requesting;
176
209
  }
210
+ /** What a connect attempt that got no answer reports (PLAN v0.26 §4 step 1): the request may have gone nowhere. */
211
+ export declare const NO_ANSWER = "no runtime answered under this root (a relay drops out-of-scope publishes silently: check the token)";
212
+ /** A session id for one connect attempt (PLAN v0.26 §5.1): 8 lowercase hex characters, 32 random bits. */
213
+ export declare function mintSid(): string;
214
+ /** Our connect request broadcast `client/<name>/<sid>/connect` and its `hello` track: the session lasts as long as it is announced. */
215
+ export interface ConnectTrack {
216
+ bc: Moq.Broadcast.Producer;
217
+ track: CallWriter;
218
+ }
219
+ /** What the runtime answered a connect request with: the hello, and our `res` track at the path it grants (`hello.res`). */
220
+ export interface Answered {
221
+ connect: ConnectTrack;
222
+ hello: Hello;
223
+ res: Moq.Broadcast.Consumer;
224
+ resSub: Moq.Track.Subscriber;
225
+ }
226
+ /**
227
+ * Asks the runtime for a session over an established connection (PLAN v0.26 §3.1): mint a `sid` for
228
+ * this attempt, write our connect request (`RPC_CALL`, `END`) on track `hello` of
229
+ * `client/<name>/<sid>/connect` and announce it on `pub`, then wait on `sub` for the answer on
230
+ * `client/<name>/<sid>/hello`: the hello (our prefix and role), or an error frame (`END|ERR`, the
231
+ * runtime's refusal, which is the attempt's error as it is: `admission_refused` when a live session
232
+ * holds the name, `invalid_input`, ...). Then our `res` track at the path the hello grants (`hello.res`,
233
+ * `runtime/rpc/@<epoch>`, PLAN v0.26 §7.2). No answer within
234
+ * `timeoutMs` is `not_connected` (`NO_ANSWER`). On any failure the request is withdrawn. `signal`
235
+ * abandons the wait (its reason is the error).
236
+ */
237
+ export declare function requestSession(pub: Moq.Origin.Producer, sub: Moq.Origin.Producer, request: ConnectRequest, timeoutMs: number, log: MoqLogScope, signal: AbortSignal): Promise<Answered>;
177
238
  /**
178
- * Opens one session to `dial`: connect with our identity as query parameters (WP §3.1), wait for
179
- * the hello on `client/<name>/hello` (readiness + grant proof + our prefix), find our `res` track at
180
- * `runtime/rpc/<sid>`, announce our `req` track under the prefix. A refused admission shows up as
181
- * the session closing before the hello: the close decides the error. `signal` abandons the dial: every
182
- * wait stops at once and the half-open connection is closed (`canceled`), as the connect timeout does
183
- * (`connection_lost`).
239
+ * Opens one session to `dial` (PLAN v0.26 §3.1): the path to dial is the URL's own or, for a bare
240
+ * `https://host:port`, the root the runtime names (`withRoot`, asked at every attempt); dial with our name
241
+ * (the relay level's scope) and the token (`jwt=`, through a relay) as the only query parameters; ask the runtime for a session (`requestSession`: our identity and project
242
+ * intent go in the connect request), then announce our `req` track on the path the hello grants (`hello.rpc`).
243
+ * A relay's refusal of the connection shows up as the session closing: an observed close wins, and a
244
+ * transport failure of a step is settled against the close (`CLOSE_GRACE_MS`). `signal` abandons the
245
+ * dial: every wait stops at once and the half-open connection is closed (`canceled`); the connection
246
+ * not established within the connect timeout is `connection_lost`.
184
247
  */
185
248
  export declare function openSession(knobs: SessionKnobs, dial: Dial, dom: string, signal?: AbortSignal): Promise<Session>;
186
249
  /** The error a call pending on a session that ended reports (WP §8.4: the request was written, so it may have run). */
package/dist/session.js CHANGED
@@ -13,7 +13,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
13
13
  // watchers, the clock probe), which write only into it. A task that loses its track records why and
14
14
  // closes the connection, nothing else: the node's loop (`node.ts`) sees the close and does the rest.
15
15
  import * as Moq from "@moq/net";
16
- import { ADMISSION_REFUSED_CLOSE_CODE, ClockSync, FLAGS, STREAM_GROUP_SPAN_MS, STREAM_SUBSCRIBE_MAX_AGE_MS, SynapseError, TYPES, decode as decodeBody, decodeHeader, frame, headerLength, helloPath, identityQuery, isClientType, isRole, moqPriority, normalizeProjectSnapshot, normalizeServicesSnapshot, normalizeTimeOffsets, normalizeTopicsSnapshot, nowUs, parseSessionPath, rpcPath, toHex, } from "@synapse-sdk/core";
16
+ import { ADMISSION_REFUSED_CLOSE_CODE, ClockSync, FLAGS, STREAM_GROUP_SPAN_MS, STREAM_SUBSCRIBE_MAX_AGE_MS, SynapseError, TYPES, decode as decodeBody, decodeHeader, frame, headerLength, helloPath, SID_LEN, connectPath, encodeConnectRequest, isClientType, isRole, moqPriority, normalizeProjectSnapshot, normalizeServicesSnapshot, normalizeTimeOffsets, normalizeTopicsSnapshot, nowUs, sessionPrefix, toHex, } from "@synapse-sdk/core";
17
17
  import { moqLogScope } from "./moq_log.js";
18
18
  import { Cell, deferred, withTimeout, TIMEOUT } from "./util.js";
19
19
  // ------------------------------------------------------------------------------ moq plumbing
@@ -313,7 +313,7 @@ const ADMISSION_REFUSED_SESSION_CODE = 64 + ADMISSION_REFUSED_CLOSE_CODE;
313
313
  const ADMISSION_REFUSED_REASON = new RegExp(`app code=${ADMISSION_REFUSED_CLOSE_CODE}(?!\\d)`);
314
314
  /**
315
315
  * The error of a session's close (`classify_close`): `admission_refused` travels as the application
316
- * close code (retryable: a live session may hold this name, the project may be settling), another
316
+ * close code (retryable: a live session holds this name; a crashed one is released after the idle timeout), another
317
317
  * application code is a broker-side failure, `unauthorized` is the relay's own refusal, everything
318
318
  * else is the transport (`connection_lost`); a clean close is a drop too (in one-shot mode a broker
319
319
  * GOAWAY looks like one).
@@ -323,7 +323,7 @@ export function closeError(err) {
323
323
  return new SynapseError("connection_lost", { message: "session closed by the broker", source: "transport" });
324
324
  const text = `${err.message} ${err.reason ?? ""}`;
325
325
  if ((err instanceof Moq.Error.Session && err.code === ADMISSION_REFUSED_SESSION_CODE) || ADMISSION_REFUSED_REASON.test(text)) {
326
- return new SynapseError("admission_refused", { message: "the runtime refused this connection, retryable: a live session may hold this name, the project may be settling, or the runtime is overloaded; retry with backoff", source: "broker", cause: err });
326
+ return new SynapseError("admission_refused", { message: "the runtime refused this connection; retryable: a live session holds this name (a crashed one is released after the idle timeout); the SDK keeps retrying with backoff", source: "broker", cause: err });
327
327
  }
328
328
  if (err instanceof Moq.Error.Session && err.code === Moq.SessionCode.Unauthorized)
329
329
  return new SynapseError("unauthorized", { message: text.trim(), source: "broker", cause: err });
@@ -334,6 +334,18 @@ export function closeError(err) {
334
334
  }
335
335
  /** `https://host:port/<root>` → `http://host:port/certificate.sha256`, the broker's plain-HTTP helper. */
336
336
  export function brokerCertHelperUrl(brokerUrl) { const u = new URL(brokerUrl); return `http://${u.host}/certificate.sha256`; }
337
+ /**
338
+ * The runtime's URL when the caller gave none: `SYNAPSE_ENDPOINT` (Bun/Node; the runtime sets it for every task it starts).
339
+ * A browser has no environment: there, as with neither, `invalid_input`.
340
+ */
341
+ export function urlOrEndpoint(url) {
342
+ if (url !== undefined)
343
+ return url;
344
+ const env = globalThis.process?.env?.SYNAPSE_ENDPOINT?.trim();
345
+ if (env)
346
+ return env;
347
+ throw new SynapseError("invalid_input", { message: "no url given and SYNAPSE_ENDPOINT is not set", source: "sdk" });
348
+ }
337
349
  const SHA256_HEX = /^[0-9a-f]{64}$/i;
338
350
  /** The certificate pin when the caller gave none (SDK §13): `SYNAPSE_BROKER_CERT_SHA256` (Bun/Node), then the helper, then `undefined` = unpinned. Per attempt. */
339
351
  export async function discoverBrokerCertSha256(brokerUrl, opts = {}) {
@@ -352,6 +364,76 @@ export async function discoverBrokerCertSha256(brokerUrl, opts = {}) {
352
364
  return undefined;
353
365
  }
354
366
  }
367
+ /**
368
+ * `token_from_url`: `url` without its `jwt=` query parameters, and the last non-empty one (as the relays
369
+ * read it). A URL without `jwt=`, or one that does not parse, is returned as it is (the dial reports it).
370
+ */
371
+ export function tokenFromUrl(url) {
372
+ let u;
373
+ try {
374
+ u = new URL(url);
375
+ }
376
+ catch {
377
+ return { url, token: undefined };
378
+ }
379
+ if (!u.searchParams.has("jwt"))
380
+ return { url, token: undefined };
381
+ const token = u.searchParams.getAll("jwt").filter(v => v !== "").at(-1);
382
+ u.searchParams.delete("jwt");
383
+ return { url: u.toString(), token };
384
+ }
385
+ /**
386
+ * `Dial::new`: the one place a user's target becomes a dial. A `jwt=` in the URL is the token (the form
387
+ * the relays read), moved out of the URL here so no URL kept or printed afterwards holds it; a `token`
388
+ * given as such wins over one in the URL.
389
+ */
390
+ export function makeDial(url, identity, cert, token) {
391
+ const t = tokenFromUrl(url);
392
+ return { url: t.url, identity, cert, token: token ?? t.token };
393
+ }
394
+ /** `root_runtime_id`: a root names a runtime when it is exactly `runtimes/<runtime id>` (a canonical lowercase UUID). */
395
+ export function isRuntimeRoot(root) { return /^runtimes\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(root); }
396
+ /**
397
+ * `with_root`: the URL to dial for `url`. `url` itself when it names a path (a runtime's root, or a
398
+ * relay's `runtimes/<runtime_id>`); else `url` with the root the runtime names at its plain-HTTP helper
399
+ * (`http://<host>:<port>/root`, the port of `url`, 443 when it has none), asked at every attempt. A
400
+ * helper that cannot be reached, answers non-2xx or answers incompletely is `connection_lost` (the
401
+ * runtime may be coming back); an answer that is not a root (`runtimes/<runtime id>` exactly; a relay has
402
+ * none: it serves many runtimes) is `invalid_input`, naming the fix. `signal` abandons the ask (its reason).
403
+ */
404
+ export async function withRoot(url, signal) {
405
+ let u;
406
+ try {
407
+ u = new URL(url);
408
+ }
409
+ catch (e) {
410
+ throw new SynapseError("invalid_input", { message: `broker url ${JSON.stringify(url)}: ${describeError(e)}`, source: "sdk" });
411
+ }
412
+ if (u.pathname !== "" && u.pathname !== "/")
413
+ return url;
414
+ const addr = `${u.hostname}:${u.port || (u.protocol === "http:" ? "80" : "443")}`;
415
+ const timeout = AbortSignal.timeout(2000);
416
+ const lost = (why) => new SynapseError("connection_lost", { message: `root helper at ${addr}: ${why}`, source: "transport" });
417
+ let root;
418
+ try {
419
+ const res = await fetch(`http://${addr}/root`, { signal: signal ? AbortSignal.any([signal, timeout]) : timeout });
420
+ if (!res.ok)
421
+ throw lost(`answered ${res.status} ${res.statusText}`.trim());
422
+ root = (await res.text()).trim();
423
+ }
424
+ catch (e) {
425
+ if (signal?.aborted)
426
+ throw signal.reason;
427
+ if (e instanceof SynapseError)
428
+ throw e;
429
+ throw timeout.aborted ? new SynapseError("connection_lost", { message: `root helper at ${addr} did not answer`, source: "transport" }) : lost(describeError(e));
430
+ }
431
+ if (!isRuntimeRoot(root)) {
432
+ throw new SynapseError("invalid_input", { message: `${url} names no runtime (its /root helper returned ${JSON.stringify(root.slice(0, 80))}); give the full path, e.g. https://relay:4443/runtimes/<runtime_id>`, source: "sdk" });
433
+ }
434
+ u.pathname = `/${root}`;
435
+ return u.toString();
436
+ }
355
437
  /** The key of a call in the session's pending map. */
356
438
  export const callKey = (id) => toHex(id);
357
439
  export class Session {
@@ -364,6 +446,8 @@ export class Session {
364
446
  log;
365
447
  /** Counts *established* sessions of the node (SDK §2); set by the node when attached. */
366
448
  epoch = 0;
449
+ /** The target generation this session was opened for (`Target.generation`); set by the node's loop when attached. */
450
+ generation = 0;
367
451
  startedMs = performance.now();
368
452
  transport;
369
453
  /** Datagrams travel only over WebTransport; the WebSocket fallback is stream-only. */
@@ -385,7 +469,7 @@ export class Session {
385
469
  #taskDone = [];
386
470
  /** Settles with the close's error once the connection closed. */
387
471
  closed;
388
- constructor(url, conn, pub, sub, hello, maxDatagram, log, reqBc, res, resSub, dom) {
472
+ constructor(url, conn, pub, sub, hello, maxDatagram, log, connect, reqBc, res, resSub, dom) {
389
473
  this.url = url;
390
474
  this.conn = conn;
391
475
  this.pub = pub;
@@ -393,6 +477,7 @@ export class Session {
393
477
  this.hello = hello;
394
478
  this.maxDatagram = maxDatagram;
395
479
  this.log = log;
480
+ this.#connect = connect;
396
481
  this.#reqBc = reqBc;
397
482
  this.#role = hello.role;
398
483
  this.transport = conn.transport;
@@ -403,6 +488,8 @@ export class Session {
403
488
  this.#resSub = resSub;
404
489
  this.#res = res;
405
490
  }
491
+ /** Our connect request `client/<name>/<sid>/connect` and its track: the session lasts as long as it is announced (PLAN v0.26 §3.1); a clean close withdraws it first. */
492
+ #connect;
406
493
  #reqBc;
407
494
  #resSub;
408
495
  #res;
@@ -581,8 +668,9 @@ export class Session {
581
668
  this.close();
582
669
  await withTimeout(this.closed, graceMs);
583
670
  }
584
- /** The synchronous end: fail its calls, finish our track, close the connection. Idempotent. */
671
+ /** The synchronous end: withdraw our connect request (the runtime ends the session), fail its calls, finish our track, close the connection. Idempotent. */
585
672
  close() {
673
+ withdraw(this.#connect);
586
674
  this.#admitting = false;
587
675
  this.#tasks.abort();
588
676
  this.log.closing();
@@ -621,16 +709,145 @@ export class Session {
621
709
  function certOption(hash) {
622
710
  return hash ? { serverCertificateHashes: [{ algorithm: "sha-256", value: hash.trim() }] } : undefined;
623
711
  }
712
+ /** What a connect attempt that got no answer reports (PLAN v0.26 §4 step 1): the request may have gone nowhere. */
713
+ export const NO_ANSWER = "no runtime answered under this root (a relay drops out-of-scope publishes silently: check the token)";
714
+ /** A session id for one connect attempt (PLAN v0.26 §5.1): 8 lowercase hex characters, 32 random bits. */
715
+ export function mintSid() { return toHex(crypto.getRandomValues(new Uint8Array(SID_LEN / 2))); }
716
+ /** Withdraws a connect request: the track finished, the broadcast un-announced and closed. Idempotent. */
717
+ function withdraw(c) {
718
+ try {
719
+ c.track.close();
720
+ }
721
+ catch { /* closed */ }
722
+ try {
723
+ c.bc.close();
724
+ }
725
+ catch { /* closed */ }
726
+ }
727
+ /**
728
+ * Asks the runtime for a session over an established connection (PLAN v0.26 §3.1): mint a `sid` for
729
+ * this attempt, write our connect request (`RPC_CALL`, `END`) on track `hello` of
730
+ * `client/<name>/<sid>/connect` and announce it on `pub`, then wait on `sub` for the answer on
731
+ * `client/<name>/<sid>/hello`: the hello (our prefix and role), or an error frame (`END|ERR`, the
732
+ * runtime's refusal, which is the attempt's error as it is: `admission_refused` when a live session
733
+ * holds the name, `invalid_input`, ...). Then our `res` track at the path the hello grants (`hello.res`,
734
+ * `runtime/rpc/@<epoch>`, PLAN v0.26 §7.2). No answer within
735
+ * `timeoutMs` is `not_connected` (`NO_ANSWER`). On any failure the request is withdrawn. `signal`
736
+ * abandons the wait (its reason is the error).
737
+ */
738
+ export async function requestSession(pub, sub, request, timeoutMs, log, signal) {
739
+ const { name } = request;
740
+ const sid = mintSid();
741
+ // Our connect request, written before it is announced (the runtime reads it once it sees it).
742
+ const path = connectPath(name, sid);
743
+ const bc = pub.createBroadcast(Moq.Path.from(path));
744
+ const connect = { bc, track: CallWriter.create(bc, "hello") };
745
+ connect.track.write({ flags: FLAGS.END, type: TYPES.RPC_CALL, seq: 0, tUs: nowUs() }, encodeConnectRequest(request));
746
+ bc.announce();
747
+ log.announced(path);
748
+ const noAnswer = () => new SynapseError("not_connected", { message: NO_ANSWER, source: "sdk" });
749
+ const stopped = (e) => { throw signal.aborted ? signal.reason : e; };
750
+ const opened = [];
751
+ try {
752
+ const hp = helloPath(name, sid);
753
+ log.requested(hp);
754
+ // Routed only: wait for the runtime to announce its answer (a blind subscription before it exists is reset as unroutable).
755
+ const helloReq = sub.request(Moq.Path.from(hp), { announced: true });
756
+ opened.push(helloReq);
757
+ const helloBc = await waitActive(helloReq, timeoutMs, signal).catch(() => stopped(noAnswer()));
758
+ const helloSub = helloBc.track("hello").subscribe(latestSubscription(moqPriority(0)));
759
+ opened.push(helloSub);
760
+ const read = async () => {
761
+ const g = await helloSub.recvGroup();
762
+ if (!g)
763
+ throw new SynapseError("connection_lost", "hello track ended");
764
+ const f = await g.readFrame();
765
+ if (!f)
766
+ throw new SynapseError("connection_lost", "empty hello");
767
+ return f.payload;
768
+ };
769
+ const abort = new Promise((_, reject) => { if (signal.aborted)
770
+ reject(signal.reason); signal.addEventListener("abort", () => reject(signal.reason), { once: true }); });
771
+ abort.catch(() => { });
772
+ const reading = read();
773
+ reading.catch(() => { });
774
+ const payload = await Promise.race([withTimeout(reading, timeoutMs), abort]).catch(e => stopped(e instanceof SynapseError ? e : new SynapseError("connection_lost", { message: `hello: ${describeError(e)}`, source: "transport", cause: e })));
775
+ if (payload === TIMEOUT)
776
+ throw noAnswer();
777
+ const hello = readAnswer(payload, name, sid);
778
+ // Our `res` track, at the path the hello grants (the runtime publishes it before it writes the hello).
779
+ const rp = hello.res;
780
+ log.requested(rp);
781
+ const resReq = sub.request(Moq.Path.from(rp), { announced: true });
782
+ opened.push(resReq);
783
+ const res = await waitActive(resReq, timeoutMs, signal).catch(() => stopped(new SynapseError("connection_lost", { message: "broker never announced our response track", source: "transport" })));
784
+ const resSub = res.track("res").subscribe(callTrackSubscription(moqPriority(0)));
785
+ helloSub.close();
786
+ helloReq.close();
787
+ return { connect, hello, res, resSub };
788
+ }
789
+ catch (e) {
790
+ for (const o of opened) {
791
+ try {
792
+ o.close();
793
+ }
794
+ catch { /* closed */ }
795
+ }
796
+ withdraw(connect);
797
+ throw e;
798
+ }
799
+ }
800
+ /** The runtime's answer frame: the hello for `client/<name>/<sid>`, or its refusal as the error. */
801
+ function readAnswer(payload, name, sid) {
802
+ let header, hlen, body;
803
+ try {
804
+ ({ header, hlen } = decodeHeader(payload));
805
+ body = decodeBody(payload.subarray(hlen));
806
+ }
807
+ catch (e) {
808
+ throw new SynapseError("invalid_input", { message: `the runtime's answer is not one this SDK understands (${describeError(e)})`, source: "sdk" });
809
+ }
810
+ if (header.type !== TYPES.RPC_RESP)
811
+ throw new SynapseError("invalid_input", { message: "hello frame is not a hello", source: "sdk" });
812
+ // The runtime's refusal: its error body is the attempt's error.
813
+ if (header.flags & FLAGS.ERR) {
814
+ const b = body;
815
+ if (!b || typeof b !== "object" || typeof b.code !== "string")
816
+ throw new SynapseError("invalid_input", { message: "the runtime's refusal is not one this SDK understands", source: "sdk" });
817
+ throw SynapseError.fromBody(b, { source: "broker" });
818
+ }
819
+ const h = body;
820
+ // A hello this SDK cannot read is a runtime/SDK version mismatch: said once, not retried.
821
+ if (!h || typeof h !== "object" || h.hello !== true || typeof h.name !== "string" || typeof h.prefix !== "string" || typeof h.rpc !== "string" || typeof h.res !== "string" || !isRole(h.role) || !isClientType(h.type) || typeof h.dom !== "string" || typeof h.broker_version !== "string") {
822
+ throw new SynapseError("invalid_input", { message: "the runtime's hello is not one this SDK understands; runtime and SDK versions differ", source: "sdk" });
823
+ }
824
+ const hello = { ...h, robot: typeof h.robot === "string" ? h.robot : "", runtime_name: typeof h.runtime_name === "string" ? h.runtime_name : "", runtime_id: typeof h.runtime_id === "string" ? h.runtime_id : "" };
825
+ if (hello.prefix !== sessionPrefix(name, sid))
826
+ throw new SynapseError("invalid_input", { message: `hello prefix ${JSON.stringify(hello.prefix)} is not client/${name}/${sid}`, source: "sdk" });
827
+ // The request path the hello grants is under our prefix: `<prefix>/rpc/@<epoch>` (PLAN v0.26 §7.1).
828
+ const granted = `${hello.prefix}/rpc/@`;
829
+ if (!hello.rpc.startsWith(granted) || hello.rpc.length === granted.length)
830
+ throw new SynapseError("invalid_input", { message: `hello rpc ${JSON.stringify(hello.rpc)} is not under ${hello.prefix}/rpc/`, source: "sdk" });
831
+ // The reply path it grants is under `runtime/rpc/` (`runtime/rpc/@<epoch>`, PLAN v0.26 §7.2).
832
+ if (!hello.res.startsWith("runtime/rpc/") || hello.res.length === "runtime/rpc/".length)
833
+ throw new SynapseError("invalid_input", { message: `hello res ${JSON.stringify(hello.res)} is not under runtime/rpc/`, source: "sdk" });
834
+ return hello;
835
+ }
836
+ /** How long a failed pre-hello step waits for the session's close to be observed (Rust `CLOSE_GRACE`). */
837
+ const CLOSE_GRACE_MS = 1000;
624
838
  /**
625
- * Opens one session to `dial`: connect with our identity as query parameters (WP §3.1), wait for
626
- * the hello on `client/<name>/hello` (readiness + grant proof + our prefix), find our `res` track at
627
- * `runtime/rpc/<sid>`, announce our `req` track under the prefix. A refused admission shows up as
628
- * the session closing before the hello: the close decides the error. `signal` abandons the dial: every
629
- * wait stops at once and the half-open connection is closed (`canceled`), as the connect timeout does
630
- * (`connection_lost`).
839
+ * Opens one session to `dial` (PLAN v0.26 §3.1): the path to dial is the URL's own or, for a bare
840
+ * `https://host:port`, the root the runtime names (`withRoot`, asked at every attempt); dial with our name
841
+ * (the relay level's scope) and the token (`jwt=`, through a relay) as the only query parameters; ask the runtime for a session (`requestSession`: our identity and project
842
+ * intent go in the connect request), then announce our `req` track on the path the hello grants (`hello.rpc`).
843
+ * A relay's refusal of the connection shows up as the session closing: an observed close wins, and a
844
+ * transport failure of a step is settled against the close (`CLOSE_GRACE_MS`). `signal` abandons the
845
+ * dial: every wait stops at once and the half-open connection is closed (`canceled`); the connection
846
+ * not established within the connect timeout is `connection_lost`.
631
847
  */
632
848
  export async function openSession(knobs, dial, dom, signal) {
633
- const name = dial.identity.name;
849
+ const { identity } = dial;
850
+ const name = identity.name;
634
851
  if (!name)
635
852
  throw new SynapseError("internal", "identity without a name");
636
853
  if (knobs.transport !== "websocket")
@@ -638,18 +855,21 @@ export async function openSession(knobs, dial, dom, signal) {
638
855
  const abandoned = () => new SynapseError("canceled", { message: "the dial was abandoned", source: "sdk" });
639
856
  if (signal?.aborted)
640
857
  throw abandoned();
858
+ // The path to dial: the URL's own, or (none given) the runtime's root, asked of it now (a runtime started again elsewhere on this address may be another runtime).
859
+ const base = await withRoot(dial.url, signal).catch((e) => { throw signal?.aborted ? abandoned() : e; });
641
860
  const pub = new Moq.Origin.Producer();
642
861
  const sub = new Moq.Origin.Producer();
643
- const sep = dial.url.includes("?") ? "&" : "?";
644
- const url = new URL(`${dial.url}${sep}${identityQuery(dial.identity)}${knobs.project ? `&project=${knobs.project}` : ""}`);
645
- // One controller for both ends of a dial: the connect timeout and the caller's `signal`.
862
+ // Only the name and the token travel in the URL: the relay level's scope (`client/<name>/**`) and, through a relay, the token that
863
+ // grants it (`jwt=`, PLAN v0.26 §3.5). Who we are goes to the runtime in the connect request.
864
+ const url = new URL(base);
865
+ url.searchParams.append("name", name);
866
+ if (dial.token !== undefined)
867
+ url.searchParams.append("jwt", dial.token);
868
+ // One controller for the dial: the connect timeout (until the connection is established) and the caller's `signal`.
646
869
  const ac = new AbortController();
647
870
  const timer = setTimeout(() => ac.abort(new SynapseError("connection_lost", "connect timed out")), knobs.connectTimeoutMs);
648
871
  const onAbandon = () => ac.abort(abandoned());
649
872
  signal?.addEventListener("abort", onAbandon, { once: true });
650
- // Settles (rejects) with the reason once the dial is stopped.
651
- const stopped = new Promise((_, reject) => ac.signal.addEventListener("abort", () => reject(ac.signal.reason), { once: true }));
652
- stopped.catch(() => { });
653
873
  const log = moqLogScope();
654
874
  const abandon = () => { signal?.removeEventListener("abort", onAbandon); log.closing(); log.release(); };
655
875
  let conn;
@@ -676,48 +896,30 @@ export async function openSession(knobs, dial, dom, signal) {
676
896
  throw e;
677
897
  throw closeError(e instanceof Error ? e : new Error(describeError(e)));
678
898
  }
899
+ clearTimeout(timer);
900
+ const request = { type: identity.type, name, user_id: identity.user_id, credential: identity.credential, project: knobs.project, dom };
901
+ // The handshake's own controller: the caller's `signal`, or the close winning the race below.
902
+ const hs = new AbortController();
903
+ const onStop = () => hs.abort(ac.signal.reason);
904
+ if (ac.signal.aborted)
905
+ onStop();
906
+ else
907
+ ac.signal.addEventListener("abort", onStop, { once: true });
908
+ // A close is the single source of truth for why a pre-hello session ended: an observed close wins the race.
909
+ let closed = false;
910
+ const closedEarly = conn.closed.then((err) => { closed = true; throw closeError(err); });
911
+ closedEarly.catch(() => { });
912
+ let answered;
679
913
  try {
680
- // The close is the single source of truth for why a pre-hello session ended, unless the dial was stopped first.
681
- const closedEarly = Promise.race([conn.closed.then(err => { throw closeError(err); }), stopped]);
682
- closedEarly.catch(() => { });
683
- const helloReq = sub.request(Moq.Path.from(helloPath(name)));
684
- log.requested(helloPath(name));
685
- const helloBc = await Promise.race([waitActive(helloReq, knobs.connectTimeoutMs, ac.signal), closedEarly]);
686
- const helloSub = helloBc.track("hello").subscribe(latestSubscription(moqPriority(0)));
687
- const g = await Promise.race([withTimeout(helloSub.recvGroup(), knobs.connectTimeoutMs), closedEarly]);
688
- if (g === TIMEOUT || !g)
689
- throw new SynapseError("connection_lost", "no hello from the broker");
690
- const first = await Promise.race([withTimeout(g.readFrame(), knobs.connectTimeoutMs), closedEarly]);
691
- if (first === TIMEOUT || !first)
692
- throw new SynapseError("connection_lost", "empty hello");
693
- helloSub.close();
694
- helloReq.close();
695
- const { header, hlen } = decodeHeader(first.payload);
696
- if (header.type !== TYPES.RPC_RESP || header.flags & FLAGS.ERR)
697
- throw new SynapseError("invalid_input", "hello frame is not a hello");
698
- const hello = decodeBody(first.payload.subarray(hlen));
699
- if (!hello || hello.hello !== true || typeof hello.prefix !== "string" || !isRole(hello.role) || !isClientType(hello.type) || typeof hello.dom !== "string")
700
- throw new SynapseError("invalid_input", "malformed hello");
701
- const sp = parseSessionPath(rpcPath(hello.prefix));
702
- if (!sp || sp.name !== name)
703
- throw new SynapseError("invalid_input", `hello prefix ${hello.prefix} is not client/${name}/<sid>`);
704
- // The broker's response track for this session.
705
- const resPath = `runtime/rpc/${sp.sid}`;
706
- log.requested(resPath);
707
- const resReq = sub.request(Moq.Path.from(resPath));
708
- const res = await Promise.race([waitActive(resReq, knobs.connectTimeoutMs, ac.signal), closedEarly]);
709
- const resSub = res.track("res").subscribe(callTrackSubscription(moqPriority(0)));
710
- // Our request track (WP §8.2), under the prefix the hello names: the only place we may publish.
711
- const reqBc = pub.createBroadcast(Moq.Path.from(rpcPath(hello.prefix)));
712
- log.announced(rpcPath(hello.prefix));
713
- const session = new Session(dial.url, conn, pub, sub, hello, knobs.maxDatagram, log, reqBc, res, resSub, dom);
714
- reqBc.announce();
715
- clearTimeout(timer);
716
- signal?.removeEventListener("abort", onAbandon);
717
- return session;
914
+ answered = await Promise.race([closedEarly, requestSession(pub, sub, request, knobs.connectTimeoutMs, log, hs.signal)]);
718
915
  }
719
916
  catch (e) {
720
- clearTimeout(timer);
917
+ hs.abort(e);
918
+ let err = e instanceof SynapseError ? e : closeError(e instanceof Error ? e : new Error(describeError(e)));
919
+ // A step that tripped over a closing connection before its close was observed: the close decides.
920
+ if (!closed && err.code === "connection_lost")
921
+ err = await settlePreHello(err, conn.closed);
922
+ ac.signal.removeEventListener("abort", onStop);
721
923
  abandon();
722
924
  try {
723
925
  conn.close();
@@ -725,8 +927,26 @@ export async function openSession(knobs, dial, dom, signal) {
725
927
  catch { /* ignore */ }
726
928
  pub.close();
727
929
  sub.close();
728
- throw e instanceof SynapseError ? e : closeError(e instanceof Error ? e : new Error(describeError(e)));
729
- }
930
+ throw err;
931
+ }
932
+ ac.signal.removeEventListener("abort", onStop);
933
+ signal?.removeEventListener("abort", onAbandon);
934
+ // Our request track (WP §8.2), on the path the hello grants (`<prefix>/rpc/@<epoch>`, PLAN v0.26 §7.1): the broadcast and its
935
+ // `req` track created (in the `Session`), then announced, so the runtime finds `req` as soon as it sees the broadcast.
936
+ const { hello, connect, res, resSub } = answered;
937
+ const reqBc = pub.createBroadcast(Moq.Path.from(hello.rpc));
938
+ log.announced(hello.rpc);
939
+ const session = new Session(base, conn, pub, sub, hello, knobs.maxDatagram, log, connect, reqBc, res, resSub, dom);
940
+ reqBc.announce();
941
+ return session;
942
+ }
943
+ /** `settle_pre_hello`: a pre-hello step failed with `err`; if the session closes within the grace that close is the error (a plain transport loss keeps `err`'s text alongside), otherwise `err`. */
944
+ async function settlePreHello(err, closed) {
945
+ const c = await withTimeout(closed, CLOSE_GRACE_MS);
946
+ if (c === TIMEOUT)
947
+ return err;
948
+ const e = closeError(c);
949
+ return e.code === "connection_lost" ? new SynapseError("connection_lost", { message: `${e.message} (${err.message})`, source: e.source, cause: e.cause }) : e;
730
950
  }
731
951
  /** The error a call pending on a session that ended reports (WP §8.4: the request was written, so it may have run). */
732
952
  /** `data` is `{ outcome: "unknown" }`, with `reason` when the session's close had one (Rust `outcome_unknown_data`). */