@lumencast/protocol 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/LICENSE +201 -0
- package/README.md +55 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +162 -0
- package/dist/cli.js.map +1 -0
- package/dist/codec.d.ts +15 -0
- package/dist/codec.d.ts.map +1 -0
- package/dist/codec.js +328 -0
- package/dist/codec.js.map +1 -0
- package/dist/conformance/bundle-hash.d.ts +4 -0
- package/dist/conformance/bundle-hash.d.ts.map +1 -0
- package/dist/conformance/bundle-hash.js +49 -0
- package/dist/conformance/bundle-hash.js.map +1 -0
- package/dist/conformance/control-client.d.ts +42 -0
- package/dist/conformance/control-client.d.ts.map +1 -0
- package/dist/conformance/control-client.js +63 -0
- package/dist/conformance/control-client.js.map +1 -0
- package/dist/conformance/harness.d.ts +41 -0
- package/dist/conformance/harness.d.ts.map +1 -0
- package/dist/conformance/harness.js +441 -0
- package/dist/conformance/harness.js.map +1 -0
- package/dist/conformance/index.d.ts +8 -0
- package/dist/conformance/index.d.ts.map +1 -0
- package/dist/conformance/index.js +12 -0
- package/dist/conformance/index.js.map +1 -0
- package/dist/conformance/loader.d.ts +9 -0
- package/dist/conformance/loader.d.ts.map +1 -0
- package/dist/conformance/loader.js +27 -0
- package/dist/conformance/loader.js.map +1 -0
- package/dist/conformance/match.d.ts +7 -0
- package/dist/conformance/match.d.ts.map +1 -0
- package/dist/conformance/match.js +82 -0
- package/dist/conformance/match.js.map +1 -0
- package/dist/conformance/placeholders.d.ts +2 -0
- package/dist/conformance/placeholders.d.ts.map +1 -0
- package/dist/conformance/placeholders.js +40 -0
- package/dist/conformance/placeholders.js.map +1 -0
- package/dist/conformance/scenario.d.ts +33 -0
- package/dist/conformance/scenario.d.ts.map +1 -0
- package/dist/conformance/scenario.js +26 -0
- package/dist/conformance/scenario.js.map +1 -0
- package/dist/envelope.d.ts +66 -0
- package/dist/envelope.d.ts.map +1 -0
- package/dist/envelope.js +111 -0
- package/dist/envelope.js.map +1 -0
- package/dist/errors.d.ts +25 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +38 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/leaf-path.d.ts +21 -0
- package/dist/leaf-path.d.ts.map +1 -0
- package/dist/leaf-path.js +51 -0
- package/dist/leaf-path.js.map +1 -0
- package/dist/sequence.d.ts +25 -0
- package/dist/sequence.d.ts.map +1 -0
- package/dist/sequence.js +51 -0
- package/dist/sequence.js.map +1 -0
- package/dist/types.d.ts +159 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +13 -0
- package/dist/types.js.map +1 -0
- package/package.json +56 -0
- package/src/cli.ts +176 -0
- package/src/codec.ts +374 -0
- package/src/conformance/bundle-hash.ts +54 -0
- package/src/conformance/control-client.ts +93 -0
- package/src/conformance/harness.ts +492 -0
- package/src/conformance/index.ts +34 -0
- package/src/conformance/loader.ts +39 -0
- package/src/conformance/match.ts +92 -0
- package/src/conformance/placeholders.ts +45 -0
- package/src/conformance/scenario.ts +71 -0
- package/src/envelope.ts +180 -0
- package/src/errors.ts +55 -0
- package/src/index.ts +63 -0
- package/src/leaf-path.ts +58 -0
- package/src/sequence.ts +60 -0
- package/src/types.ts +201 -0
package/src/codec.ts
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
// JSON envelope codec for LSDP/1 frames.
|
|
2
|
+
// Hand-rolled type guards rather than a schema library — keeps the bundle
|
|
3
|
+
// weight off the runtime hot path and the surface auditable.
|
|
4
|
+
|
|
5
|
+
import { LumencastError } from "./errors.js";
|
|
6
|
+
import { isProtocolErrorCode } from "./errors.js";
|
|
7
|
+
import {
|
|
8
|
+
PROTOCOL_VERSION,
|
|
9
|
+
type Cause,
|
|
10
|
+
type ClientFrame,
|
|
11
|
+
type DeltaFrame,
|
|
12
|
+
type ErrorFrame,
|
|
13
|
+
type InputFrame,
|
|
14
|
+
type LeafValue,
|
|
15
|
+
type Patch,
|
|
16
|
+
type PingFrame,
|
|
17
|
+
type PongFrame,
|
|
18
|
+
type SceneChangedFrame,
|
|
19
|
+
type SceneTransition,
|
|
20
|
+
type ServerFrame,
|
|
21
|
+
type SnapshotFrame,
|
|
22
|
+
type SubscribeFrame,
|
|
23
|
+
type TransitionSpec,
|
|
24
|
+
type UnsubscribeFrame,
|
|
25
|
+
} from "./types.js";
|
|
26
|
+
|
|
27
|
+
/** Encode any LSDP frame to its on-wire JSON string. */
|
|
28
|
+
export function encodeFrame(frame: ClientFrame | ServerFrame): string {
|
|
29
|
+
return JSON.stringify(frame);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Decode a JSON text frame into a typed `ServerFrame`.
|
|
34
|
+
*
|
|
35
|
+
* Throws `LumencastError` with `INTERNAL` if the bytes are not valid JSON,
|
|
36
|
+
* are not an object, or carry an unsupported `v`. Unknown `type` values
|
|
37
|
+
* resolve to `null` (forward-compatibility — receivers MUST ignore unknown
|
|
38
|
+
* frame types per LSDP/1 §13).
|
|
39
|
+
*/
|
|
40
|
+
export function decodeServerFrame(raw: string): ServerFrame | null {
|
|
41
|
+
const parsed = parseJsonObject(raw);
|
|
42
|
+
validateEnvelope(parsed);
|
|
43
|
+
|
|
44
|
+
switch (parsed["type"]) {
|
|
45
|
+
case "snapshot":
|
|
46
|
+
return decodeSnapshot(parsed);
|
|
47
|
+
case "delta":
|
|
48
|
+
return decodeDelta(parsed);
|
|
49
|
+
case "scene_changed":
|
|
50
|
+
return decodeSceneChanged(parsed);
|
|
51
|
+
case "error":
|
|
52
|
+
return decodeError(parsed);
|
|
53
|
+
case "pong":
|
|
54
|
+
return decodePong(parsed);
|
|
55
|
+
default:
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Decode a JSON text frame into a typed `ClientFrame`. Same forward-compat rule. */
|
|
61
|
+
export function decodeClientFrame(raw: string): ClientFrame | null {
|
|
62
|
+
const parsed = parseJsonObject(raw);
|
|
63
|
+
validateEnvelope(parsed);
|
|
64
|
+
|
|
65
|
+
switch (parsed["type"]) {
|
|
66
|
+
case "subscribe":
|
|
67
|
+
return decodeSubscribe(parsed);
|
|
68
|
+
case "input":
|
|
69
|
+
return decodeInput(parsed);
|
|
70
|
+
case "ping":
|
|
71
|
+
return decodePing(parsed);
|
|
72
|
+
case "unsubscribe":
|
|
73
|
+
return decodeUnsubscribe(parsed);
|
|
74
|
+
default:
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// --- decoders ---------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
function decodeSnapshot(o: Record<string, unknown>): SnapshotFrame {
|
|
82
|
+
requireFields(o, ["seq", "scene_id", "scene_version", "state"]);
|
|
83
|
+
const state = o["state"];
|
|
84
|
+
if (!isPlainObject(state)) {
|
|
85
|
+
throw protocolError(`snapshot.state must be an object`);
|
|
86
|
+
}
|
|
87
|
+
for (const [path, value] of Object.entries(state)) {
|
|
88
|
+
assertLeafValue(value, `snapshot.state[${path}]`);
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
v: PROTOCOL_VERSION,
|
|
92
|
+
type: "snapshot",
|
|
93
|
+
seq: assertInt(o["seq"], "snapshot.seq"),
|
|
94
|
+
scene_id: assertString(o["scene_id"], "snapshot.scene_id"),
|
|
95
|
+
scene_version: assertString(o["scene_version"], "snapshot.scene_version"),
|
|
96
|
+
state: state as Record<string, LeafValue>,
|
|
97
|
+
...optionalTs(o),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function decodeDelta(o: Record<string, unknown>): DeltaFrame {
|
|
102
|
+
requireFields(o, ["seq", "patches"]);
|
|
103
|
+
const frame: DeltaFrame = {
|
|
104
|
+
v: PROTOCOL_VERSION,
|
|
105
|
+
type: "delta",
|
|
106
|
+
seq: assertInt(o["seq"], "delta.seq"),
|
|
107
|
+
patches: assertPatches(o["patches"], "delta.patches"),
|
|
108
|
+
...optionalTs(o),
|
|
109
|
+
};
|
|
110
|
+
if (o["cause"] !== undefined) {
|
|
111
|
+
frame.cause = assertCause(o["cause"], "delta.cause");
|
|
112
|
+
}
|
|
113
|
+
return frame;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function decodeSceneChanged(o: Record<string, unknown>): SceneChangedFrame {
|
|
117
|
+
requireFields(o, ["seq", "scene_id", "scene_version"]);
|
|
118
|
+
const frame: SceneChangedFrame = {
|
|
119
|
+
v: PROTOCOL_VERSION,
|
|
120
|
+
type: "scene_changed",
|
|
121
|
+
seq: assertInt(o["seq"], "scene_changed.seq"),
|
|
122
|
+
scene_id: assertString(o["scene_id"], "scene_changed.scene_id"),
|
|
123
|
+
scene_version: assertString(o["scene_version"], "scene_changed.scene_version"),
|
|
124
|
+
...optionalTs(o),
|
|
125
|
+
};
|
|
126
|
+
if (typeof o["from_scene_id"] === "string") {
|
|
127
|
+
frame.from_scene_id = o["from_scene_id"];
|
|
128
|
+
}
|
|
129
|
+
if (o["transition"] !== undefined) {
|
|
130
|
+
frame.transition = assertSceneTransition(o["transition"], "scene_changed.transition");
|
|
131
|
+
}
|
|
132
|
+
return frame;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function decodeError(o: Record<string, unknown>): ErrorFrame {
|
|
136
|
+
requireFields(o, ["seq", "code", "message", "recoverable"]);
|
|
137
|
+
const code = o["code"];
|
|
138
|
+
if (!isProtocolErrorCode(code)) {
|
|
139
|
+
throw protocolError(`error.code is not in the closed taxonomy: ${String(code)}`);
|
|
140
|
+
}
|
|
141
|
+
const message = assertString(o["message"], "error.message");
|
|
142
|
+
if (typeof o["recoverable"] !== "boolean") {
|
|
143
|
+
throw protocolError(`error.recoverable must be boolean`);
|
|
144
|
+
}
|
|
145
|
+
const frame: ErrorFrame = {
|
|
146
|
+
v: PROTOCOL_VERSION,
|
|
147
|
+
type: "error",
|
|
148
|
+
seq: assertInt(o["seq"], "error.seq"),
|
|
149
|
+
code,
|
|
150
|
+
message,
|
|
151
|
+
recoverable: o["recoverable"],
|
|
152
|
+
...optionalTs(o),
|
|
153
|
+
};
|
|
154
|
+
if (o["retry_after_ms"] !== undefined) {
|
|
155
|
+
frame.retry_after_ms = assertInt(o["retry_after_ms"], "error.retry_after_ms");
|
|
156
|
+
}
|
|
157
|
+
return frame;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function decodePong(o: Record<string, unknown>): PongFrame {
|
|
161
|
+
const frame: PongFrame = { v: PROTOCOL_VERSION, type: "pong" };
|
|
162
|
+
if (typeof o["nonce"] === "string") {
|
|
163
|
+
frame.nonce = o["nonce"];
|
|
164
|
+
}
|
|
165
|
+
return frame;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function decodeSubscribe(o: Record<string, unknown>): SubscribeFrame {
|
|
169
|
+
requireFields(o, ["token"]);
|
|
170
|
+
const frame: SubscribeFrame = {
|
|
171
|
+
v: PROTOCOL_VERSION,
|
|
172
|
+
type: "subscribe",
|
|
173
|
+
token: assertString(o["token"], "subscribe.token"),
|
|
174
|
+
};
|
|
175
|
+
if (o["scene"] !== undefined && o["scene"] !== null) {
|
|
176
|
+
frame.scene = assertString(o["scene"], "subscribe.scene");
|
|
177
|
+
}
|
|
178
|
+
if (o["session"] !== undefined && o["session"] !== null) {
|
|
179
|
+
frame.session = assertString(o["session"], "subscribe.session");
|
|
180
|
+
}
|
|
181
|
+
if (o["since_sequence"] !== undefined) {
|
|
182
|
+
frame.since_sequence = assertInt(o["since_sequence"], "subscribe.since_sequence");
|
|
183
|
+
}
|
|
184
|
+
return frame;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function decodeInput(o: Record<string, unknown>): InputFrame {
|
|
188
|
+
requireFields(o, ["patches"]);
|
|
189
|
+
const frame: InputFrame = {
|
|
190
|
+
v: PROTOCOL_VERSION,
|
|
191
|
+
type: "input",
|
|
192
|
+
patches: assertPatches(o["patches"], "input.patches"),
|
|
193
|
+
};
|
|
194
|
+
if (typeof o["client_msg_id"] === "string") {
|
|
195
|
+
frame.client_msg_id = o["client_msg_id"];
|
|
196
|
+
}
|
|
197
|
+
return frame;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function decodePing(o: Record<string, unknown>): PingFrame {
|
|
201
|
+
const frame: PingFrame = { v: PROTOCOL_VERSION, type: "ping" };
|
|
202
|
+
if (typeof o["nonce"] === "string") {
|
|
203
|
+
frame.nonce = o["nonce"];
|
|
204
|
+
}
|
|
205
|
+
return frame;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function decodeUnsubscribe(_o: Record<string, unknown>): UnsubscribeFrame {
|
|
209
|
+
return { v: PROTOCOL_VERSION, type: "unsubscribe" };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// --- envelope validation ----------------------------------------------------
|
|
213
|
+
|
|
214
|
+
function parseJsonObject(raw: string): Record<string, unknown> {
|
|
215
|
+
let parsed: unknown;
|
|
216
|
+
try {
|
|
217
|
+
parsed = JSON.parse(raw);
|
|
218
|
+
} catch {
|
|
219
|
+
throw protocolError(`frame is not valid JSON`);
|
|
220
|
+
}
|
|
221
|
+
if (!isPlainObject(parsed)) {
|
|
222
|
+
throw protocolError(`frame is not a JSON object`);
|
|
223
|
+
}
|
|
224
|
+
return parsed;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function validateEnvelope(o: Record<string, unknown>): void {
|
|
228
|
+
if (o["v"] !== PROTOCOL_VERSION) {
|
|
229
|
+
throw protocolError(`envelope.v must be ${PROTOCOL_VERSION}, got ${String(o["v"])}`);
|
|
230
|
+
}
|
|
231
|
+
if (typeof o["type"] !== "string") {
|
|
232
|
+
throw protocolError(`envelope.type must be a string`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// --- assertion primitives ---------------------------------------------------
|
|
237
|
+
|
|
238
|
+
function requireFields(o: Record<string, unknown>, fields: string[]): void {
|
|
239
|
+
for (const f of fields) {
|
|
240
|
+
if (!(f in o)) {
|
|
241
|
+
throw protocolError(`missing required field: ${f}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function assertString(v: unknown, label: string): string {
|
|
247
|
+
if (typeof v !== "string") {
|
|
248
|
+
throw protocolError(`${label} must be a string`);
|
|
249
|
+
}
|
|
250
|
+
return v;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function assertInt(v: unknown, label: string): number {
|
|
254
|
+
if (typeof v !== "number" || !Number.isInteger(v)) {
|
|
255
|
+
throw protocolError(`${label} must be an integer`);
|
|
256
|
+
}
|
|
257
|
+
return v;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function assertPatches(v: unknown, label: string): Patch[] {
|
|
261
|
+
if (!Array.isArray(v) || v.length === 0) {
|
|
262
|
+
throw protocolError(`${label} must be a non-empty array`);
|
|
263
|
+
}
|
|
264
|
+
return v.map((p, i) => {
|
|
265
|
+
if (!isPlainObject(p)) {
|
|
266
|
+
throw protocolError(`${label}[${i}] must be an object`);
|
|
267
|
+
}
|
|
268
|
+
if (typeof p["path"] !== "string") {
|
|
269
|
+
throw protocolError(`${label}[${i}].path must be a string`);
|
|
270
|
+
}
|
|
271
|
+
assertLeafValue(p["value"], `${label}[${i}].value`, p["path"]);
|
|
272
|
+
const patch: Patch = { path: p["path"], value: p["value"] as LeafValue };
|
|
273
|
+
if (p["transition"] !== undefined) {
|
|
274
|
+
patch.transition = assertTransitionSpec(p["transition"], `${label}[${i}].transition`);
|
|
275
|
+
}
|
|
276
|
+
return patch;
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function assertTransitionSpec(v: unknown, label: string): TransitionSpec {
|
|
281
|
+
if (!isPlainObject(v)) {
|
|
282
|
+
throw protocolError(`${label} must be an object`);
|
|
283
|
+
}
|
|
284
|
+
const kind = v["kind"];
|
|
285
|
+
if (kind !== "tween" && kind !== "spring" && kind !== "snap") {
|
|
286
|
+
throw protocolError(`${label}.kind must be one of "tween", "spring", "snap"`);
|
|
287
|
+
}
|
|
288
|
+
const spec: TransitionSpec = { kind };
|
|
289
|
+
if (v["duration_ms"] !== undefined) {
|
|
290
|
+
spec.duration_ms = assertInt(v["duration_ms"], `${label}.duration_ms`);
|
|
291
|
+
}
|
|
292
|
+
if (typeof v["easing"] === "string") {
|
|
293
|
+
const e = v["easing"];
|
|
294
|
+
if (e !== "linear" && e !== "ease-in" && e !== "ease-out" && e !== "ease-in-out") {
|
|
295
|
+
throw protocolError(
|
|
296
|
+
`${label}.easing must be one of "linear", "ease-in", "ease-out", "ease-in-out"`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
spec.easing = e;
|
|
300
|
+
}
|
|
301
|
+
if (v["stiffness"] !== undefined) {
|
|
302
|
+
if (typeof v["stiffness"] !== "number") {
|
|
303
|
+
throw protocolError(`${label}.stiffness must be a number`);
|
|
304
|
+
}
|
|
305
|
+
spec.stiffness = v["stiffness"];
|
|
306
|
+
}
|
|
307
|
+
if (v["damping"] !== undefined) {
|
|
308
|
+
if (typeof v["damping"] !== "number") {
|
|
309
|
+
throw protocolError(`${label}.damping must be a number`);
|
|
310
|
+
}
|
|
311
|
+
spec.damping = v["damping"];
|
|
312
|
+
}
|
|
313
|
+
return spec;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function assertCause(v: unknown, label: string): Cause {
|
|
317
|
+
if (!isPlainObject(v)) {
|
|
318
|
+
throw protocolError(`${label} must be an object`);
|
|
319
|
+
}
|
|
320
|
+
const source = v["source"];
|
|
321
|
+
if (typeof source !== "string") {
|
|
322
|
+
throw protocolError(`${label}.source must be a string`);
|
|
323
|
+
}
|
|
324
|
+
const cause: Cause = { source };
|
|
325
|
+
if (typeof v["input_id"] === "string") {
|
|
326
|
+
cause.input_id = v["input_id"];
|
|
327
|
+
}
|
|
328
|
+
return cause;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function assertSceneTransition(v: unknown, label: string): SceneTransition {
|
|
332
|
+
if (!isPlainObject(v)) {
|
|
333
|
+
throw protocolError(`${label} must be an object`);
|
|
334
|
+
}
|
|
335
|
+
if (typeof v["kind"] !== "string") {
|
|
336
|
+
throw protocolError(`${label}.kind must be a string`);
|
|
337
|
+
}
|
|
338
|
+
const t: SceneTransition = { kind: v["kind"] as SceneTransition["kind"] };
|
|
339
|
+
if (v["duration_ms"] !== undefined) {
|
|
340
|
+
t.duration_ms = assertInt(v["duration_ms"], `${label}.duration_ms`);
|
|
341
|
+
}
|
|
342
|
+
return t;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function assertLeafValue(v: unknown, label: string, path?: string): asserts v is LeafValue {
|
|
346
|
+
if (v === null) return;
|
|
347
|
+
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") return;
|
|
348
|
+
if (Array.isArray(v)) {
|
|
349
|
+
v.forEach((item, i) => assertLeafValue(item, `${label}[${i}]`, path));
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
// Per LSDP/1.0.1 §3.2.1, objects are forbidden as patch values.
|
|
353
|
+
// The right error code is INVALID_VALUE (recoverable, path-scoped),
|
|
354
|
+
// not INTERNAL — see ERROR-CODES.md §1.4 + LSDP-1.md §3.4.1.
|
|
355
|
+
throw new LumencastError({
|
|
356
|
+
code: "INVALID_VALUE",
|
|
357
|
+
message: `${label}: objects are forbidden in patch values, push leaf-grain instead`,
|
|
358
|
+
recoverable: true,
|
|
359
|
+
...(path !== undefined ? { path } : {}),
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
364
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function optionalTs(o: Record<string, unknown>): { ts?: string } {
|
|
368
|
+
if (typeof o["ts"] === "string") return { ts: o["ts"] };
|
|
369
|
+
return {};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function protocolError(message: string): LumencastError {
|
|
373
|
+
return new LumencastError({ code: "INTERNAL", message, recoverable: false });
|
|
374
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Canonical sha256 hashing per LSML 1.0 §3.
|
|
2
|
+
// Duplicated from @lumencast/compiler to avoid a circular workspace dep
|
|
3
|
+
// (compiler depends on protocol). Keep the two in sync.
|
|
4
|
+
|
|
5
|
+
const ZERO_HASH = "sha256:" + "0".repeat(64);
|
|
6
|
+
|
|
7
|
+
export function canonicalize(value: unknown): string {
|
|
8
|
+
return stringify(value);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Hash an inline LSML bundle, returning the `sha256:<hex>` identity. */
|
|
12
|
+
export async function hashInlineBundle(inline: unknown): Promise<string> {
|
|
13
|
+
// Per spec: scene_version is set to all zeros during hashing.
|
|
14
|
+
const stub =
|
|
15
|
+
typeof inline === "object" && inline !== null && !Array.isArray(inline)
|
|
16
|
+
? { ...(inline as Record<string, unknown>), scene_version: ZERO_HASH }
|
|
17
|
+
: inline;
|
|
18
|
+
const canonical = canonicalize(stub);
|
|
19
|
+
const bytes = new TextEncoder().encode(canonical);
|
|
20
|
+
const subtle = getSubtle();
|
|
21
|
+
const digest = await subtle.digest("SHA-256", bytes);
|
|
22
|
+
return "sha256:" + bytesToHex(new Uint8Array(digest));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function stringify(v: unknown): string {
|
|
26
|
+
if (v === null) return "null";
|
|
27
|
+
if (typeof v === "boolean" || typeof v === "number") return JSON.stringify(v);
|
|
28
|
+
if (typeof v === "string") return JSON.stringify(v);
|
|
29
|
+
if (Array.isArray(v)) return "[" + v.map(stringify).join(",") + "]";
|
|
30
|
+
if (typeof v === "object") {
|
|
31
|
+
const obj = v as Record<string, unknown>;
|
|
32
|
+
const keys = Object.keys(obj).sort();
|
|
33
|
+
return "{" + keys.map((k) => JSON.stringify(k) + ":" + stringify(obj[k])).join(",") + "}";
|
|
34
|
+
}
|
|
35
|
+
return "null";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function bytesToHex(bytes: Uint8Array): string {
|
|
39
|
+
let s = "";
|
|
40
|
+
for (const b of bytes) s += b.toString(16).padStart(2, "0");
|
|
41
|
+
return s;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface SubtleLike {
|
|
45
|
+
digest(algorithm: "SHA-256", data: Uint8Array): Promise<ArrayBuffer>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getSubtle(): SubtleLike {
|
|
49
|
+
const c = (globalThis as unknown as { crypto?: { subtle?: SubtleLike } }).crypto;
|
|
50
|
+
if (!c?.subtle) {
|
|
51
|
+
throw new Error("conformance: crypto.subtle not available — Node >= 18 or browser required");
|
|
52
|
+
}
|
|
53
|
+
return c.subtle;
|
|
54
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// HTTP client for the LSDP/1 interop test control plane.
|
|
2
|
+
// Spec: lumencast-protocol/interop/CONTROL.md
|
|
3
|
+
|
|
4
|
+
export interface SetupRequest {
|
|
5
|
+
scenario: string;
|
|
6
|
+
tokens: Record<string, string>;
|
|
7
|
+
bundles: Array<{ id: string; hash: string; inline: unknown }>;
|
|
8
|
+
initial_state: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SetupResponse {
|
|
12
|
+
ws_url: string;
|
|
13
|
+
scene_id: string;
|
|
14
|
+
scene_version: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface StateResponse {
|
|
18
|
+
scene_id: string;
|
|
19
|
+
scene_version: string;
|
|
20
|
+
state: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface HealthResponse {
|
|
24
|
+
status: string;
|
|
25
|
+
control_plane_version: number;
|
|
26
|
+
server?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class ControlClient {
|
|
30
|
+
constructor(private readonly baseUrl: string) {}
|
|
31
|
+
|
|
32
|
+
async setup(req: SetupRequest): Promise<SetupResponse> {
|
|
33
|
+
return await this.postJson<SetupResponse>("/test/setup", req);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async reset(): Promise<void> {
|
|
37
|
+
await this.postNoBody("/test/reset");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async state(): Promise<StateResponse> {
|
|
41
|
+
return await this.getJson<StateResponse>("/test/state");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async emit(patches: Array<{ path: string; value: unknown }>): Promise<void> {
|
|
45
|
+
await this.postNoBody("/test/emit", { patches });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async health(): Promise<HealthResponse> {
|
|
49
|
+
return await this.getJson<HealthResponse>("/test/health");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private async postJson<T>(path: string, body: unknown): Promise<T> {
|
|
53
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: { "content-type": "application/json" },
|
|
56
|
+
body: JSON.stringify(body),
|
|
57
|
+
});
|
|
58
|
+
if (res.status >= 400) {
|
|
59
|
+
throw await this.toProblem(res, path);
|
|
60
|
+
}
|
|
61
|
+
return (await res.json()) as T;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private async postNoBody(path: string, body?: unknown): Promise<void> {
|
|
65
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: body !== undefined ? { "content-type": "application/json" } : {},
|
|
68
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
69
|
+
});
|
|
70
|
+
if (res.status >= 400) {
|
|
71
|
+
throw await this.toProblem(res, path);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private async getJson<T>(path: string): Promise<T> {
|
|
76
|
+
const res = await fetch(`${this.baseUrl}${path}`);
|
|
77
|
+
if (res.status >= 400) {
|
|
78
|
+
throw await this.toProblem(res, path);
|
|
79
|
+
}
|
|
80
|
+
return (await res.json()) as T;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private async toProblem(res: Response, path: string): Promise<Error> {
|
|
84
|
+
let detail: string;
|
|
85
|
+
try {
|
|
86
|
+
const body = (await res.json()) as { detail?: string; title?: string };
|
|
87
|
+
detail = body.detail ?? body.title ?? `${res.status}`;
|
|
88
|
+
} catch {
|
|
89
|
+
detail = `${res.status}`;
|
|
90
|
+
}
|
|
91
|
+
return new Error(`control: ${path} → ${res.status}: ${detail}`);
|
|
92
|
+
}
|
|
93
|
+
}
|