@lumencast/server 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 +69 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/adapters/http-poll.d.ts +16 -0
- package/dist/adapters/http-poll.d.ts.map +1 -0
- package/dist/adapters/http-poll.js +46 -0
- package/dist/adapters/http-poll.js.map +1 -0
- package/dist/auth.d.ts +33 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +65 -0
- package/dist/auth.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +154 -0
- package/dist/cli.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/replay-buffer.d.ts +32 -0
- package/dist/replay-buffer.d.ts.map +1 -0
- package/dist/replay-buffer.js +60 -0
- package/dist/replay-buffer.js.map +1 -0
- package/dist/scene.d.ts +67 -0
- package/dist/scene.d.ts.map +1 -0
- package/dist/scene.js +109 -0
- package/dist/scene.js.map +1 -0
- package/dist/server.d.ts +38 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +279 -0
- package/dist/server.js.map +1 -0
- package/dist/store.d.ts +17 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +34 -0
- package/dist/store.js.map +1 -0
- package/dist/test-control.d.ts +22 -0
- package/dist/test-control.d.ts.map +1 -0
- package/dist/test-control.js +225 -0
- package/dist/test-control.js.map +1 -0
- package/package.json +50 -0
- package/src/adapters/http-poll.ts +59 -0
- package/src/auth.ts +81 -0
- package/src/cli.ts +166 -0
- package/src/index.ts +20 -0
- package/src/replay-buffer.ts +72 -0
- package/src/scene.ts +168 -0
- package/src/server.ts +386 -0
- package/src/store.ts +40 -0
- package/src/test-control.ts +279 -0
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lumencast/server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Node server kit for LSDP/1 — HTTP+WS, scene composition, leaf store, server-side adapters, token-agnostic auth hooks.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"module": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./cli": "./dist/cli.js"
|
|
16
|
+
},
|
|
17
|
+
"bin": {
|
|
18
|
+
"lumencast-js": "./dist/cli.js"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"src"
|
|
23
|
+
],
|
|
24
|
+
"homepage": "https://github.com/Lumencast/lumencast-js/tree/main/packages/server",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "https://github.com/Lumencast/lumencast-js.git",
|
|
28
|
+
"directory": "packages/server"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=22"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public",
|
|
35
|
+
"registry": "https://registry.npmjs.org/"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"ws": "^8.18.0",
|
|
39
|
+
"@lumencast/protocol": "0.1.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/ws": "^8.5.13",
|
|
43
|
+
"vitest": "^4.1.5"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsc -b",
|
|
47
|
+
"typecheck": "tsc -b --pretty",
|
|
48
|
+
"test": "vitest run"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// http_poll adapter — fetches a URL on an interval and writes the result
|
|
2
|
+
// as leaf-grain patches into a Scene.
|
|
3
|
+
|
|
4
|
+
import type { LeafPath, LeafValue, Patch } from "@lumencast/protocol";
|
|
5
|
+
import type { Scene } from "../scene.js";
|
|
6
|
+
|
|
7
|
+
export interface HttpPollOptions {
|
|
8
|
+
url: string;
|
|
9
|
+
intervalMs: number;
|
|
10
|
+
/** Path prefix the adapter writes under — same semantic as `external_adapters[].writes_to` in LSML. */
|
|
11
|
+
writesTo: LeafPath;
|
|
12
|
+
/** Extract a flat record of leaf paths from the response. */
|
|
13
|
+
extract: (response: unknown) => Record<LeafPath, LeafValue>;
|
|
14
|
+
/** Optional headers (e.g. Authorization). */
|
|
15
|
+
headers?: Record<string, string>;
|
|
16
|
+
/** Optional error reporter — defaults to silent. */
|
|
17
|
+
onError?: (err: unknown) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function startHttpPoll(scene: Scene, options: HttpPollOptions): () => void {
|
|
21
|
+
let stopped = false;
|
|
22
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
23
|
+
|
|
24
|
+
async function tick(): Promise<void> {
|
|
25
|
+
if (stopped) return;
|
|
26
|
+
try {
|
|
27
|
+
const res = await fetch(options.url, { headers: options.headers ?? {} });
|
|
28
|
+
if (res.ok) {
|
|
29
|
+
const body = (await res.json()) as unknown;
|
|
30
|
+
const extracted = options.extract(body);
|
|
31
|
+
const patches: Patch[] = Object.entries(extracted).map(([path, value]) => ({
|
|
32
|
+
path: pathJoin(options.writesTo, path),
|
|
33
|
+
value,
|
|
34
|
+
}));
|
|
35
|
+
if (patches.length > 0) scene.update(patches);
|
|
36
|
+
} else {
|
|
37
|
+
options.onError?.(new Error(`http_poll non-OK status: ${res.status}`));
|
|
38
|
+
}
|
|
39
|
+
} catch (err) {
|
|
40
|
+
options.onError?.(err);
|
|
41
|
+
}
|
|
42
|
+
if (!stopped) {
|
|
43
|
+
timer = setTimeout(() => void tick(), options.intervalMs);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
void tick();
|
|
48
|
+
|
|
49
|
+
return () => {
|
|
50
|
+
stopped = true;
|
|
51
|
+
if (timer) clearTimeout(timer);
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function pathJoin(prefix: LeafPath, sub: LeafPath): LeafPath {
|
|
56
|
+
if (prefix.length === 0) return sub;
|
|
57
|
+
if (sub.length === 0) return prefix;
|
|
58
|
+
return `${prefix}.${sub}`;
|
|
59
|
+
}
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Auth model — token-agnostic. The server kit only consumes the result of
|
|
2
|
+
// the user-supplied `authenticate` hook. LSDP/1 §8: the protocol does not
|
|
3
|
+
// validate tokens, it transmits them.
|
|
4
|
+
|
|
5
|
+
import type { LeafPath } from "@lumencast/protocol";
|
|
6
|
+
|
|
7
|
+
export type Role = "viewer" | "operator" | "service" | "test";
|
|
8
|
+
|
|
9
|
+
export interface AuthDecision {
|
|
10
|
+
role: Role;
|
|
11
|
+
/** For role=service: restrict input writes to this path prefix. Optional. */
|
|
12
|
+
paths?: LeafPath[];
|
|
13
|
+
/** Diagnostic — surfaced in logs / metrics, not in the wire frames. */
|
|
14
|
+
subject?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type Authenticate = (token: string) => Promise<AuthDecision> | AuthDecision;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Default authenticate — accepts everything as `viewer`.
|
|
21
|
+
* Useful for local development; never deploy this to production.
|
|
22
|
+
*/
|
|
23
|
+
export const defaultAuthenticate: Authenticate = () => ({ role: "viewer" });
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Mutable token → AuthDecision map for test setups (interop control plane).
|
|
27
|
+
* NEVER use in production — tokens are kept in process memory and any caller
|
|
28
|
+
* with reset()/setMany() can rewrite the auth store.
|
|
29
|
+
*/
|
|
30
|
+
export class StaticTokens {
|
|
31
|
+
private readonly map = new Map<string, AuthDecision>();
|
|
32
|
+
|
|
33
|
+
/** Authenticate hook bound to this instance. Pass to `startServer()`. */
|
|
34
|
+
authenticate: Authenticate = (token: string) => {
|
|
35
|
+
const d = this.map.get(token);
|
|
36
|
+
if (!d) {
|
|
37
|
+
throw new (class extends Error {
|
|
38
|
+
readonly code = "AUTH_DENIED" as const;
|
|
39
|
+
readonly recoverable = false;
|
|
40
|
+
})(`unknown token`);
|
|
41
|
+
}
|
|
42
|
+
return d;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
set(token: string, decision: AuthDecision): void {
|
|
46
|
+
this.map.set(token, decision);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
setMany(entries: Iterable<[string, AuthDecision]>): void {
|
|
50
|
+
for (const [t, d] of entries) this.map.set(t, d);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
delete(token: string): boolean {
|
|
54
|
+
return this.map.delete(token);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
reset(): void {
|
|
58
|
+
this.map.clear();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
size(): number {
|
|
62
|
+
return this.map.size;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Returns true if the role is permitted to write to the given path. */
|
|
67
|
+
export function canWritePath(decision: AuthDecision, path: LeafPath): boolean {
|
|
68
|
+
// Test sessions have their own namespace.
|
|
69
|
+
if (decision.role === "test") return path.startsWith("__test.");
|
|
70
|
+
// Viewers can never write.
|
|
71
|
+
if (decision.role === "viewer") return false;
|
|
72
|
+
// Operators can write any __inputs.* path.
|
|
73
|
+
if (decision.role === "operator") return path.startsWith("__inputs.");
|
|
74
|
+
// Services can write __inputs.* paths, optionally restricted by `paths`.
|
|
75
|
+
if (decision.role === "service") {
|
|
76
|
+
if (!path.startsWith("__inputs.")) return false;
|
|
77
|
+
if (!decision.paths || decision.paths.length === 0) return true;
|
|
78
|
+
return decision.paths.some((p) => path.startsWith(p));
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// lumencast-js server CLI.
|
|
3
|
+
//
|
|
4
|
+
// Subcommands:
|
|
5
|
+
// serve-scenario --ws-port N --test-control-port M [--host H]
|
|
6
|
+
// Boots the LSDP/1 server with the interop test control plane on a
|
|
7
|
+
// separate port. Prints exactly one JSON discovery line on stdout :
|
|
8
|
+
// {"control_url":"http://...","ws_url":"ws://.../lsdp/v1"}
|
|
9
|
+
// before any other output, then stays silent on stdout (logs go to stderr).
|
|
10
|
+
|
|
11
|
+
import { parseArgs } from "node:util";
|
|
12
|
+
import { createScene } from "./scene.js";
|
|
13
|
+
import { startServer, type ServerHandle } from "./server.js";
|
|
14
|
+
import { StaticTokens } from "./auth.js";
|
|
15
|
+
import { startTestControl, type TestControlHandle } from "./test-control.js";
|
|
16
|
+
|
|
17
|
+
async function main(argv: string[]): Promise<number> {
|
|
18
|
+
const sub = argv[0];
|
|
19
|
+
if (!sub || sub === "--help" || sub === "-h") {
|
|
20
|
+
printUsage();
|
|
21
|
+
return sub ? 0 : 2;
|
|
22
|
+
}
|
|
23
|
+
if (sub === "serve-scenario") return cmdServeScenario(argv.slice(1));
|
|
24
|
+
process.stderr.write(`lumencast-js: unknown subcommand "${sub}"\n`);
|
|
25
|
+
printUsage();
|
|
26
|
+
return 2;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function printUsage(): void {
|
|
30
|
+
process.stderr.write(
|
|
31
|
+
[
|
|
32
|
+
"usage: lumencast-js <subcommand> [flags]",
|
|
33
|
+
"",
|
|
34
|
+
"subcommands:",
|
|
35
|
+
" serve-scenario --ws-port N --test-control-port M [--host H]",
|
|
36
|
+
" Boot LSDP/1 server + interop test control plane (separate ports).",
|
|
37
|
+
"",
|
|
38
|
+
].join("\n"),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function cmdServeScenario(args: string[]): Promise<number> {
|
|
43
|
+
let parsed;
|
|
44
|
+
try {
|
|
45
|
+
parsed = parseArgs({
|
|
46
|
+
args,
|
|
47
|
+
options: {
|
|
48
|
+
"ws-port": { type: "string" },
|
|
49
|
+
"test-control-port": { type: "string" },
|
|
50
|
+
host: { type: "string", default: "127.0.0.1" },
|
|
51
|
+
help: { type: "boolean", short: "h" },
|
|
52
|
+
},
|
|
53
|
+
strict: true,
|
|
54
|
+
allowPositionals: false,
|
|
55
|
+
});
|
|
56
|
+
} catch (err) {
|
|
57
|
+
process.stderr.write(`serve-scenario: ${(err as Error).message}\n`);
|
|
58
|
+
return 2;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (parsed.values["help"]) {
|
|
62
|
+
process.stderr.write("serve-scenario --ws-port N --test-control-port M [--host H]\n");
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const wsPort = parseIntFlag(parsed.values["ws-port"], "--ws-port");
|
|
67
|
+
const controlPort = parseIntFlag(parsed.values["test-control-port"], "--test-control-port");
|
|
68
|
+
if (wsPort === null || controlPort === null) return 2;
|
|
69
|
+
const host = (parsed.values["host"] as string | undefined) ?? "127.0.0.1";
|
|
70
|
+
|
|
71
|
+
// Bootstrap a synthetic scene + empty bundle provider. Both are immediately
|
|
72
|
+
// overridden by the first POST /test/setup.
|
|
73
|
+
const initialScene = createScene({
|
|
74
|
+
sceneId: "__interop_initial__",
|
|
75
|
+
sceneVersion: "sha256:" + "0".repeat(64),
|
|
76
|
+
initialState: {},
|
|
77
|
+
});
|
|
78
|
+
const auth = new StaticTokens();
|
|
79
|
+
|
|
80
|
+
let server: ServerHandle;
|
|
81
|
+
let control: TestControlHandle;
|
|
82
|
+
try {
|
|
83
|
+
server = await startServer({
|
|
84
|
+
port: wsPort,
|
|
85
|
+
host,
|
|
86
|
+
scene: initialScene,
|
|
87
|
+
bundleProvider: () => undefined,
|
|
88
|
+
authenticate: auth.authenticate,
|
|
89
|
+
});
|
|
90
|
+
} catch (err) {
|
|
91
|
+
process.stderr.write(`serve-scenario: bind ws: ${(err as Error).message}\n`);
|
|
92
|
+
return 1;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
control = await startTestControl({
|
|
96
|
+
port: controlPort,
|
|
97
|
+
host,
|
|
98
|
+
server,
|
|
99
|
+
auth,
|
|
100
|
+
});
|
|
101
|
+
} catch (err) {
|
|
102
|
+
await server.close();
|
|
103
|
+
process.stderr.write(`serve-scenario: bind control: ${(err as Error).message}\n`);
|
|
104
|
+
return 1;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Discovery line — written before any other stdout output. The matrix
|
|
108
|
+
// driver greps for "control_url" to know we're ready.
|
|
109
|
+
process.stdout.write(
|
|
110
|
+
JSON.stringify({
|
|
111
|
+
control_url: control.url,
|
|
112
|
+
ws_url: server.wsUrl,
|
|
113
|
+
}) + "\n",
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
let shutdown: (() => Promise<void>) | null = async () => {
|
|
117
|
+
shutdown = null;
|
|
118
|
+
process.stderr.write("[lumencast-js] shutting down\n");
|
|
119
|
+
try {
|
|
120
|
+
await control.close();
|
|
121
|
+
} catch {
|
|
122
|
+
// ignore
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
await server.close();
|
|
126
|
+
} catch {
|
|
127
|
+
// ignore
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
process.on("SIGINT", () => void shutdown?.());
|
|
132
|
+
process.on("SIGTERM", () => void shutdown?.());
|
|
133
|
+
|
|
134
|
+
await new Promise<void>((resolve) => {
|
|
135
|
+
const tick = (): void => {
|
|
136
|
+
if (shutdown === null) resolve();
|
|
137
|
+
else setTimeout(tick, 100);
|
|
138
|
+
};
|
|
139
|
+
tick();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function parseIntFlag(raw: unknown, name: string): number | null {
|
|
146
|
+
if (typeof raw !== "string") {
|
|
147
|
+
process.stderr.write(`${name} required\n`);
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
const n = Number.parseInt(raw, 10);
|
|
151
|
+
if (!Number.isFinite(n) || n < 0 || n > 65535) {
|
|
152
|
+
process.stderr.write(`${name}: invalid port\n`);
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
return n;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
main(process.argv.slice(2)).then(
|
|
159
|
+
(code) => {
|
|
160
|
+
process.exitCode = code;
|
|
161
|
+
},
|
|
162
|
+
(err) => {
|
|
163
|
+
process.stderr.write(`lumencast-js: ${(err as Error).stack ?? String(err)}\n`);
|
|
164
|
+
process.exitCode = 1;
|
|
165
|
+
},
|
|
166
|
+
);
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Public surface of @lumencast/server.
|
|
2
|
+
|
|
3
|
+
export { startServer, type ServerConfig, type ServerHandle } from "./server.js";
|
|
4
|
+
export { createScene, type Scene, type SceneInit } from "./scene.js";
|
|
5
|
+
export { LeafStore, type LeafStoreListener } from "./store.js";
|
|
6
|
+
export {
|
|
7
|
+
defaultAuthenticate,
|
|
8
|
+
canWritePath,
|
|
9
|
+
StaticTokens,
|
|
10
|
+
type Authenticate,
|
|
11
|
+
type AuthDecision,
|
|
12
|
+
type Role,
|
|
13
|
+
} from "./auth.js";
|
|
14
|
+
export { startHttpPoll, type HttpPollOptions } from "./adapters/http-poll.js";
|
|
15
|
+
export {
|
|
16
|
+
startTestControl,
|
|
17
|
+
installTokens,
|
|
18
|
+
type TestControlOptions,
|
|
19
|
+
type TestControlHandle,
|
|
20
|
+
} from "./test-control.js";
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Per-scene replay buffer (LSDP/1.1 §18.1).
|
|
2
|
+
// Bounded ring of recent (seq, patches, cause) emissions so a 1.1
|
|
3
|
+
// client reconnecting with `since_sequence` can resume without a fresh
|
|
4
|
+
// snapshot.
|
|
5
|
+
|
|
6
|
+
import type { Cause, Patch } from "@lumencast/protocol";
|
|
7
|
+
|
|
8
|
+
export interface ReplayRecord {
|
|
9
|
+
readonly seq: number;
|
|
10
|
+
readonly patches: Patch[];
|
|
11
|
+
readonly cause?: Cause;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Default capacity (LSDP/1.1 §18.1 SHOULD ≥ 256). */
|
|
15
|
+
export const DEFAULT_REPLAY_BUFFER_SIZE = 256;
|
|
16
|
+
|
|
17
|
+
export class ReplayBuffer {
|
|
18
|
+
private readonly cap: number;
|
|
19
|
+
private readonly records: (ReplayRecord | undefined)[];
|
|
20
|
+
private head = 0;
|
|
21
|
+
private size = 0;
|
|
22
|
+
|
|
23
|
+
constructor(capacity = DEFAULT_REPLAY_BUFFER_SIZE) {
|
|
24
|
+
this.cap = capacity > 0 ? capacity : DEFAULT_REPLAY_BUFFER_SIZE;
|
|
25
|
+
this.records = new Array<ReplayRecord | undefined>(this.cap);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Record one emission. Caller is responsible for monotonic seq. */
|
|
29
|
+
push(record: ReplayRecord): void {
|
|
30
|
+
this.records[this.head] = record;
|
|
31
|
+
this.head = (this.head + 1) % this.cap;
|
|
32
|
+
if (this.size < this.cap) this.size++;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Return every record with seq > sinceSeq, in monotonic order.
|
|
37
|
+
*
|
|
38
|
+
* The boolean is `false` when sinceSeq is older than the buffer's
|
|
39
|
+
* earliest retained entry — the caller MUST then fall back to a
|
|
40
|
+
* fresh snapshot per LSDP/1.1 §18.1.
|
|
41
|
+
*/
|
|
42
|
+
since(sinceSeq: number): { records: ReplayRecord[]; covered: boolean } {
|
|
43
|
+
if (this.size === 0) {
|
|
44
|
+
// Empty buffer — caller decides whether sinceSeq matches the
|
|
45
|
+
// current scene seq (caught up) or warrants a snapshot.
|
|
46
|
+
return { records: [], covered: true };
|
|
47
|
+
}
|
|
48
|
+
const tail = (this.head - this.size + this.cap) % this.cap;
|
|
49
|
+
const earliest = this.records[tail]!.seq;
|
|
50
|
+
if (sinceSeq + 1 < earliest) {
|
|
51
|
+
return { records: [], covered: false };
|
|
52
|
+
}
|
|
53
|
+
const out: ReplayRecord[] = [];
|
|
54
|
+
for (let i = 0; i < this.size; i++) {
|
|
55
|
+
const idx = (tail + i) % this.cap;
|
|
56
|
+
const r = this.records[idx]!;
|
|
57
|
+
if (r.seq > sinceSeq) out.push(r);
|
|
58
|
+
}
|
|
59
|
+
return { records: out, covered: true };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Clear the buffer. Used on scene_changed. */
|
|
63
|
+
reset(): void {
|
|
64
|
+
this.head = 0;
|
|
65
|
+
this.size = 0;
|
|
66
|
+
this.records.fill(undefined);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get length(): number {
|
|
70
|
+
return this.size;
|
|
71
|
+
}
|
|
72
|
+
}
|
package/src/scene.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Scene — the server's authoritative view of one Lumencast scene.
|
|
2
|
+
// Wraps a LeafStore + identity (sceneId, sceneVersion) and an optional
|
|
3
|
+
// operator_inputs schema used by the server to validate `input` frames.
|
|
4
|
+
|
|
5
|
+
import type { Cause, LeafPath, LeafValue, Patch, SceneId, SceneVersion } from "@lumencast/protocol";
|
|
6
|
+
import { LeafStore } from "./store.js";
|
|
7
|
+
import { DEFAULT_REPLAY_BUFFER_SIZE, ReplayBuffer, type ReplayRecord } from "./replay-buffer.js";
|
|
8
|
+
|
|
9
|
+
export interface OperatorInputDecl {
|
|
10
|
+
path: LeafPath;
|
|
11
|
+
/** "string" | "number" | "boolean" | "enum" | ... — see LSML 1.0 §8. */
|
|
12
|
+
type: string;
|
|
13
|
+
/** Type-specific constraints — currently maxLength, minLength, min, max, pattern. */
|
|
14
|
+
constraints?: {
|
|
15
|
+
maxLength?: number;
|
|
16
|
+
minLength?: number;
|
|
17
|
+
min?: number;
|
|
18
|
+
max?: number;
|
|
19
|
+
pattern?: string;
|
|
20
|
+
};
|
|
21
|
+
values?: unknown[];
|
|
22
|
+
writable_by?: string[];
|
|
23
|
+
[extra: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SceneInit {
|
|
27
|
+
sceneId: SceneId;
|
|
28
|
+
sceneVersion: SceneVersion;
|
|
29
|
+
initialState?: Record<LeafPath, LeafValue>;
|
|
30
|
+
/** Declared operator inputs. When set, `validateInput` enforces the schema. */
|
|
31
|
+
operatorInputs?: OperatorInputDecl[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type ValidationError =
|
|
35
|
+
| { code: "UNKNOWN_PATH"; path: LeafPath; message: string }
|
|
36
|
+
| { code: "INVALID_VALUE"; path: LeafPath; message: string };
|
|
37
|
+
|
|
38
|
+
export interface Scene {
|
|
39
|
+
readonly sceneId: SceneId;
|
|
40
|
+
readonly sceneVersion: SceneVersion;
|
|
41
|
+
readonly store: LeafStore;
|
|
42
|
+
readonly operatorInputs: OperatorInputDecl[];
|
|
43
|
+
/** Update one or more leaves. Atomic per call. Optional `cause` propagates
|
|
44
|
+
* to subscribers as the resulting Delta.cause (LSDP/1.1 §3.2.3). */
|
|
45
|
+
update(patches: Patch[] | Record<LeafPath, LeafValue>, cause?: Cause): void;
|
|
46
|
+
/** Subscribe to all patches emitted by this scene. The listener receives
|
|
47
|
+
* the per-scene `seq` (LSDP/1.1 §18.1.1) — the same value across all
|
|
48
|
+
* concurrent listeners on a given delta. */
|
|
49
|
+
onPatches(listener: (seq: number, patches: Patch[], cause?: Cause) => void): () => void;
|
|
50
|
+
/** Current per-scene seq counter (LSDP/1.1 §18.1.1). 1 on a fresh
|
|
51
|
+
* scene ; increments on every emit. Late-joining subscribers ship
|
|
52
|
+
* snapshot at this value. */
|
|
53
|
+
currentSeq(): number;
|
|
54
|
+
/** Replay records strictly after `sinceSeq` for §18.1 resume.
|
|
55
|
+
* Returns `covered: false` when sinceSeq is older than the buffer's
|
|
56
|
+
* earliest entry — the caller MUST fall back to a fresh snapshot. */
|
|
57
|
+
replaySince(sinceSeq: number): { records: ReplayRecord[]; covered: boolean };
|
|
58
|
+
/**
|
|
59
|
+
* Validate input patches against the declared operator_inputs schema.
|
|
60
|
+
* Returns the first error or null. The reserved `__test.*` namespace is
|
|
61
|
+
* always permitted (test sessions own that namespace per LSDP/1 §10).
|
|
62
|
+
*/
|
|
63
|
+
validateInput(patches: Patch[]): ValidationError | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function createScene(init: SceneInit): Scene {
|
|
67
|
+
const store = new LeafStore(init.initialState ?? {});
|
|
68
|
+
const operatorInputs = init.operatorInputs ?? [];
|
|
69
|
+
const inputsByPath = new Map<LeafPath, OperatorInputDecl>();
|
|
70
|
+
for (const oi of operatorInputs) inputsByPath.set(oi.path, oi);
|
|
71
|
+
|
|
72
|
+
// LSDP/1.1 §18.1.1 — per-scene monotonic seq. Pre-seeded to 1 so the
|
|
73
|
+
// very first subscriber's snapshot ships at seq=1 (matches every
|
|
74
|
+
// existing 1.0 conformance scenario). Subsequent emits increment.
|
|
75
|
+
let seq = 1;
|
|
76
|
+
const replay = new ReplayBuffer(DEFAULT_REPLAY_BUFFER_SIZE);
|
|
77
|
+
type SceneListener = (seq: number, patches: Patch[], cause?: Cause) => void;
|
|
78
|
+
const sceneListeners = new Set<SceneListener>();
|
|
79
|
+
|
|
80
|
+
// Bridge LeafStore.onPatches → scene listeners. Single store listener
|
|
81
|
+
// increments `seq`, records to the buffer, fans out to scene listeners.
|
|
82
|
+
store.onPatches((patches, cause) => {
|
|
83
|
+
seq += 1;
|
|
84
|
+
replay.push({ seq, patches: patches.slice(), cause });
|
|
85
|
+
for (const l of sceneListeners) l(seq, patches, cause);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
function update(input: Patch[] | Record<LeafPath, LeafValue>, cause?: Cause): void {
|
|
89
|
+
const patches: Patch[] = Array.isArray(input)
|
|
90
|
+
? input
|
|
91
|
+
: Object.entries(input).map(([path, value]) => ({ path, value }));
|
|
92
|
+
store.apply(patches, cause);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function validateInput(patches: Patch[]): ValidationError | null {
|
|
96
|
+
// No declared schema → server accepts any path (legacy / dev-mode behavior).
|
|
97
|
+
if (operatorInputs.length === 0) return null;
|
|
98
|
+
|
|
99
|
+
for (const p of patches) {
|
|
100
|
+
if (p.path.startsWith("__test.")) continue; // test namespace bypass
|
|
101
|
+
const decl = inputsByPath.get(p.path);
|
|
102
|
+
if (!decl) {
|
|
103
|
+
return {
|
|
104
|
+
code: "UNKNOWN_PATH",
|
|
105
|
+
path: p.path,
|
|
106
|
+
message: `path ${p.path} not declared in operator_inputs`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
const err = checkConstraint(decl, p.value);
|
|
110
|
+
if (err) return { code: "INVALID_VALUE", path: p.path, message: err };
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
sceneId: init.sceneId,
|
|
117
|
+
sceneVersion: init.sceneVersion,
|
|
118
|
+
store,
|
|
119
|
+
operatorInputs,
|
|
120
|
+
update,
|
|
121
|
+
onPatches: (listener) => {
|
|
122
|
+
sceneListeners.add(listener);
|
|
123
|
+
return () => {
|
|
124
|
+
sceneListeners.delete(listener);
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
currentSeq: () => seq,
|
|
128
|
+
replaySince: (sinceSeq) => replay.since(sinceSeq),
|
|
129
|
+
validateInput,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function checkConstraint(decl: OperatorInputDecl, value: unknown): string | null {
|
|
134
|
+
switch (decl.type) {
|
|
135
|
+
case "string":
|
|
136
|
+
case "text":
|
|
137
|
+
if (typeof value !== "string") return `expected string, got ${typeof value}`;
|
|
138
|
+
if (decl.constraints?.maxLength !== undefined && value.length > decl.constraints.maxLength) {
|
|
139
|
+
return `exceeds maxLength ${decl.constraints.maxLength}`;
|
|
140
|
+
}
|
|
141
|
+
if (decl.constraints?.minLength !== undefined && value.length < decl.constraints.minLength) {
|
|
142
|
+
return `below minLength ${decl.constraints.minLength}`;
|
|
143
|
+
}
|
|
144
|
+
if (decl.constraints?.pattern && !new RegExp(decl.constraints.pattern).test(value)) {
|
|
145
|
+
return `does not match pattern`;
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
case "number":
|
|
149
|
+
if (typeof value !== "number") return `expected number, got ${typeof value}`;
|
|
150
|
+
if (decl.constraints?.min !== undefined && value < decl.constraints.min) {
|
|
151
|
+
return `below min ${decl.constraints.min}`;
|
|
152
|
+
}
|
|
153
|
+
if (decl.constraints?.max !== undefined && value > decl.constraints.max) {
|
|
154
|
+
return `above max ${decl.constraints.max}`;
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
case "boolean":
|
|
158
|
+
if (typeof value !== "boolean") return `expected boolean, got ${typeof value}`;
|
|
159
|
+
return null;
|
|
160
|
+
case "enum":
|
|
161
|
+
if (decl.values && !decl.values.includes(value)) {
|
|
162
|
+
return `not in enum`;
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
default:
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|