@synapse-sdk/client 0.3.6 → 0.3.8
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/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/keyserver.d.ts +37 -0
- package/dist/keyserver.js +146 -0
- package/dist/node.d.ts +24 -2
- package/dist/node.js +65 -5
- package/dist/scene.d.ts +20 -5
- package/dist/scene.js +48 -11
- package/dist/session.d.ts +12 -2
- package/dist/session.js +38 -13
- package/dist/synapse.d.ts +32 -2
- package/dist/synapse.js +47 -6
- package/dist/target.d.ts +113 -0
- package/dist/target.js +377 -0
- package/dist/types.d.ts +99 -16
- package/package.json +4 -3
package/dist/index.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export { defaultReconnectPolicy, exponentialPolicy, fixedPolicy, withMaxAttempts
|
|
|
4
4
|
export { isTasksRefusal } from "./tasks.ts";
|
|
5
5
|
export { setMoqDebug } from "./moq_log.ts";
|
|
6
6
|
export type * from "./types.ts";
|
|
7
|
-
export { brokerCertHelperUrl, discoverBrokerCertSha256, ensureWebTransport } from "./session.ts";
|
|
7
|
+
export { brokerCertHelperUrl, discoverBrokerCertSha256, ensureWebTransport, namesRoot } from "./session.ts";
|
|
8
8
|
export { replaceEqualDeep, shallowEqual } from "./util.ts";
|
|
9
9
|
export { orderAfter, orderBefore, orderBetween, ulid } from "./scene.ts";
|
|
10
10
|
export { derivedFrameKey, derivedFrameName, materializeFrames, materializeHardpoints, validateDerivedFrame, type MaterializeReport } from "./scene_frames.ts";
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@ export { Synapse, FIRST_CONNECT_GIVE_UP_MS } from "./synapse.js";
|
|
|
4
4
|
export { defaultReconnectPolicy, exponentialPolicy, fixedPolicy, withMaxAttempts } from "./node.js";
|
|
5
5
|
export { isTasksRefusal } from "./tasks.js";
|
|
6
6
|
export { setMoqDebug } from "./moq_log.js";
|
|
7
|
-
export { brokerCertHelperUrl, discoverBrokerCertSha256, ensureWebTransport } from "./session.js";
|
|
7
|
+
export { brokerCertHelperUrl, discoverBrokerCertSha256, ensureWebTransport, namesRoot } from "./session.js";
|
|
8
8
|
export { replaceEqualDeep, shallowEqual } from "./util.js";
|
|
9
9
|
export { orderAfter, orderBefore, orderBetween, ulid } from "./scene.js";
|
|
10
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
|
-
/**
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
@@ -3,7 +3,7 @@ import { SynapseError, type ErrorBody, type ProjectSnapshot } from "@synapse-sdk
|
|
|
3
3
|
import type { Node } from "./node.ts";
|
|
4
4
|
import { StreamWriter, type Session } from "./session.ts";
|
|
5
5
|
import type { Warn } from "./topics.ts";
|
|
6
|
-
import type { LiveJoints, LivePose, LiveRemoved, LiveState, Quat, SceneAuthored, SceneDiffOp, SceneEntity, SceneEntityLive, SceneEntityRef, SceneEntityView, SceneHandle, SceneKind, SceneLive, SceneOp,
|
|
6
|
+
import type { LiveJoints, LivePose, LiveRemoved, LiveState, Quat, SceneAuthored, SceneDiffOp, SceneEntity, SceneEntityLive, SceneEntityRef, SceneEntityView, SceneHandle, SceneKind, SceneLive, SceneOp, SceneSpawnOptions, SceneState, SceneStatus, SceneTree, SceneTxn, SceneView, Vec3 } from "./types.ts";
|
|
7
7
|
import { AsyncQueue, type Deferred, type StateValue } from "./util.ts";
|
|
8
8
|
/** How many batches a feed may fall behind before it is ended (the store's `STORE_FEED_CAP`). */
|
|
9
9
|
export declare const SCENE_FEED_CAP = 256;
|
|
@@ -12,6 +12,9 @@ export declare const PATCH_PROTECTED: readonly string[];
|
|
|
12
12
|
type Entities = Map<string, SceneEntity>;
|
|
13
13
|
/** An entity named by its id or by a view of it (the id the view names now). */
|
|
14
14
|
type EntityArg = string | SceneEntityView;
|
|
15
|
+
/** A patch's `set` and `reparent`'s options, as the public interfaces take them. */
|
|
16
|
+
type PatchSet = Parameters<SceneTxn["patch"]>[1];
|
|
17
|
+
type ReparentOptions = NonNullable<Parameters<SceneTxn["reparent"]>[2]>;
|
|
15
18
|
/**
|
|
16
19
|
* A fresh entity id: a ULID (26 Crockford base-32 characters) made from a UUIDv7 (48-bit ms, version,
|
|
17
20
|
* 74 random bits, variant), so ids sort by the time they were minted; within one millisecond the
|
|
@@ -30,6 +33,14 @@ export declare function orderBetween(left: string | null | undefined, right: str
|
|
|
30
33
|
* prototype's rule). A cycle cannot come from the runtime; were one there, it is cut where it closes.
|
|
31
34
|
*/
|
|
32
35
|
export declare function treeOf(entities: Readonly<Record<string, SceneEntity>>): SceneTree;
|
|
36
|
+
/**
|
|
37
|
+
* The runtime's patch merge (`merge_set`): merges one level deep; a null deletes the key it names. A top-level null
|
|
38
|
+
* removes the component; an object merges into an object component key by key, a null sub-key removing that sub-key;
|
|
39
|
+
* an object given for a component that is absent or not an object replaces it without its null sub-keys, and nulls
|
|
40
|
+
* alone leave no component (an explicitly given `{}` is kept); any other value replaces. `undefined` is not given (the
|
|
41
|
+
* encoder skips it).
|
|
42
|
+
*/
|
|
43
|
+
export declare function mergeSet(into: Record<string, unknown>, set: Record<string, unknown>): Record<string, unknown>;
|
|
33
44
|
/**
|
|
34
45
|
* One authoring batch being built (`txn`): the ops it sends as one atomic `apply`. Ops on an entity
|
|
35
46
|
* spawned in the same batch fold into its spawn: a patch (of keys a patch may set) or a rename merges
|
|
@@ -41,11 +52,15 @@ export declare class Txn implements SceneTxn {
|
|
|
41
52
|
get ops(): readonly SceneOp[];
|
|
42
53
|
/** Spawns an entity of `kind` under `opts.parent` (default the world root) with the components given (`layer` `authored` when not given, else `calibration`; a live entity is not spawned here, it comes from `scene.live.upsert`); its id is minted here (or `opts.id`, a known one) and returned. No `order` is sent: the runtime appends it after its siblings. */
|
|
43
54
|
spawn(kind: SceneKind, opts: SceneSpawnOptions): string;
|
|
44
|
-
/** Sets components of the entity
|
|
45
|
-
patch(entity: EntityArg, set:
|
|
55
|
+
/** Sets components of the entity: merges one level deep; a null deletes the key it names. */
|
|
56
|
+
patch(entity: EntityArg, set: PatchSet): void;
|
|
46
57
|
rename(entity: EntityArg, name: string): void;
|
|
47
|
-
/**
|
|
48
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Moves the entity under `parent` (`null`: the world root), `before` or `after` a sibling (default: after the last).
|
|
60
|
+
* By default it keeps its world pose (the runtime's default: nothing sent); `preserveWorld: false` sends
|
|
61
|
+
* `preserve_world: false`, and it keeps its local xform.
|
|
62
|
+
*/
|
|
63
|
+
reparent(entity: EntityArg, parent: EntityArg | null, opts?: ReparentOptions): void;
|
|
49
64
|
/** Despawns the entity (`cascade`: with its subtree). One spawned in this batch is cancelled instead, unless it has children spawned in the batch and `cascade` is off (then the runtime decides). */
|
|
50
65
|
despawn(entity: EntityArg, opts?: {
|
|
51
66
|
cascade?: boolean;
|
package/dist/scene.js
CHANGED
|
@@ -231,14 +231,45 @@ export function treeOf(entities) {
|
|
|
231
231
|
return build(null);
|
|
232
232
|
}
|
|
233
233
|
// -------------------------------------------------------------------------------------------- txn
|
|
234
|
-
/**
|
|
235
|
-
|
|
234
|
+
/**
|
|
235
|
+
* The runtime's patch merge (`merge_set`): merges one level deep; a null deletes the key it names. A top-level null
|
|
236
|
+
* removes the component; an object merges into an object component key by key, a null sub-key removing that sub-key;
|
|
237
|
+
* an object given for a component that is absent or not an object replaces it without its null sub-keys, and nulls
|
|
238
|
+
* alone leave no component (an explicitly given `{}` is kept); any other value replaces. `undefined` is not given (the
|
|
239
|
+
* encoder skips it).
|
|
240
|
+
*/
|
|
241
|
+
export function mergeSet(into, set) {
|
|
236
242
|
const out = { ...into };
|
|
237
243
|
for (const [k, v] of Object.entries(set)) {
|
|
238
244
|
if (v === undefined)
|
|
239
245
|
continue;
|
|
246
|
+
if (v === null) {
|
|
247
|
+
delete out[k];
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (!isObject(v)) {
|
|
251
|
+
out[k] = v;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const given = Object.entries(v).filter(([, s]) => s !== undefined);
|
|
240
255
|
const old = out[k];
|
|
241
|
-
|
|
256
|
+
if (isObject(old)) {
|
|
257
|
+
const next = { ...old };
|
|
258
|
+
for (const [s, sv] of given) {
|
|
259
|
+
if (sv === null)
|
|
260
|
+
delete next[s];
|
|
261
|
+
else
|
|
262
|
+
next[s] = sv;
|
|
263
|
+
}
|
|
264
|
+
out[k] = next;
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
const kept = given.filter(([, s]) => s !== null);
|
|
268
|
+
if (kept.length === 0 && given.length > 0)
|
|
269
|
+
delete out[k];
|
|
270
|
+
else
|
|
271
|
+
out[k] = Object.fromEntries(kept);
|
|
272
|
+
}
|
|
242
273
|
}
|
|
243
274
|
return out;
|
|
244
275
|
}
|
|
@@ -264,7 +295,7 @@ export class Txn {
|
|
|
264
295
|
this.#ops.push({ op: "spawn", entity });
|
|
265
296
|
return id;
|
|
266
297
|
}
|
|
267
|
-
/** Sets components of the entity
|
|
298
|
+
/** Sets components of the entity: merges one level deep; a null deletes the key it names. */
|
|
268
299
|
patch(entity, set) {
|
|
269
300
|
const id = idOf(entity);
|
|
270
301
|
// A protected key is the runtime's to refuse: such a patch is sent as it is.
|
|
@@ -287,13 +318,19 @@ export class Txn {
|
|
|
287
318
|
}
|
|
288
319
|
this.#ops.push({ op: "rename", id, name });
|
|
289
320
|
}
|
|
290
|
-
/**
|
|
291
|
-
|
|
321
|
+
/**
|
|
322
|
+
* Moves the entity under `parent` (`null`: the world root), `before` or `after` a sibling (default: after the last).
|
|
323
|
+
* By default it keeps its world pose (the runtime's default: nothing sent); `preserveWorld: false` sends
|
|
324
|
+
* `preserve_world: false`, and it keeps its local xform.
|
|
325
|
+
*/
|
|
326
|
+
reparent(entity, parent, opts = {}) {
|
|
292
327
|
const op = { op: "reparent", id: idOf(entity), parent: parent === null ? null : idOf(parent, "parent") };
|
|
293
|
-
if (
|
|
294
|
-
op.before = idOf(
|
|
295
|
-
else if (
|
|
296
|
-
op.after = idOf(
|
|
328
|
+
if (opts.before !== undefined)
|
|
329
|
+
op.before = idOf(opts.before, "before");
|
|
330
|
+
else if (opts.after !== undefined)
|
|
331
|
+
op.after = idOf(opts.after, "after");
|
|
332
|
+
if (opts.preserveWorld === false)
|
|
333
|
+
op.preserve_world = false;
|
|
297
334
|
this.#ops.push(op);
|
|
298
335
|
}
|
|
299
336
|
/** Despawns the entity (`cascade`: with its subtree). One spawned in this batch is cancelled instead, unless it has children spawned in the batch and `cascade` is off (then the runtime decides). */
|
|
@@ -1972,7 +2009,7 @@ export class SceneHandleImpl {
|
|
|
1972
2009
|
patch: (entity, set) => this.#txn(t => t.patch(entity, set)),
|
|
1973
2010
|
upsert: (kind, opts) => this.#upsert(kind, opts),
|
|
1974
2011
|
rename: (entity, name) => this.#txn(t => t.rename(entity, name)),
|
|
1975
|
-
reparent: (entity, parent,
|
|
2012
|
+
reparent: (entity, parent, opts) => this.#txn(t => t.reparent(entity, parent, opts)),
|
|
1976
2013
|
despawn: (entity, opts) => this.#txn(t => t.despawn(entity, opts)),
|
|
1977
2014
|
setLocked: (entity, locked) => this.#txn(t => t.setLocked(entity, locked)),
|
|
1978
2015
|
});
|
package/dist/session.d.ts
CHANGED
|
@@ -89,14 +89,24 @@ export declare function describeError(e: unknown): string;
|
|
|
89
89
|
* GOAWAY looks like one).
|
|
90
90
|
*/
|
|
91
91
|
export declare function closeError(err: Error | null): SynapseError;
|
|
92
|
-
/**
|
|
92
|
+
/**
|
|
93
|
+
* `https://host[:port]` → `http://host:port/certificate.sha256`, the runtime's plain-HTTP helper on the
|
|
94
|
+
* runtime's own port (443 when the URL names none, as the Rust SDK). Before 2026-10-17 the default
|
|
95
|
+
* port was lost and a relay URL was asked on port 80, which no relay answers: a 2 s stall per attempt.
|
|
96
|
+
*/
|
|
93
97
|
export declare function brokerCertHelperUrl(brokerUrl: string): string;
|
|
98
|
+
/** Whether `url` names a root (`/runtimes/<id>`, `/cluster/<c>/runtimes/<id>`): a relay, a deployment with a real certificate, never asked for a pin (`withRoot` makes the same split). */
|
|
99
|
+
export declare function namesRoot(url: string): boolean;
|
|
94
100
|
/**
|
|
95
101
|
* The runtime's URL when the caller gave none: `SYNAPSE_ENDPOINT` (Bun/Node; the runtime sets it for every task it starts).
|
|
96
102
|
* A browser has no environment: there, as with neither, `invalid_input`.
|
|
97
103
|
*/
|
|
98
104
|
export declare function urlOrEndpoint(url: string | undefined): string;
|
|
99
|
-
/**
|
|
105
|
+
/**
|
|
106
|
+
* The certificate pin when the caller gave none (SDK §13): `SYNAPSE_BROKER_CERT_SHA256` (Bun/Node); else, for a
|
|
107
|
+
* runtime URL (no path: a self-signed certificate, pinned), its helper; a URL that names a root (a relay) is
|
|
108
|
+
* never asked and connects unpinned, against the system's roots. `undefined` = unpinned. Per attempt.
|
|
109
|
+
*/
|
|
100
110
|
export declare function discoverBrokerCertSha256(brokerUrl: string, opts?: {
|
|
101
111
|
timeoutMs?: number;
|
|
102
112
|
signal?: AbortSignal;
|
package/dist/session.js
CHANGED
|
@@ -332,8 +332,20 @@ export function closeError(err) {
|
|
|
332
332
|
return new SynapseError("unauthorized", { message: text.trim(), source: "broker", cause: err });
|
|
333
333
|
return new SynapseError("connection_lost", { message: text.trim() || err.name, source: "transport", cause: err });
|
|
334
334
|
}
|
|
335
|
-
/**
|
|
336
|
-
|
|
335
|
+
/**
|
|
336
|
+
* `https://host[:port]` → `http://host:port/certificate.sha256`, the runtime's plain-HTTP helper on the
|
|
337
|
+
* runtime's own port (443 when the URL names none, as the Rust SDK). Before 2026-10-17 the default
|
|
338
|
+
* port was lost and a relay URL was asked on port 80, which no relay answers: a 2 s stall per attempt.
|
|
339
|
+
*/
|
|
340
|
+
export function brokerCertHelperUrl(brokerUrl) { const u = new URL(brokerUrl); return `http://${u.hostname}:${u.port || (u.protocol === "http:" ? "80" : "443")}/certificate.sha256`; }
|
|
341
|
+
/** Whether `url` names a root (`/runtimes/<id>`, `/cluster/<c>/runtimes/<id>`): a relay, a deployment with a real certificate, never asked for a pin (`withRoot` makes the same split). */
|
|
342
|
+
export function namesRoot(url) { try {
|
|
343
|
+
const p = new URL(url).pathname;
|
|
344
|
+
return p !== "" && p !== "/";
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
return false;
|
|
348
|
+
} }
|
|
337
349
|
/**
|
|
338
350
|
* The runtime's URL when the caller gave none: `SYNAPSE_ENDPOINT` (Bun/Node; the runtime sets it for every task it starts).
|
|
339
351
|
* A browser has no environment: there, as with neither, `invalid_input`.
|
|
@@ -347,22 +359,35 @@ export function urlOrEndpoint(url) {
|
|
|
347
359
|
throw new SynapseError("invalid_input", { message: "no url given and SYNAPSE_ENDPOINT is not set", source: "sdk" });
|
|
348
360
|
}
|
|
349
361
|
const SHA256_HEX = /^[0-9a-f]{64}$/i;
|
|
350
|
-
/**
|
|
362
|
+
/**
|
|
363
|
+
* The certificate pin when the caller gave none (SDK §13): `SYNAPSE_BROKER_CERT_SHA256` (Bun/Node); else, for a
|
|
364
|
+
* runtime URL (no path: a self-signed certificate, pinned), its helper; a URL that names a root (a relay) is
|
|
365
|
+
* never asked and connects unpinned, against the system's roots. `undefined` = unpinned. Per attempt.
|
|
366
|
+
*/
|
|
351
367
|
export async function discoverBrokerCertSha256(brokerUrl, opts = {}) {
|
|
352
368
|
const env = globalThis.process?.env?.SYNAPSE_BROKER_CERT_SHA256?.trim();
|
|
353
369
|
if (env)
|
|
354
370
|
return env;
|
|
355
|
-
|
|
356
|
-
try {
|
|
357
|
-
const res = await fetch(brokerCertHelperUrl(brokerUrl), { signal });
|
|
358
|
-
if (!res.ok)
|
|
359
|
-
return undefined;
|
|
360
|
-
const text = (await res.text()).trim();
|
|
361
|
-
return SHA256_HEX.test(text) ? text : undefined;
|
|
362
|
-
}
|
|
363
|
-
catch {
|
|
371
|
+
if (namesRoot(brokerUrl))
|
|
364
372
|
return undefined;
|
|
365
|
-
|
|
373
|
+
const timeoutMs = opts.timeoutMs ?? 2000;
|
|
374
|
+
const signal = opts.signal ? AbortSignal.any([opts.signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs);
|
|
375
|
+
const fetched = (async () => {
|
|
376
|
+
try {
|
|
377
|
+
const res = await fetch(brokerCertHelperUrl(brokerUrl), { signal });
|
|
378
|
+
if (!res.ok)
|
|
379
|
+
return undefined;
|
|
380
|
+
const text = (await res.text()).trim();
|
|
381
|
+
return SHA256_HEX.test(text) ? text : undefined;
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
return undefined;
|
|
385
|
+
}
|
|
386
|
+
})();
|
|
387
|
+
// Bun does not abort a connect that gets no answer (measured 2026-10-17: a filtered port hung the dial for
|
|
388
|
+
// good); the race bounds the wait whatever the runtime does with the signal.
|
|
389
|
+
const r = await withTimeout(fetched, timeoutMs);
|
|
390
|
+
return r === TIMEOUT ? undefined : r;
|
|
366
391
|
}
|
|
367
392
|
/**
|
|
368
393
|
* `token_from_url`: `url` without its `jwt=` query parameters, and the last non-empty one (as the relays
|