@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/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
- /** `https://host:port/<root>` → `http://host:port/certificate.sha256`, the broker's plain-HTTP helper. */
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
- /** The certificate pin when the caller gave none (SDK §13): `SYNAPSE_BROKER_CERT_SHA256` (Bun/Node), then the helper, then `undefined` = unpinned. Per attempt. */
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
- /** `https://host:port/<root>` → `http://host:port/certificate.sha256`, the broker's plain-HTTP helper. */
336
- export function brokerCertHelperUrl(brokerUrl) { const u = new URL(brokerUrl); return `http://${u.host}/certificate.sha256`; }
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
- /** The certificate pin when the caller gave none (SDK §13): `SYNAPSE_BROKER_CERT_SHA256` (Bun/Node), then the helper, then `undefined` = unpinned. Per attempt. */
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
- const signal = opts.signal ? AbortSignal.any([opts.signal, AbortSignal.timeout(opts.timeoutMs ?? 2000)]) : AbortSignal.timeout(opts.timeoutMs ?? 2000);
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
@@ -429,7 +454,7 @@ export async function withRoot(url, signal) {
429
454
  throw timeout.aborted ? new SynapseError("connection_lost", { message: `root helper at ${addr} did not answer`, source: "transport" }) : lost(describeError(e));
430
455
  }
431
456
  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" });
