@synapse-sdk/client 0.3.4 → 0.3.7

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,7 +1,7 @@
1
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
- import type { CallEvent, CallOptions, Run, RunStatus } from "./types.ts";
4
+ import type { CallOptions, Run, RunStatus } from "./types.ts";
5
5
  /** A fresh call id (WP §8.2): 16 random bytes. */
6
6
  export declare function newCallId(): CallId;
7
7
  export declare class RunImpl<U, R> implements Run<U, R> {
@@ -17,12 +17,14 @@ export declare class RunImpl<U, R> implements Run<U, R> {
17
17
  get status(): RunStatus;
18
18
  get result(): Promise<R>;
19
19
  then<A = R, B = never>(onFulfilled?: ((v: R) => A | PromiseLike<A>) | null, onRejected?: ((e: unknown) => B | PromiseLike<B>) | null): Promise<A | B>;
20
+ catch<B = never>(onRejected?: ((e: unknown) => B | PromiseLike<B>) | null): Promise<R | B>;
21
+ finally(onFinally?: (() => void) | null): Promise<R>;
20
22
  /** The session's demux hands frames here; `null` = the session ended with the call pending. */
21
23
  deliver(f: RespFrame | null): void;
22
24
  /** Starts the per-frame reader once the request is written (frames can arrive before `writeCall` returns). */
23
25
  start(): void;
24
- nextUpdate(): Promise<U | undefined>;
25
- updates(): AsyncIterable<U>;
26
+ next(): Promise<U | undefined>;
27
+ [Symbol.asyncIterator](): AsyncIterator<U>;
26
28
  /** Cooperative cancel (WP §8.4): tells the broker, then waits for its `canceled` terminal frame. `connection_lost` without sending once the session ended. */
27
29
  cancel(reason?: string): Promise<void>;
28
30
  /** Dropped with the call in flight: the runtime is told (best effort). */
@@ -39,5 +41,3 @@ export declare function startOn<U, R>(node: Node, session: Session, svc: string,
39
41
  * unhandled rejection.
40
42
  */
41
43
  export declare function failedRun<U, R>(error: SynapseError): Run<U, R>;
42
- /** `callStream`: the updates and the result as one ordered stream; `break` cancels. */
43
- export declare function callStream<U, R>(run: Run<U, R>): AsyncGenerator<CallEvent<U, R>, void, undefined>;
package/dist/calls.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // a frame on a later session (WP §8.4: ownership is checked, never inferred).
5
5
  import { FLAGS, SynapseError, TYPES, decode as decodeBody, encode as encodeBody, nowUs, toHex, validateSync } from "@synapse-sdk/core";
6
6
  import { outcomeUnknown } from "./session.js";
7
- import { AsyncQueue, deferred } from "./util.js";
7
+ import { AsyncQueue, deferred, rethrowLater } from "./util.js";
8
8
  /** A fresh call id (WP §8.2): 16 random bytes. */
9
9
  export function newCallId() { const b = new Uint8Array(16); crypto.getRandomValues(b); return b; }
10
10
  export class RunImpl {
@@ -42,6 +42,8 @@ export class RunImpl {
42
42
  get status() { return this.#status; }
43
43
  get result() { return this.#result.promise; }
44
44
  then(onFulfilled, onRejected) { return this.#result.promise.then(onFulfilled, onRejected); }
45
+ catch(onRejected) { return this.then(undefined, onRejected); }
46
+ finally(onFinally) { return this.#result.promise.finally(onFinally); }
45
47
  /** The session's demux hands frames here; `null` = the session ended with the call pending. */
46
48
  deliver(f) { this.#frames.push(f); }
47
49
  /** Starts the per-frame reader once the request is written (frames can arrive before `writeCall` returns). */
@@ -93,11 +95,17 @@ export class RunImpl {
93
95
  continue;
94
96
  u = r.value;
95
97
  }
96
- this.#updates.push(u);
97
- try {
98
- this.opts.onUpdate?.(u);
98
+ // The callback form takes the stream (as `subscribe(topic, cb)`): a throwing `onUpdate` is rethrown later and the reading goes on.
99
+ if (this.opts.onUpdate) {
100
+ try {
101
+ this.opts.onUpdate(u);
102
+ }
103
+ catch (e) {
104
+ rethrowLater(e);
105
+ }
99
106
  }
100
- catch { /* the caller's */ }
107
+ else
108
+ this.#updates.push(u);
101
109
  }
102
110
  }
103
111
  #updates = new AsyncQueue(4096);
@@ -129,8 +137,8 @@ export class RunImpl {
129
137
  this.#result.resolve(r.value);
130
138
  }
131
139
  }
132
- async nextUpdate() { return this.#updates.next(); }
133
- updates() { return this.#updates; }
140
+ next() { return this.#updates.next(); }
141
+ [Symbol.asyncIterator]() { return this.#updates[Symbol.asyncIterator](); }
134
142
  #sendCancel(reason) {
135
143
  if (!this.session.isLive)
136
144
  return;
@@ -191,21 +199,11 @@ export function failedRun(error) {
191
199
  status: "failed",
192
200
  result,
193
201
  then: (onFulfilled, onRejected) => result.then(onFulfilled, onRejected),
194
- nextUpdate: async () => undefined,
195
- updates: async function* () { },
202
+ catch: onRejected => result.catch(onRejected),
203
+ finally: onFinally => result.finally(onFinally),
204
+ next: async () => undefined,
205
+ [Symbol.asyncIterator]: async function* () { },
196
206
  cancel: async () => { },
197
207
  [Symbol.asyncDispose]: async () => { },
198
208
  };
199
209
  }
200
- /** `callStream`: the updates and the result as one ordered stream; `break` cancels. */
201
- export async function* callStream(run) {
202
- try {
203
- for await (const u of run.updates())
204
- yield { type: "update", data: u };
205
- yield { type: "result", data: await run.result };
206
- }
207
- finally {
208
- if (run.status === "pending" || run.status === "running")
209
- run.cancel("caller").catch(() => { });
210
- }
211
- }
package/dist/index.d.ts CHANGED
@@ -1,11 +1,10 @@
1
1
  export * from "@synapse-sdk/core";
2
2
  export { Synapse, FIRST_CONNECT_GIVE_UP_MS } from "./synapse.ts";
3
3
  export { defaultReconnectPolicy, exponentialPolicy, fixedPolicy, withMaxAttempts } from "./node.ts";
4
- export { callStream } from "./calls.ts";
5
4
  export { isTasksRefusal } from "./tasks.ts";
6
5
  export { setMoqDebug } from "./moq_log.ts";
7
6
  export type * from "./types.ts";
8
- export { brokerCertHelperUrl, discoverBrokerCertSha256, ensureWebTransport } from "./session.ts";
7
+ export { brokerCertHelperUrl, discoverBrokerCertSha256, ensureWebTransport, namesRoot } from "./session.ts";
9
8
  export { replaceEqualDeep, shallowEqual } from "./util.ts";
10
9
  export { orderAfter, orderBefore, orderBetween, ulid } from "./scene.ts";
11
10
  export { derivedFrameKey, derivedFrameName, materializeFrames, materializeHardpoints, validateDerivedFrame, type MaterializeReport } from "./scene_frames.ts";
package/dist/index.js CHANGED
@@ -2,10 +2,9 @@
2
2
  export * from "@synapse-sdk/core";
3
3
  export { Synapse, FIRST_CONNECT_GIVE_UP_MS } from "./synapse.js";
4
4
  export { defaultReconnectPolicy, exponentialPolicy, fixedPolicy, withMaxAttempts } from "./node.js";
5
- export { callStream } from "./calls.js";
6
5
  export { isTasksRefusal } from "./tasks.js";
7
6
  export { setMoqDebug } from "./moq_log.js";
8
- export { brokerCertHelperUrl, discoverBrokerCertSha256, ensureWebTransport } from "./session.js";
7
+ export { brokerCertHelperUrl, discoverBrokerCertSha256, ensureWebTransport, namesRoot } from "./session.js";
9
8
  export { replaceEqualDeep, shallowEqual } from "./util.js";
10
9
  export { orderAfter, orderBefore, orderBetween, ulid } from "./scene.js";
11
10
  export { derivedFrameKey, derivedFrameName, materializeFrames, materializeHardpoints, validateDerivedFrame } from "./scene_frames.js";
@@ -0,0 +1,37 @@
1
+ import { SynapseError } from "@synapse-sdk/core";
2
+ import type { Discovery, DiscoveryState, FetchToken } from "./types.ts";
3
+ import type { Cell } from "./util.ts";
4
+ /** The key server a node asks when `setAuth` names none. */
5
+ export declare const DEFAULT_AUTH_URL = "https://e9e413af.synapse.build";
6
+ /** The discovery value with no credential set, or just after `setAuth` (a new object each time: a change). */
7
+ export declare const idleDiscovery: () => DiscoveryState;
8
+ /** The refusal when no credential is set or the sign-in gives none. */
9
+ export declare const notSignedIn: () => SynapseError;
10
+ /** `GET /discover`'s body as the `Discovery` value; `undefined` when it does not have that shape. */
11
+ export declare function discoveryOf(body: unknown): Discovery | undefined;
12
+ /**
13
+ * One `setAuth(fetchToken, { authUrl })` of one node: the key server's client and the caches it fills. A new one at every
14
+ * `setAuth` (the old one's caches go with it). It writes the node's `discovery` value only while it is the node's
15
+ * current one (`current`), so an answer arriving after a `setAuth` changes nothing.
16
+ */
17
+ export declare class Auth {
18
+ #private;
19
+ private readonly fetchToken;
20
+ private readonly discovery;
21
+ private readonly current;
22
+ constructor(fetchToken: FetchToken, authUrl: string, discovery: Cell<DiscoveryState>, current: (a: Auth) => boolean);
23
+ /** `GET /discover`, setting the value (`pending`, then `success` or `error`, the last data kept); one at a time: a call while one is out joins it. */
24
+ discover(): Promise<Discovery>;
25
+ /** The discover answer: the value's when it is `success` (`cached`), else fetched now. */
26
+ known(): Promise<{
27
+ discovery: Discovery;
28
+ cached: boolean;
29
+ }>;
30
+ /** The token for cluster `id`: the kept one while it does not expire within `FRESH_FOR_S` (`cached`), else `POST /clusters/{id}/token`, kept. */
31
+ clusterToken(id: string): Promise<{
32
+ token: string;
33
+ cached: boolean;
34
+ }>;
35
+ /** Forgets the token of cluster `id` (the relay refused it). */
36
+ dropToken(id: string): void;
37
+ }
@@ -0,0 +1,146 @@
1
+ // The key server for a signed-in person (PLAN v0.38 §3.3): what a node set with `setAuth` asks it, `GET /discover`
2
+ // (the relay and the person's clusters with their runtimes) and `POST /clusters/{id}/token` (a cluster token), with
3
+ // the person's credential fetched at each request and sent only in the `Authorization` header. Both answers are kept
4
+ // in memory, per node, until the next `setAuth`: the discover answer as the node's `discovery` state value, the cluster
5
+ // tokens by cluster id while fresh (`FRESH_FOR_S`). Nothing is written anywhere else; no token is ever put in a URL, an
6
+ // error or a log line.
7
+ import { SynapseError } from "@synapse-sdk/core";
8
+ import { describeError } from "./session.js";
9
+ import { FRESH_FOR_S, knownOfListed, label } from "./target.js";
10
+ /** The key server a node asks when `setAuth` names none. */
11
+ export const DEFAULT_AUTH_URL = "https://e9e413af.synapse.build";
12
+ /** The discovery value with no credential set, or just after `setAuth` (a new object each time: a change). */
13
+ export const idleDiscovery = () => ({ status: "idle" });
14
+ /** The refusal when no credential is set or the sign-in gives none. */
15
+ export const notSignedIn = () => new SynapseError("unauthorized", { message: "not signed in: sign in first", source: "sdk" });
16
+ const isTable = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
17
+ /** An answer that is not what the key server says it sends: `connection_lost` (retried; never a caller's mistake). */
18
+ const unreadable = (what) => new SynapseError("connection_lost", { message: `the key server's answer to ${what} could not be read`, source: "sdk" });
19
+ /** `GET /discover`'s body as the `Discovery` value; `undefined` when it does not have that shape. */
20
+ export function discoveryOf(body) {
21
+ if (!isTable(body) || typeof body.relay !== "string" || typeof body.user_id !== "string" || !Array.isArray(body.clusters))
22
+ return undefined;
23
+ const clusters = [];
24
+ for (const c of body.clusters) {
25
+ if (!isTable(c) || typeof c.id !== "string" || typeof c.name !== "string" || !isTable(c.owner) || !isTable(c.runtimes))
26
+ return undefined;
27
+ const o = c.owner;
28
+ if (typeof o.kind !== "string" || typeof o.id !== "string" || (o.slug !== undefined && o.slug !== null && typeof o.slug !== "string"))
29
+ return undefined;
30
+ const owner = typeof o.slug === "string" ? { kind: o.kind, id: o.id, slug: o.slug } : { kind: o.kind, id: o.id };
31
+ const runtimes = {};
32
+ for (const [name, id] of Object.entries(c.runtimes)) {
33
+ if (typeof id !== "string")
34
+ return undefined;
35
+ runtimes[name] = id;
36
+ }
37
+ const listed = { id: c.id, name: c.name, owner };
38
+ clusters.push({ ...listed, label: label(knownOfListed(listed)), runtimes });
39
+ }
40
+ return { relay: body.relay, userId: body.user_id, clusters };
41
+ }
42
+ /**
43
+ * One `setAuth(fetchToken, { authUrl })` of one node: the key server's client and the caches it fills. A new one at every
44
+ * `setAuth` (the old one's caches go with it). It writes the node's `discovery` value only while it is the node's
45
+ * current one (`current`), so an answer arriving after a `setAuth` changes nothing.
46
+ */
47
+ export class Auth {
48
+ fetchToken;
49
+ discovery;
50
+ current;
51
+ #tokens = new Map();
52
+ #pending;
53
+ #authUrl;
54
+ constructor(fetchToken, authUrl, discovery, current) {
55
+ this.fetchToken = fetchToken;
56
+ this.discovery = discovery;
57
+ this.current = current;
58
+ this.#authUrl = authUrl.replace(/\/+$/, "");
59
+ }
60
+ #set(f) { if (this.current(this))
61
+ this.discovery.set(f(this.discovery.get())); }
62
+ /** `GET /discover`, setting the value (`pending`, then `success` or `error`, the last data kept); one at a time: a call while one is out joins it. */
63
+ discover() {
64
+ return this.#pending ??= (async () => {
65
+ this.#set(p => (p.data ? { status: "pending", data: p.data } : { status: "pending" }));
66
+ try {
67
+ const d = discoveryOf(await this.#request("GET", "/discover"));
68
+ if (!d)
69
+ throw unreadable("discover");
70
+ this.#set(() => ({ status: "success", data: d }));
71
+ return d;
72
+ }
73
+ catch (e) {
74
+ const err = e instanceof SynapseError ? e : new SynapseError("internal", { message: describeError(e), source: "sdk" });
75
+ this.#set(p => (p.data ? { status: "error", data: p.data, error: err } : { status: "error", error: err }));
76
+ throw err;
77
+ }
78
+ finally {
79
+ this.#pending = undefined;
80
+ }
81
+ })();
82
+ }
83
+ /** The discover answer: the value's when it is `success` (`cached`), else fetched now. */
84
+ async known() {
85
+ const v = this.discovery.get();
86
+ if (this.current(this) && v.status === "success" && v.data)
87
+ return { discovery: v.data, cached: true };
88
+ return { discovery: await this.discover(), cached: false };
89
+ }
90
+ /** The token for cluster `id`: the kept one while it does not expire within `FRESH_FOR_S` (`cached`), else `POST /clusters/{id}/token`, kept. */
91
+ async clusterToken(id) {
92
+ const kept = this.#tokens.get(id);
93
+ if (kept && kept.exp > Math.floor(Date.now() / 1000) + FRESH_FOR_S)
94
+ return { token: kept.token, cached: true };
95
+ const body = await this.#request("POST", `/clusters/${encodeURIComponent(id)}/token`);
96
+ const exp = isTable(body) && typeof body.expires_at === "string" ? Date.parse(body.expires_at) : NaN;
97
+ if (!isTable(body) || typeof body.token !== "string" || body.token === "" || Number.isNaN(exp))
98
+ throw unreadable("a cluster token request");
99
+ this.#tokens.set(id, { token: body.token, exp: Math.floor(exp / 1000) });
100
+ return { token: body.token, cached: false };
101
+ }
102
+ /** Forgets the token of cluster `id` (the relay refused it). */
103
+ dropToken(id) { this.#tokens.delete(id); }
104
+ /**
105
+ * One request with the credential fetched now. The key server's answers (PLAN v0.38 §3.3.5): `401` `unauthorized`
106
+ * (and every cache dropped), `400`/`403`/`404` `invalid_input`, anything else not 2xx, or no answer, `connection_lost`;
107
+ * each with the key server's own sentence when it sent one.
108
+ */
109
+ async #request(method, path) {
110
+ let credential;
111
+ try {
112
+ credential = await this.fetchToken();
113
+ }
114
+ catch (e) {
115
+ throw new SynapseError("connection_lost", { message: `signing in gave no credential: ${describeError(e)}`, source: "sdk" });
116
+ }
117
+ if (!credential)
118
+ throw notSignedIn();
119
+ let res;
120
+ try {
121
+ res = await globalThis.fetch(`${this.#authUrl}${path}`, { method, headers: { Authorization: `Bearer ${credential}` } });
122
+ }
123
+ catch (e) {
124
+ throw new SynapseError("connection_lost", { message: `the key server could not be reached: ${describeError(e)}`, source: "transport" });
125
+ }
126
+ let body;
127
+ try {
128
+ body = JSON.parse(await res.text());
129
+ }
130
+ catch {
131
+ body = undefined;
132
+ }
133
+ if (res.ok)
134
+ return body;
135
+ const sentence = isTable(body) && typeof body.error === "string" && body.error !== "" ? body.error : `the key server answered ${res.status}`;
136
+ if (res.status === 401) {
137
+ const err = new SynapseError("unauthorized", { message: sentence, source: "sdk" });
138
+ this.#tokens.clear();
139
+ this.#set(p => (p.status === "success" ? { status: "error", data: p.data, error: err } : p));
140
+ throw err;
141
+ }
142
+ if (res.status === 400 || res.status === 403 || res.status === 404)
143
+ throw new SynapseError("invalid_input", { message: sentence, source: "sdk" });
144
+ throw new SynapseError("connection_lost", { message: sentence, source: "sdk" });
145
+ }
146
+ }
package/dist/node.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { SynapseError, type ClientType, type ErrorBody, type Identity, type TimeDomain } from "@synapse-sdk/core";
2
+ import { Auth } from "./keyserver.ts";
2
3
  import { Session, type Dial, type SessionKnobs } from "./session.ts";
3
- import type { ConnectOptions, ConnectTarget, ConnectionState, Notice, ReconnectPolicy } from "./types.ts";
4
+ import type { ConnectOptions, ConnectTarget, ConnectionState, DiscoveryState, FetchToken, Notice, ReconnectPolicy } from "./types.ts";
4
5
  import { Cell, type Deferred } from "./util.ts";
5
6
  /** `initial × 2^(attempt−1)` capped at `max`, times an equal jitter U(0.5, 1); never gives up. */
6
7
  export declare function exponentialPolicy(initialMs: number, maxMs: number): ReconnectPolicy;
@@ -108,6 +109,13 @@ export declare class Node {
108
109
  readonly projectNotices: Set<(n: Notice) => void>;
109
110
  /** Resolves once the node has ended for good: `close()`, and nothing else. */
110
111
  readonly ended: Deferred<void>;
112
+ /**
113
+ * The signed-in person's key server (`setAuth`), asked when a target named by name is dialed (PLAN v0.38 §3.2);
114
+ * `undefined`: nobody is signed in. Read at each attempt, never by a live session: signing out ends no session.
115
+ */
116
+ auth: Auth | undefined;
117
+ /** The key server's answer about the signed-in person, kept by `auth` (`node.discovery`). */
118
+ readonly discovery: Cell<DiscoveryState>;
111
119
  epochs: number;
112
120
  reconnects: number;
113
121
  callsSent: number;
@@ -118,6 +126,8 @@ export declare class Node {
118
126
  finalDom?: Promise<TimeDomain>;
119
127
  });
120
128
  get isEnded(): boolean;
129
+ /** `setAuth`: a new key-server client for `fetchToken` (none: signed out); what the previous one kept is forgotten and `discovery` is `idle` again. */
130
+ setAuth(fetchToken: FetchToken | undefined, authUrl: string): void;
121
131
  get isConnected(): boolean;
122
132
  /** 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
133
  failedOn(generation: number): boolean;
@@ -161,6 +171,13 @@ export declare class Node {
161
171
  /** Starts the loop (once the time domain is final). `first` is a `connect()` call's reply. `signals`: close on SIGINT/SIGTERM (Bun/Node). */
162
172
  run(stand: Stand, first: Reply | undefined, signals: boolean): void;
163
173
  }
174
+ /**
175
+ * Dials `dial`, a target named as the CLI names it, through the key server of `auth` (`resolveViaKeyserver`), with `open`.
176
+ * When the relay refuses (`unauthorized`) a cluster token that came from the cache, that token is dropped, one is asked
177
+ * again and dialed once more within this attempt (a token the relay stopped taking before its expiry costs one request,
178
+ * not a `failed`); a second refusal is the attempt's. A token given as such is dialed as it is.
179
+ */
180
+ export declare function dialByName<T>(dial: Dial, auth: Auth, open: (at: Dial) => Promise<T>): Promise<T>;
164
181
  /** Resolves the identity a node declares (`resolve_identity`): a missing name is minted, a `web`/`hmi` node without a `user_id` mints one. */
165
182
  export declare function resolveIdentity(o: {
166
183
  type: ClientType;
@@ -168,7 +185,12 @@ export declare function resolveIdentity(o: {
168
185
  userId?: string;
169
186
  credential?: string;
170
187
  }): Identity;
171
- /** Resolves a `ConnectTarget` against the node's current identity (fields left out are kept). */
188
+ /**
189
+ * Resolves a `ConnectTarget` against the node's current identity (fields left out are kept). A url that is not a URL is a
190
+ * target as the CLI names it (`infra-1@devel`): its grammar is checked now, and it is resolved at every attempt (through
191
+ * the key server when a person is signed in, else from the CLI's files); with neither (a browser, nobody signed in) it is
192
+ * refused at once.
193
+ */
172
194
  export declare function resolveTarget(node: Node, t: ConnectTarget): Dial;
173
195
  /** A given broker url that is not a URL is the caller's mistake: `invalid_input`. */
174
196
  export declare function checkUrl(url: string): void;
package/dist/node.js CHANGED
@@ -8,6 +8,8 @@
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 { BROWSER_REFUSAL, canReadFiles, isUrl, parseTargetStrict, resolveStr, resolveViaKeyserver } from "./target.js";
12
+ import { Auth, idleDiscovery } from "./keyserver.js";
11
13
  import { Session, closeError, defaultMaxDatagram, discoverBrokerCertSha256, makeDial, openSession, urlOrEndpoint } from "./session.js";
12
14
  import { startOn } from "./calls.js";
13
15
  import { AsyncQueue, Cell, deferred } from "./util.js";
@@ -84,6 +86,13 @@ export class Node {
84
86
  /** The loop's mailbox: unbounded, so no `close()` or target change is ever dropped. */
85
87
  #commands = new AsyncQueue(Infinity);
86
88
  #monitors = new Set();
89
+ /**
90
+ * The signed-in person's key server (`setAuth`), asked when a target named by name is dialed (PLAN v0.38 §3.2);
91
+ * `undefined`: nobody is signed in. Read at each attempt, never by a live session: signing out ends no session.
92
+ */
93
+ auth;
94
+ /** The key server's answer about the signed-in person, kept by `auth` (`node.discovery`). */
95
+ discovery = new Cell(idleDiscovery());
87
96
  epochs = 0;
88
97
  reconnects = 0;
89
98
  callsSent = 0;
@@ -98,6 +107,11 @@ export class Node {
98
107
  this.prepared = opts.finalDom ? opts.finalDom.then(d => { this.dom = d; }) : Promise.resolve();
99
108
  }
100
109
  get isEnded() { return this.#endedFlag; }
110
+ /** `setAuth`: a new key-server client for `fetchToken` (none: signed out); what the previous one kept is forgotten and `discovery` is `idle` again. */
111
+ setAuth(fetchToken, authUrl) {
112
+ this.auth = fetchToken ? new Auth(fetchToken, authUrl, this.discovery, a => this.auth === a) : undefined;
113
+ this.discovery.set(idleDiscovery());
114
+ }
101
115
  get isConnected() { return this.link.get().status === "connected"; }
102
116
  /** 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
117
  failedOn(generation) { const l = this.link.get(); return l.status === "failed" && l.generation === generation; }
@@ -328,9 +342,19 @@ async function runLoop(node, commands, stand, first) {
328
342
  if (ac.signal.aborted)
329
343
  return { kind: "opened", result: new SynapseError("canceled", "superseded") };
330
344
  try {
331
- // The pin: given, else discovered per attempt (a restarted dev broker has a fresh certificate), else unpinned.
332
- const cert = dial.cert ?? await discoverBrokerCertSha256(dial.url, { signal: ac.signal });
333
- return { kind: "opened", result: await openSession(node.knobs, { ...dial, cert }, node.dom.id, ac.signal) };
345
+ const open = async (at) => {
346
+ // The pin: given, else discovered per attempt (a restarted dev broker has a fresh certificate), else unpinned.
347
+ const cert = at.cert ?? await discoverBrokerCertSha256(at.url, { signal: ac.signal });
348
+ return openSession(node.knobs, { ...at, cert }, node.dom.id, ac.signal);
349
+ };
350
+ // A URL is dialed as it is. A target named as the CLI names it is resolved now, by one source chosen per attempt
351
+ // (PLAN v0.38 §3.2): the key server when a person is signed in (`setAuth`), else the CLI's files (a token the
352
+ // CLI refreshed since the last attempt is this attempt's). A token given as such wins.
353
+ const auth = node.auth;
354
+ const session = isUrl(dial.url) ? await open(dial)
355
+ : auth ? await dialByName(dial, auth, open)
356
+ : await resolveStr(dial.url).then(r => open({ ...dial, url: r.url, token: dial.token ?? r.token }));
357
+ return { kind: "opened", result: session };
334
358
  }
335
359
  catch (e) {
336
360
  return { kind: "opened", result: e instanceof SynapseError ? e : new SynapseError("connection_lost", { message: String(e), cause: e }) };
@@ -476,6 +500,26 @@ async function runLoop(node, commands, stand, first) {
476
500
  }
477
501
  }
478
502
  }
503
+ /**
504
+ * Dials `dial`, a target named as the CLI names it, through the key server of `auth` (`resolveViaKeyserver`), with `open`.
505
+ * When the relay refuses (`unauthorized`) a cluster token that came from the cache, that token is dropped, one is asked
506
+ * again and dialed once more within this attempt (a token the relay stopped taking before its expiry costs one request,
507
+ * not a `failed`); a second refusal is the attempt's. A token given as such is dialed as it is.
508
+ */
509
+ export async function dialByName(dial, auth, open) {
510
+ const r = await resolveViaKeyserver(dial.url, auth);
511
+ const at = { ...dial, url: r.url, token: dial.token ?? r.token };
512
+ try {
513
+ return await open(at);
514
+ }
515
+ catch (e) {
516
+ if (!(e instanceof SynapseError) || e.code !== "unauthorized" || dial.token !== undefined || !r.cached || r.cluster === undefined)
517
+ throw e;
518
+ auth.dropToken(r.cluster);
519
+ const { token } = await auth.clusterToken(r.cluster);
520
+ return open({ ...at, token });
521
+ }
522
+ }
479
523
  /** 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). */
480
524
  async function teardown(node, s, next) {
481
525
  node.setLink(next);
@@ -495,9 +539,25 @@ export function resolveIdentity(o) {
495
539
  id.credential = o.credential;
496
540
  return id;
497
541
  }
498
- /** Resolves a `ConnectTarget` against the node's current identity (fields left out are kept). */
542
+ /**
543
+ * Resolves a `ConnectTarget` against the node's current identity (fields left out are kept). A url that is not a URL is a
544
+ * target as the CLI names it (`infra-1@devel`): its grammar is checked now, and it is resolved at every attempt (through
545
+ * the key server when a person is signed in, else from the CLI's files); with neither (a browser, nobody signed in) it is
546
+ * refused at once.
547
+ */
499
548
  export function resolveTarget(node, t) {
500
- checkUrl(t.url);
549
+ if (isUrl(t.url))
550
+ checkUrl(t.url);
551
+ else {
552
+ if (!node.auth && !canReadFiles())
553
+ throw new SynapseError("invalid_input", { message: BROWSER_REFUSAL, source: "sdk" });
554
+ try {
555
+ parseTargetStrict(t.url);
556
+ }
557
+ catch (e) {
558
+ throw new SynapseError("invalid_input", { message: `${e instanceof Error ? e.message : String(e)}, or a URL`, source: "sdk" });
559
+ }
560
+ }
501
561
  const cur = node.target.get().identity;
502
562
  if (t.name !== undefined && !isValidClientName(t.name))
503
563
  throw new SynapseError("invalid_input", `name ${JSON.stringify(t.name)} is not [a-z0-9_-]{1,64}`);
package/dist/scene.d.ts CHANGED
@@ -132,8 +132,10 @@ export declare class Mirror {
132
132
  epoch: string | undefined;
133
133
  version: number;
134
134
  hash: string;
135
- /** An authored diff skipped a version; cleared by the next snapshot. */
136
- gap: boolean;
135
+ /** An authored diff skipped a version (not applied; the track is read again from its snapshot): the highest version seen then. Cleared once the copy is back at it (PLAN v0.34 §2.2). */
136
+ gap: number | undefined;
137
+ /** The live track's first keyframe of the incarnation is in the copy. */
138
+ liveKeyed: boolean;
137
139
  pending: number;
138
140
  authored: Entities;
139
141
  live: LiveTable;
@@ -146,12 +148,16 @@ export declare class Mirror {
146
148
  dropHandle(id: number): void;
147
149
  /** The hierarchy now (recomputed only after a change of the ids, a parent or an order; the same object while equal). */
148
150
  tree(): SceneTree;
149
- /** A snapshot of incarnation `epoch` replaces the authored copy. Not yet `attached`: the reader catches up first (`attach`). */
151
+ /** A snapshot of incarnation `epoch` replaces the authored copy. The first one is not yet `attached`: that is the task's, once the copy is at the version the open answered. */
150
152
  applySnapshot(epoch: string, s: Snapshot): void;
151
- /** The reader has applied everything this node held when it bound the incarnation. */
153
+ /** The copy is current as of the attach (the task checked it): `attached`. */
152
154
  attach(): void;
153
- /** One applied batch: its ops folded into the authored copy. */
154
- applyDiff(d: Diff, warn: Warn): void;
155
+ /**
156
+ * One applied batch: its ops folded into the authored copy, when it is the next version. Otherwise it is not applied
157
+ * (`false`): a gap, `synced` false until the copy is back at the version seen; the reader reads the track again from
158
+ * its snapshot (PLAN v0.34 §2.2).
159
+ */
160
+ applyDiff(d: Diff, warn: Warn): boolean;
155
161
  /** A keyframe replaces the live table. */
156
162
  applyKeyframe(k: LiveFrame): void;
157
163
  /** One tick folded into the live table; its removals go to the feeds. */
@@ -177,18 +183,22 @@ type Event = {
177
183
  } | {
178
184
  kind: "nodeEnded";
179
185
  }
186
+ /** The answer of `synapse/scene/open` of attempt `attempt`. */
187
+ | {
188
+ kind: "opened";
189
+ attempt: number;
190
+ result: Result<unknown>;
191
+ }
180
192
  /** The reader applied an authored frame of incarnation `epoch`: the mirror is at `version`. */
181
193
  | {
182
194
  kind: "applied";
183
195
  epoch: string;
184
196
  version: number;
185
197
  }
186
- /** Reading `attempt` caught up with incarnation `epoch` at `version`: the task binds the claims, then attaches the handle. */
198
+ /** The reader applied the live track's first keyframe of incarnation `epoch`. */
187
199
  | {
188
- kind: "attach";
189
- attempt: number;
200
+ kind: "liveKeyed";
190
201
  epoch: string;
191
- version: number;
192
202
  }
193
203
  /** The reader of attempt `attempt` ended: the incarnation it bound and its final frame's reason (none: the tracks or the session went). */
194
204
  | {
@@ -385,7 +395,8 @@ export declare class SceneHandleImpl implements SceneHandle {
385
395
  }
386
396
  /**
387
397
  * The active project's scene (one per project; one task per node, shared by every handle). Resolves
388
- * once the handle is attached, or at once `detached` when the runtime says no project is active; the
398
+ * once the handle is attached (the copy at the version `synapse/scene/open` answered, or past it), or at
399
+ * once `detached` when the runtime says no project is active; the
389
400
  * handle then follows the incarnations as they come and go. Without a session (before the first, between
390
401
  * two) the task waits for the next one, like after a drop.
391
402
  */