@synapse-sdk/client 0.1.0

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.
@@ -0,0 +1,466 @@
1
+ //#region src/types.d.ts
2
+ /** The minimal slice of the WHATWG WebSocket we depend on — so any conforming
3
+ * implementation (browser, Bun/Node global, a test double) can be injected. */
4
+ interface WebSocketLike {
5
+ readonly readyState: number;
6
+ send(data: string | ArrayBufferLike): void;
7
+ close(code?: number, reason?: string): void;
8
+ onopen: ((ev: unknown) => void) | null;
9
+ onmessage: ((ev: {
10
+ data: unknown;
11
+ }) => void) | null;
12
+ onclose: ((ev: {
13
+ code?: number;
14
+ reason?: string;
15
+ }) => void) | null;
16
+ onerror: ((ev: unknown) => void) | null;
17
+ }
18
+ type WebSocketCtor = new (url: string, protocols?: string | string[]) => WebSocketLike;
19
+ /** Observable connection status. `connected` means open AND verified (handshake acked). */
20
+ type ConnectionStatus = "idle" | "connecting" | "connected" | "reconnecting" | "closing" | "error";
21
+ interface ConnectionState {
22
+ readonly status: ConnectionStatus;
23
+ readonly endpoint: string | null;
24
+ readonly error: Error | null;
25
+ readonly since: number;
26
+ }
27
+ type Params = Record<string, unknown>;
28
+ type ParamsInput = Params | (() => Params | Promise<Params>);
29
+ type TokenInput = () => string | Promise<string>;
30
+ /** Runs after the socket opens; must resolve for status to become `connected`. */
31
+ type VerifyFn = (handle: VerifyHandle) => Promise<void>;
32
+ interface VerifyHandle {
33
+ channel(topic: string, params?: Params): Channel;
34
+ }
35
+ /** What to connect to. Identity = `url` + plain `params`; `token`/`verify` are *how* to
36
+ * connect, never part of identity. */
37
+ interface Target {
38
+ url: string;
39
+ params?: ParamsInput;
40
+ token?: TokenInput;
41
+ verify?: VerifyFn;
42
+ }
43
+ interface Reply {
44
+ status: "ok" | "error" | "timeout";
45
+ response: unknown;
46
+ }
47
+ type Logger = (level: "debug" | "info" | "warn" | "error", msg: string, data?: unknown) => void;
48
+ interface ConnectionOptions {
49
+ /** Defaults to `globalThis.WebSocket` (browser / Bun / Node 22+ / Deno / edge). */
50
+ WebSocket?: WebSocketCtor;
51
+ serializer?: Serializer;
52
+ /** Heartbeat cadence; a missed reply within one interval declares the socket dead.
53
+ * HMIs want this low (e.g. 2000). Default 15000. */
54
+ heartbeatIntervalMs?: number;
55
+ requestTimeoutMs?: number;
56
+ /** Max wait for a healthy socket's `close` during teardown before force-fence. Default 2000. */
57
+ closeTimeoutMs?: number;
58
+ reconnectAfterMs?: (tries: number) => number;
59
+ vsn?: string;
60
+ logger?: Logger;
61
+ }
62
+ /** One decoded Phoenix v2 frame: `[join_ref, ref, topic, event, payload]`. */
63
+ interface Message {
64
+ join_ref: string | null;
65
+ ref: string | null;
66
+ topic: string;
67
+ event: string;
68
+ payload: unknown;
69
+ }
70
+ interface Serializer {
71
+ encode(msg: Message): string | ArrayBufferLike;
72
+ decode(raw: string | ArrayBufferLike): Message;
73
+ }
74
+ /** The surface a Channel needs from its Connection (so Channel depends on a contract,
75
+ * not the concrete class). */
76
+ interface ChannelHost {
77
+ _makeRef(): string;
78
+ _send(msg: Message): void;
79
+ _request(msg: Message, timeoutMs?: number): Promise<Reply>;
80
+ _isOpen(): boolean;
81
+ _removeChannel(topic: string): void;
82
+ _log: Logger;
83
+ }
84
+ interface SynapseOptions extends ConnectionOptions {
85
+ /** Stable node identity (defaults to a random uuid). */
86
+ userId?: string;
87
+ /** Human-friendly display name. */
88
+ name?: string;
89
+ /** Role the bus authorizes against: "agent" (default) or "viewer". */
90
+ role?: string;
91
+ /** Join `synapse:system` on connect and gate readiness on a server-assigned role.
92
+ * Default `true`. Set `false` for headless nodes that don't need a session/role —
93
+ * then `ready` simply means transport-connected. */
94
+ system?: boolean;
95
+ }
96
+ /** What to connect to. Identity params (user_id/type/name) are merged in automatically. */
97
+ interface ConnectTarget {
98
+ url: string;
99
+ params?: Target["params"];
100
+ token?: Target["token"];
101
+ verify?: Target["verify"];
102
+ /** Project to activate on the system channel (request controller of it). */
103
+ project?: string;
104
+ /** Request the controller role (vs viewer). Needs a `project`; `type: "hmi"` also auto-controllers. */
105
+ controller?: boolean;
106
+ }
107
+ /**
108
+ * Server-assigned session role from the `synapse:system` handshake. **Open-ended** — the
109
+ * server may assign roles beyond these (the Python node already has `agent`), so the type is
110
+ * any string; the constants are just for ergonomic, typo-safe comparison
111
+ * (`role === SystemRole.Controller`). Add entries here as the server grows.
112
+ */
113
+ declare const SystemRole: {
114
+ readonly Viewer: "viewer";
115
+ readonly Controller: "controller";
116
+ readonly Agent: "agent";
117
+ };
118
+ type SystemRole = (typeof SystemRole)[keyof typeof SystemRole] | (string & {});
119
+ /**
120
+ * The node's readiness, three layers deep:
121
+ * - `status` — transport: reaching the socket (`connecting`) → reached it (`connected`).
122
+ * - `role` — system: who the server says you are once negotiated (null until then).
123
+ * - `ready` — negotiated and usable: `connected` AND (role assigned, or system disabled).
124
+ */
125
+ interface SystemState {
126
+ readonly status: ConnectionStatus;
127
+ readonly role: SystemRole | null;
128
+ readonly projectId: string | null;
129
+ readonly ready: boolean;
130
+ readonly error: Error | null;
131
+ }
132
+ /** A `state_diff` from a state mirror — absolute + version-ordered (keys changed/removed). */
133
+ interface StateDiff {
134
+ changed?: Record<string, unknown>;
135
+ removed?: string[];
136
+ version?: number;
137
+ }
138
+ /** A service's capability card (from the catalog). Open-ended; always has a `name`. */
139
+ interface ServiceCard {
140
+ name: string;
141
+ description?: string;
142
+ [key: string]: unknown;
143
+ }
144
+ /** A state resource entry in the catalog. */
145
+ interface ResourceInfo {
146
+ name: string;
147
+ kind: string;
148
+ }
149
+ /** A live catalog change as providers / state resources are added or removed. */
150
+ interface CatalogChange {
151
+ change: "added" | "removed";
152
+ name: string;
153
+ kind: "service" | "resource";
154
+ }
155
+ type Frame = Record<string, unknown>;
156
+ interface CallOptions {
157
+ onUpdate?: (frame: Frame) => void;
158
+ /** Cancel the call the web-standard way; the server is told to stop the provider. */
159
+ signal?: AbortSignal;
160
+ /** Bounds the initial accept (ack). */
161
+ timeout?: number;
162
+ /** Idle deadline (ms): fail the call if no `update`/`result`/`error` arrives within this
163
+ * window after the ack — reset on each `update`. Catches a wedged provider that an overall
164
+ * `AbortSignal.timeout` can't tell apart from legitimately slow work. Off by default. */
165
+ idleTimeout?: number;
166
+ }
167
+ interface SubscribeOptions {
168
+ /** Channel join params — e.g. ROS topics need `{ type: "std_msgs/String" }`. */
169
+ opts?: Record<string, unknown>;
170
+ }
171
+ interface StreamEvent {
172
+ type: "update" | "result";
173
+ data: Frame;
174
+ }
175
+ interface Subscription {
176
+ unsubscribe(): void;
177
+ }
178
+ interface CallContext {
179
+ update(data: Frame): void;
180
+ /** Aborted when the caller cancels — poll it for cooperative cancellation. */
181
+ readonly signal: AbortSignal;
182
+ /** The id of the call being served — handy for logging/correlation. */
183
+ readonly taskId: string;
184
+ }
185
+ type ServiceHandler = (input: Frame, ctx: CallContext) => unknown | Promise<unknown>;
186
+ interface ServiceRegistration {
187
+ unregister(): void;
188
+ }
189
+ //#endregion
190
+ //#region src/channel.d.ts
191
+ type ChannelState = "closed" | "joining" | "joined" | "leaving" | "errored";
192
+ type ChannelHandler = (payload: unknown, ref: string | null) => void;
193
+ /**
194
+ * One topic on the connection. Owned and reference-counted by `Connection` (one Channel
195
+ * per topic). Membership is independent of any UI component lifecycle: listeners attach
196
+ * and detach freely via `on`/`off` without re-joining, and the channel auto-rejoins when
197
+ * the socket reconnects.
198
+ */
199
+ declare class Channel {
200
+ readonly topic: string;
201
+ state: ChannelState;
202
+ private host;
203
+ private params;
204
+ private joinRef;
205
+ private joinPromise;
206
+ private joinResponse;
207
+ private bindings;
208
+ constructor(host: ChannelHost, topic: string, params: Params);
209
+ /** Idempotent: a join in flight or already joined returns the existing result. */
210
+ join(timeoutMs?: number): Promise<unknown>;
211
+ private doJoin;
212
+ leave(timeoutMs?: number): Promise<void>;
213
+ /** Fire-and-forget; for handlers that reply with `{:noreply, ...}` (e.g. pub/sub publish). */
214
+ push(event: string, payload?: unknown): void;
215
+ /** Send and await the reply; for handlers that reply with `{:reply, ...}`. */
216
+ request(event: string, payload?: unknown, timeoutMs?: number): Promise<Reply>;
217
+ on(event: string, cb: ChannelHandler): ChannelHandler;
218
+ off(event: string, cb: ChannelHandler): void;
219
+ _trigger(event: string, payload: unknown, ref: string | null): void;
220
+ /** Called when a (new) socket is open. Re-establishes membership if needed. */
221
+ _rejoin(): void;
222
+ /** Called when the socket goes down: the join is no longer valid; we'll rejoin on reconnect. */
223
+ _onSocketDown(): void;
224
+ }
225
+ //#endregion
226
+ //#region src/connection.d.ts
227
+ /**
228
+ * Single-socket connection supervisor.
229
+ *
230
+ * Invariant (the whole point): **at most one socket is ever acted upon.** A new socket is
231
+ * never created until the previous one's `close` is confirmed (healthy switch) or it has
232
+ * been fenced and abandoned (dead socket). Three mechanics enforce it:
233
+ *
234
+ * 1. **Serialized reconcile** — every transition runs through one queue; the latest
235
+ * desired target always wins (rapid `connect`/`disconnect` coalesce).
236
+ * 2. **Generation + live-identity guards** — every socket event checks it belongs to the
237
+ * current desired-epoch *and* the current socket; late events from a superseded or
238
+ * replaced socket are inert.
239
+ * 3. **Verified teardown** — switching/closing awaits the real `close` event (with a
240
+ * force-fence timeout) before any new socket opens.
241
+ *
242
+ * No global/`window` coupling, so discarding a socket is clean — nothing to leak or
243
+ * resurrect on tab visibility.
244
+ */
245
+ declare class Connection implements ChannelHost {
246
+ private readonly WS;
247
+ private readonly serializer;
248
+ private readonly heartbeatIntervalMs;
249
+ private readonly requestTimeoutMs;
250
+ private readonly closeTimeoutMs;
251
+ private readonly reconnectAfterMs;
252
+ private readonly vsn;
253
+ readonly _log: Logger;
254
+ private desired;
255
+ private live;
256
+ private generation;
257
+ private state;
258
+ private listeners;
259
+ private channels;
260
+ private pending;
261
+ private refCounter;
262
+ private reconciling;
263
+ private dirty;
264
+ private reconnectAttempt;
265
+ private reconnectTimer;
266
+ private closed;
267
+ constructor(options?: ConnectionOptions);
268
+ getState(): ConnectionState;
269
+ /** Observe connection state. Returns an unsubscribe fn. `getState`/state are stable
270
+ * references between changes, so this is `useSyncExternalStore`-friendly. */
271
+ subscribe(listener: (s: ConnectionState) => void): () => void;
272
+ /** Declarative + idempotent. Sets the desired target and converges to it. Resolves when
273
+ * this target reaches `connected`; rejects if a different target supersedes it. */
274
+ connect(target: Target): Promise<void>;
275
+ /** Tear down to `idle`. Resolves once fully closed. */
276
+ disconnect(): Promise<void>;
277
+ /** Permanent dispose — closes the socket and stops everything. */
278
+ close(): void;
279
+ /** Get-or-create the (single) channel for a topic. Joined now if the socket is open,
280
+ * otherwise it joins automatically once a socket comes up. */
281
+ channel(topic: string, params?: Params): Channel;
282
+ _makeRef(): string;
283
+ _isOpen(): boolean;
284
+ _send(msg: Message): void;
285
+ _request(msg: Message, timeoutMs?: number): Promise<Reply>;
286
+ _removeChannel(topic: string): void;
287
+ private reconcile;
288
+ private runReconcile;
289
+ private step;
290
+ private bringUp;
291
+ private onOpen;
292
+ private verifyHandle;
293
+ private setConnected;
294
+ private teardown;
295
+ private replaceDead;
296
+ private onUnexpectedClose;
297
+ /** Detach handlers and best-effort close, without awaiting (the socket may never close). */
298
+ private fence;
299
+ private afterSocketGone;
300
+ private scheduleReconnect;
301
+ private cancelReconnect;
302
+ private startHeartbeat;
303
+ private sendHeartbeat;
304
+ private clearHeartbeat;
305
+ private onMessage;
306
+ private sendRaw;
307
+ private flushPending;
308
+ private setState;
309
+ private failTarget;
310
+ private desiredKey;
311
+ private waitForTarget;
312
+ private bumpGeneration;
313
+ }
314
+ //#endregion
315
+ //#region src/node.d.ts
316
+ type MessageHandler = (m: Frame) => void;
317
+ /**
318
+ * A Synapse bus node. Wraps the single-socket {@link Connection} and adds the bus verbs —
319
+ * `call`, `callStream`, `subscribe`, `publish`, `service` — mirroring the Python `Node`.
320
+ * Any node can both consume and provide (a browser can publish joystick state *and* expose
321
+ * a service the robot calls into).
322
+ */
323
+ declare class Synapse {
324
+ readonly connection: Connection;
325
+ private identity;
326
+ private tasks;
327
+ private consumerChannels;
328
+ private pubsubChannels;
329
+ private pubsubSubs;
330
+ private stateHandles;
331
+ private catalogChannelP;
332
+ private catalogWatchers;
333
+ private readonly systemEnabled;
334
+ private systemChannel;
335
+ private role;
336
+ private projectId;
337
+ private systemError;
338
+ private systemRequest;
339
+ private systemState;
340
+ private systemListeners;
341
+ private systemGen;
342
+ constructor(options?: SynapseOptions);
343
+ /**
344
+ * Connect (and negotiate the system session). Identity params are merged in. Resolves when
345
+ * **ready** — transport connected *and* a server role assigned (unless `system: false`,
346
+ * where ready just means connected). Declarative + idempotent. To request control, pass
347
+ * `{ project, controller: true }`.
348
+ */
349
+ connect(target: ConnectTarget): Promise<void>;
350
+ disconnect(): Promise<void>;
351
+ close(): void;
352
+ getState(): ConnectionState;
353
+ /** Observe connection state — `useSyncExternalStore`-friendly. */
354
+ subscribeState(listener: (s: ConnectionState) => void): () => void;
355
+ /** The full readiness — `{ status, role, projectId, ready, error }`. */
356
+ getSystem(): SystemState;
357
+ /** Observe readiness — `useSyncExternalStore`-friendly. The connection indicator binds
358
+ * to `{ status, role }` (transport status + server role) and `ready` is the gate. */
359
+ subscribeSystem(listener: (s: SystemState) => void): () => void;
360
+ /** Resolves once **ready** (negotiated + role assigned), or rejects after `timeoutMs`. */
361
+ whenReady(timeoutMs?: number): Promise<void>;
362
+ /** Activate a project (request control of it). Role updates arrive live via the session. */
363
+ activateProject(projectId: string): Promise<void>;
364
+ /** Step back to viewer for the active project. */
365
+ deactivateProject(): Promise<void>;
366
+ /** Resolves once the transport is connected — used internally before channel ops (channels
367
+ * need the socket, not a role). */
368
+ private awaitConnected;
369
+ private onTransport;
370
+ private enterSystem;
371
+ private onSessionState;
372
+ private setRole;
373
+ private requireSystem;
374
+ private systemPayload;
375
+ private computeSystem;
376
+ private emitSystem;
377
+ call<T = unknown>(service: string, input?: Frame, opts?: CallOptions): Promise<T>;
378
+ /** Call as a loop: yields each `update` then the terminal `result`. Breaking cancels. */
379
+ callStream(service: string, input?: Frame, opts?: {
380
+ timeout?: number;
381
+ idleTimeout?: number;
382
+ }): AsyncGenerator<StreamEvent>;
383
+ publish(resource: string, data?: Frame, opts?: SubscribeOptions): Promise<void>;
384
+ subscribe(resource: string, callback: MessageHandler, opts?: SubscribeOptions): Subscription;
385
+ subscribe(resource: string, opts?: SubscribeOptions): AsyncIterable<Frame>;
386
+ /** A render-loop-friendly latest-value holder for high-rate topics (three.js / `useFrame`). */
387
+ latest<T extends Frame = Frame>(resource: string, opts?: SubscribeOptions): LatestValue<T>;
388
+ /**
389
+ * Open a live, always-current local mirror of a `synapse:state:<name>` resource — a
390
+ * shared key→value map that converges across nodes. `handle.value` is kept up to date
391
+ * from the initial sync and each diff; write with `handle.put`/`handle.delete`. Cached
392
+ * per resource (repeated calls share one mirror).
393
+ */
394
+ openState(resource: string): Promise<StateHandle>;
395
+ private doOpenState;
396
+ /** Capability cards of every registered service. */
397
+ listServices(): Promise<ServiceCard[]>;
398
+ /** One service's capability card (throws `BusError` if unknown). */
399
+ describe(service: string): Promise<ServiceCard>;
400
+ /** State resources currently registered on the bus. */
401
+ listResources(): Promise<ResourceInfo[]>;
402
+ /** Watch providers / resources coming and going. With a callback → push (returns a
403
+ * Subscription); without → an async iterator of `{ change, name, kind }`. */
404
+ watchCatalog(callback: (c: CatalogChange) => void): Subscription;
405
+ watchCatalog(): AsyncIterable<CatalogChange>;
406
+ private watchCatalogIterator;
407
+ private catalog;
408
+ private openCatalog;
409
+ service(name: string, handler: ServiceHandler, card?: Frame): ServiceRegistration;
410
+ private runProvider;
411
+ private withIdentity;
412
+ private consumerChannel;
413
+ private routeTask;
414
+ private subsFor;
415
+ private ensurePubsub;
416
+ private subscribePush;
417
+ private subscribePull;
418
+ }
419
+ /**
420
+ * Latest-value holder for high-frequency topics. Read `.get()` inside a three.js / r3f
421
+ * `useFrame` loop (non-reactive) and mutate the scene directly — React never re-renders.
422
+ */
423
+ declare class LatestValue<T> {
424
+ private value;
425
+ private listeners;
426
+ private sub;
427
+ constructor(subscribe: (cb: (m: T) => void) => Subscription);
428
+ get(): T | undefined;
429
+ onChange(cb: (v: T) => void): () => void;
430
+ close(): void;
431
+ }
432
+ /**
433
+ * A live local mirror of a `synapse:state:<name>` resource. `value` is kept current from the
434
+ * initial `state_sync` and each `state_diff` (absolute, version-ordered); a version gap
435
+ * triggers an automatic `resync`. Writes (`put`/`delete`) are server-confirmed and come back
436
+ * as a diff (self-included). `value` is replaced (new reference) on each change, so it binds
437
+ * cleanly to `useSyncExternalStore` / reads cleanly in a `useFrame` loop.
438
+ */
439
+ declare class StateHandle {
440
+ readonly resource: string;
441
+ private ch;
442
+ private _value;
443
+ private _version;
444
+ private listeners;
445
+ private resyncing;
446
+ constructor(resource: string, ch: Channel);
447
+ /** The current converged map (new reference on every change). */
448
+ get value(): Record<string, unknown>;
449
+ /** The version of the last applied op. */
450
+ get version(): number;
451
+ put(key: string, value: unknown): Promise<void>;
452
+ delete(key: string): Promise<void>;
453
+ /** Observe each applied change: `(value, diff) => …`. Returns unsubscribe. */
454
+ subscribe(listener: (value: Record<string, unknown>, diff: StateDiff) => void): () => void;
455
+ /** Force a full re-sync from the server. */
456
+ resync(): Promise<void>;
457
+ close(): void;
458
+ _applySync(sync: {
459
+ version?: number;
460
+ state?: Record<string, unknown>;
461
+ }): void;
462
+ _onDiff(diff: StateDiff): void;
463
+ private emit;
464
+ }
465
+ //#endregion
466
+ export { SynapseOptions as A, ServiceCard as C, StreamEvent as D, StateDiff as E, VerifyFn as F, VerifyHandle as I, WebSocketCtor as L, SystemState as M, Target as N, SubscribeOptions as O, TokenInput as P, WebSocketLike as R, Serializer as S, ServiceRegistration as T, Message as _, Channel as a, Reply as b, CallOptions as c, ConnectTarget as d, ConnectionOptions as f, Logger as g, Frame as h, Connection as i, SystemRole as j, Subscription as k, CatalogChange as l, ConnectionStatus as m, StateHandle as n, ChannelHandler as o, ConnectionState as p, Synapse as r, CallContext as s, LatestValue as t, ChannelHost as u, Params as v, ServiceHandler as w, ResourceInfo as x, ParamsInput as y };
@@ -0,0 +1,63 @@
1
+ import { C as ServiceCard, M as SystemState, O as SubscribeOptions, c as CallOptions, h as Frame, j as SystemRole, m as ConnectionStatus, n as StateHandle, p as ConnectionState, r as Synapse, w as ServiceHandler, x as ResourceInfo } from "./node-DbD7G0Nx.js";
2
+ import { ReactNode } from "react";
3
+
4
+ //#region src/react.d.ts
5
+ /** Pass as the resource to `useSubscribe` to disable it conditionally (à la Convex). */
6
+ declare const skip: "skip";
7
+ declare function SynapseProvider({
8
+ client,
9
+ children
10
+ }: {
11
+ client: Synapse;
12
+ children: ReactNode;
13
+ }): ReactNode;
14
+ /** The `Synapse` client from the nearest provider. */
15
+ declare function useSynapse(): Synapse;
16
+ /** The full connection state; re-renders on any status/endpoint/error change. */
17
+ declare function useConnectionState(): ConnectionState;
18
+ /** Just the status — `idle | connecting | connected | reconnecting | closing | error`. */
19
+ declare function useConnectionStatus(): ConnectionStatus;
20
+ /** Full readiness — `{ status, role, projectId, ready, error }`. Bind your connection
21
+ * indicator to `{ status, role }`. */
22
+ declare function useSystem(): SystemState;
23
+ /** The gate: transport-connected AND a server role assigned (or `system: false`). */
24
+ declare function useReady(): boolean;
25
+ /** The server-assigned session role (`viewer` / `controller` / …), or null until negotiated. */
26
+ declare function useRole(): SystemRole | null;
27
+ /**
28
+ * Live value of a pub/sub resource — the latest message (latest-wins, no queue growth), or
29
+ * `undefined` until the first arrives. Pass `skip` to disable. For 60 Hz data feeding
30
+ * three.js, prefer the imperative `synapse.latest(resource).get()` inside `useFrame` instead
31
+ * (this hook re-renders on every message).
32
+ */
33
+ declare function useSubscribe<T extends Frame = Frame>(resource: string, opts?: SubscribeOptions): T | undefined;
34
+ /** A stable bound `call` — like TanStack/Convex `useMutation` returning a callable. */
35
+ declare function useCall(): <R = unknown>(service: string, input?: Frame, opts?: CallOptions) => Promise<R>;
36
+ /**
37
+ * Register this node as a provider for the lifetime of the component (e.g. a browser
38
+ * exposing a joystick service the robot calls into). The handler is read from a ref, so it
39
+ * can close over fresh props without re-registering.
40
+ */
41
+ declare function useService(name: string, handler: ServiceHandler, card?: Frame): void;
42
+ /**
43
+ * Live mirror of a `synapse:state:<name>` resource. Returns `[value, handle]`: `value` is the
44
+ * converged map (re-renders on change), `handle` exposes `put`/`delete` (null until opened).
45
+ *
46
+ * @example
47
+ * const [scene, handle] = useStateMirror("world/collision")
48
+ * // render scene.box_1 …; handle?.put("box_2", { xyz: [1, 0, 0] })
49
+ */
50
+ declare function useStateMirror(resource: string): readonly [Record<string, unknown>, StateHandle | null];
51
+ /**
52
+ * Live discovery — the bus's services and state resources, refreshed when any provider or
53
+ * resource is added/removed.
54
+ *
55
+ * @example
56
+ * const { services, resources } = useCatalog()
57
+ */
58
+ declare function useCatalog(): {
59
+ services: ServiceCard[];
60
+ resources: ResourceInfo[];
61
+ };
62
+ //#endregion
63
+ export { SynapseProvider, skip, useCall, useCatalog, useConnectionState, useConnectionStatus, useReady, useRole, useService, useStateMirror, useSubscribe, useSynapse, useSystem };
package/dist/react.js ADDED
@@ -0,0 +1,141 @@
1
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
2
+ import { jsx } from "react/jsx-runtime";
3
+ //#region src/react.tsx
4
+ /** Pass as the resource to `useSubscribe` to disable it conditionally (à la Convex). */
5
+ const skip = "skip";
6
+ const SynapseContext = createContext(null);
7
+ function SynapseProvider({ client, children }) {
8
+ return /* @__PURE__ */ jsx(SynapseContext.Provider, {
9
+ value: client,
10
+ children
11
+ });
12
+ }
13
+ /** The `Synapse` client from the nearest provider. */
14
+ function useSynapse() {
15
+ const client = useContext(SynapseContext);
16
+ if (!client) throw new Error("useSynapse must be used within <SynapseProvider>");
17
+ return client;
18
+ }
19
+ /** The full connection state; re-renders on any status/endpoint/error change. */
20
+ function useConnectionState() {
21
+ const client = useSynapse();
22
+ const subscribe = useCallback((cb) => client.subscribeState(() => cb()), [client]);
23
+ const getSnapshot = useCallback(() => client.getState(), [client]);
24
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
25
+ }
26
+ /** Just the status — `idle | connecting | connected | reconnecting | closing | error`. */
27
+ function useConnectionStatus() {
28
+ return useConnectionState().status;
29
+ }
30
+ /** Full readiness — `{ status, role, projectId, ready, error }`. Bind your connection
31
+ * indicator to `{ status, role }`. */
32
+ function useSystem() {
33
+ const client = useSynapse();
34
+ const subscribe = useCallback((cb) => client.subscribeSystem(() => cb()), [client]);
35
+ const getSnapshot = useCallback(() => client.getSystem(), [client]);
36
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
37
+ }
38
+ /** The gate: transport-connected AND a server role assigned (or `system: false`). */
39
+ function useReady() {
40
+ return useSystem().ready;
41
+ }
42
+ /** The server-assigned session role (`viewer` / `controller` / …), or null until negotiated. */
43
+ function useRole() {
44
+ return useSystem().role;
45
+ }
46
+ /**
47
+ * Live value of a pub/sub resource — the latest message (latest-wins, no queue growth), or
48
+ * `undefined` until the first arrives. Pass `skip` to disable. For 60 Hz data feeding
49
+ * three.js, prefer the imperative `synapse.latest(resource).get()` inside `useFrame` instead
50
+ * (this hook re-renders on every message).
51
+ */
52
+ function useSubscribe(resource, opts) {
53
+ const client = useSynapse();
54
+ const disabled = resource === skip;
55
+ const holder = useMemo(() => disabled ? null : client.latest(resource, opts), [
56
+ client,
57
+ resource,
58
+ disabled,
59
+ opts ? JSON.stringify(opts) : ""
60
+ ]);
61
+ useEffect(() => () => holder?.close(), [holder]);
62
+ return useSyncExternalStore(useCallback((cb) => holder ? holder.onChange(() => cb()) : () => {}, [holder]), useCallback(() => holder?.get(), [holder]), () => void 0);
63
+ }
64
+ /** A stable bound `call` — like TanStack/Convex `useMutation` returning a callable. */
65
+ function useCall() {
66
+ const client = useSynapse();
67
+ return useMemo(() => client.call.bind(client), [client]);
68
+ }
69
+ /**
70
+ * Register this node as a provider for the lifetime of the component (e.g. a browser
71
+ * exposing a joystick service the robot calls into). The handler is read from a ref, so it
72
+ * can close over fresh props without re-registering.
73
+ */
74
+ function useService(name, handler, card) {
75
+ const client = useSynapse();
76
+ const handlerRef = useRef(handler);
77
+ handlerRef.current = handler;
78
+ useEffect(() => {
79
+ const reg = client.service(name, (input, ctx) => handlerRef.current(input, ctx), card);
80
+ return () => reg.unregister();
81
+ }, [client, name]);
82
+ }
83
+ const EMPTY_STATE = Object.freeze({});
84
+ /**
85
+ * Live mirror of a `synapse:state:<name>` resource. Returns `[value, handle]`: `value` is the
86
+ * converged map (re-renders on change), `handle` exposes `put`/`delete` (null until opened).
87
+ *
88
+ * @example
89
+ * const [scene, handle] = useStateMirror("world/collision")
90
+ * // render scene.box_1 …; handle?.put("box_2", { xyz: [1, 0, 0] })
91
+ */
92
+ function useStateMirror(resource) {
93
+ const client = useSynapse();
94
+ const [handle, setHandle] = useState(null);
95
+ useEffect(() => {
96
+ let active = true;
97
+ setHandle(null);
98
+ client.openState(resource).then((h) => {
99
+ if (active) setHandle(h);
100
+ }).catch(() => {});
101
+ return () => {
102
+ active = false;
103
+ };
104
+ }, [client, resource]);
105
+ return [useSyncExternalStore(useCallback((cb) => handle ? handle.subscribe(() => cb()) : () => {}, [handle]), useCallback(() => handle?.value ?? EMPTY_STATE, [handle]), () => EMPTY_STATE), handle];
106
+ }
107
+ /**
108
+ * Live discovery — the bus's services and state resources, refreshed when any provider or
109
+ * resource is added/removed.
110
+ *
111
+ * @example
112
+ * const { services, resources } = useCatalog()
113
+ */
114
+ function useCatalog() {
115
+ const client = useSynapse();
116
+ const [catalog, setCatalog] = useState({
117
+ services: [],
118
+ resources: []
119
+ });
120
+ useEffect(() => {
121
+ let active = true;
122
+ const refresh = async () => {
123
+ try {
124
+ const [services, resources] = await Promise.all([client.listServices(), client.listResources()]);
125
+ if (active) setCatalog({
126
+ services,
127
+ resources
128
+ });
129
+ } catch {}
130
+ };
131
+ refresh();
132
+ const sub = client.watchCatalog(() => void refresh());
133
+ return () => {
134
+ active = false;
135
+ sub.unsubscribe();
136
+ };
137
+ }, [client]);
138
+ return catalog;
139
+ }
140
+ //#endregion
141
+ export { SynapseProvider, skip, useCall, useCatalog, useConnectionState, useConnectionStatus, useReady, useRole, useService, useStateMirror, useSubscribe, useSynapse, useSystem };