@lumencast/dev-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 +67 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/server.d.ts +52 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +422 -0
- package/dist/server.js.map +1 -0
- package/package.json +46 -0
- package/src/index.ts +3 -0
- package/src/server.ts +533 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
// Mock LSDP/1 server for Lumencast SDK tests.
|
|
2
|
+
//
|
|
3
|
+
// Behaviour parity with a real LSDP/1 server (LSDP/1 spec):
|
|
4
|
+
// - WebSocket subprotocol negotiation: `lsdp.v1`
|
|
5
|
+
// - subscribe → snapshot(seq=1) → delta(seq=2,3,...)
|
|
6
|
+
// - scene_changed(seq=N) → snapshot(seq=1) (sequence reset)
|
|
7
|
+
// - error frame on malformed input (taxonomy-aligned codes)
|
|
8
|
+
//
|
|
9
|
+
// What it skips: auth (any token accepted), rate limiting, persistence.
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
13
|
+
import { dirname, resolve as resolvePath } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { WebSocketServer, type WebSocket } from "ws";
|
|
16
|
+
import {
|
|
17
|
+
delta as deltaFrame,
|
|
18
|
+
decodeClientFrame,
|
|
19
|
+
encodeFrame,
|
|
20
|
+
errorFrame,
|
|
21
|
+
pong,
|
|
22
|
+
sceneChanged as sceneChangedFrame,
|
|
23
|
+
snapshot as snapshotFrame,
|
|
24
|
+
WS_SUBPROTOCOL,
|
|
25
|
+
WS_SUBPROTOCOL_V1_1,
|
|
26
|
+
type LeafValue,
|
|
27
|
+
type Patch,
|
|
28
|
+
type SceneId,
|
|
29
|
+
type SceneVersion,
|
|
30
|
+
} from "@lumencast/protocol";
|
|
31
|
+
|
|
32
|
+
export interface DevServerConfig {
|
|
33
|
+
/** Listening port (0 = random). */
|
|
34
|
+
port?: number;
|
|
35
|
+
/** Hostname to bind. Defaults to 127.0.0.1. */
|
|
36
|
+
host?: string;
|
|
37
|
+
/** Initial scene id. */
|
|
38
|
+
initialSceneId: SceneId;
|
|
39
|
+
/** Initial scene version (sha256:...). */
|
|
40
|
+
initialSceneVersion: SceneVersion;
|
|
41
|
+
/** LSML bundle for the initial scene (passed through, not validated by the mock). */
|
|
42
|
+
initialBundle: unknown;
|
|
43
|
+
/** Initial state (path → value). */
|
|
44
|
+
initialState: Record<string, LeafValue>;
|
|
45
|
+
/**
|
|
46
|
+
* Optional in-process demo host. When set, the dev-server serves a
|
|
47
|
+
* static HTML page at `GET /` that mounts the runtime against this
|
|
48
|
+
* very server. Use for local end-to-end demos: open the printed
|
|
49
|
+
* httpUrl in a browser to see the scene render live.
|
|
50
|
+
*
|
|
51
|
+
* `runtimeBundlePath` is the absolute path to the prebuilt runtime
|
|
52
|
+
* (`@lumencast/runtime/dist/lumencast.js`). Defaults to a workspace
|
|
53
|
+
* resolution lookup ; pass an explicit path to override.
|
|
54
|
+
*/
|
|
55
|
+
demoHost?: {
|
|
56
|
+
runtimeBundlePath?: string;
|
|
57
|
+
/** Optional title shown in the browser tab. Defaults to "Lumencast demo". */
|
|
58
|
+
title?: string;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface DevServer {
|
|
63
|
+
/** WS URL clients connect to. */
|
|
64
|
+
readonly wsUrl: string;
|
|
65
|
+
/** HTTP base URL (bundle fetch + control plane). */
|
|
66
|
+
readonly httpUrl: string;
|
|
67
|
+
/** Add or replace a bundle by scene_version. */
|
|
68
|
+
registerBundle(sceneVersion: SceneVersion, bundle: unknown): void;
|
|
69
|
+
/** Push a delta to all live subscribers of the active scene. */
|
|
70
|
+
pushDelta(patches: Patch[]): void;
|
|
71
|
+
/** Atomically switch the active scene; emits scene_changed + snapshot to subscribers. */
|
|
72
|
+
switchScene(args: SwitchSceneArgs): void;
|
|
73
|
+
/** Close every connection and stop listening. */
|
|
74
|
+
close(): Promise<void>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface SwitchSceneArgs {
|
|
78
|
+
sceneId: SceneId;
|
|
79
|
+
sceneVersion: SceneVersion;
|
|
80
|
+
bundle: unknown;
|
|
81
|
+
state: Record<string, LeafValue>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface Subscriber {
|
|
85
|
+
ws: WebSocket;
|
|
86
|
+
seq: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function startDevServer(config: DevServerConfig): Promise<DevServer> {
|
|
90
|
+
let activeSceneId: SceneId = config.initialSceneId;
|
|
91
|
+
let activeSceneVersion: SceneVersion = config.initialSceneVersion;
|
|
92
|
+
let activeState: Record<string, LeafValue> = { ...config.initialState };
|
|
93
|
+
const bundles = new Map<SceneVersion, unknown>();
|
|
94
|
+
bundles.set(config.initialSceneVersion, config.initialBundle);
|
|
95
|
+
|
|
96
|
+
const subscribers = new Set<Subscriber>();
|
|
97
|
+
|
|
98
|
+
const httpServer: Server = createServer((req, res) => handleHttp(req, res));
|
|
99
|
+
|
|
100
|
+
await new Promise<void>((resolve, reject) => {
|
|
101
|
+
httpServer.once("error", reject);
|
|
102
|
+
httpServer.listen(config.port ?? 0, config.host ?? "127.0.0.1", () => {
|
|
103
|
+
httpServer.off("error", reject);
|
|
104
|
+
resolve();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const address = httpServer.address();
|
|
109
|
+
if (!address || typeof address === "string") {
|
|
110
|
+
throw new Error("dev-server: failed to bind HTTP server");
|
|
111
|
+
}
|
|
112
|
+
const host = config.host ?? "127.0.0.1";
|
|
113
|
+
const port = address.port;
|
|
114
|
+
|
|
115
|
+
const wss = new WebSocketServer({
|
|
116
|
+
server: httpServer,
|
|
117
|
+
handleProtocols: (offered: Set<string>) => {
|
|
118
|
+
// LSDP/1.1 preferred, 1.0 fallback.
|
|
119
|
+
if (offered.has(WS_SUBPROTOCOL_V1_1)) return WS_SUBPROTOCOL_V1_1;
|
|
120
|
+
if (offered.has(WS_SUBPROTOCOL)) return WS_SUBPROTOCOL;
|
|
121
|
+
return false;
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
wss.on("connection", (socket) => {
|
|
126
|
+
const sub: Subscriber = { ws: socket, seq: 0 };
|
|
127
|
+
subscribers.add(sub);
|
|
128
|
+
|
|
129
|
+
socket.on("message", (data) => handleMessage(sub, String(data)));
|
|
130
|
+
socket.on("close", () => {
|
|
131
|
+
subscribers.delete(sub);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
function handleMessage(sub: Subscriber, raw: string): void {
|
|
136
|
+
let frame;
|
|
137
|
+
try {
|
|
138
|
+
frame = decodeClientFrame(raw);
|
|
139
|
+
} catch {
|
|
140
|
+
sendError(sub, "INVALID_VALUE", "malformed client frame", false);
|
|
141
|
+
sub.ws.close(1002, "INVALID_VALUE");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (frame === null) return; // unknown frame type — forward-compat ignore
|
|
145
|
+
|
|
146
|
+
switch (frame.type) {
|
|
147
|
+
case "subscribe": {
|
|
148
|
+
sub.seq = 1;
|
|
149
|
+
send(
|
|
150
|
+
sub,
|
|
151
|
+
snapshotFrame({
|
|
152
|
+
seq: 1,
|
|
153
|
+
scene_id: activeSceneId,
|
|
154
|
+
scene_version: activeSceneVersion,
|
|
155
|
+
state: { ...activeState },
|
|
156
|
+
ts: new Date().toISOString(),
|
|
157
|
+
}),
|
|
158
|
+
);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
case "input": {
|
|
162
|
+
// Mock-mode echoes inputs as deltas to all subscribers.
|
|
163
|
+
broadcastDelta(frame.patches);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
case "ping": {
|
|
167
|
+
send(sub, pong());
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function broadcastDelta(patches: Patch[]): void {
|
|
174
|
+
for (const p of patches) activeState[p.path] = p.value;
|
|
175
|
+
for (const sub of subscribers) {
|
|
176
|
+
sub.seq += 1;
|
|
177
|
+
send(sub, deltaFrame({ seq: sub.seq, patches }));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function pushDelta(patches: Patch[]): void {
|
|
182
|
+
broadcastDelta(patches);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function switchScene(args: SwitchSceneArgs): void {
|
|
186
|
+
activeSceneId = args.sceneId;
|
|
187
|
+
activeSceneVersion = args.sceneVersion;
|
|
188
|
+
activeState = { ...args.state };
|
|
189
|
+
bundles.set(args.sceneVersion, args.bundle);
|
|
190
|
+
|
|
191
|
+
for (const sub of subscribers) {
|
|
192
|
+
sub.seq += 1;
|
|
193
|
+
send(
|
|
194
|
+
sub,
|
|
195
|
+
sceneChangedFrame({
|
|
196
|
+
seq: sub.seq,
|
|
197
|
+
scene_id: args.sceneId,
|
|
198
|
+
scene_version: args.sceneVersion,
|
|
199
|
+
}),
|
|
200
|
+
);
|
|
201
|
+
// LSDP/1 §3.3: the next server frame after scene_changed is a snapshot
|
|
202
|
+
// for the new scene with seq = 1.
|
|
203
|
+
sub.seq = 1;
|
|
204
|
+
send(
|
|
205
|
+
sub,
|
|
206
|
+
snapshotFrame({
|
|
207
|
+
seq: 1,
|
|
208
|
+
scene_id: args.sceneId,
|
|
209
|
+
scene_version: args.sceneVersion,
|
|
210
|
+
state: { ...activeState },
|
|
211
|
+
ts: new Date().toISOString(),
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function registerBundle(version: SceneVersion, bundle: unknown): void {
|
|
218
|
+
bundles.set(version, bundle);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function send(sub: Subscriber, frame: object): void {
|
|
222
|
+
if (sub.ws.readyState !== sub.ws.OPEN) return;
|
|
223
|
+
sub.ws.send(encodeFrame(frame as Parameters<typeof encodeFrame>[0]));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function sendError(
|
|
227
|
+
sub: Subscriber,
|
|
228
|
+
code: Parameters<typeof errorFrame>[0]["code"],
|
|
229
|
+
message: string,
|
|
230
|
+
recoverable: boolean,
|
|
231
|
+
): void {
|
|
232
|
+
sub.seq += 1;
|
|
233
|
+
send(sub, errorFrame({ seq: sub.seq, code, message, recoverable }));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function handleHttp(req: IncomingMessage, res: ServerResponse): void {
|
|
237
|
+
res.setHeader("access-control-allow-origin", "*");
|
|
238
|
+
res.setHeader("access-control-allow-methods", "GET, POST, OPTIONS");
|
|
239
|
+
res.setHeader("access-control-allow-headers", "*");
|
|
240
|
+
if (req.method === "OPTIONS") {
|
|
241
|
+
res.statusCode = 204;
|
|
242
|
+
res.end();
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const url = req.url ?? "/";
|
|
247
|
+
|
|
248
|
+
if (req.method === "GET" && url === "/lsdp/v1/health") {
|
|
249
|
+
writeJson(res, 200, { status: "ok" });
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// GET /lsdp/v1/scenes/{id}/bundle?v=sha256:...
|
|
254
|
+
const bundleMatch = url.match(/^\/lsdp\/v1\/scenes\/([^/?]+)\/bundle(?:\?v=([^&]+))?$/);
|
|
255
|
+
if (req.method === "GET" && bundleMatch) {
|
|
256
|
+
const versionParam = bundleMatch[2] ? decodeURIComponent(bundleMatch[2]) : undefined;
|
|
257
|
+
const bundle = versionParam ? bundles.get(versionParam) : bundles.get(activeSceneVersion);
|
|
258
|
+
if (!bundle) {
|
|
259
|
+
writeJson(res, 404, { error: "bundle_not_found" });
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
res.statusCode = 200;
|
|
263
|
+
res.setHeader("content-type", "application/json");
|
|
264
|
+
res.setHeader("cache-control", "public, max-age=31536000, immutable");
|
|
265
|
+
res.end(JSON.stringify(bundle));
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (req.method === "POST" && url === "/__mock/delta") {
|
|
270
|
+
readJson(req)
|
|
271
|
+
.then((body) => {
|
|
272
|
+
const patches = (body as { patches: Patch[] }).patches;
|
|
273
|
+
pushDelta(patches);
|
|
274
|
+
res.statusCode = 204;
|
|
275
|
+
res.end();
|
|
276
|
+
})
|
|
277
|
+
.catch((err) => writeJson(res, 400, { error: String(err) }));
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (req.method === "POST" && url === "/__mock/scene-changed") {
|
|
282
|
+
readJson(req)
|
|
283
|
+
.then((body) => {
|
|
284
|
+
switchScene(body as SwitchSceneArgs);
|
|
285
|
+
res.statusCode = 204;
|
|
286
|
+
res.end();
|
|
287
|
+
})
|
|
288
|
+
.catch((err) => writeJson(res, 400, { error: String(err) }));
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (req.method === "POST" && url === "/__mock/register-bundle") {
|
|
293
|
+
readJson(req)
|
|
294
|
+
.then((body) => {
|
|
295
|
+
const { sceneVersion, bundle } = body as {
|
|
296
|
+
sceneVersion: SceneVersion;
|
|
297
|
+
bundle: unknown;
|
|
298
|
+
};
|
|
299
|
+
registerBundle(sceneVersion, bundle);
|
|
300
|
+
res.statusCode = 204;
|
|
301
|
+
res.end();
|
|
302
|
+
})
|
|
303
|
+
.catch((err) => writeJson(res, 400, { error: String(err) }));
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Demo host (optional) — serves a static HTML at `/` and the
|
|
308
|
+
// runtime bundle at `/lumencast.js` so a developer can open the
|
|
309
|
+
// server URL in a browser and see the scene render live without
|
|
310
|
+
// any external Vite/build step.
|
|
311
|
+
if (config.demoHost && req.method === "GET" && (url === "/" || url === "/index.html")) {
|
|
312
|
+
const wsUrl = `ws://${host}:${port}/lsdp/v1`;
|
|
313
|
+
const title = config.demoHost.title ?? "Lumencast demo";
|
|
314
|
+
const html = renderDemoHostHtml({ wsUrl, title });
|
|
315
|
+
res.statusCode = 200;
|
|
316
|
+
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
317
|
+
res.end(html);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (config.demoHost && req.method === "GET" && isStaticAssetPath(url)) {
|
|
321
|
+
const bundlePath = resolveRuntimeBundlePath(config.demoHost.runtimeBundlePath);
|
|
322
|
+
if (!bundlePath) {
|
|
323
|
+
writeJson(res, 500, {
|
|
324
|
+
error: "runtime_bundle_not_found",
|
|
325
|
+
hint: "pnpm --filter @lumencast/runtime build (or pass demoHost.runtimeBundlePath)",
|
|
326
|
+
});
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
// The runtime bundle is split into chunks. Serve any file under
|
|
330
|
+
// its dist/ tree by name, sandboxed to that directory.
|
|
331
|
+
const distDir = dirname(bundlePath);
|
|
332
|
+
const requested = url.split("?")[0]!.replace(/^\//, "");
|
|
333
|
+
const filePath = resolvePath(distDir, requested);
|
|
334
|
+
// Sandbox check : file must live under distDir.
|
|
335
|
+
if (!filePath.startsWith(distDir)) {
|
|
336
|
+
writeJson(res, 400, { error: "path_traversal" });
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (!existsSync(filePath)) {
|
|
340
|
+
writeJson(res, 404, { error: "static_not_found", path: requested });
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
try {
|
|
344
|
+
const body = readFileSync(filePath);
|
|
345
|
+
res.statusCode = 200;
|
|
346
|
+
res.setHeader("content-type", contentTypeFor(requested));
|
|
347
|
+
res.setHeader("cache-control", "no-store");
|
|
348
|
+
res.end(body);
|
|
349
|
+
} catch (e) {
|
|
350
|
+
writeJson(res, 500, { error: "static_read_failed", detail: String(e) });
|
|
351
|
+
}
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (req.method === "POST" && url === "/__mock/reset") {
|
|
356
|
+
activeSceneId = config.initialSceneId;
|
|
357
|
+
activeSceneVersion = config.initialSceneVersion;
|
|
358
|
+
activeState = { ...config.initialState };
|
|
359
|
+
bundles.clear();
|
|
360
|
+
bundles.set(config.initialSceneVersion, config.initialBundle);
|
|
361
|
+
res.statusCode = 204;
|
|
362
|
+
res.end();
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
writeJson(res, 404, { error: "not_found" });
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function close(): Promise<void> {
|
|
370
|
+
for (const sub of subscribers) {
|
|
371
|
+
try {
|
|
372
|
+
sub.ws.close(1000, "shutdown");
|
|
373
|
+
} catch {
|
|
374
|
+
// ignore
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
subscribers.clear();
|
|
378
|
+
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
|
379
|
+
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
wsUrl: `ws://${host}:${port}/lsdp/v1`,
|
|
384
|
+
httpUrl: `http://${host}:${port}`,
|
|
385
|
+
registerBundle,
|
|
386
|
+
pushDelta,
|
|
387
|
+
switchScene,
|
|
388
|
+
close,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
393
|
+
res.statusCode = status;
|
|
394
|
+
res.setHeader("content-type", "application/json");
|
|
395
|
+
res.end(JSON.stringify(body));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function readJson(req: IncomingMessage): Promise<unknown> {
|
|
399
|
+
const chunks: Buffer[] = [];
|
|
400
|
+
for await (const chunk of req) {
|
|
401
|
+
chunks.push(chunk as Buffer);
|
|
402
|
+
}
|
|
403
|
+
const body = Buffer.concat(chunks).toString("utf8");
|
|
404
|
+
return body.length === 0 ? {} : JSON.parse(body);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Resolve the on-disk path of `@lumencast/runtime`'s built bundle.
|
|
409
|
+
* Walks up from this file's directory looking for the workspace's
|
|
410
|
+
* `packages/runtime/dist/lumencast.js`. Returns the override when
|
|
411
|
+
* provided.
|
|
412
|
+
*/
|
|
413
|
+
/** Heuristic : a static asset request looks like `/something.ext`
|
|
414
|
+
* (single segment, with extension), and isn't an LSDP API path or a
|
|
415
|
+
* mock control plane path. The dev-server's other GET routes are
|
|
416
|
+
* matched before this one, so this only fires when nothing else
|
|
417
|
+
* caught the URL. */
|
|
418
|
+
function isStaticAssetPath(url: string): boolean {
|
|
419
|
+
const path = url.split("?")[0] ?? "/";
|
|
420
|
+
if (path === "/" || path === "/index.html") return false; // handled above
|
|
421
|
+
if (path.startsWith("/lsdp/") || path.startsWith("/__mock/")) return false;
|
|
422
|
+
return /\.[a-zA-Z0-9]{1,8}$/.test(path);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function contentTypeFor(filename: string): string {
|
|
426
|
+
const lower = filename.toLowerCase();
|
|
427
|
+
if (lower.endsWith(".js") || lower.endsWith(".mjs"))
|
|
428
|
+
return "application/javascript; charset=utf-8";
|
|
429
|
+
if (lower.endsWith(".css")) return "text/css; charset=utf-8";
|
|
430
|
+
if (lower.endsWith(".html")) return "text/html; charset=utf-8";
|
|
431
|
+
if (lower.endsWith(".json") || lower.endsWith(".map")) return "application/json; charset=utf-8";
|
|
432
|
+
if (lower.endsWith(".svg")) return "image/svg+xml";
|
|
433
|
+
if (lower.endsWith(".png")) return "image/png";
|
|
434
|
+
if (lower.endsWith(".woff2")) return "font/woff2";
|
|
435
|
+
return "application/octet-stream";
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function resolveRuntimeBundlePath(override?: string): string | null {
|
|
439
|
+
if (override) {
|
|
440
|
+
return existsSync(override) ? override : null;
|
|
441
|
+
}
|
|
442
|
+
// Best-effort workspace lookup. dev-server lives at
|
|
443
|
+
// packages/dev-server/src/server.ts ; the runtime bundle lives at
|
|
444
|
+
// packages/runtime/dist/lumencast.js relative to the same workspace
|
|
445
|
+
// root.
|
|
446
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
447
|
+
const candidates = [
|
|
448
|
+
resolvePath(here, "../../runtime/dist/lumencast.js"),
|
|
449
|
+
resolvePath(here, "../../../runtime/dist/lumencast.js"),
|
|
450
|
+
resolvePath(process.cwd(), "node_modules/@lumencast/runtime/dist/lumencast.js"),
|
|
451
|
+
resolvePath(process.cwd(), "packages/runtime/dist/lumencast.js"),
|
|
452
|
+
];
|
|
453
|
+
for (const c of candidates) {
|
|
454
|
+
if (existsSync(c)) return c;
|
|
455
|
+
}
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Render the static demo HTML host. The page mounts the runtime
|
|
460
|
+
* against the same dev-server's WS endpoint and watches the scene
|
|
461
|
+
* update live.
|
|
462
|
+
*
|
|
463
|
+
* The runtime is built as ES modules with externalised peer deps
|
|
464
|
+
* (react, framer-motion, @preact/signals-react, etc). The demo HTML
|
|
465
|
+
* uses an importmap to resolve those bare specifiers to esm.sh CDN
|
|
466
|
+
* URLs so the browser can load everything without a bundler.
|
|
467
|
+
*
|
|
468
|
+
* @lumencast/protocol is also externalised — we map it back to the
|
|
469
|
+
* runtime's own dist relative path because it travels with the bundle.
|
|
470
|
+
*/
|
|
471
|
+
function renderDemoHostHtml(opts: { wsUrl: string; title: string }): string {
|
|
472
|
+
const importMap = {
|
|
473
|
+
imports: {
|
|
474
|
+
react: "https://esm.sh/react@19?dev",
|
|
475
|
+
"react/jsx-runtime": "https://esm.sh/react@19/jsx-runtime?dev",
|
|
476
|
+
"react/jsx-dev-runtime": "https://esm.sh/react@19/jsx-dev-runtime?dev",
|
|
477
|
+
"react-dom": "https://esm.sh/react-dom@19?dev",
|
|
478
|
+
"react-dom/client": "https://esm.sh/react-dom@19/client?dev",
|
|
479
|
+
"framer-motion": "https://esm.sh/framer-motion@12?dev&deps=react@19,react-dom@19",
|
|
480
|
+
"@preact/signals-react":
|
|
481
|
+
"https://esm.sh/@preact/signals-react@3?dev&deps=react@19,react-dom@19",
|
|
482
|
+
"@preact/signals-react/runtime":
|
|
483
|
+
"https://esm.sh/@preact/signals-react@3/runtime?dev&deps=react@19,react-dom@19",
|
|
484
|
+
"@lumencast/protocol": "https://esm.sh/@lumencast/protocol",
|
|
485
|
+
},
|
|
486
|
+
};
|
|
487
|
+
return `<!doctype html>
|
|
488
|
+
<html lang="en">
|
|
489
|
+
<head>
|
|
490
|
+
<meta charset="utf-8" />
|
|
491
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
492
|
+
<title>${escapeHtml(opts.title)}</title>
|
|
493
|
+
<style>
|
|
494
|
+
html, body { margin: 0; padding: 0; height: 100%; background: #000; color: #fff; font-family: system-ui, sans-serif; }
|
|
495
|
+
#scene { position: relative; width: 100vw; height: 100vh; overflow: hidden; }
|
|
496
|
+
#lumencast-error { position: fixed; bottom: 0; left: 0; right: 0; padding: 12px; background: #b00020; font-family: monospace; font-size: 12px; display: none; white-space: pre-wrap; }
|
|
497
|
+
#lumencast-status { position: fixed; top: 8px; right: 8px; padding: 4px 8px; font-family: monospace; font-size: 11px; background: rgba(0,0,0,0.5); border-radius: 4px; opacity: 0.7; }
|
|
498
|
+
</style>
|
|
499
|
+
<script type="importmap">${JSON.stringify(importMap)}</script>
|
|
500
|
+
</head>
|
|
501
|
+
<body>
|
|
502
|
+
<div id="scene" data-testid="lumencast-scene-root"></div>
|
|
503
|
+
<div id="lumencast-status">connecting…</div>
|
|
504
|
+
<div id="lumencast-error"></div>
|
|
505
|
+
<script type="module">
|
|
506
|
+
import { mount } from "/lumencast.js";
|
|
507
|
+
|
|
508
|
+
const target = document.getElementById("scene");
|
|
509
|
+
const status = document.getElementById("lumencast-status");
|
|
510
|
+
const errBox = document.getElementById("lumencast-error");
|
|
511
|
+
|
|
512
|
+
mount({
|
|
513
|
+
target,
|
|
514
|
+
serverUrl: ${JSON.stringify(opts.wsUrl)},
|
|
515
|
+
token: "demo",
|
|
516
|
+
mode: "broadcast",
|
|
517
|
+
onStatus: (s) => { status.textContent = s; },
|
|
518
|
+
onError: (err) => {
|
|
519
|
+
errBox.style.display = "block";
|
|
520
|
+
errBox.textContent = "[lumencast] " + (err && err.message ? err.message : String(err));
|
|
521
|
+
console.error("[lumencast]", err);
|
|
522
|
+
},
|
|
523
|
+
});
|
|
524
|
+
</script>
|
|
525
|
+
</body>
|
|
526
|
+
</html>`;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function escapeHtml(s: string): string {
|
|
530
|
+
return s.replace(/[&<>"']/g, (c) =>
|
|
531
|
+
c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : c === '"' ? """ : "'",
|
|
532
|
+
);
|
|
533
|
+
}
|