457
+ 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>/cluster/<cluster>/runtimes/<runtime_id>`, source: "sdk" });
433
458
  }
434
459
  u.pathname = `/${root}`;
435
460
  return u.toString();
package/dist/store.d.ts CHANGED
@@ -9,6 +9,8 @@ export declare const ENDED_CONNECTION_LOST = "connection_lost";
9
9
  export declare const ENDED_CLIENT = "client";
10
10
  /** A watcher this many batches behind is ended, never grown. */
11
11
  export declare const STORE_FEED_CAP = 256;
12
+ /** How long an incarnation the open's reply named has to show up on the announcement cursor (no "live" marker in @moq/net's JS cursor). */
13
+ export declare const ANNOUNCE_WITHIN_MS = 1000;
12
14
  /** The mirror and what the runtime said about the namespace; written by the task and its reader only. */
13
15
  export declare class State {
14
16
  #private;
@@ -24,7 +26,8 @@ export declare class State {
24
26
  onDeath: StoreOnDeath | undefined;
25
27
  absent: boolean;
26
28
  loaded: boolean;
27
- gap: boolean;
29
+ /** A 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). */
30
+ gap: number | undefined;
28
31
  pending: number;
29
32
  ended: string | undefined;
30
33
  subscribe(l: () => void): () => void;
@@ -32,7 +35,11 @@ export declare class State {
32
35
  /** A feed its reader has left: it is closed and gets nothing more. */
33
36
  unwatch(q: AsyncQueue<StoreChange[]>): void;
34
37
  applySnapshot(s: StoreSnapshot): void;
35
- applyDiff(d: StoreDiff, warn: Warn): void;
38
+ /**
39
+ * One diff, applied when it is the next version. Otherwise it is not applied (`false`): a gap, `synced` false until the
40
+ * copy is back at the version seen; the reader reads the track again from its snapshot (PLAN v0.34 §2.2).
41
+ */
42
+ applyDiff(d: StoreDiff, warn: Warn): boolean;
36
43
  /** Nothing of the incarnation is known until the runtime answers the next open. */
37
44
  reset(): void;
38
45
  opened(a: StoreAccess | undefined): void;
@@ -140,11 +147,12 @@ export declare class StoreHandleImpl implements StoreHandle {
140
147
  destroy(): Promise<void>;
141
148
  /** `for await (const value of store.watch())`: the whole value after each change; `store.watch(key)`: that key's value each time it changes. Ends with the handle; leaving the loop removes the feed. */
142
149
  watch(key?: string): AsyncGenerator<unknown, void, undefined>;
143
- /** The whole value after its next change (`store_gone` once this handle is closed). */
150
+ /** The whole value after its next change; `changed(key)`: that key's value after its next change (`undefined` after a delete), as `watch(key)` yields it. `store_gone` once this handle is closed. Armed at the call. */
144
151
  changed(): Promise<Record<string, unknown>>;
152
+ changed(key: string): Promise<unknown>;
145
153
  /** `cb` with the whole value on every change of the value or the status (the framework bridge, React's `useStore`); returns the remover. */
146
154
  onChange(cb: (value: Record<string, unknown>) => void): () => void;
147
- /** Waits for the namespace to be in (the first snapshot applied, or absent). */
155
+ /** Waits for the namespace to be in: the value current as of the open (the version the runtime answered applied), or the namespace absent. */
148
156
  ready(): Promise<void>;
149
157
  close(): void;
150
158
  [Symbol.asyncDispose](): Promise<void>;
package/dist/store.js CHANGED
@@ -21,7 +21,7 @@ const ENDED_STORE_GONE = "store_gone";
21
21
  /** A watcher this many batches behind is ended, never grown. */
22
22
  export const STORE_FEED_CAP = 256;
23
23
  /** How long an incarnation the open's reply named has to show up on the announcement cursor (no "live" marker in @moq/net's JS cursor). */
24
- const ANNOUNCE_WITHIN_MS = 1000;
24
+ export const ANNOUNCE_WITHIN_MS = 1000;
25
25
  const isFinal = (reason) => reason === ENDED_DESTROYED;
26
26
  const gone = (name, why) => new SynapseError("store_gone", `store ${JSON.stringify(name)}: ${why}`);
27
27
  // ------------------------------------------------------------------------------------- state
@@ -39,7 +39,8 @@ export class State {
39
39
  onDeath;
40
40
  absent = false;
41
41
  loaded = false;
42
- gap = false;
42
+ /** A 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). */
43
+ gap;
43
44
  pending = 0;
44
45
  ended;
45
46
  #listeners = new Set();
@@ -92,13 +93,22 @@ export class State {
92
93
  this.onDeath = s.on_death;
93
94
  this.absent = false;
94
95
  this.loaded = true;
95
- this.gap = false;
96
+ this.#heal();
96
97
  this.#emit(records);
97
98
  }
99
+ /** A gap is over once the copy is back at the highest version seen when it opened. */
100
+ #heal() { if (this.gap !== undefined && this.version >= this.gap)
101
+ this.gap = undefined; }
102
+ /**
103
+ * One diff, applied when it is the next version. Otherwise it is not applied (`false`): a gap, `synced` false until the
104
+ * copy is back at the version seen; the reader reads the track again from its snapshot (PLAN v0.34 §2.2).
105
+ */
98
106
  applyDiff(d, warn) {
99
107
  if (d.version !== this.version + 1) {
100
- warn(`store: diff out of sequence (have ${this.version}, got ${d.version}); not synced until the next snapshot`);
101
- this.gap = true;
108
+ warn(`store: diff out of sequence (have ${this.version}, got ${d.version}): not applied; reading the track again from its snapshot`);
109
+ this.gap = Math.max(this.gap ?? d.version, d.version);
110
+ this.#notify();
111
+ return false;
102
112
  }
103
113
  const records = [];
104
114
  const flat = new Map(this.flat);
@@ -119,7 +129,9 @@ export class State {
119
129
  }
120
130
  this.flat = flat;
121
131
  this.version = d.version;
132
+ this.#heal();
122
133
  this.#emit(records);
134
+ return true;
123
135
  }
124
136
  #clear() {
125
137
  const records = [...this.rows].map(([key, r]) => ({ kind: "delete", key, previousValue: r.v, by: undefined }));
@@ -135,7 +147,7 @@ export class State {
135
147
  this.epoch = undefined;
136
148
  this.version = 0;
137
149
  this.loaded = false;
138
- this.gap = false;
150
+ this.gap = undefined;
139
151
  this.mode = undefined;
140
152
  this.owner = undefined;
141
153
  this.mine = false;
@@ -151,7 +163,7 @@ export class State {
151
163
  this.epoch = undefined;
152
164
  this.version = 0;
153
165
  this.loaded = false;
154
- this.gap = false;
166
+ this.gap = undefined;
155
167
  this.mode = a?.mode;
156
168
  this.owner = a?.owner ?? undefined;
157
169
  this.mine = a?.mine ?? false;
@@ -177,7 +189,7 @@ export class State {
177
189
  status(nodeConnected, closed = false) {
178
190
  const ended = closed ? ENDED_CLIENT : this.ended;
179
191
  const connected = ended === undefined && nodeConnected;
180
- return { synced: connected && this.pending === 0 && !this.gap && (this.loaded || this.absent), connected, pending: this.pending, mode: this.mode, owner: this.owner, mine: this.mine, minRole: this.minRole, onDeath: this.onDeath, absent: this.absent, version: this.version, epoch: this.epoch, ended };
192
+ return { synced: connected && this.pending === 0 && this.gap === undefined && (this.loaded || this.absent), connected, pending: this.pending, mode: this.mode, owner: this.owner, mine: this.mine, minRole: this.minRole, onDeath: this.onDeath, absent: this.absent, version: this.version, epoch: this.epoch, ended };
181
193
  }
182
194
  }
183
195
  function sameValue(a, b) {
@@ -686,7 +698,8 @@ function announcedEpoch(base, prefix) {
686
698
  /**
687
699
  * Reads the incarnation the open bound (`want`; `undefined`: absent, the first one announced), writing
688
700
  * the mirror and telling the task of each frame applied; returns how the track ended (`undefined`:
689
- * no final frame — the session, or the broker, went; the task replays).
701
+ * no final frame — the session, or the broker, went; the task replays). A diff out of sequence is not applied: the
702
+ * subscription is let go and the track subscribed again, which delivers the open group from its snapshot (PLAN v0.34 §2.2).
690
703
  */
691
704
  async function follow(ns, session, announced, want, signal) {
692
705
  const { state, warn, name } = ns;
@@ -739,74 +752,86 @@ async function follow(ns, session, announced, want, signal) {
739
752
  return undefined;
740
753
  }
741
754
  announced.close();
742
- sub = bc.track(STORE_TRACK).subscribe(latestSubscription(moqPriority(1)));
743
- const s = sub;
755
+ const track = bc.track(STORE_TRACK);
744
756
  // Closing the subscriber ends `recvGroup`, not a read on the group it already handed us: that group is closed too.
745
757
  let g;
746
- signal.addEventListener("abort", () => { s.close(); g?.close(); }, { once: true });
747
- // The first group read is the open one, from its start: a snapshot, then the diffs.
748
- for (;;) {
749
- g = await s.recvGroup();
750
- if (!g)
751
- break;
758
+ signal.addEventListener("abort", () => { sub?.close(); g?.close(); }, { once: true });
759
+ // A gap: this subscription is let go and the track subscribed again, which delivers the open group from its
760
+ // snapshot (PLAN v0.34 §2.2; no counter, no timer).
761
+ subscribe: for (;;) {
762
+ if (signal.aborted)
763
+ return undefined;
764
+ try {
765
+ sub?.close();
766
+ }
767
+ catch { /* closed */ }
768
+ const s = sub = track.subscribe(latestSubscription(moqPriority(1)));
769
+ // The first group read is the open one, from its start: a snapshot, then the diffs.
752
770
  for (;;) {
753
- let f;
754
- try {
755
- f = await g.readFrame();
756
- }
757
- catch {
758
- break;
759
- }
760
- if (!f)
771
+ g = await s.recvGroup();
772
+ if (!g)
761
773
  break;
762
- let header, hlen;
763
- try {
764
- ({ header, hlen } = decodeHeader(f.payload));
765
- }
766
- catch {
767
- warn(`store ${name}: frame not decodable`);
768
- continue;
769
- }
770
- let body;
771
- try {
772
- body = decodeBody(f.payload.subarray(hlen));
773
- }
774
- catch {
775
- warn(`store ${name}: frame body not decodable`);
776
- continue;
777
- }
778
- if (header.flags & FLAGS.END) {
779
- const fin = body;
780
- return fin && typeof fin.reason === "string" ? fin.reason : ENDED_STORE_GONE;
781
- }
782
- // A stopped reader no longer writes the mirror: it belongs to the next incarnation (or to none).
783
- if (header.flags & FLAGS.KEY) {
784
- const snap = body;
785
- if (!snap || snap.epoch !== epoch) {
786
- warn(`store ${name}: snapshot of another epoch dropped`);
787
- continue;
774
+ for (;;) {
775
+ let f;
776
+ try {
777
+ f = await g.readFrame();
788
778
  }
789
- if (signal.aborted)
790
- continue;
791
- if (state.ended === undefined)
792
- state.applySnapshot(snap);
793
- ns.post({ kind: "applied", epoch, version: snap.version });
794
- }
795
- else if (header.flags & FLAGS.DELTA) {
796
- const diff = body;
797
- if (!diff || typeof diff.version !== "number") {
798
- warn(`store ${name}: diff not decodable`);
779
+ catch {
780
+ break;
781
+ }
782
+ if (!f)
783
+ break;
784
+ let header, hlen;
785
+ try {
786
+ ({ header, hlen } = decodeHeader(f.payload));
787
+ }
788
+ catch {
789
+ warn(`store ${name}: frame not decodable`);
799
790
  continue;
800
791
  }
801
- if (signal.aborted)
792
+ let body;
793
+ try {
794
+ body = decodeBody(f.payload.subarray(hlen));
795
+ }
796
+ catch {
797
+ warn(`store ${name}: frame body not decodable`);
802
798
  continue;
803
- if (state.ended === undefined && state.loaded)
804
- state.applyDiff(diff, warn);
805
- ns.post({ kind: "applied", epoch, version: diff.version });
799
+ }
800
+ if (header.flags & FLAGS.END) {
801
+ const fin = body;
802
+ return fin && typeof fin.reason === "string" ? fin.reason : ENDED_STORE_GONE;
803
+ }
804
+ // A stopped reader no longer writes the mirror: it belongs to the next incarnation (or to none).
805
+ if (header.flags & FLAGS.KEY) {
806
+ const snap = body;
807
+ if (!snap || snap.epoch !== epoch) {
808
+ warn(`store ${name}: snapshot of another epoch dropped`);
809
+ continue;
810
+ }
811
+ if (signal.aborted)
812
+ continue;
813
+ if (state.ended === undefined)
814
+ state.applySnapshot(snap);
815
+ ns.post({ kind: "applied", epoch, version: snap.version });
816
+ }
817
+ else if (header.flags & FLAGS.DELTA) {
818
+ const diff = body;
819
+ if (!diff || typeof diff.version !== "number") {
820
+ warn(`store ${name}: diff not decodable`);
821
+ continue;
822
+ }
823
+ if (signal.aborted)
824
+ continue;
825
+ if (state.ended === undefined && state.loaded && !state.applyDiff(diff, warn)) {
826
+ g.close();
827
+ continue subscribe;
828
+ }
829
+ ns.post({ kind: "applied", epoch, version: diff.version });
830
+ }
806
831
  }
807
832
  }
833
+ return undefined; // the track ended with no final frame: the session (or the broker) went
808
834
  }
809
- return undefined; // the track ended with no final frame: the session (or the broker) went
810
835
  }
811
836
  catch {
812
837
  return undefined;
@@ -902,10 +927,15 @@ export class StoreHandleImpl {
902
927
  this.ns.state.unwatch(q);
903
928
  }
904
929
  }
905
- /** The whole value after its next change (`store_gone` once this handle is closed). */
906
- changed() {
930
+ changed(key) {
907
931
  if (this.#closed)
908
932
  return Promise.reject(this.#ended());
933
+ if (key !== undefined) {
934
+ // the first step of `watch(key)`, started now (its feed opens at the first `next()`), then left
935
+ const it = this.watch(key)[Symbol.asyncIterator]();
936
+ return it.next().then(r => { void it.return(undefined); if (r.done)
937
+ throw this.#ended(); return r.value; });
938
+ }
909
939
  const from = this.get();
910
940
  const d = deferred();
911
941
  const un = this.ns.state.subscribe(() => { const v = this.get(); if (v !== from) {
@@ -919,7 +949,7 @@ export class StoreHandleImpl {
919
949
  }
920
950
  /** `cb` with the whole value on every change of the value or the status (the framework bridge, React's `useStore`); returns the remover. */
921
951
  onChange(cb) { return this.ns.state.subscribe(() => cb(this.get())); }
922
- /** Waits for the namespace to be in (the first snapshot applied, or absent). */
952
+ /** Waits for the namespace to be in: the value current as of the open (the version the runtime answered applied), or the namespace absent. */
923
953
  ready() { return this.ns.request(reply => ({ kind: "join", reply })); }
924
954
  close() {
925
955
  if (this.#closed)
package/dist/synapse.d.ts CHANGED
@@ -2,7 +2,7 @@ import { type Age, type ErrorBody, type ProjectSnapshot, type ServiceContract, t
2
2
  import { Node } from "./node.ts";
3
3
  import { StoreHandleImpl } from "./store.ts";
4
4
  import { type Warn } from "./topics.ts";
5
- import type { CallEvent, CallOptions, Catalog, ConnectOptions, ConnectTarget, ConnectionState, Envelope, Info, Metrics, Publisher, Registration, Run, ServeHandler, ServeOptions, SceneHandle, StoreHandle, StoreOptions, SubscribeOptions, Subscription, Tasks, View, ViewOptions } from "./types.ts";
5
+ import type { CallOptions, Catalog, ConnectOptions, ConnectTarget, ConnectionState, Discovery, DiscoveryState, Envelope, FetchToken, Info, Metrics, Publisher, Registration, Run, ServeHandler, ServeOptions, SceneHandle, StoreHandle, StoreOptions, SubscribeOptions, Subscription, Tasks, View, ViewOptions } from "./types.ts";
6
6
  import { type StateValue } from "./util.ts";
7
7
  export { FIRST_CONNECT_GIVE_UP_MS } from "./node.ts";
8
8
  export declare class Synapse implements AsyncDisposable {
@@ -15,7 +15,8 @@ export declare class Synapse implements AsyncDisposable {
15
15
  */
16
16
  constructor(opts: ConnectOptions);
17
17
  /**
18
- * Connects the node to `target` (a string is the url), the one connect verb: the first connect, and how an HMI moves
18
+ * Connects the node to `target` (a string is the url: a URL, or in Bun/Node a target such as `"infra-1@devel"`, see
19
+ * `ConnectTarget.url`), the one connect verb: the first connect, and how an HMI moves
19
20
  * from one robot's runtime to another. The current session, if any, is torn down before `target` is dialed; every
20
21
  * handle re-binds to the new session as across a reconnect. Identity fields `target` leaves out keep the node's.
21
22
  *
@@ -41,6 +42,30 @@ export declare class Synapse implements AsyncDisposable {
41
42
  * resolves quietly). Resolves once the session is gone; already `idle`: at once. `not_connected` once the node has ended.
42
43
  */
43
44
  disconnect(): Promise<void>;
45
+ /**
46
+ * Signs a person in for connecting by name (`connect("infra-1@devel")`) through the key server: `fetchToken` gives the
47
+ * person's credential and is called each time the key server is asked (never kept); `authUrl` is the key server
48
+ * (default `https://e9e413af.synapse.build`; an `http://` one is taken as it is, for a key server on your own machine while
49
+ * developing, never in production: the credential travels in the header). `setAuth(undefined)` signs out. Either way,
50
+ * what the node kept from the key server (your clusters, the cluster tokens) is forgotten and `discovery` is `idle` again.
51
+ *
52
+ * A live session is never touched: the sign-in is read only when the node dials, so signing out ends nothing now, and
53
+ * the next dial of a target named by name fails `unauthorized` (`failed`). With a person signed in, a target named by
54
+ * name is resolved through the key server also in Bun/Node, instead of the CLI's files. Call it before `connect()`.
55
+ */
56
+ setAuth(fetchToken: FetchToken | undefined, opts?: {
57
+ authUrl?: string;
58
+ }): void;
59
+ /**
60
+ * Asks the key server for the signed-in person's clusters and their runtimes, sets `discovery` from the answer and
61
+ * resolves with it; one request at a time (a call while one is out joins it). Rejects `unauthorized` when nobody is
62
+ * signed in (`setAuth`; `discovery` stays `idle`) or the key server does not take the sign-in, `invalid_input` when it
63
+ * refuses the request, `connection_lost` when it cannot be reached or is busy (its sentence when it gave one). Without
64
+ * a sign-in it does not read the CLI's files.
65
+ */
66
+ discover(): Promise<Discovery>;
67
+ /** What the key server said about the signed-in person (`discover()`), a state value: `get()` now, `onChange` for each change, `changed()` for the next one, `watch()` for each one in a loop. Connecting by name reads the same answer. */
68
+ readonly discovery: StateValue<DiscoveryState>;
44
69
  /** The node's engine (the SDK's own modules and tests use it). */
