@synapse-sdk/core 0.0.0-stage → 0.3.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 +2 -2
- package/dist/clock.d.ts +105 -0
- package/dist/clock.js +136 -0
- package/dist/contracts.d.ts +127 -0
- package/dist/contracts.js +148 -0
- package/dist/error_codes.json +212 -0
- package/dist/errors.d.ts +55 -0
- package/dist/errors.js +75 -0
- package/dist/fec.d.ts +64 -0
- package/dist/fec.js +175 -0
- package/dist/fec_flow.d.ts +117 -0
- package/dist/fec_flow.js +156 -0
- package/dist/fec_wasm.d.ts +13 -0
- package/dist/fec_wasm.js +40 -0
- package/dist/header.d.ts +109 -0
- package/dist/header.js +245 -0
- package/dist/identity.d.ts +16 -0
- package/dist/identity.js +24 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +16 -0
- package/dist/media.d.ts +52 -0
- package/dist/media.js +58 -0
- package/dist/msgpack.d.ts +23 -0
- package/dist/msgpack.js +437 -0
- package/dist/schema.d.ts +6 -0
- package/dist/schema.js +23 -0
- package/dist/sha256.d.ts +2 -0
- package/dist/sha256.js +70 -0
- package/dist/standard.d.ts +48 -0
- package/dist/standard.js +14 -0
- package/dist/time.d.ts +10 -0
- package/dist/time.js +85 -0
- package/dist/types.d.ts +265 -0
- package/dist/types.js +102 -0
- package/package.json +32 -4
- package/wasm/synapse_wasm.d.ts +162 -0
- package/wasm/synapse_wasm.js +474 -0
- package/wasm/synapse_wasm_bg.wasm +0 -0
- package/wasm/synapse_wasm_bg.wasm.d.ts +28 -0
package/README.md
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @synapse-sdk/core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Part of the Synapse SDK for JavaScript. Documentation: the repository.
|
package/dist/clock.d.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { TimeOffsets, TimeRequest, TimeResponse } from "./types.ts";
|
|
2
|
+
/** Probes sent back to back at connect; the lowest-RTT one is the first estimate. */
|
|
3
|
+
export declare const CLOCK_BURST = 7;
|
|
4
|
+
/** One probe every 2 s after the burst. */
|
|
5
|
+
export declare const CLOCK_INTERVAL_US = 2000000;
|
|
6
|
+
/** The sliding window the candidate (lowest RTT) is picked from. */
|
|
7
|
+
export declare const CLOCK_WINDOW = 15;
|
|
8
|
+
/** Largest change to the estimate per probe after the burst, microseconds. */
|
|
9
|
+
export declare const CLOCK_MAX_SLEW_US = 500;
|
|
10
|
+
/** The four timestamps of one probe (Cristian / SNTP): `t1` local send, `t2` remote receive, `t3` remote send, `t4` local receive. */
|
|
11
|
+
export interface Probe {
|
|
12
|
+
t1: number;
|
|
13
|
+
t2: number;
|
|
14
|
+
t3: number;
|
|
15
|
+
t4: number;
|
|
16
|
+
}
|
|
17
|
+
/** Local clock minus remote clock: `((t1 − t2) + (t4 − t3)) / 2` (integer division toward zero, as Rust's i64 `/`). Off by at most `probeRttUs / 2` on an uneven path. */
|
|
18
|
+
export declare function probeOffsetUs(p: Probe): number;
|
|
19
|
+
/** Network round trip `(t4 − t1) − (t3 − t2)`: the remote's own processing time is excluded. */
|
|
20
|
+
export declare function probeRttUs(p: Probe): number;
|
|
21
|
+
/** One answered probe as the window keeps it. */
|
|
22
|
+
export interface ClockSample {
|
|
23
|
+
/** Local minus remote, from this probe alone. */
|
|
24
|
+
offsetUs: number;
|
|
25
|
+
rttUs: number;
|
|
26
|
+
/** Local clock when the answer arrived (`t4`). */
|
|
27
|
+
at: number;
|
|
28
|
+
}
|
|
29
|
+
/** What a node reports about its clock (SDK §4.4 `clock()`); the camelCase spelling of Rust's `ClockInfo`. */
|
|
30
|
+
export interface ClockInfo {
|
|
31
|
+
/** This node's time-domain id. */
|
|
32
|
+
dom: string;
|
|
33
|
+
/** The broker's time-domain id (from the hello). */
|
|
34
|
+
brokerDom: string;
|
|
35
|
+
/** `dom − brokerDom`, microseconds; 0 until the first sample and when `sameDom`. */
|
|
36
|
+
offsetUs: number;
|
|
37
|
+
/** Error bound on `offsetUs`; 0 when `sameDom`. */
|
|
38
|
+
epsUs: number;
|
|
39
|
+
/** Round trip of the sample the estimate rests on. */
|
|
40
|
+
rttUs: number;
|
|
41
|
+
/** `offsetUs` now minus the offset at the end of the connect burst: the drift tracked so far. */
|
|
42
|
+
driftUs: number;
|
|
43
|
+
/** Probes answered on this session. */
|
|
44
|
+
samples: number;
|
|
45
|
+
/** `dom === brokerDom`: the broker's clock is ours, nothing to estimate. */
|
|
46
|
+
sameDom: boolean;
|
|
47
|
+
/** Local clock when the estimate last changed (`undefined` before the first sample). */
|
|
48
|
+
updatedAt: number | undefined;
|
|
49
|
+
}
|
|
50
|
+
/** Whether `offsetUs`/`epsUs` mean anything yet: same domain, or at least one sample. */
|
|
51
|
+
export declare function hasEstimate(c: ClockInfo): boolean;
|
|
52
|
+
/**
|
|
53
|
+
* The estimator for one session. Drive it with `nextProbeAt()`, `request()` and `response()`;
|
|
54
|
+
* read `info()` (a new object on every change, so it can sit in a cell).
|
|
55
|
+
*/
|
|
56
|
+
export declare class ClockSync {
|
|
57
|
+
#private;
|
|
58
|
+
/** A fresh estimator for a session whose hello named `brokerDom`. When it equals `dom` the estimator is done at once: 0 ± 0, `nextProbeAt()` undefined. */
|
|
59
|
+
constructor(dom: string, brokerDom: string, nowUs: number);
|
|
60
|
+
info(): ClockInfo;
|
|
61
|
+
get sameDom(): boolean;
|
|
62
|
+
/** The window's samples, oldest first. */
|
|
63
|
+
samples(): readonly ClockSample[];
|
|
64
|
+
/**
|
|
65
|
+
* When the next probe is due in the local clock: `undefined` when there is nothing to probe (same
|
|
66
|
+
* domain), the time of the previous probe (i.e. right away) while the burst is not spent, then
|
|
67
|
+
* `CLOCK_INTERVAL_US` after the previous probe. A probe that got no answer still counts as sent,
|
|
68
|
+
* so a dead link is probed at the interval, not hammered.
|
|
69
|
+
*/
|
|
70
|
+
nextProbeAt(): number | undefined;
|
|
71
|
+
/** Builds the next `synapse/time` request with `t1 = nowUs` and the current estimate, if any. Marks the probe sent; the answer goes to `response()`. */
|
|
72
|
+
request(nowUs: number): TimeRequest;
|
|
73
|
+
/** The probe got no answer (timeout, session lost): forget it; the schedule goes on. */
|
|
74
|
+
probeLost(): void;
|
|
75
|
+
/**
|
|
76
|
+
* Records the answer to the outstanding probe, read at local time `t4`, and updates the estimate.
|
|
77
|
+
* Returns the sample, or `undefined` when the answer is not to the outstanding probe (`t1`
|
|
78
|
+
* differs: late or duplicate) or the broker turned out to share our domain (then the estimator
|
|
79
|
+
* switches to the same-domain shortcut).
|
|
80
|
+
*/
|
|
81
|
+
response(resp: TimeResponse, t4: number): ClockSample | undefined;
|
|
82
|
+
}
|
|
83
|
+
/** A publisher's stamp converted into our clock, with the error bound to fold into any freshness judgment. */
|
|
84
|
+
export interface LocalTime {
|
|
85
|
+
localUs: number;
|
|
86
|
+
epsUs: number;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Converts `tUs`, stamped by a publisher in time domain `dom`, into the clock of the node `me`
|
|
90
|
+
* describes (SDK §4.4, the three judgment modes):
|
|
91
|
+
* - same domain (`dom === me.dom`): exact, `epsUs` 0;
|
|
92
|
+
* - different domain with a known offset: through the broker's clock,
|
|
93
|
+
* `local = tUs − offset(dom) + offset(me)`, `eps = eps(dom) + eps(me)`; the broker's own domain
|
|
94
|
+
* has offset 0 and eps 0, the publisher's comes from the `runtime/time` table, ours from `me`;
|
|
95
|
+
* - no entry for `dom` (or no estimate of our own yet): `undefined`, no judgment: never drop on age.
|
|
96
|
+
*/
|
|
97
|
+
export declare function toLocalUs(me: ClockInfo, table: TimeOffsets, tUs: number, dom: string): LocalTime | undefined;
|
|
98
|
+
/** How old a frame is on our clock (`nowUs − local`), with the conversion's error bound; `undefined` when no judgment is possible. */
|
|
99
|
+
export interface Age {
|
|
100
|
+
ageUs: number;
|
|
101
|
+
epsUs: number;
|
|
102
|
+
}
|
|
103
|
+
export declare function ageOf(me: ClockInfo, table: TimeOffsets, tUs: number, dom: string, nowUs: number): Age | undefined;
|
|
104
|
+
/** The erring-stale rule (SDK §4.4): a frame that *might* be older than `thresholdUs` counts as stale; an unknown age is never judged. */
|
|
105
|
+
export declare function isStale(age: Age | undefined, thresholdUs: number): boolean;
|
package/dist/clock.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/** Probes sent back to back at connect; the lowest-RTT one is the first estimate. */
|
|
2
|
+
export const CLOCK_BURST = 7;
|
|
3
|
+
/** One probe every 2 s after the burst. */
|
|
4
|
+
export const CLOCK_INTERVAL_US = 2_000_000;
|
|
5
|
+
/** The sliding window the candidate (lowest RTT) is picked from. */
|
|
6
|
+
export const CLOCK_WINDOW = 15;
|
|
7
|
+
/** Largest change to the estimate per probe after the burst, microseconds. */
|
|
8
|
+
export const CLOCK_MAX_SLEW_US = 500;
|
|
9
|
+
const U32_MAX = 0xffff_ffff;
|
|
10
|
+
/** Local clock minus remote clock: `((t1 − t2) + (t4 − t3)) / 2` (integer division toward zero, as Rust's i64 `/`). Off by at most `probeRttUs / 2` on an uneven path. */
|
|
11
|
+
export function probeOffsetUs(p) { return Math.trunc((p.t1 - p.t2 + (p.t4 - p.t3)) / 2); }
|
|
12
|
+
/** Network round trip `(t4 − t1) − (t3 − t2)`: the remote's own processing time is excluded. */
|
|
13
|
+
export function probeRttUs(p) { return p.t4 - p.t1 - (p.t3 - p.t2); }
|
|
14
|
+
function freshClockInfo(dom, brokerDom, updatedAt) {
|
|
15
|
+
const sameDom = dom === brokerDom;
|
|
16
|
+
return { dom, brokerDom, offsetUs: 0, epsUs: 0, rttUs: 0, driftUs: 0, samples: 0, sameDom, updatedAt: sameDom ? updatedAt : undefined };
|
|
17
|
+
}
|
|
18
|
+
/** Whether `offsetUs`/`epsUs` mean anything yet: same domain, or at least one sample. */
|
|
19
|
+
export function hasEstimate(c) { return c.sameDom || c.samples > 0; }
|
|
20
|
+
/**
|
|
21
|
+
* The estimator for one session. Drive it with `nextProbeAt()`, `request()` and `response()`;
|
|
22
|
+
* read `info()` (a new object on every change, so it can sit in a cell).
|
|
23
|
+
*/
|
|
24
|
+
export class ClockSync {
|
|
25
|
+
#info;
|
|
26
|
+
#window = [];
|
|
27
|
+
#estimate;
|
|
28
|
+
#sent = 0;
|
|
29
|
+
#outstanding;
|
|
30
|
+
#lastProbeAt;
|
|
31
|
+
#startedAt;
|
|
32
|
+
/** A fresh estimator for a session whose hello named `brokerDom`. When it equals `dom` the estimator is done at once: 0 ± 0, `nextProbeAt()` undefined. */
|
|
33
|
+
constructor(dom, brokerDom, nowUs) {
|
|
34
|
+
this.#info = freshClockInfo(dom, brokerDom, nowUs);
|
|
35
|
+
this.#startedAt = nowUs;
|
|
36
|
+
}
|
|
37
|
+
info() { return this.#info; }
|
|
38
|
+
get sameDom() { return this.#info.sameDom; }
|
|
39
|
+
/** The window's samples, oldest first. */
|
|
40
|
+
samples() { return this.#window; }
|
|
41
|
+
/**
|
|
42
|
+
* When the next probe is due in the local clock: `undefined` when there is nothing to probe (same
|
|
43
|
+
* domain), the time of the previous probe (i.e. right away) while the burst is not spent, then
|
|
44
|
+
* `CLOCK_INTERVAL_US` after the previous probe. A probe that got no answer still counts as sent,
|
|
45
|
+
* so a dead link is probed at the interval, not hammered.
|
|
46
|
+
*/
|
|
47
|
+
nextProbeAt() {
|
|
48
|
+
if (this.#info.sameDom)
|
|
49
|
+
return undefined;
|
|
50
|
+
if (this.#lastProbeAt === undefined)
|
|
51
|
+
return this.#startedAt;
|
|
52
|
+
return this.#sent < CLOCK_BURST ? this.#lastProbeAt : this.#lastProbeAt + CLOCK_INTERVAL_US;
|
|
53
|
+
}
|
|
54
|
+
/** Builds the next `synapse/time` request with `t1 = nowUs` and the current estimate, if any. Marks the probe sent; the answer goes to `response()`. */
|
|
55
|
+
request(nowUs) {
|
|
56
|
+
this.#sent++;
|
|
57
|
+
this.#lastProbeAt = nowUs;
|
|
58
|
+
this.#outstanding = nowUs;
|
|
59
|
+
const req = { t1: nowUs, dom: this.#info.dom };
|
|
60
|
+
if (this.#estimate && !this.#info.sameDom) {
|
|
61
|
+
req.offset_us = this.#estimate.offsetUs;
|
|
62
|
+
req.rtt_us = this.#info.rttUs;
|
|
63
|
+
req.eps_us = this.#info.epsUs;
|
|
64
|
+
}
|
|
65
|
+
return req;
|
|
66
|
+
}
|
|
67
|
+
/** The probe got no answer (timeout, session lost): forget it; the schedule goes on. */
|
|
68
|
+
probeLost() { this.#outstanding = undefined; }
|
|
69
|
+
/**
|
|
70
|
+
* Records the answer to the outstanding probe, read at local time `t4`, and updates the estimate.
|
|
71
|
+
* Returns the sample, or `undefined` when the answer is not to the outstanding probe (`t1`
|
|
72
|
+
* differs: late or duplicate) or the broker turned out to share our domain (then the estimator
|
|
73
|
+
* switches to the same-domain shortcut).
|
|
74
|
+
*/
|
|
75
|
+
response(resp, t4) {
|
|
76
|
+
if (this.#outstanding !== resp.t1)
|
|
77
|
+
return undefined;
|
|
78
|
+
this.#outstanding = undefined;
|
|
79
|
+
if (resp.dom === this.#info.dom) {
|
|
80
|
+
this.#info = freshClockInfo(this.#info.dom, resp.dom, t4);
|
|
81
|
+
this.#estimate = undefined;
|
|
82
|
+
this.#window.length = 0;
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
const probe = { t1: resp.t1, t2: resp.t2, t3: resp.t3, t4 };
|
|
86
|
+
const sample = { offsetUs: probeOffsetUs(probe), rttUs: Math.min(Math.max(probeRttUs(probe), 0), U32_MAX), at: t4 };
|
|
87
|
+
this.#window.push(sample);
|
|
88
|
+
while (this.#window.length > CLOCK_WINDOW)
|
|
89
|
+
this.#window.shift();
|
|
90
|
+
// The candidate: lowest RTT in the window, the newest on a tie (it carries the least drift).
|
|
91
|
+
let candidate = this.#window[0];
|
|
92
|
+
for (const s of this.#window)
|
|
93
|
+
if (s.rttUs <= candidate.rttUs)
|
|
94
|
+
candidate = s;
|
|
95
|
+
const inBurst = this.#sent <= CLOCK_BURST;
|
|
96
|
+
const prev = this.#estimate;
|
|
97
|
+
// No estimate yet, or still in the burst: the lowest-RTT sample *is* the estimate; afterwards slew toward it.
|
|
98
|
+
const offsetUs = !prev || inBurst ? candidate.offsetUs : prev.offsetUs + Math.min(Math.max(candidate.offsetUs - prev.offsetUs, -CLOCK_MAX_SLEW_US), CLOCK_MAX_SLEW_US);
|
|
99
|
+
const initialUs = prev && !inBurst ? prev.initialUs : offsetUs;
|
|
100
|
+
this.#estimate = { offsetUs, initialUs };
|
|
101
|
+
const residual = Math.min(Math.abs(candidate.offsetUs - offsetUs), U32_MAX);
|
|
102
|
+
const i = this.#info;
|
|
103
|
+
this.#info = { ...i, offsetUs, rttUs: candidate.rttUs, epsUs: Math.min(Math.trunc(candidate.rttUs / 2) + residual, U32_MAX), driftUs: offsetUs - initialUs, samples: i.samples + 1, updatedAt: t4 };
|
|
104
|
+
return sample;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Converts `tUs`, stamped by a publisher in time domain `dom`, into the clock of the node `me`
|
|
109
|
+
* describes (SDK §4.4, the three judgment modes):
|
|
110
|
+
* - same domain (`dom === me.dom`): exact, `epsUs` 0;
|
|
111
|
+
* - different domain with a known offset: through the broker's clock,
|
|
112
|
+
* `local = tUs − offset(dom) + offset(me)`, `eps = eps(dom) + eps(me)`; the broker's own domain
|
|
113
|
+
* has offset 0 and eps 0, the publisher's comes from the `runtime/time` table, ours from `me`;
|
|
114
|
+
* - no entry for `dom` (or no estimate of our own yet): `undefined`, no judgment: never drop on age.
|
|
115
|
+
*/
|
|
116
|
+
export function toLocalUs(me, table, tUs, dom) {
|
|
117
|
+
if (dom === me.dom)
|
|
118
|
+
return { localUs: tUs, epsUs: 0 };
|
|
119
|
+
if (!hasEstimate(me))
|
|
120
|
+
return undefined;
|
|
121
|
+
let offPub = 0, epsPub = 0;
|
|
122
|
+
if (dom !== me.brokerDom) {
|
|
123
|
+
const e = table[dom];
|
|
124
|
+
if (!e)
|
|
125
|
+
return undefined;
|
|
126
|
+
offPub = e.offset_us;
|
|
127
|
+
epsPub = e.eps_us;
|
|
128
|
+
}
|
|
129
|
+
return { localUs: tUs - offPub + me.offsetUs, epsUs: Math.min(epsPub + me.epsUs, U32_MAX) };
|
|
130
|
+
}
|
|
131
|
+
export function ageOf(me, table, tUs, dom, nowUs) {
|
|
132
|
+
const l = toLocalUs(me, table, tUs, dom);
|
|
133
|
+
return l && { ageUs: nowUs - l.localUs, epsUs: l.epsUs };
|
|
134
|
+
}
|
|
135
|
+
/** The erring-stale rule (SDK §4.4): a frame that *might* be older than `thresholdUs` counts as stale; an unknown age is never judged. */
|
|
136
|
+
export function isStale(age, thresholdUs) { return age !== undefined && age.ageUs + age.epsUs > thresholdUs; }
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { type JsonSchema } from "./schema.ts";
|
|
2
|
+
import type { StandardSchemaV1 } from "./standard.ts";
|
|
3
|
+
import { type Card, type Delivery, type FecParams, type Role, type TrackDesc, type TimeDomain } from "./types.ts";
|
|
4
|
+
export type ToJsonSchema = (schema: StandardSchemaV1, io: "input" | "output") => JsonSchema;
|
|
5
|
+
/** Registers a fallback converter for validators without `~standard.jsonSchema` (e.g. valibot via `@valibot/to-json-schema`). */
|
|
6
|
+
export declare function setJsonSchemaConverter(fn: ToJsonSchema | undefined): void;
|
|
7
|
+
/** JSON Schema 2020-12 for a Standard Schema. Uses `~standard.jsonSchema` when the library ships it, else the converter. `$schema` is dropped so hashes do not depend on the dialect URL. */
|
|
8
|
+
export declare function jsonSchemaOf(schema: StandardSchemaV1, io: "input" | "output", toJsonSchema?: ToJsonSchema): JsonSchema;
|
|
9
|
+
/** The MIME type of a msgpack body, the default `contentType`. */
|
|
10
|
+
export declare const MSGPACK = "application/msgpack";
|
|
11
|
+
/** The MIME type of a body the SDK does not touch (a `raw` topic). */
|
|
12
|
+
export declare const OCTET_STREAM = "application/octet-stream";
|
|
13
|
+
export interface TopicDef<S extends StandardSchemaV1 | undefined = StandardSchemaV1 | undefined> {
|
|
14
|
+
/** Slash path (`imu`, `cam/front`). */
|
|
15
|
+
name: string;
|
|
16
|
+
/** The body's schema; its JSON Schema rides in the descriptor and the value type is inferred from it. */
|
|
17
|
+
schema?: S;
|
|
18
|
+
/** How frames travel (WP §6): `datagram` (the default: one datagram per message, newest wins) or `stream` (ordered, nothing dropped). */
|
|
19
|
+
delivery?: Delivery;
|
|
20
|
+
/** MIME type of the body; default `application/msgpack`. A topic with another type is `raw`: bodies are bytes. */
|
|
21
|
+
contentType?: string;
|
|
22
|
+
/** `datagram` only: FEC coding of each message into symbols (WP §6.3), for bodies larger than a datagram (video). `symbolSize` 0 = sized from the session's datagram budget. */
|
|
23
|
+
fec?: TopicFec | true;
|
|
24
|
+
/** Describes a raw body's layout for other readers (e.g. a video stream's codec and dimensions). */
|
|
25
|
+
layout?: Record<string, unknown>;
|
|
26
|
+
toJsonSchema?: ToJsonSchema;
|
|
27
|
+
}
|
|
28
|
+
export interface TopicFec {
|
|
29
|
+
symbolSize?: number;
|
|
30
|
+
repairPct?: number;
|
|
31
|
+
deadlineMs?: number;
|
|
32
|
+
}
|
|
33
|
+
export interface TopicContract<TIn = unknown, TOut = TIn> {
|
|
34
|
+
readonly kind: "topic";
|
|
35
|
+
readonly name: string;
|
|
36
|
+
readonly schema: StandardSchemaV1<TIn, TOut> | undefined;
|
|
37
|
+
readonly delivery: Delivery;
|
|
38
|
+
readonly contentType: string;
|
|
39
|
+
/** `true` when the body is not msgpack: published and delivered as bytes. */
|
|
40
|
+
readonly raw: boolean;
|
|
41
|
+
/** FEC parameters (wire shape); `symbol_size` 0 = sized from the session. Only on `datagram` topics. */
|
|
42
|
+
readonly fec: FecParams | undefined;
|
|
43
|
+
readonly layout: Record<string, unknown> | undefined;
|
|
44
|
+
/** JSON Schema of the wire body (input side: defaulted fields optional), or undefined for schemaless/raw topics. */
|
|
45
|
+
readonly jsonSchema: JsonSchema | undefined;
|
|
46
|
+
/** sha256 of the canonical JSON Schema; rides in the descriptor. */
|
|
47
|
+
readonly schemaHash: string | undefined;
|
|
48
|
+
/** Builds the WP §7.2 descriptor for a publisher in time domain `dom`. */
|
|
49
|
+
desc(dom: TimeDomain): TrackDesc;
|
|
50
|
+
/** Phantom types for inference; never set at runtime. */
|
|
51
|
+
readonly "~types"?: {
|
|
52
|
+
input: TIn;
|
|
53
|
+
output: TOut;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export type TopicInput<T> = T extends TopicContract<infer I, unknown> ? I : unknown;
|
|
57
|
+
export type TopicOutput<T> = T extends TopicContract<unknown, infer O> ? O : unknown;
|
|
58
|
+
export declare function defineTopic<S extends StandardSchemaV1>(def: TopicDef<S> & {
|
|
59
|
+
schema: S;
|
|
60
|
+
}): TopicContract<StandardSchemaV1.InferInput<S>, StandardSchemaV1.InferOutput<S>>;
|
|
61
|
+
export declare function defineTopic(def: TopicDef<undefined> & {
|
|
62
|
+
contentType: string;
|
|
63
|
+
}): TopicContract<Uint8Array, Uint8Array>;
|
|
64
|
+
export declare function defineTopic<T = unknown>(def: TopicDef<undefined>): TopicContract<T, T>;
|
|
65
|
+
/** A topic argument: a contract or a bare name (the string escape hatch: msgpack, `datagram`, no schema). */
|
|
66
|
+
export type TopicRef<TIn = unknown, TOut = TIn> = TopicContract<TIn, TOut> | string;
|
|
67
|
+
export declare function topicName(ref: TopicRef): string;
|
|
68
|
+
/** Resolves the string escape hatch to an ad-hoc contract. */
|
|
69
|
+
export declare function resolveTopic<TIn, TOut>(ref: TopicRef<TIn, TOut>): TopicContract<TIn, TOut>;
|
|
70
|
+
export interface ServiceDef<I extends StandardSchemaV1 | undefined, U extends StandardSchemaV1 | undefined, O extends StandardSchemaV1 | undefined> {
|
|
71
|
+
/** Slash path like a topic (`arm/getPose`); filled in by `defineApi` when omitted. */
|
|
72
|
+
name?: string;
|
|
73
|
+
input?: I;
|
|
74
|
+
/** The progress updates a long call sends (`ctx.update`); the card says `streaming` when present. */
|
|
75
|
+
update?: U;
|
|
76
|
+
output?: O;
|
|
77
|
+
/** Minimum caller role enforced by the broker (card `min_role`); default `agent`. */
|
|
78
|
+
minRole?: Role;
|
|
79
|
+
description?: string;
|
|
80
|
+
/** Whether a caller may cancel (card `cancelable`); default true. */
|
|
81
|
+
cancelable?: boolean;
|
|
82
|
+
toJsonSchema?: ToJsonSchema;
|
|
83
|
+
}
|
|
84
|
+
export interface ServiceContract<TArgs = unknown, TInput = TArgs, TUpdate = unknown, TResult = unknown> {
|
|
85
|
+
readonly kind: "service";
|
|
86
|
+
/** Service name, a slash path (`arm/getPose`); empty until `defineApi` names it. */
|
|
87
|
+
name: string;
|
|
88
|
+
readonly input: StandardSchemaV1<TArgs, TInput> | undefined;
|
|
89
|
+
readonly update: StandardSchemaV1<unknown, TUpdate> | undefined;
|
|
90
|
+
readonly output: StandardSchemaV1<unknown, TResult> | undefined;
|
|
91
|
+
readonly minRole: Role;
|
|
92
|
+
readonly description: string | undefined;
|
|
93
|
+
readonly cancelable: boolean;
|
|
94
|
+
readonly jsonSchemas: {
|
|
95
|
+
input?: JsonSchema;
|
|
96
|
+
update?: JsonSchema;
|
|
97
|
+
output?: JsonSchema;
|
|
98
|
+
};
|
|
99
|
+
/** Hash over `{input, update, output}` (what a caller compares a listed card against). */
|
|
100
|
+
readonly schemaHash: string;
|
|
101
|
+
/** Builds the WP §8.5 card. */
|
|
102
|
+
card(): Card;
|
|
103
|
+
readonly "~types"?: {
|
|
104
|
+
args: TArgs;
|
|
105
|
+
input: TInput;
|
|
106
|
+
update: TUpdate;
|
|
107
|
+
result: TResult;
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
export type ServiceArgs<S> = S extends ServiceContract<infer A, unknown, unknown, unknown> ? A : unknown;
|
|
111
|
+
export type ServiceInput<S> = S extends ServiceContract<unknown, infer I, unknown, unknown> ? I : unknown;
|
|
112
|
+
export type ServiceUpdate<S> = S extends ServiceContract<unknown, unknown, infer U, unknown> ? U : unknown;
|
|
113
|
+
export type ServiceResult<S> = S extends ServiceContract<unknown, unknown, unknown, infer R> ? R : unknown;
|
|
114
|
+
type InOr<S, D> = S extends StandardSchemaV1 ? StandardSchemaV1.InferInput<S> : D;
|
|
115
|
+
type OutOr<S, D> = S extends StandardSchemaV1 ? StandardSchemaV1.InferOutput<S> : D;
|
|
116
|
+
export declare function defineService<I extends StandardSchemaV1 | undefined = undefined, U extends StandardSchemaV1 | undefined = undefined, O extends StandardSchemaV1 | undefined = undefined>(def: ServiceDef<I, U, O>): ServiceContract<InOr<I, unknown>, OutOr<I, unknown>, OutOr<U, unknown>, OutOr<O, unknown>>;
|
|
117
|
+
/** A service argument: a contract or a bare name (the string escape hatch: untyped args and result). */
|
|
118
|
+
export type ServiceRef<TArgs = unknown, TInput = TArgs, TUpdate = unknown, TResult = unknown> = ServiceContract<TArgs, TInput, TUpdate, TResult> | string;
|
|
119
|
+
export declare function serviceName(ref: ServiceRef): string;
|
|
120
|
+
/** Nested tree of service contracts; `defineApi` names each leaf by its slash path (`arm/getPose`, like topics). */
|
|
121
|
+
export type ApiTree = {
|
|
122
|
+
readonly [k: string]: ServiceContract<any, any, any, any> | ApiTree;
|
|
123
|
+
};
|
|
124
|
+
export declare function defineApi<T extends ApiTree>(tree: T, prefix?: string): T;
|
|
125
|
+
export declare function isServiceContract(v: unknown): v is ServiceContract<any, any, any, any>;
|
|
126
|
+
export declare function isTopicContract(v: unknown): v is TopicContract<any, any>;
|
|
127
|
+
export {};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Contracts (SDK §3): `defineTopic`, `defineService`, `defineApi` on Standard Schema V1.
|
|
2
|
+
// A contract is a plain object; `import type` of one pulls no runtime code. A contract gives one
|
|
3
|
+
// declaration to both sides of a topic or a service: the publisher's/provider's descriptor or card
|
|
4
|
+
// (the JSON Schema the broker serves) and the subscriber's/caller's static types.
|
|
5
|
+
import { cardSchemaHash, schemaHash } from "./schema.js";
|
|
6
|
+
import { FEC_DEFAULTS } from "./types.js";
|
|
7
|
+
let globalToJsonSchema;
|
|
8
|
+
/** Registers a fallback converter for validators without `~standard.jsonSchema` (e.g. valibot via `@valibot/to-json-schema`). */
|
|
9
|
+
export function setJsonSchemaConverter(fn) { globalToJsonSchema = fn; }
|
|
10
|
+
/** JSON Schema 2020-12 for a Standard Schema. Uses `~standard.jsonSchema` when the library ships it, else the converter. `$schema` is dropped so hashes do not depend on the dialect URL. */
|
|
11
|
+
export function jsonSchemaOf(schema, io, toJsonSchema) {
|
|
12
|
+
const conv = schema["~standard"].jsonSchema;
|
|
13
|
+
let js;
|
|
14
|
+
if (conv)
|
|
15
|
+
js = conv[io]({ target: "draft-2020-12" });
|
|
16
|
+
else if (toJsonSchema ?? globalToJsonSchema)
|
|
17
|
+
js = (toJsonSchema ?? globalToJsonSchema)(schema, io);
|
|
18
|
+
else
|
|
19
|
+
throw new Error(`schema from ${schema["~standard"].vendor} has no ~standard.jsonSchema; pass toJsonSchema or call setJsonSchemaConverter()`);
|
|
20
|
+
if ("$schema" in js) {
|
|
21
|
+
const { $schema: _drop, ...rest } = js;
|
|
22
|
+
return rest;
|
|
23
|
+
}
|
|
24
|
+
return js;
|
|
25
|
+
}
|
|
26
|
+
function memo(f) { const m = { done: false }; return () => { if (!m.done) {
|
|
27
|
+
m.v = f();
|
|
28
|
+
m.done = true;
|
|
29
|
+
} return m.v; }; }
|
|
30
|
+
// ------------------------------------------------------------------------------------- topics
|
|
31
|
+
/** The MIME type of a msgpack body, the default `contentType`. */
|
|
32
|
+
export const MSGPACK = "application/msgpack";
|
|
33
|
+
/** The MIME type of a body the SDK does not touch (a `raw` topic). */
|
|
34
|
+
export const OCTET_STREAM = "application/octet-stream";
|
|
35
|
+
export function defineTopic(def) {
|
|
36
|
+
const contentType = def.contentType ?? MSGPACK;
|
|
37
|
+
const raw = contentType !== MSGPACK;
|
|
38
|
+
const schema = def.schema;
|
|
39
|
+
if (schema && raw)
|
|
40
|
+
throw new Error(`topic ${def.name}: a ${contentType} topic cannot carry a schema`);
|
|
41
|
+
const delivery = def.delivery ?? "datagram";
|
|
42
|
+
if (def.fec && delivery !== "datagram")
|
|
43
|
+
throw new Error(`topic ${def.name}: fec is a datagram setting (WP §6)`);
|
|
44
|
+
const f = def.fec === true ? {} : def.fec;
|
|
45
|
+
const fec = f ? { symbol_size: f.symbolSize ?? 0, repair_pct: f.repairPct ?? FEC_DEFAULTS.repair_pct, deadline_ms: f.deadlineMs ?? FEC_DEFAULTS.deadline_ms } : undefined;
|
|
46
|
+
if (fec && fec.symbol_size !== 0 && (fec.symbol_size < 8 || fec.symbol_size % 8 !== 0))
|
|
47
|
+
throw new Error(`topic ${def.name}: fec symbolSize must be a multiple of 8`);
|
|
48
|
+
const js = memo(() => (schema ? jsonSchemaOf(schema, "input", def.toJsonSchema) : undefined));
|
|
49
|
+
const hash = memo(() => { const s = js(); return s ? schemaHash(s) : undefined; });
|
|
50
|
+
const c = {
|
|
51
|
+
kind: "topic",
|
|
52
|
+
name: def.name,
|
|
53
|
+
schema,
|
|
54
|
+
delivery,
|
|
55
|
+
contentType,
|
|
56
|
+
raw,
|
|
57
|
+
fec,
|
|
58
|
+
layout: def.layout,
|
|
59
|
+
get jsonSchema() { return js(); },
|
|
60
|
+
get schemaHash() { return hash(); },
|
|
61
|
+
desc(dom) {
|
|
62
|
+
const d = { delivery: this.delivery, content_type: this.contentType, dom };
|
|
63
|
+
const s = js();
|
|
64
|
+
if (s) {
|
|
65
|
+
d.schema = s;
|
|
66
|
+
d.schema_hash = hash();
|
|
67
|
+
}
|
|
68
|
+
if (this.layout)
|
|
69
|
+
d.layout = this.layout;
|
|
70
|
+
if (this.fec)
|
|
71
|
+
d.fec = { ...this.fec };
|
|
72
|
+
return d;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
return c;
|
|
76
|
+
}
|
|
77
|
+
export function topicName(ref) { return typeof ref === "string" ? ref : ref.name; }
|
|
78
|
+
/** Resolves the string escape hatch to an ad-hoc contract. */
|
|
79
|
+
export function resolveTopic(ref) {
|
|
80
|
+
if (typeof ref !== "string")
|
|
81
|
+
return ref;
|
|
82
|
+
return defineTopic({ name: ref });
|
|
83
|
+
}
|
|
84
|
+
export function defineService(def) {
|
|
85
|
+
const js = memo(() => {
|
|
86
|
+
const out = {};
|
|
87
|
+
if (def.input)
|
|
88
|
+
out.input = jsonSchemaOf(def.input, "input", def.toJsonSchema);
|
|
89
|
+
if (def.update)
|
|
90
|
+
out.update = jsonSchemaOf(def.update, "output", def.toJsonSchema);
|
|
91
|
+
if (def.output)
|
|
92
|
+
out.output = jsonSchemaOf(def.output, "output", def.toJsonSchema);
|
|
93
|
+
return out;
|
|
94
|
+
});
|
|
95
|
+
const hash = memo(() => { const s = js(); return cardSchemaHash(s.input, s.update, s.output); });
|
|
96
|
+
const c = {
|
|
97
|
+
kind: "service",
|
|
98
|
+
name: def.name ?? "",
|
|
99
|
+
input: def.input,
|
|
100
|
+
update: def.update,
|
|
101
|
+
output: def.output,
|
|
102
|
+
minRole: def.minRole ?? "agent",
|
|
103
|
+
description: def.description,
|
|
104
|
+
cancelable: def.cancelable ?? true,
|
|
105
|
+
get jsonSchemas() { return js(); },
|
|
106
|
+
get schemaHash() { return hash(); },
|
|
107
|
+
card() {
|
|
108
|
+
if (!this.name)
|
|
109
|
+
throw new Error("service contract has no name; give it one or place it in defineApi()");
|
|
110
|
+
const s = js();
|
|
111
|
+
const card = { name: this.name, streaming: !!def.update, cancelable: this.cancelable, min_role: this.minRole };
|
|
112
|
+
if (this.description !== undefined)
|
|
113
|
+
card.description = this.description;
|
|
114
|
+
if (s.input)
|
|
115
|
+
card.input_schema = s.input;
|
|
116
|
+
if (s.output)
|
|
117
|
+
card.output_schema = s.output;
|
|
118
|
+
if (s.update)
|
|
119
|
+
card.update_schema = s.update;
|
|
120
|
+
return card;
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
return c;
|
|
124
|
+
}
|
|
125
|
+
export function serviceName(ref) {
|
|
126
|
+
const n = typeof ref === "string" ? ref : ref.name;
|
|
127
|
+
if (!n)
|
|
128
|
+
throw new Error("service contract has no name; give it one or place it in defineApi()");
|
|
129
|
+
return n;
|
|
130
|
+
}
|
|
131
|
+
export function defineApi(tree, prefix = "") {
|
|
132
|
+
for (const [k, v] of Object.entries(tree)) {
|
|
133
|
+
const path = prefix ? `${prefix}/${k}` : k;
|
|
134
|
+
if (isServiceContract(v)) {
|
|
135
|
+
if (!v.name)
|
|
136
|
+
v.name = path;
|
|
137
|
+
}
|
|
138
|
+
else
|
|
139
|
+
defineApi(v, path);
|
|
140
|
+
}
|
|
141
|
+
return tree;
|
|
142
|
+
}
|
|
143
|
+
export function isServiceContract(v) {
|
|
144
|
+
return !!v && typeof v === "object" && v.kind === "service";
|
|
145
|
+
}
|
|
146
|
+
export function isTopicContract(v) {
|
|
147
|
+
return !!v && typeof v === "object" && v.kind === "topic";
|
|
148
|
+
}
|