@scenoco-three/editor 0.3.0 → 0.3.2

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/app/document.ts CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  type SceneGraph,
13
13
  } from '@scenoco-three/core';
14
14
  import type { NodeOrigin, SceneProvenance } from '@scenoco-three/compiler';
15
- import type { EditOp, EditTarget } from '../src/protocol.js';
15
+ import type { EditOp, EditTarget } from '../dist/protocol.js';
16
16
  import { UndoStack } from './commands.js';
17
17
 
18
18
  export type TransformChannel = 'position' | 'rotation' | 'scale';
package/app/session.ts CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  } from 'three';
4
4
  import { Engine, Registry, bundleKind, type Bundle, type Component } from '@scenoco-three/core';
5
5
  import type { SceneProvenance } from '@scenoco-three/compiler';
6
- import type { EditOp, EditorResult, SimStep } from '../src/protocol.js';
6
+ import type { EditOp, EditorResult, SimStep } from '../dist/protocol.js';
7
7
 
8
8
  export type EditorMode = 'edit' | 'play';
9
9
 
package/app/wsClient.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { WS_PATH, type EditorCommand, type EditorPush, type EditorResponse, type EditorResult } from '../src/protocol.js';
1
+ import { WS_PATH, type EditorCommand, type EditorPush, type EditorResponse, type EditorResult } from '../dist/protocol.js';
2
2
  import type { EditorSession } from './session.js';
3
3
 