45
70
  get node(): Node;
46
71
  /** Where the node stands (`ConnectionState`), a state value: `get()` now, `onChange` for each change, `changed()` for the next one, `watch()` for each one in a loop. */
@@ -74,18 +99,15 @@ export declare class Synapse implements AsyncDisposable {
74
99
  /** The topic's latest value as a view (SDK §4.3); subscribed while someone listens. */
75
100
  view<TIn, TOut, S = TOut>(topic: TopicContract<TIn, TOut>, opts?: ViewOptions<TOut, S>): View<S>;
76
101
  view<TOut = unknown, S = TOut>(topic: string, opts?: ViewOptions<TOut, S>): View<S>;
77
- /** Starts a call on the current session (SDK §5.2): `await` it for the result, or read `updates()` first. With no session (before the first, between two) the Run is already failed `not_connected`: nothing was sent. */
102
+ /** Starts a call on the current session (SDK §5.2): `await` it for the result, read its updates (`for await (const u of run)`, `await run.next()`), or pass `onUpdate`. With no session (before the first, between two) the Run is already failed `not_connected`: nothing was sent. */
78
103
  call<TArgs, TInput, U, R>(svc: ServiceContract<TArgs, TInput, U, R>, args: TArgs, opts?: CallOptions<U>): Run<U, R>;
79
104
  call<U = unknown, R = unknown>(svc: string, args?: unknown, opts?: CallOptions<U>): Run<U, R>;
80
- /** The call's updates and result as one ordered stream; `break` cancels. */
81
- callStream<TArgs, TInput, U, R>(svc: ServiceContract<TArgs, TInput, U, R>, args: TArgs, opts?: CallOptions<U>): AsyncGenerator<CallEvent<U, R>, void, undefined>;
82
- callStream<U = unknown, R = unknown>(svc: string, args?: unknown, opts?: CallOptions<U>): AsyncGenerator<CallEvent<U, R>, void, undefined>;
83
105
  /** Registers `handler` as the provider of `svc`; resolves once the runtime lists us on the current session. One provider per service path per node. */
84
106
  serve<TArgs, TInput, U, R>(svc: ServiceContract<TArgs, TInput, U, R>, handler: ServeHandler<TInput, U, R>, opts?: ServeOptions): Promise<Registration>;
85
107
  serve<I = unknown, U = unknown, R = unknown>(svc: string, handler: ServeHandler<I, U, R>, opts?: ServeOptions): Promise<Registration>;
86
108
  /** Opens namespace `name` (SDK §9); resolves once the mirror is in (or the namespace is known absent). */
87
109
  store(name: string, opts?: StoreOptions): Promise<StoreHandle>;
88
- /** The active project's scene (PLAN v0.24 §5); resolves attached, or at once `detached` when no project is active. */
110
+ /** The active project's scene (PLAN v0.24 §5); resolves attached (current as of the attach: at the version the runtime answered), or at once `detached` when no project is active. */
89
111
  scene(): Promise<SceneHandle>;
90
112
  /** The runtime's catalogs as the current session last read them (empty between sessions), a state value: `get()` now, `onChange` for each change, `changed()` for the next one, `watch()` for each one in a loop. */
91
113
  readonly catalog: StateValue<Catalog>;
package/dist/synapse.js CHANGED
@@ -3,8 +3,9 @@
3
3
  // own tasks, told about sessions by the loop; every fact about the world is read from the current
4
4
  // `Session` (the runtime's statement) at the moment it is asked for, never copied here.
5
5
  import { SynapseError, ageOf, fecSymbolSizeFor, isServiceContract, nowUs, resolveTopic, serviceName, } from "@synapse-sdk/core";
6
- import { callStream, failedRun, startOn } from "./calls.js";
6
+ import { failedRun, startOn } from "./calls.js";
7
7
  import { Node, createNode, endpointDial, resolveTarget } from "./node.js";
8
+ import { DEFAULT_AUTH_URL, notSignedIn } from "./keyserver.js";
8
9
  import { ServiceRegistration } from "./serve.js";
9
10
  import { openScene } from "./scene.js";
10
11
  import { Namespace, StoreHandleImpl, attachStore, openStore } from "./store.js";
@@ -25,18 +26,21 @@ function descKey(d) {
25
26
  return JSON.stringify({ delivery: d.delivery, content_type: d.content_type, schema_hash: d.schema_hash ?? null, fec: d.fec ?? null });
26
27
  }
27
28
  /**
28
- * A fact of the node as a state value (`node.status`, `node.catalog`, `node.project`): `read` is the fact now, the same
29
- * object while it is unchanged. It changes with the node's link and, while connected, with the current session's values
30
- * that `watched` names; a callback is rebound to the new session's values on every link change.
29
+ * A fact of the node as a state value (`node.status`, `node.catalog`, `node.project`, `node.discovery`): `read` is the fact
30
+ * now, the same object while it is unchanged. It changes with the node's link, with the node's own values `kept` names,
31
+ * and, while connected, with the current session's values that `watched` names; a callback is rebound to the new
32
+ * session's values on every link change.
31
33
  */
32
34
  class NodeCell {
33
35
  node;
34
36
  read;
35
37
  watched;
36
- constructor(node, read, watched) {
38
+ kept;
39
+ constructor(node, read, watched, kept = []) {
37
40
  this.node = node;
38
41
  this.read = read;
39
42
  this.watched = watched;
43
+ this.kept = kept;
40
44
  }
41
45
  get() { return this.read(); }
42
46
  /** `cb` with the value on each change of what it is read from; returns the remover. */
@@ -51,7 +55,9 @@ class NodeCell {
51
55
  };
52
56
  bind();
53
57
  const unLink = this.node.link.subscribe(() => { bind(); listener(); });
54
- return () => { unLink(); for (const u of un)
58
+ const unKept = this.kept.map(c => c.subscribe(listener));
59
+ return () => { unLink(); for (const u of unKept)
60
+ u(); for (const u of un)
55
61
  u(); };
56
62
  }
57
63
  /** The value after its next change; once the node ends, the value then, whether or not it changed (nothing will change any more). */
@@ -104,9 +110,11 @@ export class Synapse {
104
110
  return lastCatalog;
105
111
  }, s => [s.topics, s.services]);
106
112
  this.project = new NodeCell(n, () => n.currentSession()?.project.get(), s => [s.project]);
113
+ this.discovery = new NodeCell(n, () => n.discovery.get(), () => [], [n.discovery]);
107
114
  }
108
115
  /**
109
- * Connects the node to `target` (a string is the url), the one connect verb: the first connect, and how an HMI moves
116
+ * Connects the node to `target` (a string is the url: a URL, or in Bun/Node a target such as `"infra-1@devel"`, see
117
+ * `ConnectTarget.url`), the one connect verb: the first connect, and how an HMI moves
110
118
  * from one robot's runtime to another. The current session, if any, is torn down before `target` is dialed; every
111
119
  * handle re-binds to the new session as across a reconnect. Identity fields `target` leaves out keep the node's.
112
120
  *
@@ -149,6 +157,34 @@ export class Synapse {
149
157
  * resolves quietly). Resolves once the session is gone; already `idle`: at once. `not_connected` once the node has ended.
150
158
  */
151
159
  disconnect() { return this.#node.declare(undefined); }
160
+ // -------------------------------------------------------------------------------- sign-in
161
+ /**
162
+ * Signs a person in for connecting by name (`connect("infra-1@devel")`) through the key server: `fetchToken` gives the
163
+ * person's credential and is called each time the key server is asked (never kept); `authUrl` is the key server
164
+ * (default `https://e9e413af.synapse.build`; an `http://` one is taken as it is, for a key server on your own machine while
165
+ * developing, never in production: the credential travels in the header). `setAuth(undefined)` signs out. Either way,
166
+ * what the node kept from the key server (your clusters, the cluster tokens) is forgotten and `discovery` is `idle` again.
167
+ *
168
+ * A live session is never touched: the sign-in is read only when the node dials, so signing out ends nothing now, and
169
+ * the next dial of a target named by name fails `unauthorized` (`failed`). With a person signed in, a target named by
170
+ * name is resolved through the key server also in Bun/Node, instead of the CLI's files. Call it before `connect()`.
171
+ */
172
+ setAuth(fetchToken, opts = {}) { this.#node.setAuth(fetchToken, opts.authUrl ?? DEFAULT_AUTH_URL); }
173
+ /**
174
+ * Asks the key server for the signed-in person's clusters and their runtimes, sets `discovery` from the answer and
175
+ * resolves with it; one request at a time (a call while one is out joins it). Rejects `unauthorized` when nobody is
176
+ * signed in (`setAuth`; `discovery` stays `idle`) or the key server does not take the sign-in, `invalid_input` when it
177
+ * refuses the request, `connection_lost` when it cannot be reached or is busy (its sentence when it gave one). Without
178
+ * a sign-in it does not read the CLI's files.
179
+ */
180
+ discover() {
181
+ const auth = this.#node.auth;
182
+ const p = auth ? auth.discover() : Promise.reject(notSignedIn());
183
+ p.catch(() => { });
184
+ return p;
185
+ }
186
+ /** What the key server said about the signed-in person (`discover()`), a state value: `get()` now, `onChange` for each change, `changed()` for the next one, `watch()` for each one in a loop. Connecting by name reads the same answer. */
187
+ discovery;
152
188
  /** The node's engine (the SDK's own modules and tests use it). */
153
189
  get node() { return this.#node; }
154
190
  #session() { return this.#node.currentSession(); }
@@ -240,9 +276,6 @@ export class Synapse {
240
276
  const contract = isServiceContract(svc) ? svc : undefined;
241
277
  return startOn(this.node, s, serviceName(svc), args, opts, contract);
242
278
  }
243
- callStream(svc, args = {}, opts = {}) {
244
- return callStream(this.call(svc, args, opts));
245
- }
246
279
  async serve(svc, handler, opts = {}) {
247
280
  const n = this.node;
248
281
  const name = serviceName(svc);
@@ -273,7 +306,7 @@ export class Synapse {
273
306
  store(name, opts = {}) { return openStore(this.node, this.#stores, name, opts, this.warn); }
274
307
  // ---------------------------------------------------------------------------------- scene
275
308
  #scenes = { current: undefined };
276
- /** The active project's scene (PLAN v0.24 §5); resolves attached, or at once `detached` when no project is active. */
309
+ /** The active project's scene (PLAN v0.24 §5); resolves attached (current as of the attach: at the version the runtime answered), or at once `detached` when no project is active. */
277
310
  scene() { return openScene(this.node, this.#scenes, this.warn); }
278
311
  // -------------------------------------------------------------------------------- catalog
279
312
  /** The runtime's catalogs as the current session last read them (empty between sessions), a state value: `get()` now, `onChange` for each change, `changed()` for the next one, `watch()` for each one in a loop. */