@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 +113 -0
- package/dist/index.d.ts +74 -0
- package/dist/index.js +15 -0
- package/dist/node-CSSIXG4k.js +1501 -0
- package/dist/node-DbD7G0Nx.d.ts +466 -0
- package/dist/react.d.ts +63 -0
- package/dist/react.js +141 -0
- package/package.json +57 -0
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
|
+
```
|
package/dist/index.d.ts
ADDED
|
@@ -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 };
|