@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
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Scenario YAML loader. Mirrors the Go reference at
|
|
2
|
+
// lumencast-go/conformance/scenario.go and the contract in
|
|
3
|
+
// lumencast-protocol/conformance/v1/SCENARIO-FORMAT.md.
|
|
4
|
+
|
|
5
|
+
import { parse as parseYaml } from "yaml";
|
|
6
|
+
|
|
7
|
+
export type Tag = "required" | "recommended" | "extended";
|
|
8
|
+
export type Target = "any" | "server" | "runtime";
|
|
9
|
+
|
|
10
|
+
export type StepKind =
|
|
11
|
+
| "client-sends"
|
|
12
|
+
| "server-sends"
|
|
13
|
+
| "server-emits"
|
|
14
|
+
| "expect-runtime-state"
|
|
15
|
+
| "expect-server-state"
|
|
16
|
+
| "expect-no-frame-for"
|
|
17
|
+
| "expect-client-action";
|
|
18
|
+
|
|
19
|
+
export type ClientAction = "close-with-reason" | "reconnect";
|
|
20
|
+
|
|
21
|
+
export interface Step {
|
|
22
|
+
kind: StepKind;
|
|
23
|
+
/** client-sends, server-sends */
|
|
24
|
+
frame?: Record<string, unknown>;
|
|
25
|
+
/** expect-runtime-state, expect-server-state */
|
|
26
|
+
state?: Record<string, unknown>;
|
|
27
|
+
/** expect-no-frame-for */
|
|
28
|
+
duration_ms?: number;
|
|
29
|
+
/** expect-client-action */
|
|
30
|
+
action?: ClientAction;
|
|
31
|
+
reason?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface BundleDecl {
|
|
35
|
+
id: string;
|
|
36
|
+
inline: Record<string, unknown>;
|
|
37
|
+
/** sha256:<hex> — populated lazily by computeBundleHashes(). */
|
|
38
|
+
hash?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface Scenario {
|
|
42
|
+
name: string;
|
|
43
|
+
description: string;
|
|
44
|
+
tag: Tag;
|
|
45
|
+
target: Target;
|
|
46
|
+
spec_refs?: string[];
|
|
47
|
+
bundles?: BundleDecl[];
|
|
48
|
+
steps: Step[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function parseScenario(raw: string): Scenario {
|
|
52
|
+
const obj = parseYaml(raw) as Partial<Scenario> | undefined;
|
|
53
|
+
if (!obj || typeof obj !== "object") {
|
|
54
|
+
throw new Error("scenario: not a YAML mapping");
|
|
55
|
+
}
|
|
56
|
+
if (!obj.name) throw new Error("scenario: missing name");
|
|
57
|
+
if (!Array.isArray(obj.steps)) throw new Error("scenario: missing steps[]");
|
|
58
|
+
|
|
59
|
+
const tag: Tag = (obj.tag as Tag | undefined) ?? "required";
|
|
60
|
+
const target: Target = (obj.target as Target | undefined) ?? "any";
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
name: obj.name,
|
|
64
|
+
description: obj.description ?? "",
|
|
65
|
+
tag,
|
|
66
|
+
target,
|
|
67
|
+
...(obj.spec_refs ? { spec_refs: obj.spec_refs } : {}),
|
|
68
|
+
...(obj.bundles ? { bundles: obj.bundles as BundleDecl[] } : {}),
|
|
69
|
+
steps: obj.steps as Step[],
|
|
70
|
+
};
|
|
71
|
+
}
|
package/src/envelope.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Envelope helpers — convenience constructors that stamp `v` automatically.
|
|
2
|
+
// Useful when emitting frames from a server or test fixture.
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
PROTOCOL_VERSION,
|
|
6
|
+
type Cause,
|
|
7
|
+
type DeltaFrame,
|
|
8
|
+
type ErrorCode,
|
|
9
|
+
type ErrorFrame,
|
|
10
|
+
type InputFrame,
|
|
11
|
+
type LeafPath,
|
|
12
|
+
type LeafValue,
|
|
13
|
+
type Patch,
|
|
14
|
+
type PingFrame,
|
|
15
|
+
type PongFrame,
|
|
16
|
+
type SceneChangedFrame,
|
|
17
|
+
type SceneId,
|
|
18
|
+
type SceneTransition,
|
|
19
|
+
type SceneVersion,
|
|
20
|
+
type SessionId,
|
|
21
|
+
type SnapshotFrame,
|
|
22
|
+
type SubscribeFrame,
|
|
23
|
+
type UnsubscribeFrame,
|
|
24
|
+
} from "./types.js";
|
|
25
|
+
|
|
26
|
+
export interface SnapshotInit {
|
|
27
|
+
seq: number;
|
|
28
|
+
scene_id: SceneId;
|
|
29
|
+
scene_version: SceneVersion;
|
|
30
|
+
state: Record<LeafPath, LeafValue>;
|
|
31
|
+
ts?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function snapshot(init: SnapshotInit): SnapshotFrame {
|
|
35
|
+
const frame: SnapshotFrame = {
|
|
36
|
+
v: PROTOCOL_VERSION,
|
|
37
|
+
type: "snapshot",
|
|
38
|
+
seq: init.seq,
|
|
39
|
+
scene_id: init.scene_id,
|
|
40
|
+
scene_version: init.scene_version,
|
|
41
|
+
state: init.state,
|
|
42
|
+
};
|
|
43
|
+
if (init.ts !== undefined) frame.ts = init.ts;
|
|
44
|
+
return frame;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface DeltaInit {
|
|
48
|
+
seq: number;
|
|
49
|
+
patches: Patch[];
|
|
50
|
+
ts?: string;
|
|
51
|
+
/** LSDP/1.1 §3.2.3 — optional provenance metadata. */
|
|
52
|
+
cause?: Cause;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function delta(init: DeltaInit): DeltaFrame {
|
|
56
|
+
if (init.patches.length === 0) {
|
|
57
|
+
throw new Error("delta.patches must be non-empty");
|
|
58
|
+
}
|
|
59
|
+
const frame: DeltaFrame = {
|
|
60
|
+
v: PROTOCOL_VERSION,
|
|
61
|
+
type: "delta",
|
|
62
|
+
seq: init.seq,
|
|
63
|
+
patches: init.patches,
|
|
64
|
+
};
|
|
65
|
+
if (init.ts !== undefined) frame.ts = init.ts;
|
|
66
|
+
if (init.cause !== undefined) frame.cause = init.cause;
|
|
67
|
+
return frame;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface SceneChangedInit {
|
|
71
|
+
seq: number;
|
|
72
|
+
scene_id: SceneId;
|
|
73
|
+
scene_version: SceneVersion;
|
|
74
|
+
ts?: string;
|
|
75
|
+
/** LSDP/1.1 §3.3.1 — previously active scene id. */
|
|
76
|
+
from_scene_id?: SceneId;
|
|
77
|
+
/** LSDP/1.1 §3.3.1 — show-level scene transition. */
|
|
78
|
+
transition?: SceneTransition;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function sceneChanged(init: SceneChangedInit): SceneChangedFrame {
|
|
82
|
+
const frame: SceneChangedFrame = {
|
|
83
|
+
v: PROTOCOL_VERSION,
|
|
84
|
+
type: "scene_changed",
|
|
85
|
+
seq: init.seq,
|
|
86
|
+
scene_id: init.scene_id,
|
|
87
|
+
scene_version: init.scene_version,
|
|
88
|
+
};
|
|
89
|
+
if (init.ts !== undefined) frame.ts = init.ts;
|
|
90
|
+
if (init.from_scene_id !== undefined) frame.from_scene_id = init.from_scene_id;
|
|
91
|
+
if (init.transition !== undefined) frame.transition = init.transition;
|
|
92
|
+
return frame;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface ErrorInit {
|
|
96
|
+
seq: number;
|
|
97
|
+
code: ErrorCode;
|
|
98
|
+
message: string;
|
|
99
|
+
recoverable: boolean;
|
|
100
|
+
/**
|
|
101
|
+
* REQUIRED for path-scoped codes (`WRITE_FORBIDDEN`, `UNKNOWN_PATH`,
|
|
102
|
+
* `INVALID_VALUE`) per LSDP/1.0.1 §3.4.1.
|
|
103
|
+
*/
|
|
104
|
+
path?: LeafPath;
|
|
105
|
+
retry_after_ms?: number;
|
|
106
|
+
requested_version?: string;
|
|
107
|
+
supported_version?: string;
|
|
108
|
+
session?: string;
|
|
109
|
+
ts?: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function errorFrame(init: ErrorInit): ErrorFrame {
|
|
113
|
+
const frame: ErrorFrame = {
|
|
114
|
+
v: PROTOCOL_VERSION,
|
|
115
|
+
type: "error",
|
|
116
|
+
seq: init.seq,
|
|
117
|
+
code: init.code,
|
|
118
|
+
message: init.message,
|
|
119
|
+
recoverable: init.recoverable,
|
|
120
|
+
};
|
|
121
|
+
if (init.path !== undefined) frame.path = init.path;
|
|
122
|
+
if (init.retry_after_ms !== undefined) frame.retry_after_ms = init.retry_after_ms;
|
|
123
|
+
if (init.requested_version !== undefined) frame.requested_version = init.requested_version;
|
|
124
|
+
if (init.supported_version !== undefined) frame.supported_version = init.supported_version;
|
|
125
|
+
if (init.session !== undefined) frame.session = init.session;
|
|
126
|
+
if (init.ts !== undefined) frame.ts = init.ts;
|
|
127
|
+
return frame;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function pong(nonce?: string): PongFrame {
|
|
131
|
+
const frame: PongFrame = { v: PROTOCOL_VERSION, type: "pong" };
|
|
132
|
+
if (nonce !== undefined) frame.nonce = nonce;
|
|
133
|
+
return frame;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface SubscribeInit {
|
|
137
|
+
token: string;
|
|
138
|
+
scene?: SceneId;
|
|
139
|
+
session?: SessionId;
|
|
140
|
+
/** LSDP/1.1 §4.1 — incremental resume from a known last-seen seq. */
|
|
141
|
+
since_sequence?: number;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function subscribe(init: SubscribeInit): SubscribeFrame {
|
|
145
|
+
const frame: SubscribeFrame = {
|
|
146
|
+
v: PROTOCOL_VERSION,
|
|
147
|
+
type: "subscribe",
|
|
148
|
+
token: init.token,
|
|
149
|
+
};
|
|
150
|
+
if (init.scene !== undefined) frame.scene = init.scene;
|
|
151
|
+
if (init.session !== undefined) frame.session = init.session;
|
|
152
|
+
if (init.since_sequence !== undefined) frame.since_sequence = init.since_sequence;
|
|
153
|
+
return frame;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface InputInit {
|
|
157
|
+
patches: Patch[];
|
|
158
|
+
/** LSDP/1.1 §4.2 — optimistic-UI correlation tag. */
|
|
159
|
+
client_msg_id?: string;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function input(patches: Patch[], init?: { client_msg_id?: string }): InputFrame {
|
|
163
|
+
if (patches.length === 0) {
|
|
164
|
+
throw new Error("input.patches must be non-empty");
|
|
165
|
+
}
|
|
166
|
+
const frame: InputFrame = { v: PROTOCOL_VERSION, type: "input", patches };
|
|
167
|
+
if (init?.client_msg_id !== undefined) frame.client_msg_id = init.client_msg_id;
|
|
168
|
+
return frame;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function ping(nonce?: string): PingFrame {
|
|
172
|
+
const frame: PingFrame = { v: PROTOCOL_VERSION, type: "ping" };
|
|
173
|
+
if (nonce !== undefined) frame.nonce = nonce;
|
|
174
|
+
return frame;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** LSDP/1.1 §4.4 — clean teardown signal. */
|
|
178
|
+
export function unsubscribe(): UnsubscribeFrame {
|
|
179
|
+
return { v: PROTOCOL_VERSION, type: "unsubscribe" };
|
|
180
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Error helpers — typed exception + code-set membership check.
|
|
2
|
+
// Code semantics live in lumencast-protocol/spec/ERROR-CODES.md.
|
|
3
|
+
|
|
4
|
+
import type { ErrorCode } from "./types.js";
|
|
5
|
+
|
|
6
|
+
const ERROR_CODES = new Set<ErrorCode>([
|
|
7
|
+
"AUTH_DENIED",
|
|
8
|
+
"WRITE_FORBIDDEN",
|
|
9
|
+
"SCENE_NOT_FOUND",
|
|
10
|
+
"BUNDLE_FETCH_FAILED",
|
|
11
|
+
"BUNDLE_INCOMPATIBLE",
|
|
12
|
+
"VERSION_GAP",
|
|
13
|
+
"VERSION_MISMATCH",
|
|
14
|
+
"UNKNOWN_PATH",
|
|
15
|
+
"INVALID_VALUE",
|
|
16
|
+
"RATE_LIMIT",
|
|
17
|
+
"TEST_SESSION_EXPIRED",
|
|
18
|
+
"INTERNAL",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
export function isProtocolErrorCode(value: unknown): value is ErrorCode {
|
|
22
|
+
return typeof value === "string" && ERROR_CODES.has(value as ErrorCode);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface LumencastErrorInit {
|
|
26
|
+
code: ErrorCode;
|
|
27
|
+
message: string;
|
|
28
|
+
recoverable: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* REQUIRED for path-scoped codes (`WRITE_FORBIDDEN`, `UNKNOWN_PATH`,
|
|
31
|
+
* `INVALID_VALUE`) per LSDP/1.0.1 §3.4.1.
|
|
32
|
+
*/
|
|
33
|
+
path?: string;
|
|
34
|
+
retry_after_ms?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Typed runtime exception. Mirrors the shape of an LSDP `error` frame so that
|
|
39
|
+
* runtime hosts can match `error.code` against the closed taxonomy.
|
|
40
|
+
*/
|
|
41
|
+
export class LumencastError extends Error {
|
|
42
|
+
readonly code: ErrorCode;
|
|
43
|
+
readonly recoverable: boolean;
|
|
44
|
+
readonly path: string | undefined;
|
|
45
|
+
readonly retry_after_ms: number | undefined;
|
|
46
|
+
|
|
47
|
+
constructor(init: LumencastErrorInit) {
|
|
48
|
+
super(init.message);
|
|
49
|
+
this.name = "LumencastError";
|
|
50
|
+
this.code = init.code;
|
|
51
|
+
this.recoverable = init.recoverable;
|
|
52
|
+
this.path = init.path;
|
|
53
|
+
this.retry_after_ms = init.retry_after_ms;
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Public surface of @lumencast/protocol.
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
PROTOCOL_VERSION,
|
|
5
|
+
WS_SUBPROTOCOL,
|
|
6
|
+
WS_SUBPROTOCOL_V1_1,
|
|
7
|
+
WS_SUBPROTOCOLS,
|
|
8
|
+
type Cause,
|
|
9
|
+
type ClientFrame,
|
|
10
|
+
type DeltaFrame,
|
|
11
|
+
type ErrorCode,
|
|
12
|
+
type ErrorFrame,
|
|
13
|
+
type InputFrame,
|
|
14
|
+
type LeafPath,
|
|
15
|
+
type LeafValue,
|
|
16
|
+
type Patch,
|
|
17
|
+
type PingFrame,
|
|
18
|
+
type PongFrame,
|
|
19
|
+
type SceneChangedFrame,
|
|
20
|
+
type SceneId,
|
|
21
|
+
type SceneTransition,
|
|
22
|
+
type SceneVersion,
|
|
23
|
+
type ServerFrame,
|
|
24
|
+
type SessionId,
|
|
25
|
+
type SnapshotFrame,
|
|
26
|
+
type SubscribeFrame,
|
|
27
|
+
type TransitionSpec,
|
|
28
|
+
type UnsubscribeFrame,
|
|
29
|
+
} from "./types.js";
|
|
30
|
+
|
|
31
|
+
export { LumencastError, isProtocolErrorCode, type LumencastErrorInit } from "./errors.js";
|
|
32
|
+
|
|
33
|
+
export { encodeFrame, decodeServerFrame, decodeClientFrame } from "./codec.js";
|
|
34
|
+
|
|
35
|
+
export { SequenceTracker, type SequenceObservation } from "./sequence.js";
|
|
36
|
+
|
|
37
|
+
export {
|
|
38
|
+
RESERVED_NAMESPACES,
|
|
39
|
+
type ReservedNamespace,
|
|
40
|
+
parseLeafPath,
|
|
41
|
+
formatLeafPath,
|
|
42
|
+
isReservedPath,
|
|
43
|
+
isUnknownReservedPath,
|
|
44
|
+
substituteScope,
|
|
45
|
+
} from "./leaf-path.js";
|
|
46
|
+
|
|
47
|
+
export {
|
|
48
|
+
snapshot,
|
|
49
|
+
delta,
|
|
50
|
+
sceneChanged,
|
|
51
|
+
errorFrame,
|
|
52
|
+
pong,
|
|
53
|
+
subscribe,
|
|
54
|
+
input,
|
|
55
|
+
ping,
|
|
56
|
+
unsubscribe,
|
|
57
|
+
type SnapshotInit,
|
|
58
|
+
type DeltaInit,
|
|
59
|
+
type SceneChangedInit,
|
|
60
|
+
type ErrorInit,
|
|
61
|
+
type SubscribeInit,
|
|
62
|
+
type InputInit,
|
|
63
|
+
} from "./envelope.js";
|
package/src/leaf-path.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// LeafPath canonical form + scope substitution.
|
|
2
|
+
// Reference: LSDP/1 §10 (reserved namespaces) and LSML 1.0 §7 (repeat scope).
|
|
3
|
+
|
|
4
|
+
import type { LeafPath } from "./types.js";
|
|
5
|
+
|
|
6
|
+
/** A path segment is alphanumeric + underscore. Numeric indices are decimal. */
|
|
7
|
+
const SEGMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$|^[0-9]+$/;
|
|
8
|
+
|
|
9
|
+
/** Reserved top-level namespaces per LSDP/1 §10. */
|
|
10
|
+
export const RESERVED_NAMESPACES = ["__inputs", "__system", "__test", "__schema"] as const;
|
|
11
|
+
export type ReservedNamespace = (typeof RESERVED_NAMESPACES)[number];
|
|
12
|
+
|
|
13
|
+
/** Parse a `LeafPath` string into its segments. Throws on malformed input. */
|
|
14
|
+
export function parseLeafPath(path: LeafPath): string[] {
|
|
15
|
+
if (path.length === 0) throw new Error("leaf-path is empty");
|
|
16
|
+
const segs = path.split(".");
|
|
17
|
+
for (const s of segs) {
|
|
18
|
+
if (!SEGMENT_RE.test(s)) {
|
|
19
|
+
throw new Error(`leaf-path segment is not valid: ${s}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return segs;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Format an array of segments back to a `LeafPath`. */
|
|
26
|
+
export function formatLeafPath(segments: string[]): LeafPath {
|
|
27
|
+
return segments.join(".");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** True if the path begins with a reserved namespace (`__inputs`, `__system`, ...). */
|
|
31
|
+
export function isReservedPath(path: LeafPath): boolean {
|
|
32
|
+
const head = path.split(".", 1)[0];
|
|
33
|
+
return (RESERVED_NAMESPACES as readonly string[]).includes(head ?? "");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** True if the path begins with `__` but is NOT one of the four declared namespaces. */
|
|
37
|
+
export function isUnknownReservedPath(path: LeafPath): boolean {
|
|
38
|
+
if (!path.startsWith("__")) return false;
|
|
39
|
+
const head = path.split(".", 1)[0] ?? "";
|
|
40
|
+
return !(RESERVED_NAMESPACES as readonly string[]).includes(head);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Substitute `{scope}` placeholders inside a path template.
|
|
45
|
+
* Used by `repeat` primitives to bind per-item paths.
|
|
46
|
+
*
|
|
47
|
+
* Example: `substituteScope("{player}.score", { player: "players.0" })`
|
|
48
|
+
* → "players.0.score"
|
|
49
|
+
*/
|
|
50
|
+
export function substituteScope(template: LeafPath, scopes: Record<string, string>): LeafPath {
|
|
51
|
+
return template.replace(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name: string) => {
|
|
52
|
+
const replacement = scopes[name];
|
|
53
|
+
if (replacement === undefined) {
|
|
54
|
+
throw new Error(`unknown scope identifier in path template: ${name}`);
|
|
55
|
+
}
|
|
56
|
+
return replacement;
|
|
57
|
+
});
|
|
58
|
+
}
|
package/src/sequence.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Monotonic sequence tracker with gap detection.
|
|
2
|
+
// LSDP/1.1 §18.1.1: seq is per-scene, NOT per-subscription. The first
|
|
3
|
+
// frame of a fresh subscription can carry any seq >= 1 (late-joining
|
|
4
|
+
// subscribers see the current scene seq). The tracker rebases to the
|
|
5
|
+
// snapshot value after scene_changed via observeSnapshot.
|
|
6
|
+
//
|
|
7
|
+
// Receiver rules:
|
|
8
|
+
// - first frame on a fresh tracker → establish baseline (any seq >= 1)
|
|
9
|
+
// - seq > last + 1 → gap, runtime MUST close + reconnect
|
|
10
|
+
// - seq <= last → replay, runtime MUST drop silently
|
|
11
|
+
// - seq == last + 1 → in-order, accept
|
|
12
|
+
|
|
13
|
+
export type SequenceObservation =
|
|
14
|
+
| { kind: "in-order"; seq: number }
|
|
15
|
+
| { kind: "duplicate"; seq: number; lastSeq: number }
|
|
16
|
+
| { kind: "gap"; seq: number; lastSeq: number };
|
|
17
|
+
|
|
18
|
+
export class SequenceTracker {
|
|
19
|
+
private lastSeq = 0;
|
|
20
|
+
|
|
21
|
+
/** Observe an incoming server seq. Returns the classification. */
|
|
22
|
+
observe(seq: number): SequenceObservation {
|
|
23
|
+
if (!Number.isInteger(seq) || seq < 1) {
|
|
24
|
+
// Treat malformed seq as a gap so the runtime reconnects.
|
|
25
|
+
return { kind: "gap", seq, lastSeq: this.lastSeq };
|
|
26
|
+
}
|
|
27
|
+
if (this.lastSeq === 0) {
|
|
28
|
+
// Fresh tracker — any seq >= 1 establishes the baseline
|
|
29
|
+
// (LSDP/1.1 §18.1.1).
|
|
30
|
+
this.lastSeq = seq;
|
|
31
|
+
return { kind: "in-order", seq };
|
|
32
|
+
}
|
|
33
|
+
if (seq <= this.lastSeq) {
|
|
34
|
+
return { kind: "duplicate", seq, lastSeq: this.lastSeq };
|
|
35
|
+
}
|
|
36
|
+
if (seq > this.lastSeq + 1) {
|
|
37
|
+
return { kind: "gap", seq, lastSeq: this.lastSeq };
|
|
38
|
+
}
|
|
39
|
+
this.lastSeq = seq;
|
|
40
|
+
return { kind: "in-order", seq };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Rebase the tracker to a snapshot's seq. Called after scene_changed
|
|
44
|
+
* or back-pressure recovery — the tracker takes the snapshot value as
|
|
45
|
+
* the new baseline regardless of previous state. */
|
|
46
|
+
observeSnapshot(seq: number): void {
|
|
47
|
+
if (Number.isInteger(seq) && seq >= 1) {
|
|
48
|
+
this.lastSeq = seq;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Reset the tracker. Called on reconnect with no resume. */
|
|
53
|
+
reset(): void {
|
|
54
|
+
this.lastSeq = 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get last(): number {
|
|
58
|
+
return this.lastSeq;
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// LSDP/1 wire types and shared shapes.
|
|
2
|
+
// Canonical reference: lumencast-protocol/spec/LSDP-1.md (and ERROR-CODES.md, RUNTIME-API.md).
|
|
3
|
+
|
|
4
|
+
/** LSDP major version. Bumped only on breaking envelope/semantic changes. */
|
|
5
|
+
export const PROTOCOL_VERSION = 1 as const;
|
|
6
|
+
|
|
7
|
+
/** LSDP/1.0 WebSocket subprotocol — see LSDP/1 §1. Kept for 1.0 client compat. */
|
|
8
|
+
export const WS_SUBPROTOCOL = "lsdp.v1" as const;
|
|
9
|
+
|
|
10
|
+
/** LSDP/1.1 WebSocket subprotocol — opts into the additive 1.1 frame surface
|
|
11
|
+
* (since_sequence resume, unsubscribe, transition, cause, nonce, client_msg_id,
|
|
12
|
+
* from_scene_id + show transition). See LSDP/1.1 envelope/header section. */
|
|
13
|
+
export const WS_SUBPROTOCOL_V1_1 = "lsdp.v1.1" as const;
|
|
14
|
+
|
|
15
|
+
/** Canonical advertise/accept list, ordered by preference (1.1 first, 1.0 fallback). */
|
|
16
|
+
export const WS_SUBPROTOCOLS = [WS_SUBPROTOCOL_V1_1, WS_SUBPROTOCOL] as const;
|
|
17
|
+
|
|
18
|
+
/** A leaf path expressed as a dot-separated string. See LSDP/1 §10 for reserved namespaces. */
|
|
19
|
+
export type LeafPath = string;
|
|
20
|
+
|
|
21
|
+
/** A scene identifier — operator-chosen, not derived. */
|
|
22
|
+
export type SceneId = string;
|
|
23
|
+
|
|
24
|
+
/** A scene version — sha256 hash prefixed with `sha256:`. */
|
|
25
|
+
export type SceneVersion = string;
|
|
26
|
+
|
|
27
|
+
/** A test session identifier. */
|
|
28
|
+
export type SessionId = string;
|
|
29
|
+
|
|
30
|
+
/** Permitted JSON values inside a `delta.patches[].value`. Objects are forbidden — push leaf-grain. */
|
|
31
|
+
export type LeafValue = string | number | boolean | null | LeafValue[];
|
|
32
|
+
|
|
33
|
+
/** Closed taxonomy of LSDP/1 error codes. Match by exact string equality. */
|
|
34
|
+
export type ErrorCode =
|
|
35
|
+
| "AUTH_DENIED"
|
|
36
|
+
| "WRITE_FORBIDDEN"
|
|
37
|
+
| "SCENE_NOT_FOUND"
|
|
38
|
+
| "BUNDLE_FETCH_FAILED"
|
|
39
|
+
| "BUNDLE_INCOMPATIBLE"
|
|
40
|
+
| "VERSION_GAP"
|
|
41
|
+
| "VERSION_MISMATCH"
|
|
42
|
+
| "UNKNOWN_PATH"
|
|
43
|
+
| "INVALID_VALUE"
|
|
44
|
+
| "RATE_LIMIT"
|
|
45
|
+
| "TEST_SESSION_EXPIRED"
|
|
46
|
+
| "INTERNAL";
|
|
47
|
+
|
|
48
|
+
/** Per-leaf animation directive on a delta patch (LSDP/1.1 §3.2.2).
|
|
49
|
+
* Servers MAY emit ; runtimes interpret when applying the new value.
|
|
50
|
+
* 1.0 receivers ignore. */
|
|
51
|
+
export interface TransitionSpec {
|
|
52
|
+
kind: "tween" | "spring" | "snap";
|
|
53
|
+
/** tween only */
|
|
54
|
+
duration_ms?: number;
|
|
55
|
+
/** tween only */
|
|
56
|
+
easing?: "linear" | "ease-in" | "ease-out" | "ease-in-out";
|
|
57
|
+
/** spring only */
|
|
58
|
+
stiffness?: number;
|
|
59
|
+
/** spring only */
|
|
60
|
+
damping?: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Optional provenance metadata on a delta (LSDP/1.1 §3.2.3). Receivers
|
|
64
|
+
* MUST NOT use it for semantic decisions — debug/audit only. */
|
|
65
|
+
export interface Cause {
|
|
66
|
+
/** e.g. "operator:user-abc", "adapter:http_poll", "service:ranker" */
|
|
67
|
+
source: string;
|
|
68
|
+
/** Echoes InputFrame.client_msg_id verbatim when the delta was caused
|
|
69
|
+
* by an operator input. */
|
|
70
|
+
input_id?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Show-level scene-swap transition on a scene_changed frame
|
|
74
|
+
* (LSDP/1.1 §3.3.1). Runtimes that don't recognise `kind` fall back
|
|
75
|
+
* to crossfade. */
|
|
76
|
+
export interface SceneTransition {
|
|
77
|
+
kind: "crossfade" | (string & {}); // open string for vendor kinds
|
|
78
|
+
duration_ms?: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A leaf-grain patch. */
|
|
82
|
+
export interface Patch {
|
|
83
|
+
path: LeafPath;
|
|
84
|
+
value: LeafValue;
|
|
85
|
+
/** Optional 1.1 per-leaf transition directive. */
|
|
86
|
+
transition?: TransitionSpec;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// --- Server → client frames -------------------------------------------------
|
|
90
|
+
|
|
91
|
+
interface BaseFrame {
|
|
92
|
+
v: typeof PROTOCOL_VERSION;
|
|
93
|
+
/** Monotonically increasing per subscription. Required on server frames except `pong`. */
|
|
94
|
+
seq?: number;
|
|
95
|
+
/** ISO 8601 timestamp. SHOULD be sent on snapshots and errors; MAY be omitted on deltas. */
|
|
96
|
+
ts?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface SnapshotFrame extends BaseFrame {
|
|
100
|
+
type: "snapshot";
|
|
101
|
+
seq: number;
|
|
102
|
+
scene_id: SceneId;
|
|
103
|
+
scene_version: SceneVersion;
|
|
104
|
+
/** Flat dictionary of leaf paths to JSON values. */
|
|
105
|
+
state: Record<LeafPath, LeafValue>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface DeltaFrame extends BaseFrame {
|
|
109
|
+
type: "delta";
|
|
110
|
+
seq: number;
|
|
111
|
+
/** Non-empty array of patches; applied left-to-right, atomic per frame. */
|
|
112
|
+
patches: Patch[];
|
|
113
|
+
/** Optional provenance (LSDP/1.1 §3.2.3). Debug/audit only. */
|
|
114
|
+
cause?: Cause;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface SceneChangedFrame extends BaseFrame {
|
|
118
|
+
type: "scene_changed";
|
|
119
|
+
seq: number;
|
|
120
|
+
scene_id: SceneId;
|
|
121
|
+
scene_version: SceneVersion;
|
|
122
|
+
/** Previously active scene id (LSDP/1.1 §3.3.1). 1.0 receivers ignore. */
|
|
123
|
+
from_scene_id?: SceneId;
|
|
124
|
+
/** Show-level transition between old and new scene (LSDP/1.1 §3.3.1). */
|
|
125
|
+
transition?: SceneTransition;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface ErrorFrame extends BaseFrame {
|
|
129
|
+
type: "error";
|
|
130
|
+
seq: number;
|
|
131
|
+
code: ErrorCode;
|
|
132
|
+
message: string;
|
|
133
|
+
recoverable: boolean;
|
|
134
|
+
/**
|
|
135
|
+
* REQUIRED for path-scoped codes (`WRITE_FORBIDDEN`, `UNKNOWN_PATH`,
|
|
136
|
+
* `INVALID_VALUE`) per LSDP/1.0.1 §3.4.1. Forbidden for codes that
|
|
137
|
+
* are not path-scoped.
|
|
138
|
+
*/
|
|
139
|
+
path?: LeafPath;
|
|
140
|
+
/** Optional, for `RATE_LIMIT`. */
|
|
141
|
+
retry_after_ms?: number;
|
|
142
|
+
/** Optional, for `BUNDLE_INCOMPATIBLE`. */
|
|
143
|
+
requested_version?: string;
|
|
144
|
+
supported_version?: string;
|
|
145
|
+
/** Optional, for `TEST_SESSION_EXPIRED`. */
|
|
146
|
+
session?: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface PongFrame {
|
|
150
|
+
v: typeof PROTOCOL_VERSION;
|
|
151
|
+
type: "pong";
|
|
152
|
+
/** Echoes PingFrame.nonce verbatim (LSDP/1.1 §3.5). 1.0 servers omit. */
|
|
153
|
+
nonce?: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export type ServerFrame = SnapshotFrame | DeltaFrame | SceneChangedFrame | ErrorFrame | PongFrame;
|
|
157
|
+
|
|
158
|
+
// --- Client → server frames -------------------------------------------------
|
|
159
|
+
|
|
160
|
+
export interface SubscribeFrame {
|
|
161
|
+
v: typeof PROTOCOL_VERSION;
|
|
162
|
+
type: "subscribe";
|
|
163
|
+
/** Opaque authentication token. */
|
|
164
|
+
token: string;
|
|
165
|
+
/** Required for test mode (preview a specific scene); forbidden for live. */
|
|
166
|
+
scene?: SceneId;
|
|
167
|
+
/** Required for test mode with isolated session; forbidden otherwise. */
|
|
168
|
+
session?: SessionId;
|
|
169
|
+
/** Last seq the client successfully observed before disconnect
|
|
170
|
+
* (LSDP/1.1 §4.1, §18). Server resumes with deltas from
|
|
171
|
+
* since_sequence+1 if the replay buffer covers, else fresh snapshot.
|
|
172
|
+
* 1.0 servers MUST ignore this field. Omit (or 0) means no resume. */
|
|
173
|
+
since_sequence?: number;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface InputFrame {
|
|
177
|
+
v: typeof PROTOCOL_VERSION;
|
|
178
|
+
type: "input";
|
|
179
|
+
/** Non-empty. Server validates each path against active scene's `operator_inputs`. */
|
|
180
|
+
patches: Patch[];
|
|
181
|
+
/** Free-form correlation tag (LSDP/1.1 §4.2). Server MUST echo
|
|
182
|
+
* verbatim in the resulting Delta.cause.input_id. 1.0 servers ignore. */
|
|
183
|
+
client_msg_id?: string;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface PingFrame {
|
|
187
|
+
v: typeof PROTOCOL_VERSION;
|
|
188
|
+
type: "ping";
|
|
189
|
+
/** Free-form correlation identifier (LSDP/1.1 §4.3). Receiver MUST
|
|
190
|
+
* echo verbatim in the Pong reply. */
|
|
191
|
+
nonce?: string;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Clean teardown signal (LSDP/1.1 §4.4). Server MUST close the
|
|
195
|
+
* WebSocket within 1 second of receipt. No data flows after. */
|
|
196
|
+
export interface UnsubscribeFrame {
|
|
197
|
+
v: typeof PROTOCOL_VERSION;
|
|
198
|
+
type: "unsubscribe";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export type ClientFrame = SubscribeFrame | InputFrame | PingFrame | UnsubscribeFrame;
|