@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.
package/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # @synapse-sdk/client
2
+
3
+ Isomorphic TypeScript SDK for the **Synapse** robotics bus (Phoenix Channels). Browser,
4
+ Bun, Node, Deno, edge. The companion to `python_client` — same model (call / stream /
5
+ subscribe / publish / provide, realm-prefixed addresses), idiomatic TypeScript.
6
+
7
+ ```sh
8
+ # internal (git) for now:
9
+ bun add github:<org>/<repo> # or: pnpm add github:<org>/<repo>
10
+ ```
11
+
12
+ Imports: `@synapse-sdk/client` (core) and `@synapse-sdk/client/react` (hooks; React is an
13
+ optional peer dependency).
14
+
15
+ > Built for robot control and HMIs, where a stale or still-open socket is a safety hazard.
16
+ > So we **own the connection lifecycle** (a single, verified socket) and reuse the installed
17
+ > `phoenix` package only for the wire serializer.
18
+
19
+ ## The safety invariant
20
+
21
+ > **At most one socket is ever acted upon.** A new socket is never created until the previous
22
+ > one's `close` is confirmed (healthy switch) or it's been fenced and abandoned (dead socket).
23
+
24
+ Three mechanics enforce it (`src/connection.ts`):
25
+
26
+ 1. **Serialized reconcile** — `connect`/`disconnect` set a *desired target*; one queue
27
+ converges to it, latest wins. Mashing the connect button coalesces.
28
+ 2. **Generation + live-identity guards** — every socket event checks it belongs to the
29
+ current desired-epoch *and* the current socket, so a late message/close from a superseded
30
+ or replaced socket is inert.
31
+ 3. **Verified teardown** — switching awaits the real `close` (with a force-fence timeout)
32
+ before any new socket opens. Dead sockets (heartbeat timeout) are fenced and abandoned —
33
+ never awaited, never read from or written to again.
34
+
35
+ No `window` coupling, so discarding a socket is clean. The fuzz test runs 100 random
36
+ connect/disconnect/retarget ops and asserts simultaneously-open sockets never exceeds **1**.
37
+
38
+ ## Quick start
39
+
40
+ ```ts
41
+ import { Synapse } from "@synapse-sdk/client"
42
+
43
+ const synapse = new Synapse() // browser/Bun: uses the built-in WebSocket
44
+
45
+ // declarative, idempotent target — the "connect button" calls this
46
+ await synapse.connect({
47
+ url: "wss://robot-7.tail.ts.net/runtime/api/v1/socket",
48
+ params: { type: "web" },
49
+ token: () => auth.getToken(), // → Phoenix auth subprotocol, fresh on every (re)connect
50
+ })
51
+ await synapse.connect({ url: "wss://robot-9/…", token }) // retarget: same verb, safe switch
52
+ await synapse.disconnect()
53
+
54
+ synapse.subscribeState((s) => render(s.status)) // idle|connecting|connected|reconnecting|closing|error
55
+
56
+ // consume — mirrors the Python node
57
+ const sum = await synapse.call("rosbridge:/add_two_ints", { a: 2, b: 3 }, { onUpdate: show, signal })
58
+ for await (const ev of synapse.callStream("planner/plan", req)) { /* ev.type: update|result */ }
59
+ synapse.subscribe("telemetry/pose", (m) => render(m))
60
+ await synapse.publish("teleop/cmd_vel", axes)
61
+
62
+ // provide — from any node, including the browser (the robot calls in)
63
+ synapse.service("teleop/joystick", (input, ctx) => drive(input))
64
+ ```
65
+
66
+ ### three.js / react-three-fiber (no React re-renders)
67
+
68
+ High-frequency data bypasses React entirely via `latest()` — read `.get()` in the render loop:
69
+
70
+ ```ts
71
+ const joints = synapse.latest("rosbridge:/joint_states", { opts: { type: "sensor_msgs/JointState" } })
72
+ useFrame(() => {
73
+ const s = joints.get() // non-reactive, latest-wins (no queue growth)
74
+ if (s) robotRef.current?.setJointValues(toMap(s.name, s.position))
75
+ })
76
+ ```
77
+
78
+ Connection *status* (low-frequency) drives UI through a selector hook; robot *data*
79
+ (high-frequency) drives the scene imperatively. React stays quiet at 60 Hz.
80
+
81
+ ## Layout (single package)
82
+
83
+ ```
84
+ src/
85
+ connection.ts single-socket state machine — the safety core
86
+ channel.ts one topic on the connection (join/push/request/on, auto-rejoin)
87
+ serializer.ts wraps the installed `phoenix` Serializer (v2 text + binary frames)
88
+ node.ts Synapse — the bus verbs (call/callStream/subscribe/publish/service/latest)
89
+ address.ts realm-prefix parsing (`rosbridge:/x` → { realm, name })
90
+ errors.ts ConnectionError / SupersededError / ChannelError / BusError
91
+ types.ts public types
92
+ index.ts barrel
93
+ test/ bun:test — connection (fuzz/invariant), channel, node
94
+ ```
95
+
96
+ ## Develop
97
+
98
+ ```bash
99
+ bun install
100
+ bun test # invariant fuzz, channel, call/subscribe/publish/provider
101
+ bun examples/live_demo.ts # end-to-end against a running runtime on :9001
102
+ bunx tsc --noEmit # type-check
103
+ ```
104
+
105
+ ## Status & roadmap
106
+
107
+ - [x] Single-socket connection core — fuzz-proven invariant
108
+ - [x] Bus verbs: `call` / `callStream` / `subscribe` / `publish` / `service` / `latest`
109
+ - [x] Wire frames reconciled against the live Elixir `ServiceChannel` / `PubSubChannel`
110
+ - [ ] React adapter — `useConnectionStatus` / `useSubscription` / `useService` + zustand
111
+ - [ ] System-channel handshake as the default `verify` (server role → verified `connected`)
112
+ - [ ] State mirrors (`synapse:state:*`) + discovery (`synapse:catalog`)
113
+ ```
@@ -0,0 +1,74 @@
1
+ import { A as SynapseOptions, C as ServiceCard, D as StreamEvent, E as StateDiff, F as VerifyFn, I as VerifyHandle, L as WebSocketCtor, M as SystemState, N as Target, O as SubscribeOptions, P as TokenInput, R as WebSocketLike, S as Serializer, T as ServiceRegistration, _ as Message, a as Channel, b as Reply, c as CallOptions, d as ConnectTarget, f as ConnectionOptions, g as Logger, h as Frame, i as Connection, j as SystemRole, k as Subscription, l as CatalogChange, m as ConnectionStatus, n as StateHandle, o as ChannelHandler, p as ConnectionState, r as Synapse, s as CallContext, t as LatestValue, u as ChannelHost, v as Params, w as ServiceHandler, x as ResourceInfo, y as ParamsInput } from "./node-DbD7G0Nx.js";
2
+
3
+ //#region src/serializer.d.ts
4
+ /**
5
+ * The wire serializer — the **installed** Phoenix `Serializer` (v2 text + binary frames),
6
+ * wrapped to our `Serializer` interface so wire-compat tracks the `phoenix` package. Its
7
+ * `encode`/`decode` are callback-style but synchronous, so we capture the result inline.
8
+ */
9
+ declare const serializer: Serializer;
10
+ //#endregion
11
+ //#region src/address.d.ts
12
+ interface Address {
13
+ realm: string;
14
+ name: string;
15
+ }
16
+ /**
17
+ * Split a `[realm:]name` address into `{ realm, name }`; default realm `synapse`.
18
+ * Only a bare-identifier prefix counts as a realm, so a synapse name with `/` and no realm
19
+ * passes through unchanged. Mirrors the Python client.
20
+ *
21
+ * "camera/capture" -> { realm: "synapse", name: "camera/capture" }
22
+ * "rosbridge:/add_two_ints" -> { realm: "rosbridge", name: "/add_two_ints" }
23
+ */
24
+ declare function address(addr: string): Address;
25
+ //#endregion
26
+ //#region src/errors.d.ts
27
+ declare class ConnectionError extends Error {
28
+ constructor(message: string, options?: {
29
+ cause?: unknown;
30
+ });
31
+ }
32
+ declare class TimeoutError extends ConnectionError {
33
+ constructor(message: string);
34
+ }
35
+ /** A newer `connect(...)` (or `disconnect()`) replaced the target this promise was awaiting. */
36
+ declare class SupersededError extends ConnectionError {
37
+ constructor();
38
+ }
39
+ declare class ChannelError extends Error {
40
+ topic: string;
41
+ response: unknown;
42
+ constructor(topic: string, message: string, response?: unknown);
43
+ }
44
+ interface BusErrorPayload {
45
+ code?: string;
46
+ message?: string;
47
+ }
48
+ /**
49
+ * Known bus error codes — server-emitted (`unauthorized`, `service_unavailable`,
50
+ * `provider_error`, `invalid_input`, `unknown_event`, `ros_error`) plus client-side
51
+ * (`bad_ack`, `not_connected`, `timeout`, `cancelled`). Match on these to avoid magic
52
+ * strings; treat the set as **non-exhaustive** (the server may add codes).
53
+ */
54
+ declare const BusErrorCode: {
55
+ readonly Unauthorized: "unauthorized";
56
+ readonly ServiceUnavailable: "service_unavailable";
57
+ readonly ProviderError: "provider_error";
58
+ readonly InvalidInput: "invalid_input";
59
+ readonly UnknownEvent: "unknown_event";
60
+ readonly RosError: "ros_error";
61
+ readonly BadAck: "bad_ack";
62
+ readonly NotConnected: "not_connected";
63
+ readonly Timeout: "timeout";
64
+ readonly Cancelled: "cancelled";
65
+ };
66
+ type BusErrorCode = (typeof BusErrorCode)[keyof typeof BusErrorCode];
67
+ /** Raised when a service call fails — at accept time or mid-flight (see the bus error model). */
68
+ declare class BusError extends Error {
69
+ code: string;
70
+ detail: unknown;
71
+ constructor(error: BusErrorPayload | unknown);
72
+ }
73
+ //#endregion
74
+ export { type Address, BusError, BusErrorCode, type BusErrorPayload, type CallContext, type CallOptions, type CatalogChange, Channel, ChannelError, type ChannelHandler, type ChannelHost, type ConnectTarget, Connection, ConnectionError, type ConnectionOptions, type ConnectionState, type ConnectionStatus, type Frame, LatestValue, type Logger, type Message, type Params, type ParamsInput, type Reply, type ResourceInfo, type Serializer, type ServiceCard, type ServiceHandler, type ServiceRegistration, type StateDiff, StateHandle, type StreamEvent, type SubscribeOptions, type Subscription, SupersededError, Synapse, type SynapseOptions, SystemRole, type SystemState, type Target, TimeoutError, type TokenInput, type VerifyFn, type VerifyHandle, type WebSocketCtor, type WebSocketLike, address, serializer };
package/dist/index.js ADDED
@@ -0,0 +1,15 @@
1
+ import { a as Connection, c as BusError, d as ConnectionError, f as SupersededError, i as address, l as BusErrorCode, n as StateHandle, o as serializer, p as TimeoutError, r as Synapse, s as Channel, t as LatestValue, u as ChannelError } from "./node-CSSIXG4k.js";
2
+ //#region src/types.ts
3
+ /**
4
+ * Server-assigned session role from the `synapse:system` handshake. **Open-ended** — the
5
+ * server may assign roles beyond these (the Python node already has `agent`), so the type is
6
+ * any string; the constants are just for ergonomic, typo-safe comparison
7
+ * (`role === SystemRole.Controller`). Add entries here as the server grows.
8
+ */
9
+ const SystemRole = {
10
+ Viewer: "viewer",
11
+ Controller: "controller",
12
+ Agent: "agent"
13
+ };
14
+ //#endregion
15
+ export { BusError, BusErrorCode, Channel, ChannelError, Connection, ConnectionError, LatestValue, StateHandle, SupersededError, Synapse, SystemRole, TimeoutError, address, serializer };