@synapse-sdk/client 0.1.0 → 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 +1 -111
- package/dist/calls.d.ts +37 -0
- package/dist/calls.js +192 -0
- package/dist/channel.d.ts +84 -0
- package/dist/channel.js +215 -0
- package/dist/fec.d.ts +85 -0
- package/dist/fec.js +281 -0
- package/dist/index.d.ts +10 -74
- package/dist/index.js +10 -15
- package/dist/moq_log.d.ts +23 -0
- package/dist/moq_log.js +176 -0
- package/dist/node.d.ts +145 -0
- package/dist/node.js +407 -0
- package/dist/scene.d.ts +299 -0
- package/dist/scene.js +1531 -0
- package/dist/scene_frames.d.ts +51 -0
- package/dist/scene_frames.js +215 -0
- package/dist/serve.d.ts +46 -0
- package/dist/serve.js +281 -0
- package/dist/session.d.ts +189 -0
- package/dist/session.js +745 -0
- package/dist/signals.d.ts +10 -0
- package/dist/signals.js +63 -0
- package/dist/store.d.ts +145 -0
- package/dist/store.js +898 -0
- package/dist/synapse.d.ts +89 -0
- package/dist/synapse.js +256 -0
- package/dist/topics.d.ts +136 -0
- package/dist/topics.js +938 -0
- package/dist/types.d.ts +679 -0
- package/dist/types.js +1 -0
- package/dist/util.d.ts +67 -0
- package/dist/util.js +227 -0
- package/dist/view.d.ts +19 -0
- package/dist/view.js +145 -0
- package/package.json +18 -39
- package/dist/node-CSSIXG4k.js +0 -1501
- package/dist/node-DbD7G0Nx.d.ts +0 -466
- package/dist/react.d.ts +0 -63
- package/dist/react.js +0 -141
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { SceneEntity, SceneTxn, Xform } from "./types.ts";
|
|
2
|
+
/** The ops the materializers queue. */
|
|
3
|
+
type FrameTxn = Pick<SceneTxn, "spawn" | "patch" | "rename" | "despawn">;
|
|
4
|
+
/** The entities to reconcile against: `scene.entities` (id → entity) or a list of entities. */
|
|
5
|
+
type Entities = Iterable<SceneEntity> | Readonly<Record<string, SceneEntity>>;
|
|
6
|
+
/** The presentation name of a materialized derived frame: `<parent name>_<frame>` (e.g. `ur5e_tool0`). */
|
|
7
|
+
export declare function derivedFrameName(parentName: string, frame: string): string;
|
|
8
|
+
/** Which declared entry a derived frame materializes (its `props.materializes`); `null` when it carries none. */
|
|
9
|
+
export declare function derivedFrameKey(frame: Record<string, unknown>): string | null;
|
|
10
|
+
/** The checks a derived frame of `parentId` must pass: `flags.derived` set, `parent` is `parentId`, `props.materializes` present. Returns the failures (empty: it passes). */
|
|
11
|
+
export declare function validateDerivedFrame(frame: Record<string, unknown>, parentId: string): string[];
|
|
12
|
+
/**
|
|
13
|
+
* What a materializer queued. `frames`: every reconciled declared name → its frame's id (existing or
|
|
14
|
+
* spawned); `spawned`/`updated`/`removed`: the frame ids it spawned, renamed or patched, despawned;
|
|
15
|
+
* `vetoes`: retractions it refused because something is parented under the frame (listed); `errors`:
|
|
16
|
+
* what it could not reconcile (a missing rest pose, two frames for one name, a frame with no
|
|
17
|
+
* `props.materializes`, a name declared by both sources). `clean`: no vetoes and no errors.
|
|
18
|
+
*/
|
|
19
|
+
export interface MaterializeReport {
|
|
20
|
+
frames: Record<string, string>;
|
|
21
|
+
spawned: string[];
|
|
22
|
+
updated: string[];
|
|
23
|
+
removed: string[];
|
|
24
|
+
vetoes: string[];
|
|
25
|
+
errors: string[];
|
|
26
|
+
readonly clean: boolean;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Reconciles `robot`'s `robot.frames` with its derived frames (those with `props.from` equal to
|
|
30
|
+
* `provenance`, default `"urdf"`; a frame without `from` counts as `"urdf"`), queueing the ops on
|
|
31
|
+
* `txn`. `entities` is the scene now (`scene.entities`). `restPoses` maps declared names to poses
|
|
32
|
+
* (FK at the authored joints, the caller's work); a name with no frame yet and no pose is an error,
|
|
33
|
+
* never a spawn, so a call with no poses only renames, backfills `from` and retracts.
|
|
34
|
+
*
|
|
35
|
+
* Per declared name: no frame → spawn one (`kind: "frame"`, the derived name, the pose,
|
|
36
|
+
* `flags.derived`, `props: {materializes, from}`); one frame → rename it if the robot was renamed,
|
|
37
|
+
* set a missing `from`, set the pose if one is given and differs. A frame whose name is no longer
|
|
38
|
+
* declared is despawned when nothing is parented under it, else vetoed. A derived child with no
|
|
39
|
+
* `props.materializes` stops the whole reconcile (an error, no ops): repair it by hand first.
|
|
40
|
+
*/
|
|
41
|
+
export declare function materializeFrames(txn: FrameTxn, robot: SceneEntity, entities: Entities, restPoses?: Record<string, Xform>, opts?: {
|
|
42
|
+
provenance?: string;
|
|
43
|
+
}): MaterializeReport;
|
|
44
|
+
/**
|
|
45
|
+
* Reconciles `entity`'s `hardpoints` with its derived frames of `props.from: "hardpoint"`, queueing
|
|
46
|
+
* the ops on `txn`, as `materializeFrames` does for `robot.frames`; the pose of each is the
|
|
47
|
+
* hardpoint's `xform` (identity when absent), so every declared name has one. Hardpoint frames are
|
|
48
|
+
* static: nothing animates them. Robot frames and hardpoint frames on one entity leave each other alone.
|
|
49
|
+
*/
|
|
50
|
+
export declare function materializeHardpoints(txn: FrameTxn, entity: SceneEntity, entities: Entities): MaterializeReport;
|
|
51
|
+
export {};
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Derived frames (scene spec A.4.4, §6): the frame entities an entity's declarations stand for, and
|
|
2
|
+
// the one materializer every stack runs to keep them in step. Two declarations make derived frames:
|
|
3
|
+
// a robot's `robot.frames` (names only; the rest pose is the caller's FK at the authored joints,
|
|
4
|
+
// `props.from: "urdf"`) and an entity's `hardpoints` (the pose is in the data, `props.from:
|
|
5
|
+
// "hardpoint"`). A derived frame is a `frame` child of the declaring entity with `flags.derived` and
|
|
6
|
+
// `props.materializes` naming the declared entry; `(parent, materializes)` is its identity, its name
|
|
7
|
+
// (`<parent name>_<frame>`) follows the parent's.
|
|
8
|
+
//
|
|
9
|
+
// The functions here only queue ops on a txn and say what they queued; they never call the runtime.
|
|
10
|
+
// Nothing applies until the txn is sent, so a caller that wants all or nothing checks `clean` first.
|
|
11
|
+
/** The presentation name of a materialized derived frame: `<parent name>_<frame>` (e.g. `ur5e_tool0`). */
|
|
12
|
+
export function derivedFrameName(parentName, frame) { return `${parentName}_${frame}`; }
|
|
13
|
+
/** Which declared entry a derived frame materializes (its `props.materializes`); `null` when it carries none. */
|
|
14
|
+
export function derivedFrameKey(frame) {
|
|
15
|
+
const key = frame.props?.materializes;
|
|
16
|
+
return typeof key === "string" && key ? key : null;
|
|
17
|
+
}
|
|
18
|
+
/** The checks a derived frame of `parentId` must pass: `flags.derived` set, `parent` is `parentId`, `props.materializes` present. Returns the failures (empty: it passes). */
|
|
19
|
+
export function validateDerivedFrame(frame, parentId) {
|
|
20
|
+
const errors = [];
|
|
21
|
+
if (!frame.flags?.derived)
|
|
22
|
+
errors.push("flags.derived must be true");
|
|
23
|
+
if (frame.parent !== parentId)
|
|
24
|
+
errors.push("parent must be the deriving entity id");
|
|
25
|
+
if (!(frame.props ?? {}).materializes)
|
|
26
|
+
errors.push("props.materializes is required");
|
|
27
|
+
return errors;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Reconciles `robot`'s `robot.frames` with its derived frames (those with `props.from` equal to
|
|
31
|
+
* `provenance`, default `"urdf"`; a frame without `from` counts as `"urdf"`), queueing the ops on
|
|
32
|
+
* `txn`. `entities` is the scene now (`scene.entities`). `restPoses` maps declared names to poses
|
|
33
|
+
* (FK at the authored joints, the caller's work); a name with no frame yet and no pose is an error,
|
|
34
|
+
* never a spawn, so a call with no poses only renames, backfills `from` and retracts.
|
|
35
|
+
*
|
|
36
|
+
* Per declared name: no frame → spawn one (`kind: "frame"`, the derived name, the pose,
|
|
37
|
+
* `flags.derived`, `props: {materializes, from}`); one frame → rename it if the robot was renamed,
|
|
38
|
+
* set a missing `from`, set the pose if one is given and differs. A frame whose name is no longer
|
|
39
|
+
* declared is despawned when nothing is parented under it, else vetoed. A derived child with no
|
|
40
|
+
* `props.materializes` stops the whole reconcile (an error, no ops): repair it by hand first.
|
|
41
|
+
*/
|
|
42
|
+
export function materializeFrames(txn, robot, entities, restPoses = {}, opts = {}) {
|
|
43
|
+
const report = emptyReport();
|
|
44
|
+
const provenance = opts.provenance ?? "urdf";
|
|
45
|
+
if (typeof robot.id !== "string" || typeof robot.name !== "string") {
|
|
46
|
+
report.errors.push("robot entity needs an id and a name");
|
|
47
|
+
return report;
|
|
48
|
+
}
|
|
49
|
+
if (!isPlainObject(robot.robot)) {
|
|
50
|
+
report.errors.push("not a robot: entity carries no robot component");
|
|
51
|
+
return report;
|
|
52
|
+
}
|
|
53
|
+
const declared = (robot.robot.frames ?? []);
|
|
54
|
+
if (!Array.isArray(declared) || declared.some(n => typeof n !== "string" || !n)) {
|
|
55
|
+
report.errors.push("robot.frames must be a list of frame names");
|
|
56
|
+
return report;
|
|
57
|
+
}
|
|
58
|
+
for (const name of sourceCollisions(robot)) {
|
|
59
|
+
report.errors.push(`declared frame "${name}" collides with a hardpoint of the same name — robot.frames and hardpoints share one join-key space per entity`);
|
|
60
|
+
}
|
|
61
|
+
if (report.errors.length > 0)
|
|
62
|
+
return report;
|
|
63
|
+
const decl = new Map();
|
|
64
|
+
for (const name of declared)
|
|
65
|
+
decl.set(name, restPoses[name] ?? null);
|
|
66
|
+
reconcileDerived(txn, robot.id, robot.name, decl, provenance, "robot.frames", entities, report, name => `declared frame "${name}": no rest pose provided — FK at the canonical config is the caller's engine work`);
|
|
67
|
+
return report;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Reconciles `entity`'s `hardpoints` with its derived frames of `props.from: "hardpoint"`, queueing
|
|
71
|
+
* the ops on `txn`, as `materializeFrames` does for `robot.frames`; the pose of each is the
|
|
72
|
+
* hardpoint's `xform` (identity when absent), so every declared name has one. Hardpoint frames are
|
|
73
|
+
* static: nothing animates them. Robot frames and hardpoint frames on one entity leave each other alone.
|
|
74
|
+
*/
|
|
75
|
+
export function materializeHardpoints(txn, entity, entities) {
|
|
76
|
+
const report = emptyReport();
|
|
77
|
+
if (typeof entity.id !== "string" || typeof entity.name !== "string") {
|
|
78
|
+
report.errors.push("entity needs an id and a name");
|
|
79
|
+
return report;
|
|
80
|
+
}
|
|
81
|
+
const hardpoints = (entity.hardpoints ?? []);
|
|
82
|
+
if (!Array.isArray(hardpoints)) {
|
|
83
|
+
report.errors.push("hardpoints must be a list");
|
|
84
|
+
return report;
|
|
85
|
+
}
|
|
86
|
+
const decl = new Map();
|
|
87
|
+
for (const hp of hardpoints) {
|
|
88
|
+
const name = isPlainObject(hp) ? hp.name : undefined;
|
|
89
|
+
if (typeof name !== "string" || name === "") {
|
|
90
|
+
report.errors.push(`hardpoint without a usable name: ${JSON.stringify(hp)}`);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (decl.has(name)) {
|
|
94
|
+
report.errors.push(`duplicate hardpoint name "${name}"`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
decl.set(name, hp.xform ?? { t: [0, 0, 0], q: [0, 0, 0, 1] });
|
|
98
|
+
}
|
|
99
|
+
for (const name of sourceCollisions(entity)) {
|
|
100
|
+
report.errors.push(`hardpoint "${name}" collides with a declared robot frame of the same name — robot.frames and hardpoints share one join-key space per entity`);
|
|
101
|
+
}
|
|
102
|
+
if (report.errors.length > 0)
|
|
103
|
+
return report;
|
|
104
|
+
// Every hardpoint has a pose (its own, or identity), so the missing-pose message is never used.
|
|
105
|
+
reconcileDerived(txn, entity.id, entity.name, decl, "hardpoint", "hardpoints", entities, report, name => `hardpoint "${name}" has no pose`);
|
|
106
|
+
return report;
|
|
107
|
+
}
|
|
108
|
+
function emptyReport() {
|
|
109
|
+
return {
|
|
110
|
+
frames: {}, spawned: [], updated: [], removed: [], vetoes: [], errors: [],
|
|
111
|
+
get clean() { return this.vetoes.length === 0 && this.errors.length === 0; },
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function isPlainObject(v) {
|
|
115
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
116
|
+
}
|
|
117
|
+
function listOf(entities) {
|
|
118
|
+
return Symbol.iterator in entities ? [...entities] : Object.values(entities);
|
|
119
|
+
}
|
|
120
|
+
// The names both `robot.frames` and `hardpoints` declare on one entity: two declarations would claim
|
|
121
|
+
// one derived frame (its identity is (parent, materializes)), so both materializers refuse them.
|
|
122
|
+
function sourceCollisions(entity) {
|
|
123
|
+
const frames = isPlainObject(entity.robot) && Array.isArray(entity.robot.frames) ? entity.robot.frames : [];
|
|
124
|
+
const hps = Array.isArray(entity.hardpoints) ? entity.hardpoints : [];
|
|
125
|
+
const hpNames = new Set(hps.map(h => (isPlainObject(h) ? h.name : undefined)).filter((n) => typeof n === "string"));
|
|
126
|
+
return frames.filter((n) => typeof n === "string" && hpNames.has(n)).sort();
|
|
127
|
+
}
|
|
128
|
+
// The reconcile both materializers share. `declared`: name → pose (`null`: the frame must already
|
|
129
|
+
// exist). Only derived children whose `props.from` (absent: "urdf") is `provenance` are matched or
|
|
130
|
+
// retracted; a derived child with no `props.materializes`, of either source, stops the reconcile.
|
|
131
|
+
function reconcileDerived(txn, parentId, parentName, declared, provenance, sourceLabel, entities, report, missingPoseError) {
|
|
132
|
+
const ents = listOf(entities);
|
|
133
|
+
const byKey = new Map();
|
|
134
|
+
for (const ent of ents) {
|
|
135
|
+
if (ent.parent !== parentId || !ent.flags?.derived)
|
|
136
|
+
continue;
|
|
137
|
+
const key = derivedFrameKey(ent);
|
|
138
|
+
if (key === null) {
|
|
139
|
+
report.errors.push(`derived frame "${ent.id}" carries no props.materializes join key — repair it by hand before materializing`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const from = (ent.props ?? {}).from ?? "urdf";
|
|
143
|
+
if (from !== provenance)
|
|
144
|
+
continue; // the other declaration's frame: left alone
|
|
145
|
+
const bucket = byKey.get(key);
|
|
146
|
+
if (bucket)
|
|
147
|
+
bucket.push(ent);
|
|
148
|
+
else
|
|
149
|
+
byKey.set(key, [ent]);
|
|
150
|
+
}
|
|
151
|
+
if (report.errors.length > 0)
|
|
152
|
+
return; // a frame no declaration can claim: no decision is safe until it is repaired
|
|
153
|
+
for (const [name, pose] of declared) {
|
|
154
|
+
const matches = byKey.get(name) ?? [];
|
|
155
|
+
if (matches.length > 1) {
|
|
156
|
+
report.errors.push(`declared frame "${name}" has ${matches.length} derived entities (${matches.map(e => e.id).join(", ")}) — resolve manually`);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (matches.length === 0) {
|
|
160
|
+
if (pose === null) {
|
|
161
|
+
report.errors.push(missingPoseError(name));
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const fid = txn.spawn("frame", {
|
|
165
|
+
parent: parentId,
|
|
166
|
+
name: derivedFrameName(parentName, name),
|
|
167
|
+
xform: pose,
|
|
168
|
+
flags: { derived: true },
|
|
169
|
+
props: { materializes: name, from: provenance },
|
|
170
|
+
});
|
|
171
|
+
report.spawned.push(fid);
|
|
172
|
+
report.frames[name] = fid;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const ent = matches[0];
|
|
176
|
+
const fid = ent.id;
|
|
177
|
+
report.frames[name] = fid;
|
|
178
|
+
let changed = false;
|
|
179
|
+
const expected = derivedFrameName(parentName, name);
|
|
180
|
+
if (ent.name !== expected) {
|
|
181
|
+
txn.rename(fid, expected);
|
|
182
|
+
changed = true;
|
|
183
|
+
}
|
|
184
|
+
if (!("from" in (ent.props ?? {}))) {
|
|
185
|
+
txn.patch(fid, { props: { from: provenance } });
|
|
186
|
+
changed = true;
|
|
187
|
+
}
|
|
188
|
+
if (pose !== null) {
|
|
189
|
+
const current = (ent.xform ?? {});
|
|
190
|
+
const drifted = Object.entries(pose).some(([k, v]) => JSON.stringify(current[k]) !== JSON.stringify(v));
|
|
191
|
+
if (drifted) {
|
|
192
|
+
txn.patch(fid, { xform: pose });
|
|
193
|
+
changed = true;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (changed)
|
|
197
|
+
report.updated.push(fid);
|
|
198
|
+
}
|
|
199
|
+
for (const [key, matches] of byKey) {
|
|
200
|
+
if (declared.has(key))
|
|
201
|
+
continue;
|
|
202
|
+
for (const ent of matches) {
|
|
203
|
+
const dependents = ents.filter(e => e.parent === ent.id);
|
|
204
|
+
if (dependents.length > 0) {
|
|
205
|
+
const deps = dependents.map(d => d.name ?? d.id).join(", ");
|
|
206
|
+
report.vetoes.push(`frame "${key}" (${ent.name ?? ent.id}) was retracted from ${sourceLabel} but has ` +
|
|
207
|
+
`${dependents.length} dependent(s) parented under it: ${deps} — reparent or remove them first`);
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
txn.despawn(ent.id);
|
|
211
|
+
report.removed.push(ent.id);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
package/dist/serve.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type Card, type ErrorBody, type ServiceContract } from "@synapse-sdk/core";
|
|
2
|
+
import { Handle, type Event as HandleEvent, type Intent } from "./channel.ts";
|
|
3
|
+
import type { Node } from "./node.ts";
|
|
4
|
+
import { type Session } from "./session.ts";
|
|
5
|
+
import type { RegVerdict, Registration, ServeHandler } from "./types.ts";
|
|
6
|
+
import type { Warn } from "./topics.ts";
|
|
7
|
+
/** The reason `ctx.signal` carries when our track or session went (WP §8.4). */
|
|
8
|
+
export declare const TRACK_LOST = "track_lost";
|
|
9
|
+
export declare class ServiceRegistration<I, U, R> implements Intent, Registration {
|
|
10
|
+
#private;
|
|
11
|
+
readonly node: Node;
|
|
12
|
+
readonly name: string;
|
|
13
|
+
private readonly contract;
|
|
14
|
+
private readonly handler;
|
|
15
|
+
readonly warn: Warn;
|
|
16
|
+
readonly registerSvc = "synapse/service/register";
|
|
17
|
+
readonly unregisterSvc = "synapse/service/unregister";
|
|
18
|
+
readonly card: Card;
|
|
19
|
+
readonly handle: Handle;
|
|
20
|
+
/** How many times a `calls` track broke and the registration was renewed on the same session. */
|
|
21
|
+
callsTrackLost: number;
|
|
22
|
+
constructor(node: Node, name: string, card: Card, contract: ServiceContract<unknown, I, U, R> | undefined, handler: ServeHandler<I, U, R>, warn: Warn);
|
|
23
|
+
get verdict(): RegVerdict;
|
|
24
|
+
get bind(): string | undefined;
|
|
25
|
+
ready(): Promise<void>;
|
|
26
|
+
unregister(): void;
|
|
27
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
28
|
+
registerArgs(bind: string): unknown;
|
|
29
|
+
unregisterArgs(bind: string): unknown;
|
|
30
|
+
/** Announces the card and `out`, installs the binding, then starts its `calls` reader. */
|
|
31
|
+
install(s: Session, bind: string, mailbox: (e: HandleEvent) => void): void;
|
|
32
|
+
retract(): void;
|
|
33
|
+
setVerdict(v: {
|
|
34
|
+
status: "unbound";
|
|
35
|
+
} | {
|
|
36
|
+
status: "pending";
|
|
37
|
+
epoch: number;
|
|
38
|
+
} | {
|
|
39
|
+
status: "admitted";
|
|
40
|
+
epoch: number;
|
|
41
|
+
}): void;
|
|
42
|
+
/** The task is exiting: whatever handler still runs is this registration's to cancel. */
|
|
43
|
+
exited(): void;
|
|
44
|
+
}
|
|
45
|
+
/** What a handler's throw becomes on the wire: a `ServiceError` verbatim (`data.app = true` for an application code), anything else `provider_error`. */
|
|
46
|
+
export declare function errorBodyOf(e: unknown): ErrorBody;
|
package/dist/serve.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// Serving (SDK §5.3, WP §8.1, §8.6), the JS spelling of the Rust client's `serve.rs`.
|
|
2
|
+
//
|
|
3
|
+
// A registration is an intent (`channel.ts`): its handle task asks `synapse/service/register` on
|
|
4
|
+
// every session and, admitted, installs the broadcast `<prefix>/svc/<bind>/<service>` — `card` (one
|
|
5
|
+
// retained frame) and `out` (our replies) — and starts the binding's `calls` reader on
|
|
6
|
+
// `runtime/svc/<bind>/<service>`, the track the broker creates when our announce lands. A call
|
|
7
|
+
// starts the handler; a cancel aborts the handler it names; the reader ending (the broker ended the
|
|
8
|
+
// registration, the track broke) is told to the task, which renews the registration on the same
|
|
9
|
+
// session with a fresh bind. Nothing resumes across sessions: a handler still running when its
|
|
10
|
+
// binding is retracted is aborted (`track_lost`); its late result is dropped, never written to a
|
|
11
|
+
// dead track — the broker has failed the caller already.
|
|
12
|
+
import { ERROR_CODES, FLAGS, ServiceError, SynapseError, TYPES, decode as decodeBody, decodeHeader, encode as encodeBody, moqPriority, nowUs, servicePath, toHex, validateSync, } from "@synapse-sdk/core";
|
|
13
|
+
import { Handle, start as startHandle } from "./channel.js";
|
|
14
|
+
import * as Moq from "@moq/net";
|
|
15
|
+
import { CallWriter, RETAINED_FRAME_MAX_AGE_MS, callTrackSubscription, frameTooLarge, readTrack, waitActive, writeLatest } from "./session.js";
|
|
16
|
+
/** The reason `ctx.signal` carries when our track or session went (WP §8.4). */
|
|
17
|
+
export const TRACK_LOST = "track_lost";
|
|
18
|
+
export class ServiceRegistration {
|
|
19
|
+
node;
|
|
20
|
+
name;
|
|
21
|
+
contract;
|
|
22
|
+
handler;
|
|
23
|
+
warn;
|
|
24
|
+
registerSvc = "synapse/service/register";
|
|
25
|
+
unregisterSvc = "synapse/service/unregister";
|
|
26
|
+
card;
|
|
27
|
+
#bound;
|
|
28
|
+
#verdict = { status: "unbound" };
|
|
29
|
+
#running = new Map();
|
|
30
|
+
handle;
|
|
31
|
+
/** How many times a `calls` track broke and the registration was renewed on the same session. */
|
|
32
|
+
callsTrackLost = 0;
|
|
33
|
+
constructor(node, name, card, contract, handler, warn) {
|
|
34
|
+
this.node = node;
|
|
35
|
+
this.name = name;
|
|
36
|
+
this.contract = contract;
|
|
37
|
+
this.handler = handler;
|
|
38
|
+
this.warn = warn;
|
|
39
|
+
this.card = { ...card, name };
|
|
40
|
+
this.handle = startHandle(node, this);
|
|
41
|
+
}
|
|
42
|
+
get verdict() { return this.#verdict; }
|
|
43
|
+
get bind() { return this.#bound?.bind; }
|
|
44
|
+
ready() { return this.handle.ready(); }
|
|
45
|
+
unregister() { this.handle.unregister(); }
|
|
46
|
+
async [Symbol.asyncDispose]() { this.unregister(); }
|
|
47
|
+
registerArgs(bind) { return { service: this.name, card: this.card, bind }; }
|
|
48
|
+
unregisterArgs(bind) { return { service: this.name, bind }; }
|
|
49
|
+
/** Announces the card and `out`, installs the binding, then starts its `calls` reader. */
|
|
50
|
+
install(s, bind, mailbox) {
|
|
51
|
+
const bc = s.createBroadcast(servicePath(s.prefix, bind, this.name));
|
|
52
|
+
const card = bc.createTrack("card", { priority: moqPriority(1), maxAge: Moq.Time.Milli(RETAINED_FRAME_MAX_AGE_MS) });
|
|
53
|
+
writeLatest(card, { flags: FLAGS.KEY, type: TYPES.PUB, seq: 1, tUs: nowUs() }, encodeBody(this.card));
|
|
54
|
+
const out = CallWriter.create(bc, "out");
|
|
55
|
+
bc.announce();
|
|
56
|
+
const stop = new AbortController();
|
|
57
|
+
const old = this.#bound;
|
|
58
|
+
this.#bound = { bind, bc, card, out, stop };
|
|
59
|
+
if (old)
|
|
60
|
+
this.#finish(old);
|
|
61
|
+
void this.#serveOn(s, bind, out, stop.signal, mailbox);
|
|
62
|
+
}
|
|
63
|
+
retract() { const b = this.#bound; this.#bound = undefined; if (b)
|
|
64
|
+
this.#finish(b); }
|
|
65
|
+
setVerdict(v) {
|
|
66
|
+
this.#verdict = v.status === "admitted" ? { status: "registered", epoch: v.epoch } : v;
|
|
67
|
+
}
|
|
68
|
+
/** The task is exiting: whatever handler still runs is this registration's to cancel. */
|
|
69
|
+
exited() { this.#cancelHandlers(undefined); }
|
|
70
|
+
/** Cancels the binding's handlers (`track_lost`), closes `out` (late results are dropped) and ends the broadcast. */
|
|
71
|
+
#finish(b) {
|
|
72
|
+
b.stop.abort();
|
|
73
|
+
this.#cancelHandlers(b.bind);
|
|
74
|
+
try {
|
|
75
|
+
b.out.close();
|
|
76
|
+
}
|
|
77
|
+
catch { /* closed */ }
|
|
78
|
+
try {
|
|
79
|
+
b.card.close();
|
|
80
|
+
}
|
|
81
|
+
catch { /* closed */ }
|
|
82
|
+
try {
|
|
83
|
+
b.bc.close();
|
|
84
|
+
}
|
|
85
|
+
catch { /* closed */ }
|
|
86
|
+
}
|
|
87
|
+
#cancelHandlers(bind) {
|
|
88
|
+
for (const [k, r] of [...this.#running]) {
|
|
89
|
+
if (bind !== undefined && r.bind !== bind)
|
|
90
|
+
continue;
|
|
91
|
+
this.#running.delete(k);
|
|
92
|
+
r.reason ??= TRACK_LOST;
|
|
93
|
+
r.ac.abort(new SynapseError("canceled", r.reason));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** Reads the binding's `calls` track until it ends or the binding is retracted; an end is told to the task (a renewal). */
|
|
97
|
+
async #serveOn(s, bind, out, stop, mailbox) {
|
|
98
|
+
const epoch = s.epoch;
|
|
99
|
+
let why;
|
|
100
|
+
try {
|
|
101
|
+
const req = s.request(`runtime/svc/${bind}/${this.name}`);
|
|
102
|
+
const bc = await waitActive(req, 10_000, stop);
|
|
103
|
+
const sub = bc.track("calls").subscribe(callTrackSubscription(moqPriority(0)));
|
|
104
|
+
const err = await readTrack(sub, payload => this.#onCallsFrame(payload, bind, out), stop);
|
|
105
|
+
req.close();
|
|
106
|
+
if (stop.aborted)
|
|
107
|
+
return;
|
|
108
|
+
if (err) {
|
|
109
|
+
this.callsTrackLost++;
|
|
110
|
+
why = `calls track lost: ${err instanceof Error ? err.message : String(err)}`;
|
|
111
|
+
}
|
|
112
|
+
else
|
|
113
|
+
why = "calls track ended: the broker ended the registration";
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
if (stop.aborted)
|
|
117
|
+
return;
|
|
118
|
+
this.callsTrackLost++;
|
|
119
|
+
why = `calls track lost: ${e instanceof Error ? e.message : String(e)}`;
|
|
120
|
+
}
|
|
121
|
+
this.warn(`service ${this.name} (epoch ${epoch}): ${why}; renewing the registration on this session`);
|
|
122
|
+
mailbox({ kind: "ended", bind });
|
|
123
|
+
}
|
|
124
|
+
#onCallsFrame(payload, bind, out) {
|
|
125
|
+
let header, hlen;
|
|
126
|
+
try {
|
|
127
|
+
({ header, hlen } = decodeHeader(payload));
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
let body;
|
|
133
|
+
try {
|
|
134
|
+
body = decodeBody(payload.subarray(hlen));
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (header.type === TYPES.RPC_CALL) {
|
|
140
|
+
const call = body;
|
|
141
|
+
if (!call || !(call.call instanceof Uint8Array) || call.call.length !== 16) {
|
|
142
|
+
this.warn(`service ${this.name}: bad inbound call dropped`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const k = toHex(call.call);
|
|
146
|
+
// Only for the binding still installed: a reader stopped mid-frame after its binding's handlers were cancelled starts none behind that scan.
|
|
147
|
+
if (this.#bound?.bind !== bind)
|
|
148
|
+
return;
|
|
149
|
+
if (this.#running.has(k))
|
|
150
|
+
return;
|
|
151
|
+
const ac = new AbortController();
|
|
152
|
+
const running = { ac, bind, reason: undefined };
|
|
153
|
+
this.#running.set(k, running);
|
|
154
|
+
void this.#runCall(call, k, running, out);
|
|
155
|
+
}
|
|
156
|
+
else if (header.type === TYPES.RPC_CANCEL) {
|
|
157
|
+
const c = body;
|
|
158
|
+
if (!c || !(c.call instanceof Uint8Array))
|
|
159
|
+
return;
|
|
160
|
+
const r = this.#running.get(toHex(c.call));
|
|
161
|
+
if (r) {
|
|
162
|
+
r.reason = c.reason ?? "caller";
|
|
163
|
+
r.ac.abort(new SynapseError("canceled", r.reason));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/** Runs one call's handler and writes its terminal frame: the one place a call ends, whatever the handler does. */
|
|
168
|
+
async #runCall(call, k, running, out) {
|
|
169
|
+
const id = call.call;
|
|
170
|
+
// Frames are written only while the call is this registration's to answer (`running` installed): a
|
|
171
|
+
// late update or result, after a cancel or a retract, is dropped.
|
|
172
|
+
const header = (flags) => ({ flags, type: TYPES.RPC_RESP, seq: 0, tUs: nowUs() });
|
|
173
|
+
// The terminal frame always reaches the caller: a result that does not encode is written as
|
|
174
|
+
// `provider_error` instead (Rust serve.rs: `encode result`), and a result over moq's frame limit,
|
|
175
|
+
// refused before anything is written, as a `provider_error` naming the limit.
|
|
176
|
+
const finish = (flags, body) => {
|
|
177
|
+
if (this.#running.get(k) !== running)
|
|
178
|
+
return;
|
|
179
|
+
this.#running.delete(k);
|
|
180
|
+
const failed = (message) => encodeBody({ call: id, body: { code: "provider_error", message } });
|
|
181
|
+
let payload;
|
|
182
|
+
try {
|
|
183
|
+
payload = encodeBody({ call: id, body });
|
|
184
|
+
}
|
|
185
|
+
catch (e) {
|
|
186
|
+
flags = FLAGS.END | FLAGS.ERR;
|
|
187
|
+
payload = failed(`encode result: ${describe(e)}`);
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
out.write(header(flags), payload);
|
|
191
|
+
}
|
|
192
|
+
catch (e) {
|
|
193
|
+
if (!(e instanceof SynapseError && e.code === "invalid_input")) {
|
|
194
|
+
this.warn(`service ${this.name}: write result: ${describe(e)}`);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
out.write(header(FLAGS.END | FLAGS.ERR), failed(frameTooLarge("result", payload.length).message));
|
|
199
|
+
}
|
|
200
|
+
catch (e2) {
|
|
201
|
+
this.warn(`service ${this.name}: write result: ${describe(e2)}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
const ctx = {
|
|
206
|
+
call: k, caller: call.caller, role: call.role, deadlineMs: call.deadline_ms, signal: running.ac.signal,
|
|
207
|
+
// An update's failure is the handler's to see (Rust `Ctx::update`): one that does not encode is
|
|
208
|
+
// `internal`, one over moq's frame limit `invalid_input`, refused before anything is written.
|
|
209
|
+
update: (u) => {
|
|
210
|
+
let v = u;
|
|
211
|
+
if (this.contract?.update) {
|
|
212
|
+
const r = validateSync(this.contract.update, u);
|
|
213
|
+
if (r.issues)
|
|
214
|
+
throw new SynapseError("invalid_input", r.issues[0]?.message ?? "invalid update");
|
|
215
|
+
v = r.value;
|
|
216
|
+
}
|
|
217
|
+
if (this.#running.get(k) !== running)
|
|
218
|
+
return;
|
|
219
|
+
let payload;
|
|
220
|
+
try {
|
|
221
|
+
payload = encodeBody({ call: id, body: v });
|
|
222
|
+
}
|
|
223
|
+
catch (e) {
|
|
224
|
+
throw new SynapseError("internal", { message: `encode update: ${describe(e)}`, source: "sdk", cause: e });
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
out.write(header(0), payload);
|
|
228
|
+
}
|
|
229
|
+
catch (e) {
|
|
230
|
+
if (e instanceof SynapseError && e.code === "invalid_input")
|
|
231
|
+
throw frameTooLarge("update", payload.length);
|
|
232
|
+
throw new SynapseError("internal", { message: `write update: ${describe(e)}`, source: "sdk", cause: e });
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
let result;
|
|
237
|
+
try {
|
|
238
|
+
let input = call.args;
|
|
239
|
+
if (this.contract?.input) {
|
|
240
|
+
const r = validateSync(this.contract.input, call.args);
|
|
241
|
+
if (r.issues)
|
|
242
|
+
throw new SynapseError("invalid_input", { message: r.issues[0]?.message ?? "invalid input", data: { issues: r.issues } });
|
|
243
|
+
input = r.value;
|
|
244
|
+
}
|
|
245
|
+
let value = await this.handler(input, ctx);
|
|
246
|
+
if (this.contract?.output) {
|
|
247
|
+
const r = validateSync(this.contract.output, value);
|
|
248
|
+
if (r.issues)
|
|
249
|
+
throw new SynapseError("provider_error", `invalid output: ${r.issues[0]?.message ?? ""}`);
|
|
250
|
+
value = r.value;
|
|
251
|
+
}
|
|
252
|
+
result = { value };
|
|
253
|
+
}
|
|
254
|
+
catch (e) {
|
|
255
|
+
result = { error: errorBodyOf(e) };
|
|
256
|
+
}
|
|
257
|
+
// Cancelled or retracted meanwhile: the broker has answered the caller; the late result is dropped by `write`.
|
|
258
|
+
if ("value" in result)
|
|
259
|
+
finish(FLAGS.END, result.value);
|
|
260
|
+
else
|
|
261
|
+
finish(FLAGS.END | FLAGS.ERR, result.error);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const describe = (e) => e instanceof Error ? e.message : String(e);
|
|
265
|
+
/** What a handler's throw becomes on the wire: a `ServiceError` verbatim (`data.app = true` for an application code), anything else `provider_error`. */
|
|
266
|
+
export function errorBodyOf(e) {
|
|
267
|
+
if (e instanceof SynapseError) {
|
|
268
|
+
const known = ERROR_CODES.has(e.code);
|
|
269
|
+
const b = { code: e.code };
|
|
270
|
+
if (e.message)
|
|
271
|
+
b.message = e.message;
|
|
272
|
+
if (e instanceof ServiceError && !known) {
|
|
273
|
+
const data = e.data !== undefined && e.data !== null && typeof e.data === "object" && !Array.isArray(e.data) ? { ...e.data } : e.data === undefined ? {} : { value: e.data };
|
|
274
|
+
b.data = { ...data, app: true };
|
|
275
|
+
}
|
|
276
|
+
else if (e.data !== undefined)
|
|
277
|
+
b.data = e.data;
|
|
278
|
+
return b;
|
|
279
|
+
}
|
|
280
|
+
return { code: "provider_error", message: e instanceof Error ? e.message : String(e) };
|
|
281
|
+
}
|