4
4
  /**
package/dist/args.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ /** Split a comma-separated list, trimming blanks. */
2
+ export declare function parseList(v: string | undefined): string[] | undefined;
3
+ /** Flags that take a value; other `--flags` are booleans (`''`), the rest positionals. */
4
+ export declare const VALUE_FLAGS: Record<string, string>;
5
+ export declare function parseArgs(args: string[]): {
6
+ positionals: string[];
7
+ options: Record<string, string>;
8
+ };
package/dist/args.js ADDED
@@ -0,0 +1,28 @@
1
+ // Pure CLI-arg helpers (no vite imports), so they're unit-testable.
2
+ /** Split a comma-separated list, trimming blanks. */
3
+ export function parseList(v) {
4
+ return v ? v.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
5
+ }
6
+ /** Flags that take a value; other `--flags` are booleans (`''`), the rest positionals. */
7
+ export const VALUE_FLAGS = {
8
+ '--components': 'components',
9
+ '--packages': 'packages',
10
+ '--port': 'port',
11
+ };
12
+ export function parseArgs(args) {
13
+ const positionals = [];
14
+ const options = {};
15
+ for (let i = 0; i < args.length; i++) {
16
+ const arg = args[i];
17
+ if (arg in VALUE_FLAGS) {
18
+ options[VALUE_FLAGS[arg]] = args[++i] ?? '';
19
+ }
20
+ else if (arg.startsWith('--')) {
21
+ options[arg.slice(2)] = ''; // boolean flag, e.g. --no-open
22
+ }
23
+ else {
24
+ positionals.push(arg);
25
+ }
26
+ }
27
+ return { positionals, options };
28
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
3
+ import { resolve } from 'node:path';
4
+ import { parseArgs, parseList } from '../args.js';
5
+ import { startEditorServer } from '../server.js';
6
+ import { startMcpServer } from '../mcp.js';
7
+ const USAGE = `scenoco-editor — open a scene/prefab and drive it from an MCP client
8
+
9
+ Usage:
10
+ scenoco-editor [scene|prefab] [--port <n>] [--no-open]
11
+
12
+ With no file, the editor opens a picker listing the scenes/prefabs in the
13
+ project's assets/ folder. Components are scanned from that assets/ folder and tag
14
+ packages are read from its package.json — no --components/--packages needed.
15
+
16
+ Options:
17
+ --port <n> Fixed MCP server port — set this so the endpoint an MCP client
18
+ connects to is stable across restarts (the editor's own HTTP
19
+ server always takes a free port; it doesn't need to be fixed)
20
+ --no-open Don't auto-open the editor in a browser
21
+
22
+ Starts a local editor and an MCP server. The editor opens in your default
23
+ browser; when you stop the server (Ctrl+C) it tells the page to close its own
24
+ tab, so no stale tab is left behind. Point an MCP client at the printed
25
+ endpoint; its tools — open_file, get_preview, run, pause, stop, get_telemetry —
26
+ drive the live editor.`;
27
+ async function main(argv) {
28
+ const { positionals, options } = parseArgs(argv);
29
+ const file = positionals[0];
30
+ if (file === '-h' || file === '--help') {
31
+ console.log(USAGE);
32
+ return 0;
33
+ }
34
+ // npm/npx rewrites process.cwd() to the nearest enclosing package.json when
35
+ // run from inside a workspace package without its own node_modules/.bin
36
+ // (e.g. `npx scenoco-editor` from examples/breakout/scenes). INIT_CWD still
37
+ // holds the shell's actual directory.
38
+ const cwd = process.env.INIT_CWD || process.cwd();
39
+ // Components default to assets/ and tag packages to the project's package.json
40
+ // deps (see startEditorServer); --components/--packages remain as optional
41
+ // overrides for non-standard layouts.
42
+ // The editor's HTTP server can float on any free port — the browser is pointed
43
+ // at whatever it gets. `--port` fixes the MCP endpoint instead, so an MCP
44
+ // client's configured URL stays valid across editor restarts.
45
+ const editor = await startEditorServer({
46
+ cwd,
47
+ ...(parseList(options.components) ? { componentRoots: parseList(options.components) } : {}),
48
+ ...(parseList(options.packages) ? { packages: parseList(options.packages) } : {}),
49
+ });
50
+ const mcp = await startMcpServer(editor.bridge, {
51
+ ...(options.port ? { port: Number(options.port) } : {}),
52
+ });
53
+ // With no file, open bare — the editor page shows a picker of the assets/ scenes.
54
+ const query = file ? `?file=${encodeURIComponent(resolve(cwd, file))}` : '';
55
+ const appUrl = `${editor.url}${query}`;
56
+ console.log(`\n editor: ${appUrl}`);
57
+ if (editor.networkUrl)
58
+ console.log(` wi-fi: ${editor.networkUrl}${query}`);
59
+ console.log(` mcp: ${mcp.url} ← add this to your MCP client (transport: http)`);
60
+ console.log(`\n press Ctrl+C to stop\n`);
61
+ if (!('no-open' in options))
62
+ openBrowser(appUrl);
63
+ let closing = false;
64
+ const shutdown = async () => {
65
+ if (closing)
66
+ return;
67
+ closing = true;
68
+ // Tell the editor page to close its own tab, then give the notification a
69
+ // moment to reach the browser before we tear the socket down. Works in any
70
+ // browser/OS (the tab was opened with the platform's default `open`), though
71
+ // window.close() is best-effort — see the app's wsClient for the fallback.
72
+ editor.bridge.push({ push: 'shutdown' });
73
+ await new Promise((r) => setTimeout(r, 250).unref());
74
+ // Connected browsers keep WS/HMR/keep-alive sockets open, so `close()` can
75
+ // stall waiting for them to drain — cap it so Ctrl+C always exits promptly.
76
+ const deadline = new Promise((r) => setTimeout(r, 1500).unref());
77
+ await Promise.race([Promise.all([mcp.close().catch(() => { }), editor.close().catch(() => { })]), deadline]);
78
+ process.exit(0);
79
+ };
80
+ process.on('SIGINT', () => void shutdown());
81
+ process.on('SIGTERM', () => void shutdown());
82
+ await new Promise(() => { }); // keep alive
83
+ return 0;
84
+ }
85
+ /** Open a URL in the default browser (best-effort, per platform). */
86
+ function openBrowser(url) {
87
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
88
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
89
+ try {
90
+ spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
91
+ }
92
+ catch {
93
+ /* non-fatal: the URL is printed */
94
+ }
95
+ }
96
+ process.exitCode = await main(process.argv.slice(2)).catch((e) => {
97
+ console.error(e instanceof Error ? e.message : String(e));
98
+ return 1;
99
+ });
@@ -0,0 +1,3 @@
1
+ export { startEditorServer, type ServerOptions, type EditorServer, type EditorBridge } from './server.js';
2
+ export { startMcpServer, buildMcpServer, type McpHandle } from './mcp.js';
3
+ export { WS_PATH, commandId, type EditorCommand, type EditorResponse, type EditorResult, } from './protocol.js';
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ // `@scenoco-three/editor` — programmatic API behind the `scenoco-editor` CLI.
2
+ // Serves the editor app, bridges it to an MCP server over a WebSocket, and exposes
3
+ // MCP tools (open_file / get_preview / run / pause / stop / get_telemetry).
4
+ export { startEditorServer } from './server.js';
5
+ export { startMcpServer, buildMcpServer } from './mcp.js';
6
+ export { WS_PATH, commandId, } from './protocol.js';
package/dist/mcp.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { EditorBridge } from './server.js';
3
+ export interface McpHandle {
4
+ /** The MCP endpoint URL to add to an MCP client. */
5
+ url: string;
6
+ close(): Promise<void>;
7
+ }
8
+ /**
9
+ * Start a stateless MCP server (Streamable HTTP) whose tools relay to the live
10
+ * editor over `bridge`. A fresh `McpServer` + transport is built per request.
11
+ */
12
+ export declare function startMcpServer(bridge: EditorBridge, opts?: {
13
+ port?: number;
14
+ }): Promise<McpHandle>;
15
+ /** Build an McpServer whose tools relay to the editor. */
16
+ export declare function buildMcpServer(bridge: EditorBridge): McpServer;
package/dist/mcp.js ADDED
@@ -0,0 +1,150 @@
1
+ import { createServer as createHttpServer } from 'node:http';
2
+ import { createServer as createNetServer } from 'node:net';
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
5
+ import { z } from 'zod';
6
+ import { commandId } from './protocol.js';
7
+ /**
8
+ * Start a stateless MCP server (Streamable HTTP) whose tools relay to the live
9
+ * editor over `bridge`. A fresh `McpServer` + transport is built per request.
10
+ */
11
+ export async function startMcpServer(bridge, opts = {}) {
12
+ const port = opts.port ?? (await freePort());
13
+ const http = createHttpServer((req, res) => void handle(req, res, bridge));
14
+ await new Promise((resolve) => http.listen(port, resolve));
15
+ return {
16
+ url: `http://localhost:${port}/mcp`,
17
+ close: () => new Promise((resolve) => http.close(() => resolve())),
18
+ };
19
+ }
20
+ /** Build an McpServer whose tools relay to the editor. */
21
+ export function buildMcpServer(bridge) {
22
+ const server = new McpServer({ name: 'scenoco-editor', version: '0.1.0' });
23
+ const relay = (cmd) => relayToEditor(bridge, cmd);
24
+ server.registerTool('open_file', { description: 'Open a scene (.scene.xml) or prefab (.prefab.xml) in the editor (edit mode).', inputSchema: { path: z.string() } }, ({ path }) => relay({ id: commandId(), type: 'open', path }));
25
+ server.registerTool('get_preview', {
26
+ description: 'Capture the current editor view as a PNG. Optionally frame a specific camera.',
27
+ inputSchema: {
28
+ camera: z.string().optional().describe('Camera position "x y z"'),
29
+ lookAt: z.string().optional().describe('Look-at point "x y z"'),
30
+ },
31
+ }, ({ camera, lookAt }) => relay({ id: commandId(), type: 'preview', ...(camera ? { camera } : {}), ...(lookAt ? { lookAt } : {}) }));
32
+ server.registerTool('playback', {
33
+ description: 'Control gameplay: play (run components/systems/physics), pause, or stop (back to edit mode).',
34
+ inputSchema: { state: z.enum(['play', 'pause', 'stop']) },
35
+ }, ({ state }) => relay({ id: commandId(), type: state === 'play' ? 'run' : state }));
36
+ server.registerTool('get_telemetry', { description: 'Current property values (JSON) for the node/components at a scene id.', inputSchema: { id: z.string() } }, ({ id }) => relay({ id: commandId(), type: 'telemetry', targetId: id }));
37
+ // A simulation command — one entry in the `simulate` batch. Loose (all fields
38
+ // optional) so the agent composes any step; only `do` is required.
39
+ const simStep = z.object({
40
+ do: z.enum(['play', 'pause', 'stop', 'step', 'tap', 'keyDown', 'keyUp', 'click', 'move', 'gamepad', 'preview', 'telemetry']),
41
+ key: z.string().optional().describe("tap/keyDown/keyUp: key name, e.g. 'Space', 'W', 'LeftArrow'"),
42
+ frames: z.number().int().optional(),
43
+ dt: z.number().optional(),
44
+ x: z.number().optional(),
45
+ y: z.number().optional(),
46
+ dx: z.number().optional(),
47
+ dy: z.number().optional(),
48
+ button: z.enum(['left', 'right', 'middle']).optional(),
49
+ index: z.number().int().optional(),
50
+ buttons: z.array(z.boolean()).optional(),
51
+ axes: z.array(z.number()).optional(),
52
+ camera: z.string().optional(),
53
+ lookAt: z.string().optional(),
54
+ id: z.string().optional(),
55
+ });
56
+ server.registerTool('simulate', {
57
+ description: 'Drive gameplay: an ordered batch of steps; returns previews (images) and telemetry (JSON) in order. ' +
58
+ 'Each step is one self-contained verb: { do: "play"|"pause"|"stop" | "step"(frames) | "tap"(key) | ' +
59
+ '"keyDown"(key) | "keyUp"(key) | "click"(x,y,button) | "move"(x,y,dx,dy) | "preview"(camera,lookAt) | "telemetry"(id) }. ' +
60
+ 'Example: [{do:"play"},{do:"tap",key:"Space"},{do:"step",frames:90},{do:"preview"}].',
61
+ inputSchema: { commands: z.array(simStep) },
62
+ }, ({ commands }) => relay({ id: commandId(), type: 'simulate', steps: commands }));
63
+ return server;
64
+ }
65
+ /** Send a command to the editor and map its reply to MCP tool content. */
66
+ async function relayToEditor(bridge, cmd) {
67
+ let response;
68
+ try {
69
+ response = await bridge.send(cmd);
70
+ }
71
+ catch (e) {
72
+ return { isError: true, content: [{ type: 'text', text: e instanceof Error ? e.message : String(e) }] };
73
+ }
74
+ if (!response.ok)
75
+ return { isError: true, content: [{ type: 'text', text: response.error }] };
76
+ return toContent(response.result);
77
+ }
78
+ function toContent(result) {
79
+ switch (result.kind) {
80
+ case 'image': {
81
+ const base64 = result.dataUrl.replace(/^data:image\/png;base64,/, '');
82
+ return { content: [{ type: 'image', data: base64, mimeType: 'image/png' }] };
83
+ }
84
+ case 'json':
85
+ return { content: [{ type: 'text', text: JSON.stringify(result.data, null, 2) }] };
86
+ case 'multi': {
87
+ // Flatten each capture's blocks, preserving step order.
88
+ const content = result.results.flatMap((r) => toContent(r).content);
89
+ return { content: content.length > 0 ? content : [{ type: 'text', text: 'ok' }] };
90
+ }
91
+ case 'ok':
92
+ default:
93
+ return { content: [{ type: 'text', text: result.info ?? 'ok' }] };
94
+ }
95
+ }
96
+ async function handle(req, res, bridge) {
97
+ res.setHeader('Access-Control-Allow-Origin', '*');
98
+ res.setHeader('Access-Control-Allow-Headers', 'content-type, mcp-session-id, mcp-protocol-version');
99
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
100
+ if (req.method === 'OPTIONS') {
101
+ res.statusCode = 204;
102
+ res.end();
103
+ return;
104
+ }
105
+ if ((req.url ?? '').split('?')[0] !== '/mcp') {
106
+ res.statusCode = 404;
107
+ res.end('not found');
108
+ return;
109
+ }
110
+ if (req.method !== 'POST') {
111
+ res.statusCode = 405;
112
+ res.setHeader('Allow', 'POST');
113
+ res.end(JSON.stringify({ error: 'this MCP endpoint is stateless — use POST' }));
114
+ return;
115
+ }
116
+ const body = await readJson(req);
117
+ // Stateless: a fresh server + transport per request.
118
+ const server = buildMcpServer(bridge);
119
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
120
+ res.on('close', () => {
121
+ void transport.close();
122
+ void server.close();
123
+ });
124
+ await server.connect(transport);
125
+ await transport.handleRequest(req, res, body);
126
+ }
127
+ function readJson(req) {
128
+ return new Promise((resolve) => {
129
+ let data = '';
130
+ req.on('data', (c) => (data += c));
131
+ req.on('end', () => {
132
+ try {
133
+ resolve(data ? JSON.parse(data) : undefined);
134
+ }
135
+ catch {
136
+ resolve(undefined);
137
+ }
138
+ });
139
+ });
140
+ }
141
+ function freePort() {
142
+ return new Promise((res, rej) => {
143
+ const probe = createNetServer();
144
+ probe.on('error', rej);
145
+ probe.listen(0, () => {
146
+ const port = probe.address().port;
147
+ probe.close(() => res(port));
148
+ });
149
+ });
150
+ }
@@ -0,0 +1,161 @@
1
+ /** WebSocket path the editor connects to on the dev server. */
2
+ export declare const WS_PATH = "/scenoco-ws";
3
+ /** A command sent MCP-server → editor (id-correlated with its response). */
4
+ export type EditorCommand = {
5
+ id: string;
6
+ type: 'open';
7
+ path: string;
8
+ } | {
9
+ id: string;
10
+ type: 'preview';
11
+ camera?: string;
12
+ lookAt?: string;
13
+ size?: string;
14
+ } | {
15
+ id: string;
16
+ type: 'run';
17
+ } | {
18
+ id: string;
19
+ type: 'pause';
20
+ } | {
21
+ id: string;
22
+ type: 'stop';
23
+ } | {
24
+ id: string;
25
+ type: 'telemetry';
26
+ targetId: string;
27
+ } | {
28
+ id: string;
29
+ type: 'simulate';
30
+ steps: SimStep[];
31
+ };
32
+ /**
33
+ * One step in a `simulate` batch — transport, frame stepping, synthetic input, or
34
+ * a capture. Executed in order against the live engine, so an agent can drive the
35
+ * game to a precise condition and sample preview/telemetry there.
36
+ */
37
+ export type SimStep = {
38
+ do: 'play' | 'pause' | 'stop';
39
+ }
40
+ /** Advance the simulation by `frames` ticks (default 1) of `dt` seconds (default 1/60). */
41
+ | {
42
+ do: 'step';
43
+ frames?: number;
44
+ dt?: number;
45
+ }
46
+ /** Keyboard — `key` is a Unity KeyCode name (e.g. 'Space', 'W'). Each action is its own verb. */
47
+ | {
48
+ do: 'tap';
49
+ key: string;
50
+ } | {
51
+ do: 'keyDown';
52
+ key: string;
53
+ } | {
54
+ do: 'keyUp';
55
+ key: string;
56
+ }
57
+ /** Mouse — `x,y` canvas-relative px (top-left). `click` = down+up; button defaults to left. */
58
+ | {
59
+ do: 'click';
60
+ x?: number;
61
+ y?: number;
62
+ button?: 'left' | 'right' | 'middle';
63
+ } | {
64
+ do: 'move';
65
+ x?: number;
66
+ y?: number;
67
+ dx?: number;
68
+ dy?: number;
69
+ }
70
+ /** Gamepad: synthetic pad `index` (default 0) with pressed `buttons` + `axes`. */
71
+ | {
72
+ do: 'gamepad';
73
+ index?: number;
74
+ buttons?: boolean[];
75
+ axes?: number[];
76
+ } | {
77
+ do: 'preview';
78
+ camera?: string;
79
+ lookAt?: string;
80
+ } | {
81
+ do: 'telemetry';
82
+ id: string;
83
+ };
84
+ /** The editor's reply, keyed by the command id. */
85
+ export type EditorResponse = {
86
+ id: string;
87
+ ok: true;
88
+ result: EditorResult;
89
+ } | {
90
+ id: string;
91
+ ok: false;
92
+ error: string;
93
+ };
94
+ /**
95
+ * An unsolicited server→editor notification (not id-correlated).
96
+ *
97
+ * - `fileChanged`: a watched file changed on disk — the open scene/prefab
98
+ * (`role: 'open'`) or one of its `src=` deps, a prefab/material (`role: 'dep'`).
99
+ * - `shutdown`: the dev server is stopping; the editor page closes itself so no
100
+ * stale tab is left behind.
101
+ */
102
+ export type EditorPush = {
103
+ push: 'fileChanged';
104
+ file: string;
105
+ role: 'open' | 'dep';
106
+ } | {
107
+ push: 'shutdown';
108
+ };
109
+ /** Result payloads, discriminated by `kind`. */
110
+ export type EditorResult = {
111
+ kind: 'ok';
112
+ info?: string;
113
+ } | {
114
+ kind: 'image';
115
+ dataUrl: string;
116
+ } | {
117
+ kind: 'json';
118
+ data: unknown;
119
+ } | {
120
+ kind: 'multi';
121
+ results: EditorResult[];
122
+ };
123
+ /** Random correlation id for a command. */
124
+ export declare function commandId(): string;
125
+ /**
126
+ * Which compiled element an edit targets. Resolved server-side via the source
127
+ * map (compiled index → source element), so it survives prefab expansion where
128
+ * structural paths don't. `id`s are carried for readability/debugging.
129
+ */
130
+ export type EditTarget = {
131
+ kind: 'node';
132
+ nodeIndex: number;
133
+ id?: string;
134
+ } | {
135
+ kind: 'component';
136
+ componentIndex: number;
137
+ nodeIndex: number;
138
+ nodeId?: string;
139
+ /** Component tag + its ordinal among same-typed components on the node. */
140
+ type: string;
141
+ ordinal: number;
142
+ } | {
143
+ kind: 'attachment';
144
+ nodeIndex: number;
145
+ /** Owning node id; absent for the scene-root, which `root` then marks. */
146
+ nodeId?: string;
147
+ root?: boolean;
148
+ /** Attachment tag + its ordinal among same-tag attachments on the node. */
149
+ tag: string;
150
+ ordinal: number;
151
+ };
152
+ /**
153
+ * One attribute change in compiled form (numbers/arrays/strings/bools). The
154
+ * server serializes `value` to an attribute string using the registry codecs,
155
+ * or removes the attribute when it equals the registry default (minimal diff).
156
+ */
157
+ export interface EditOp {
158
+ target: EditTarget;
159
+ attr: string;
160
+ value: number | boolean | string | number[];
161
+ }
@@ -0,0 +1,9 @@
1
+ // Shared command/response protocol for the WebSocket bridge between the MCP
2
+ // server (Node) and the editor app (browser). Pure types + a tiny correlation
3
+ // helper, so both ends agree and the relay logic is unit-testable.
4
+ /** WebSocket path the editor connects to on the dev server. */
5
+ export const WS_PATH = '/scenoco-ws';
6
+ /** Random correlation id for a command. */
7
+ export function commandId() {
8
+ return Math.random().toString(36).slice(2, 10);
9
+ }
@@ -0,0 +1,57 @@
1
+ import { type EditorCommand, type EditorPush, type EditorResponse } from './protocol.js';
2
+ export interface ServerOptions {
3
+ /** Directories scanned for `@component`/`@node` tags (default `['assets']`). */
4
+ componentRoots?: string[];
5
+ /** Tag packages to register (default: the `@scenoco-three/*` deps in package.json). */
6
+ packages?: string[];
7
+ /** Fixed port (default: a free OS port). */
8
+ port?: number;
9
+ /** Base directory `componentRoots` are resolved against (default: `process.cwd()`). */
10
+ cwd?: string;
11
+ }
12
+ /** Relays MCP tool commands to the connected editor page and awaits its reply. */
13
+ export interface EditorBridge {
14
+ connected(): boolean;
15
+ send(cmd: EditorCommand): Promise<EditorResponse>;
16
+ /** Send an unsolicited notification to the editor page (e.g. a file change). */
17
+ push(msg: EditorPush): void;
18
+ }
19
+ /** Watches the open file + its `src=` deps and pushes change notifications. */
20
+ export interface FileWatcher {
21
+ /** (Re)arm the watch set after a successful compile of `openFile` with `deps`. */
22
+ arm(openFile: string, openContents: string, deps: {
23
+ path: string;
24
+ contents: string;
25
+ }[]): void;
26
+ /** Record content the editor itself just wrote, so its own save isn't seen as external. */
27
+ note(file: string, contents: string): void;
28
+ setPush(push: (msg: EditorPush) => void): void;
29
+ close(): Promise<void>;
30
+ }
31
+ export interface EditorServer {
32
+ /** Local URL the editor app is served at. */
33
+ url: string;
34
+ /** LAN URL (open on another device on the same network). */
35
+ networkUrl?: string;
36
+ bridge: EditorBridge;
37
+ close(): Promise<void>;
38
+ }
39
+ /**
40
+ * Start the editor's Vite dev server: serves the app, registers all project
41
+ * components/packages, compiles any scene/prefab on demand, and bridges the page
42
+ * to the MCP server over a WebSocket.
43
+ */
44
+ export declare function startEditorServer(opts?: ServerOptions): Promise<EditorServer>;
45
+ /** A scene/prefab source the picker can open, addressed relative to `cwd`. */
46
+ export interface SceneFile {
47
+ /** Path relative to `cwd` — what the editor passes to `?file=` / `open()`. */
48
+ file: string;
49
+ /** Whether it's a scene or a prefab, for the picker's label. */
50
+ kind: 'scene' | 'prefab';
51
+ }
52
+ /**
53
+ * Every `*.scene.xml` / `*.prefab.xml` under the asset `roots` (default assets/),
54
+ * relative to `cwd`. Isolated to the roots — the walk never escapes them, and
55
+ * build/cache dirs are skipped. Sorted for a stable picker order.
56
+ */
57
+ export declare function listSceneFiles(roots: string[], cwd: string): SceneFile[];
package/dist/server.js ADDED
@@ -0,0 +1,374 @@
1
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { createServer as createNetServer } from 'node:net';
3
+ import { dirname, relative, resolve, sep } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import chokidar from 'chokidar';
6
+ import { createServer } from 'vite';
7
+ import { WebSocketServer } from 'ws';
8
+ import { bundlePrefab, bundleScene, collectedPackageSchemas, compilePrefabXml, compileSceneXml, componentLoadError, detectTagPackages, generateMaterialXsd, generatePrefabXsd, generateXsd, loadComponents, patchSceneXml, scanComponents, } from '@scenoco-three/compiler';
9
+ import { WS_PATH } from './protocol.js';
10
+ const appDir = fileURLToPath(new URL('../app', import.meta.url));
11
+ /** Loads `src=` resources from disk, relative to the referencing file. */
12
+ const fsResolver = (request, referrer) => {
13
+ const path = resolve(referrer ? dirname(referrer) : '.', request);
14
+ return existsSync(path) ? { path, contents: readFileSync(path, 'utf8') } : null;
15
+ };
16
+ /**
17
+ * (Re)register the project's components (change-aware — a no-op when nothing
18
+ * changed) and, while the registry is healthy, keep the project's `schema/` XSDs
19
+ * in sync. A corrupted component file no longer wipes the registry: the last-good
20
+ * set stays live and the failure is returned for the editor/MCP to surface.
21
+ */
22
+ async function refreshComponents(roots, cwd, packages) {
23
+ await loadComponents(roots, cwd, { packages }).catch(() => { });
24
+ const error = componentLoadError();
25
+ if (!error)
26
+ regenerateSchemas(cwd);
27
+ return error;
28
+ }
29
+ /**
30
+ * Rewrite `schema/{scene,prefab,material}.xsd` plus each tag package's XSD from the
31
+ * live registry, so an IDE linked to them tracks component edits without a manual
32
+ * `scenoco export`. Opt-in: only maintains an *existing* `schema/` dir; writes only
33
+ * files whose content changed (no git churn, no watcher feedback loop).
34
+ */
35
+ function regenerateSchemas(cwd) {
36
+ const dir = resolve(cwd, 'schema');
37
+ if (!existsSync(dir))
38
+ return;
39
+ writeIfChanged(resolve(dir, 'scene.xsd'), generateXsd());
40
+ writeIfChanged(resolve(dir, 'prefab.xsd'), generatePrefabXsd());
41
+ writeIfChanged(resolve(dir, 'material.xsd'), generateMaterialXsd());
42
+ for (const schema of collectedPackageSchemas()) {
43
+ try {
44
+ writeIfChanged(resolve(dir, schema.filename), readFileSync(schema.path, 'utf8'));
45
+ }
46
+ catch {
47
+ /* a package XSD went missing — skip it */
48
+ }
49
+ }
50
+ }
51
+ function writeIfChanged(path, content) {
52
+ try {
53
+ if (existsSync(path) && readFileSync(path, 'utf8') === content)
54
+ return;
55
+ }
56
+ catch {
57
+ /* unreadable — fall through and overwrite */
58
+ }
59
+ writeFileSync(path, content);
60
+ }
61
+ /**
62
+ * Start the editor's Vite dev server: serves the app, registers all project
63
+ * components/packages, compiles any scene/prefab on demand, and bridges the page
64
+ * to the MCP server over a WebSocket.
65
+ */
66
+ export async function startEditorServer(opts = {}) {
67
+ const cwd = opts.cwd ?? process.cwd();
68
+ // Convention over configuration: scan `assets/` for components and register the
69
+ // `@scenoco-three/*` tag packages listed in the project's package.json, so the
70
+ // editor needs no `--components`/`--packages`.
71
+ const componentRoots = opts.componentRoots ?? ['assets'];
72
+ const packages = opts.packages ?? detectTagPackages(cwd);
73
+ const port = opts.port ?? (await freePort());
74
+ const watcher = createFileWatcher();
75
+ const server = await createServer({
76
+ configFile: false,
77
+ root: appDir,
78
+ logLevel: 'warn',
79
+ server: { host: true, port, strictPort: true, fs: { strict: false } },
80
+ plugins: [allComponentsPlugin(componentRoots, packages, cwd), bundleEndpointPlugin(componentRoots, packages, cwd, watcher)],
81
+ });
82
+ await server.listen();
83
+ const bridge = attachBridge(server);
84
+ watcher.setPush((msg) => bridge.push(msg));
85
+ const url = server.resolvedUrls?.local[0] ?? `http://localhost:${port}/`;
86
+ const networkUrl = server.resolvedUrls?.network[0];
87
+ return {
88
+ url,
89
+ ...(networkUrl ? { networkUrl } : {}),
90
+ bridge,
91
+ close: async () => {
92
+ await watcher.close();
93
+ await server.close();
94
+ },
95
+ };
96
+ }
97
+ /**
98
+ * A push-based file watcher: tracks the open file plus its `src=` dependencies,
99
+ * and notifies the editor when one changes on disk. Changes whose content matches
100
+ * what we last compiled/wrote are ignored — so the editor's own save (and no-op
101
+ * touches) don't masquerade as external edits.
102
+ */
103
+ function createFileWatcher() {
104
+ const w = chokidar.watch([], { ignoreInitial: true, awaitWriteFinish: true });
105
+ const content = new Map(); // abs path → last-known content
106
+ let openFile = '';
107
+ let push = () => { };
108
+ const onChange = (changed) => {
109
+ const abs = resolve(changed);
110
+ if (!content.has(abs))
111
+ return; // not in our set
112
+ let text;
113
+ try {
114
+ text = readFileSync(abs, 'utf8');
115
+ }
116
+ catch {
117
+ return; // deleted mid-event; ignore
118
+ }
119
+ if (text === content.get(abs))
120
+ return; // our own write or a no-op touch
121
+ content.set(abs, text);
122
+ push({ push: 'fileChanged', file: abs, role: abs === openFile ? 'open' : 'dep' });
123
+ };
124
+ w.on('change', onChange);
125
+ w.on('add', onChange);
126
+ return {
127
+ arm(file, openContents, deps) {
128
+ const abs = resolve(file);
129
+ const next = new Map([[abs, openContents]]);
130
+ for (const d of deps)
131
+ next.set(resolve(d.path), d.contents);
132
+ for (const old of content.keys())
133
+ if (!next.has(old))
134
+ w.unwatch(old);
135
+ for (const p of next.keys())
136
+ if (!content.has(p))
137
+ w.add(p);
138
+ content.clear();
139
+ for (const [k, v] of next)
140
+ content.set(k, v);
141
+ openFile = abs;
142
+ },
143
+ note(file, contents) {
144
+ content.set(resolve(file), contents);
145
+ },
146
+ setPush(fn) {
147
+ push = fn;
148
+ },
149
+ close: () => w.close(),
150
+ };
151
+ }
152
+ /** `virtual:scenoco-components` → import every project component + package + built-in nodes. */
153
+ function allComponentsPlugin(roots, packages, cwd) {
154
+ const id = 'virtual:scenoco-components';
155
+ const resolved = '\0' + id;
156
+ return {
157
+ name: 'scenoco:all-components',
158
+ resolveId: (source) => (source === id ? resolved : null),
159
+ load(source) {
160
+ if (source !== resolved)
161
+ return null;
162
+ const files = [...scanComponents(roots, cwd).files.keys()].map((f) => `import ${JSON.stringify(`/@fs${f}`)};`);
163
+ const pkgs = packages.map((p) => `import ${JSON.stringify(p)};`);
164
+ return ['import "@scenoco-three/core/nodes";', ...pkgs, ...files].join('\n');
165
+ },
166
+ };
167
+ }
168
+ /** `GET /@scenoco/bundle?file=<abs>` → the compiled runtime bundle (JSON). */
169
+ function bundleEndpointPlugin(roots, packages, cwd, watcher) {
170
+ return {
171
+ name: 'scenoco:bundle-endpoint',
172
+ async configureServer(server) {
173
+ // Register components in this Node process so compilation/validation works,
174
+ // and publish the project's XSDs (if it has a schema/ dir).
175
+ await refreshComponents(roots, cwd, packages);
176
+ // POST /@scenoco/save — surgical, attribute-level write-back to the source.
177
+ server.middlewares.use((req, res, next) => {
178
+ if (req.method !== 'POST' || !req.url || !req.url.startsWith('/@scenoco/save'))
179
+ return next();
180
+ void readJsonBody(req)
181
+ .then((body) => {
182
+ const { file, edits } = (body ?? {});
183
+ if (!file || !Array.isArray(edits))
184
+ throw new Error('expected { file, edits }');
185
+ const abs = resolve(cwd, file);
186
+ const { text, changed, unresolved } = patchSceneXml(readFileSync(abs, 'utf8'), edits);
187
+ if (changed > 0)
188
+ writeFileSync(abs, text);
189
+ watcher.note(abs, text); // our own write — don't report it back as an external change
190
+ res.setHeader('Content-Type', 'application/json');
191
+ res.end(JSON.stringify({ ok: true, changed, unresolved: unresolved.length }));
192
+ })
193
+ .catch((e) => {
194
+ res.statusCode = 400;
195
+ res.setHeader('Content-Type', 'application/json');
196
+ res.end(JSON.stringify({ error: e instanceof Error ? e.message : String(e) }));
197
+ });
198
+ });
199
+ // GET /@scenoco/files — the scenes/prefabs under the project's asset roots,
200
+ // for the start-up file picker (used when the editor is opened with no ?file=).
201
+ // Isolated to `roots` (default assets/): nothing outside them is listed.
202
+ server.middlewares.use((req, res, next) => {
203
+ if (!req.url || !req.url.startsWith('/@scenoco/files'))
204
+ return next();
205
+ res.setHeader('Content-Type', 'application/json');
206
+ res.end(JSON.stringify({ files: listSceneFiles(roots, cwd) }));
207
+ });
208
+ server.middlewares.use((req, res, next) => {
209
+ if (!req.url || !req.url.startsWith('/@scenoco/bundle'))
210
+ return next();
211
+ void (async () => {
212
+ // Pick up any component edits (and re-sync schema/) before compiling, with
213
+ // last-good fallback — a broken component is reported, not fatal.
214
+ const compError = await refreshComponents(roots, cwd, packages);
215
+ try {
216
+ const file = new URL(req.url, 'http://x').searchParams.get('file');
217
+ if (!file)
218
+ throw new Error('missing ?file=');
219
+ const abs = resolve(cwd, file);
220
+ const xml = readFileSync(abs, 'utf8');
221
+ const isPrefab = abs.endsWith('.prefab.xml');
222
+ // Track which src= files the compile touches, so we can watch them too.
223
+ const deps = [];
224
+ const trackingResolver = (request, referrer) => {
225
+ const r = fsResolver(request, referrer);
226
+ if (r)
227
+ deps.push({ path: r.path, contents: r.contents });
228
+ return r;
229
+ };
230
+ const result = isPrefab
231
+ ? compilePrefabXml(xml, { path: abs, resolve: trackingResolver })
232
+ : compileSceneXml(xml, { path: abs, resolve: trackingResolver });
233
+ if (!result.ok) {
234
+ res.statusCode = 400;
235
+ res.setHeader('Content-Type', 'application/json');
236
+ // A component load failure is the likely root cause of an "unknown tag"
237
+ // compile error — pass it along so the editor can explain it.
238
+ res.end(JSON.stringify({ error: 'compile failed', diagnostics: result.diagnostics, componentError: compError?.message }));
239
+ return;
240
+ }
241
+ // A scene's sibling `*.ui.html` (its DOM UI overlay) is embedded in the
242
+ // bundle and watched as a dep, so editing it live-refreshes the overlay
243
+ // — visible statically even in edit mode (engine.overlay).
244
+ if (!isPrefab) {
245
+ const uiFile = abs.replace(/\.(scene\.)?xml$/, '.ui.html');
246
+ if (existsSync(uiFile)) {
247
+ const uiHtml = readFileSync(uiFile, 'utf8');
248
+ result.scene.ui = uiHtml;
249
+ deps.push({ path: uiFile, contents: uiHtml });
250
+ }
251
+ }
252
+ watcher.arm(abs, xml, deps); // watch this file + its src/ui deps for changes
253
+ const bundle = isPrefab ? bundlePrefab(result.scene) : bundleScene(result.scene);
254
+ res.setHeader('Content-Type', 'application/json');
255
+ // `componentError` is present when the scene still compiled (stale last-good
256
+ // registry) but a component file is currently broken — a warning, not fatal.
257
+ res.end(JSON.stringify({ bundle, provenance: result.provenance, componentError: compError?.message }));
258
+ }
259
+ catch (e) {
260
+ res.statusCode = 400;
261
+ res.setHeader('Content-Type', 'application/json');
262
+ res.end(JSON.stringify({ error: e instanceof Error ? e.message : String(e), componentError: compError?.message }));
263
+ }
264
+ })();
265
+ });
266
+ },
267
+ };
268
+ }
269
+ /** WebSocket bridge: one editor page ↔ the MCP server, with id-correlated replies. */
270
+ function attachBridge(server) {
271
+ const wss = new WebSocketServer({ noServer: true });
272
+ let editor;
273
+ const pending = new Map();
274
+ server.httpServer?.on('upgrade', (req, socket, head) => {
275
+ // Only our path; Vite's HMR upgrades (vite-hmr subprotocol) fall through to it.
276
+ if (!req.url || !req.url.startsWith(WS_PATH))
277
+ return;
278
+ wss.handleUpgrade(req, socket, head, (ws) => {
279
+ editor = ws;
280
+ ws.on('message', (data) => {
281
+ try {
282
+ const r = JSON.parse(String(data));
283
+ pending.get(r.id)?.(r);
284
+ pending.delete(r.id);
285
+ }
286
+ catch {
287
+ /* ignore malformed */
288
+ }
289
+ });
290
+ ws.on('close', () => {
291
+ if (editor === ws)
292
+ editor = undefined;
293
+ });
294
+ });
295
+ });
296
+ return {
297
+ connected: () => !!editor && editor.readyState === editor.OPEN,
298
+ send: (cmd) => new Promise((res, rej) => {
299
+ if (!editor || editor.readyState !== editor.OPEN) {
300
+ rej(new Error('no editor connected — open the editor page in a browser first'));
301
+ return;
302
+ }
303
+ pending.set(cmd.id, res);
304
+ editor.send(JSON.stringify(cmd));
305
+ setTimeout(() => {
306
+ if (pending.delete(cmd.id))
307
+ rej(new Error('editor did not respond in time'));
308
+ }, 15000);
309
+ }),
310
+ push: (msg) => {
311
+ if (editor && editor.readyState === editor.OPEN)
312
+ editor.send(JSON.stringify(msg));
313
+ },
314
+ };
315
+ }
316
+ /** Read a request's JSON body (for the save endpoint). */
317
+ function readJsonBody(req) {
318
+ return new Promise((resolve, reject) => {
319
+ let data = '';
320
+ req.on('data', (c) => (data += c));
321
+ req.on('end', () => {
322
+ try {
323
+ resolve(data ? JSON.parse(data) : undefined);
324
+ }
325
+ catch (e) {
326
+ reject(e instanceof Error ? e : new Error(String(e)));
327
+ }
328
+ });
329
+ req.on('error', reject);
330
+ });
331
+ }
332
+ /**
333
+ * Every `*.scene.xml` / `*.prefab.xml` under the asset `roots` (default assets/),
334
+ * relative to `cwd`. Isolated to the roots — the walk never escapes them, and
335
+ * build/cache dirs are skipped. Sorted for a stable picker order.
336
+ */
337
+ export function listSceneFiles(roots, cwd) {
338
+ const found = new Map(); // keyed by file, dedupes overlapping roots
339
+ const skip = new Set(['node_modules', 'dist', '.scenoco', '.git']);
340
+ const walk = (dir) => {
341
+ let entries;
342
+ try {
343
+ entries = readdirSync(dir, { withFileTypes: true });
344
+ }
345
+ catch {
346
+ return; // missing/unreadable root — nothing to list
347
+ }
348
+ for (const e of entries) {
349
+ const abs = resolve(dir, e.name);
350
+ if (e.isDirectory()) {
351
+ if (!e.name.startsWith('.') && !skip.has(e.name))
352
+ walk(abs);
353
+ }
354
+ else if (e.name.endsWith('.scene.xml') || e.name.endsWith('.prefab.xml')) {
355
+ const file = relative(cwd, abs).split(sep).join('/');
356
+ found.set(file, { file, kind: e.name.endsWith('.prefab.xml') ? 'prefab' : 'scene' });
357
+ }
358
+ }
359
+ };
360
+ for (const root of roots)
361
+ walk(resolve(cwd, root));
362
+ return [...found.values()].sort((a, b) => a.file.localeCompare(b.file));
363
+ }
364
+ /** Ask the OS for a free TCP port (avoids Vite's 5173-default collisions). */
365
+ function freePort() {
366
+ return new Promise((res, rej) => {
367
+ const probe = createNetServer();
368
+ probe.on('error', rej);
369
+ probe.listen(0, () => {
370
+ const port = probe.address().port;
371
+ probe.close(() => res(port));
372
+ });
373
+ });
374
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scenoco-three/editor",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Local preview + headless frame render for SceNoCo scenes/prefabs (for multimodal LLMs)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,7 +33,7 @@
33
33
  },
34
34
  "scripts": {
35
35
  "build": "tsc -p tsconfig.build.json",
36
- "typecheck": "npm --prefix ../core run build && npm --prefix ../compiler run build && npm --prefix ../vite run build && tsc --noEmit -p tsconfig.json"
36
+ "typecheck": "npm --prefix ../core run build && npm --prefix ../compiler run build && npm --prefix ../vite run build && npm run build && tsc --noEmit -p tsconfig.json"
37
37
  },
38
38
  "dependencies": {
39
39
  "@modelcontextprotocol/sdk": "^1.12.0",