@synapse-sdk/client 0.1.0 → 0.3.3

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/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/util.d.ts ADDED
@@ -0,0 +1,67 @@
1
+ export interface Deferred<T> {
2
+ promise: Promise<T>;
3
+ resolve: (v: T | PromiseLike<T>) => void;
4
+ reject: (e: unknown) => void;
5
+ settled: boolean;
6
+ }
7
+ export declare function deferred<T>(): Deferred<T>;
8
+ /** A value plus listeners. `changed()` resolves on the next `set`. */
9
+ export declare class Cell<T> {
10
+ #private;
11
+ constructor(v: T);
12
+ get(): T;
13
+ set(v: T): void;
14
+ update(f: (v: T) => T): void;
15
+ subscribe(l: (v: T) => void): () => void;
16
+ changed(): Promise<T>;
17
+ get listenerCount(): number;
18
+ }
19
+ export declare const DONE: unique symbol;
20
+ /**
21
+ * Bounded FIFO with one consumer. `push` never blocks: past `capacity` the oldest item is dropped
22
+ * and `onDrop` is told (`capacity` `Infinity`: unbounded, nothing is dropped). `close()` ends the
23
+ * stream after buffered items; `close(true)` discards them.
24
+ */
25
+ export declare class AsyncQueue<T> implements AsyncIterable<T> {
26
+ #private;
27
+ capacity: number;
28
+ private readonly onDrop?;
29
+ constructor(capacity: number, onDrop?: ((dropped: T) => void) | undefined);
30
+ get size(): number;
31
+ get closed(): boolean;
32
+ push(v: T): boolean;
33
+ /**
34
+ * Backpressure form: waits while the queue is full instead of dropping, so a reader that awaits
35
+ * it stops taking from the transport (QUIC flow control then pushes back on the publisher).
36
+ * Resolves `false` when the queue is closed meanwhile.
37
+ */
38
+ pushWhenRoom(v: T): Promise<boolean>;
39
+ pull(): Promise<T | typeof DONE>;
40
+ /**
41
+ * `pull()` as `undefined` at the end. A `next()` that is waiting receives the next pushed item
42
+ * (it is not buffered): a caller that races `next()` against something else and loses must keep
43
+ * that promise and use it later, or the item it receives is lost.
44
+ */
45
+ next(): Promise<T | undefined>;
46
+ /** An item already queued, without waiting. */
47
+ tryNext(): T | undefined;
48
+ /** Ends the queue. Buffered items are still delivered unless `discard`. Resolves when drained. */
49
+ close(discard?: boolean): Promise<void>;
50
+ [Symbol.asyncIterator](): AsyncGenerator<T, void, undefined>;
51
+ }
52
+ export declare function sleep(ms: number, signal?: AbortSignal): Promise<void>;
53
+ export declare const TIMEOUT: unique symbol;
54
+ /** Races `p` against a timeout; resolves to TIMEOUT instead of throwing so callers can branch. */
55
+ export declare function withTimeout<T>(p: Promise<T>, ms: number): Promise<T | typeof TIMEOUT>;
56
+ export declare function nowMs(): number;
57
+ /** Keeps `prev` references wherever `next` is deep-equal to them (TanStack Query's replaceEqualDeep). */
58
+ export declare function replaceEqualDeep<T>(prev: unknown, next: T): T;
59
+ export declare function shallowEqual(a: unknown, b: unknown): boolean;
60
+ /** Percentile over a small ring of samples. */
61
+ export declare class Ring {
62
+ #private;
63
+ private readonly cap;
64
+ constructor(cap?: number);
65
+ push(v: number): void;
66
+ percentile(p: number): number | undefined;
67
+ }
package/dist/util.js ADDED
@@ -0,0 +1,227 @@
1
+ // Small primitives shared by the client: a value cell with change notification, a bounded async
2
+ // queue, deferreds, sleeps that honour AbortSignals, and TanStack-style structural sharing.
3
+ export function deferred() {
4
+ const d = { settled: false };
5
+ d.promise = new Promise((res, rej) => {
6
+ d.resolve = v => { d.settled = true; res(v); };
7
+ d.reject = e => { d.settled = true; rej(e); };
8
+ });
9
+ return d;
10
+ }
11
+ /** A value plus listeners. `changed()` resolves on the next `set`. */
12
+ export class Cell {
13
+ #v;
14
+ #listeners = new Set();
15
+ #waiters;
16
+ constructor(v) { this.#v = v; }
17
+ get() { return this.#v; }
18
+ set(v) {
19
+ this.#v = v;
20
+ const w = this.#waiters;
21
+ this.#waiters = undefined;
22
+ w?.resolve(v);
23
+ for (const l of [...this.#listeners]) {
24
+ try {
25
+ l(v);
26
+ }
27
+ catch (e) {
28
+ queueMicrotask(() => { throw e; });
29
+ }
30
+ }
31
+ }
32
+ update(f) { this.set(f(this.#v)); }
33
+ subscribe(l) { this.#listeners.add(l); return () => { this.#listeners.delete(l); }; }
34
+ changed() { this.#waiters ??= deferred(); return this.#waiters.promise; }
35
+ get listenerCount() { return this.#listeners.size; }
36
+ }
37
+ export const DONE = Symbol("done");
38
+ /**
39
+ * Bounded FIFO with one consumer. `push` never blocks: past `capacity` the oldest item is dropped
40
+ * and `onDrop` is told (`capacity` `Infinity`: unbounded, nothing is dropped). `close()` ends the
41
+ * stream after buffered items; `close(true)` discards them.
42
+ */
43
+ export class AsyncQueue {
44
+ capacity;
45
+ onDrop;
46
+ #items = [];
47
+ #waiter;
48
+ #closed = false;
49
+ #drained;
50
+ constructor(capacity, onDrop) {
51
+ this.capacity = capacity;
52
+ this.onDrop = onDrop;
53
+ }
54
+ get size() { return this.#items.length; }
55
+ get closed() { return this.#closed; }
56
+ push(v) {
57
+ if (this.#closed)
58
+ return false;
59
+ if (this.#waiter) {
60
+ const w = this.#waiter;
61
+ this.#waiter = undefined;
62
+ w.resolve(v);
63
+ return true;
64
+ }
65
+ if (this.#items.length >= this.capacity) {
66
+ const d = this.#items.shift();
67
+ this.onDrop?.(d);
68
+ }
69
+ this.#items.push(v);
70
+ return true;
71
+ }
72
+ /**
73
+ * Backpressure form: waits while the queue is full instead of dropping, so a reader that awaits
74
+ * it stops taking from the transport (QUIC flow control then pushes back on the publisher).
75
+ * Resolves `false` when the queue is closed meanwhile.
76
+ */
77
+ async pushWhenRoom(v) {
78
+ while (!this.#closed && !this.#waiter && this.#items.length >= this.capacity) {
79
+ this.#room ??= deferred();
80
+ await this.#room.promise;
81
+ }
82
+ return this.push(v);
83
+ }
84
+ #room;
85
+ #signalRoom() { if (this.#room) {
86
+ const r = this.#room;
87
+ this.#room = undefined;
88
+ r.resolve();
89
+ } }
90
+ pull() {
91
+ if (this.#items.length) {
92
+ const v = this.#items.shift();
93
+ this.#signalRoom();
94
+ if (this.#closed && this.#items.length === 0)
95
+ this.#drained?.resolve();
96
+ return Promise.resolve(v);
97
+ }
98
+ if (this.#closed)
99
+ return Promise.resolve(DONE);
100
+ this.#waiter ??= deferred();
101
+ return this.#waiter.promise;
102
+ }
103
+ /**
104
+ * `pull()` as `undefined` at the end. A `next()` that is waiting receives the next pushed item
105
+ * (it is not buffered): a caller that races `next()` against something else and loses must keep
106
+ * that promise and use it later, or the item it receives is lost.
107
+ */
108
+ next() { return this.pull().then(v => (v === DONE ? undefined : v)); }
109
+ /** An item already queued, without waiting. */
110
+ tryNext() {
111
+ if (!this.#items.length)
112
+ return undefined;
113
+ const v = this.#items.shift();
114
+ this.#signalRoom();
115
+ if (this.#closed && this.#items.length === 0)
116
+ this.#drained?.resolve();
117
+ return v;
118
+ }
119
+ /** Ends the queue. Buffered items are still delivered unless `discard`. Resolves when drained. */
120
+ close(discard = false) {
121
+ if (discard)
122
+ this.#items.length = 0;
123
+ this.#closed = true;
124
+ this.#signalRoom();
125
+ if (this.#waiter) {
126
+ const w = this.#waiter;
127
+ this.#waiter = undefined;
128
+ w.resolve(DONE);
129
+ }
130
+ if (this.#items.length === 0)
131
+ return Promise.resolve();
132
+ this.#drained ??= deferred();
133
+ return this.#drained.promise;
134
+ }
135
+ async *[Symbol.asyncIterator]() {
136
+ for (;;) {
137
+ const v = await this.pull();
138
+ if (v === DONE)
139
+ return;
140
+ yield v;
141
+ }
142
+ }
143
+ }
144
+ export function sleep(ms, signal) {
145
+ return new Promise((resolve, reject) => {
146
+ if (signal?.aborted) {
147
+ reject(signal.reason);
148
+ return;
149
+ }
150
+ const t = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(); }, ms);
151
+ const onAbort = () => { clearTimeout(t); reject(signal.reason); };
152
+ signal?.addEventListener("abort", onAbort, { once: true });
153
+ });
154
+ }
155
+ export const TIMEOUT = Symbol("timeout");
156
+ /** Races `p` against a timeout; resolves to TIMEOUT instead of throwing so callers can branch. */
157
+ export function withTimeout(p, ms) {
158
+ let t;
159
+ return Promise.race([p.finally(() => clearTimeout(t)), new Promise(r => { t = setTimeout(() => r(TIMEOUT), ms); })]);
160
+ }
161
+ export function nowMs() { return performance.now(); }
162
+ /** Keeps `prev` references wherever `next` is deep-equal to them (TanStack Query's replaceEqualDeep). */
163
+ export function replaceEqualDeep(prev, next) {
164
+ if (prev === next)
165
+ return next;
166
+ const pa = Array.isArray(prev), na = Array.isArray(next);
167
+ if (pa && na) {
168
+ const p = prev, n = next;
169
+ let equal = p.length === n.length;
170
+ const out = new Array(n.length);
171
+ for (let i = 0; i < n.length; i++) {
172
+ out[i] = replaceEqualDeep(p[i], n[i]);
173
+ if (out[i] !== p[i])
174
+ equal = false;
175
+ }
176
+ return (equal ? prev : out);
177
+ }
178
+ if (!pa && !na && isPlainObject(prev) && isPlainObject(next)) {
179
+ const p = prev, n = next;
180
+ const nk = Object.keys(n);
181
+ const pk = Object.keys(p);
182
+ let equal = nk.length === pk.length;
183
+ const out = {};
184
+ for (const k of nk) {
185
+ out[k] = replaceEqualDeep(p[k], n[k]);
186
+ if (out[k] !== p[k] || !(k in p))
187
+ equal = false;
188
+ }
189
+ return (equal ? prev : out);
190
+ }
191
+ return next;
192
+ }
193
+ function isPlainObject(v) {
194
+ if (!v || typeof v !== "object")
195
+ return false;
196
+ const proto = Object.getPrototypeOf(v);
197
+ return proto === Object.prototype || proto === null;
198
+ }
199
+ export function shallowEqual(a, b) {
200
+ if (a === b)
201
+ return true;
202
+ if (!a || !b || typeof a !== "object" || typeof b !== "object")
203
+ return false;
204
+ const ka = Object.keys(a), kb = Object.keys(b);
205
+ if (ka.length !== kb.length)
206
+ return false;
207
+ for (const k of ka)
208
+ if (a[k] !== b[k])
209
+ return false;
210
+ return true;
211
+ }
212
+ /** Percentile over a small ring of samples. */
213
+ export class Ring {
214
+ cap;
215
+ #buf = [];
216
+ constructor(cap = 512) {
217
+ this.cap = cap;
218
+ }
219
+ push(v) { if (this.#buf.length >= this.cap)
220
+ this.#buf.shift(); this.#buf.push(v); }
221
+ percentile(p) {
222
+ if (!this.#buf.length)
223
+ return undefined;
224
+ const s = [...this.#buf].sort((a, b) => a - b);
225
+ return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))];
226
+ }
227
+ }
package/dist/view.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ import type { Node } from "./node.ts";
2
+ import type { Subscription, View, ViewOptions, ViewState } from "./types.ts";
3
+ export declare class TopicView<T, S> implements View<S> {
4
+ #private;
5
+ private readonly node;
6
+ private readonly opts;
7
+ private readonly onClose;
8
+ constructor(node: Node, open: () => Subscription<T>, opts: ViewOptions<T, S>, onClose: () => void);
9
+ get(): ViewState<S>;
10
+ /** Resolves on the first value; `not_connected` if the node ends (or the view is closed) first. */
11
+ ready(): Promise<void>;
12
+ /** Resolves with the state after the next change; a closed view resolves with its final state. */
13
+ changed(): Promise<ViewState<S>>;
14
+ /** Called on every state change; the returned function removes the listener. */
15
+ subscribe(listener: () => void): () => void;
16
+ /** Ends the view for good: the subscription is closed, listeners are dropped, waiters settle, the registry entry is released. */
17
+ close(): void;
18
+ [Symbol.asyncDispose](): Promise<void>;
19
+ }
package/dist/view.js ADDED
@@ -0,0 +1,145 @@
1
+ // A view (SDK §4.3): a topic's value now, as a cell for frameworks — `get()` any time, `subscribe`
2
+ // for changes, `changed()` for the next one, `ready()` for the first. State, not history: of every
3
+ // batch the subscription returns the view keeps the newest envelope (by its publisher's stamp),
4
+ // compared with structural sharing so `get()` is reference-stable while the selected value is
5
+ // deep-equal. The view is subscribed from its creation until `close()`, like the Rust `View`.
6
+ import { SynapseError } from "@synapse-sdk/core";
7
+ import { deferred, replaceEqualDeep } from "./util.js";
8
+ export class TopicView {
9
+ node;
10
+ opts;
11
+ onClose;
12
+ #state;
13
+ #listeners = new Set();
14
+ #sub;
15
+ #staleTimer;
16
+ #unwatchLink;
17
+ #ready;
18
+ #changed;
19
+ #closed = false;
20
+ constructor(node, open, opts, onClose) {
21
+ this.node = node;
22
+ this.opts = opts;
23
+ this.onClose = onClose;
24
+ this.#state = { data: undefined, status: "pending", connection: node.state().status, lastChangeAtUs: undefined };
25
+ this.#sub = open();
26
+ this.#unwatchLink = node.link.subscribe(() => this.#set({ connection: node.state().status }));
27
+ void this.#read();
28
+ }
29
+ get() { return this.#state; }
30
+ /** Resolves on the first value; `not_connected` if the node ends (or the view is closed) first. */
31
+ ready() {
32
+ if (!this.#ready) {
33
+ this.#ready = deferred();
34
+ this.#ready.promise.catch(() => { });
35
+ this.#settleReady();
36
+ }
37
+ return this.#ready.promise;
38
+ }
39
+ #settleReady() {
40
+ const d = this.#ready;
41
+ if (!d || d.settled)
42
+ return;
43
+ const s = this.#state;
44
+ if (s.status === "live" || s.status === "stale")
45
+ d.resolve();
46
+ else if (s.connection === "closed" || s.connection === "failed" || this.#closed)
47
+ d.reject(new SynapseError("not_connected", "the node ended before the view got a value"));
48
+ }
49
+ /** Resolves with the state after the next change; a closed view resolves with its final state. */
50
+ changed() {
51
+ if (this.#closed)
52
+ return Promise.resolve(this.#state);
53
+ this.#changed ??= deferred();
54
+ return this.#changed.promise;
55
+ }
56
+ #settleChanged() {
57
+ const c = this.#changed;
58
+ this.#changed = undefined;
59
+ c?.resolve(this.#state);
60
+ }
61
+ /** Called on every state change; the returned function removes the listener. */
62
+ subscribe(listener) {
63
+ if (this.#closed)
64
+ return () => { };
65
+ this.#listeners.add(listener);
66
+ return () => { this.#listeners.delete(listener); };
67
+ }
68
+ async #read() {
69
+ for (;;) {
70
+ const batch = await this.#sub.next();
71
+ if (!batch) {
72
+ if (!this.#closed)
73
+ this.#set({ connection: this.node.state().status });
74
+ return;
75
+ }
76
+ if (this.#closed)
77
+ return;
78
+ let newest = batch[0];
79
+ for (const e of batch)
80
+ if (e.tUs > newest.tUs)
81
+ newest = e;
82
+ this.#onData(newest);
83
+ }
84
+ }
85
+ #onData(env) {
86
+ let selected;
87
+ try {
88
+ selected = this.opts.select ? this.opts.select(env.data) : env.data;
89
+ }
90
+ catch (e) {
91
+ queueMicrotask(() => { throw e; });
92
+ return;
93
+ }
94
+ const prev = this.#state.data;
95
+ const next = replaceEqualDeep(prev, selected);
96
+ // `staleTime` counts from the value's own stamp when the publisher is in our time domain, else from arrival.
97
+ let staleInMs;
98
+ if (this.opts.staleTime !== undefined) {
99
+ const ageUs = env.dom === this.node.dom.id ? Math.max(0, this.node.currentSession()?.nowUs() ?? 0) - env.tUs : 0;
100
+ staleInMs = this.opts.staleTime - ageUs / 1000;
101
+ }
102
+ const status = staleInMs !== undefined && staleInMs <= 0 ? "stale" : "live";
103
+ if (next !== prev || this.#state.status !== status)
104
+ this.#set({ data: next, status, lastChangeAtUs: env.tUs });
105
+ if (this.#staleTimer !== undefined) {
106
+ clearTimeout(this.#staleTimer);
107
+ this.#staleTimer = undefined;
108
+ }
109
+ if (staleInMs !== undefined && staleInMs > 0)
110
+ this.#staleTimer = setTimeout(() => { this.#staleTimer = undefined; this.#set({ status: "stale" }); }, staleInMs);
111
+ }
112
+ #set(patch) {
113
+ const s = this.#state;
114
+ let changed = false;
115
+ for (const k of Object.keys(patch))
116
+ if (s[k] !== patch[k]) {
117
+ changed = true;
118
+ break;
119
+ }
120
+ if (!changed)
121
+ return;
122
+ this.#state = { ...s, ...patch };
123
+ for (const l of [...this.#listeners])
124
+ l();
125
+ this.#settleChanged();
126
+ this.#settleReady();
127
+ }
128
+ /** Ends the view for good: the subscription is closed, listeners are dropped, waiters settle, the registry entry is released. */
129
+ close() {
130
+ if (this.#closed)
131
+ return;
132
+ this.#closed = true;
133
+ this.#sub.unsubscribe();
134
+ this.#unwatchLink();
135
+ if (this.#staleTimer !== undefined) {
136
+ clearTimeout(this.#staleTimer);
137
+ this.#staleTimer = undefined;
138
+ }
139
+ this.#listeners.clear();
140
+ this.#settleChanged();
141
+ this.#settleReady();
142
+ this.onClose();
143
+ }
144
+ async [Symbol.asyncDispose]() { this.close(); }
145
+ }
package/package.json CHANGED
@@ -1,57 +1,36 @@
1
1
  {
2
2
  "name": "@synapse-sdk/client",
3
- "version": "0.1.0",
3
+ "version": "0.3.3",
4
+ "description": "Synapse SDK client: connect a node to the runtime; topics, services, calls, store, video.",
4
5
  "type": "module",
5
- "description": "Isomorphic TypeScript SDK for the Synapse robotics bus (Phoenix Channels).",
6
- "license": "UNLICENSED",
7
- "main": "./dist/index.js",
8
- "module": "./dist/index.js",
9
- "types": "./dist/index.d.ts",
6
+ "sideEffects": false,
10
7
  "exports": {
11
8
  ".": {
12
9
  "types": "./dist/index.d.ts",
13
- "import": "./dist/index.js"
14
- },
15
- "./react": {
16
- "types": "./dist/react.d.ts",
17
- "import": "./dist/react.js"
10
+ "default": "./dist/index.js"
18
11
  }
19
12
  },
20
- "files": ["dist"],
21
- "sideEffects": false,
22
- "engines": {
23
- "node": ">=20"
24
- },
25
- "scripts": {
26
- "build": "tsdown src/index.ts src/react.tsx --platform neutral --dts",
27
- "prepare": "tsdown src/index.ts src/react.tsx --platform neutral --dts",
28
- "test": "bun test",
29
- "typecheck": "tsc --noEmit"
30
- },
31
- "publishConfig": {
32
- "access": "restricted"
33
- },
13
+ "files": [
14
+ "dist"
15
+ ],
34
16
  "dependencies": {
35
- "phoenix": "^1.8.8"
17
+ "@synapse-sdk/core": "0.3.3",
18
+ "@moq/net": "0.4.2"
36
19
  },
37
20
  "peerDependencies": {
38
- "react": ">=18"
21
+ "@moq/web-transport": "0.1.5"
39
22
  },
40
23
  "peerDependenciesMeta": {
41
- "react": {
24
+ "@moq/web-transport": {
42
25
  "optional": true
43
26
  }
44
27
  },
45
- "devDependencies": {
46
- "@happy-dom/global-registrator": "^20.10.2",
47
- "@testing-library/dom": "^10.4.1",
48
- "@testing-library/react": "^16.3.2",
49
- "@types/bun": "latest",
50
- "@types/phoenix": "^1.6.7",
51
- "@types/react": "^19.2.17",
52
- "react": "^19.2.7",
53
- "react-dom": "^19.2.7",
54
- "tsdown": "^0.22.2",
55
- "typescript": "^5"
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "license": "UNLICENSED",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/taiga-robotics/synapse"
56
35
  }
57
36
  }