@scenoco-three/editor 0.3.2 → 0.5.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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -17
  3. package/app/cameraRig.ts +73 -12
  4. package/app/commands.ts +5 -1
  5. package/app/desktopControls.ts +13 -10
  6. package/app/document.ts +474 -427
  7. package/app/editLog.ts +127 -0
  8. package/app/editor.css +51 -0
  9. package/app/gizmo.ts +100 -6
  10. package/app/index.html +6 -122
  11. package/app/main.ts +488 -293
  12. package/app/meta.ts +24 -0
  13. package/app/picking.ts +47 -0
  14. package/app/session.ts +487 -210
  15. package/app/touchControls.ts +85 -62
  16. package/app/ui-react/AddMemberPopup.tsx +114 -0
  17. package/app/ui-react/BottomLauncher.tsx +15 -0
  18. package/app/ui-react/EditorUI.tsx +22 -0
  19. package/app/ui-react/FilePicker.tsx +80 -0
  20. package/app/ui-react/HeadBtn.tsx +21 -0
  21. package/app/ui-react/InspectorSheet.tsx +475 -0
  22. package/app/ui-react/NodeListSheet.tsx +56 -0
  23. package/app/ui-react/TopBar.tsx +137 -0
  24. package/app/ui-react/Tree.tsx +106 -0
  25. package/app/ui-react/cx.ts +4 -0
  26. package/app/ui-react/fields.tsx +407 -0
  27. package/app/ui-react/format.ts +30 -0
  28. package/app/ui-react/mount.tsx +31 -0
  29. package/app/ui-react/store.ts +80 -0
  30. package/app/ui-react/types.ts +88 -0
  31. package/app/ui-react/ui.module.css +250 -0
  32. package/app/ui-react/useSize.ts +20 -0
  33. package/app/values.ts +137 -0
  34. package/app/vite-env.d.ts +4 -2
  35. package/dist/cli/index.js +93 -70
  36. package/dist/index.d.ts +10 -3
  37. package/dist/index.js +9 -5
  38. package/dist/mcp.d.ts +28 -12
  39. package/dist/mcp.js +172 -131
  40. package/dist/protocol.d.ts +74 -65
  41. package/dist/protocol.js +17 -4
  42. package/dist/server.d.ts +106 -39
  43. package/dist/server.js +387 -324
  44. package/package.json +26 -11
  45. package/app/filePicker.ts +0 -64
  46. package/app/panels.ts +0 -553
  47. package/app/toolbar.ts +0 -131
  48. package/app/wsClient.ts +0 -92
  49. package/dist/args.d.ts +0 -8
  50. package/dist/args.js +0 -28
package/dist/server.js CHANGED
@@ -1,374 +1,437 @@
1
- import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { createServer as createNetServer } from 'node:net';
3
- import { dirname, relative, resolve, sep } from 'node:path';
3
+ import { dirname, join, relative, resolve, sep } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
- import chokidar from 'chokidar';
5
+ import react from '@vitejs/plugin-react';
6
6
  import { createServer } from 'vite';
7
7
  import { WebSocketServer } from 'ws';
8
- import { bundlePrefab, bundleScene, collectedPackageSchemas, compilePrefabXml, compileSceneXml, componentLoadError, detectTagPackages, generateMaterialXsd, generatePrefabXsd, generateXsd, loadComponents, patchSceneXml, scanComponents, } from '@scenoco-three/compiler';
8
+ import { build, findAssets, formatDiagnostic, indexDocument, insertChild, moveElement, removeElement, setAttribute, } from '@scenoco-three/compiler';
9
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
- };
10
+ const appRoot = fileURLToPath(new URL('../app', import.meta.url));
16
11
  /**
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.
12
+ * The editor's own dev server.
13
+ *
14
+ * It serves the editor page from this package and lets it import the **project's** generated
15
+ * modules over `/@fs`, which is the arrangement decided when this was ported: riding the
16
+ * project's own Vite would put a React app inside the game's build.
17
+ *
18
+ * Its jobs are few. Compile the project **with provenance** so the page can map a mesh back to
19
+ * the element that built it. Serve the model and the document text the inspector needs. Take a
20
+ * save and splice it into the source. Watch, rebuild, and tell the page.
21
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;
22
+ export async function startEditorServer(options = {}) {
23
+ const root = resolve(options.root ?? process.cwd());
24
+ let model;
25
+ /** Compile the project. Provenance is always on here — that is what an editor is. */
26
+ const compile = (rescan) => {
27
+ if (rescan)
28
+ model = undefined;
29
+ const result = build({
30
+ root,
31
+ // No list means the project's own dependencies decide, which is what `build` does with
32
+ // an absent `modules` — the editor must accept exactly what `vite dev` accepts.
33
+ ...(options.modules ? { modules: options.modules } : {}),
34
+ provenance: true,
35
+ ...(model ? { model } : {}),
36
+ });
37
+ model = result.model;
38
+ if (result.diagnostics.length === 0)
39
+ return { ok: true };
40
+ return { ok: false, message: result.diagnostics.map((d) => formatDiagnostic(d)).join('\n') };
41
+ };
42
+ const first = compile(true);
43
+ if (!first.ok)
44
+ throw new Error(`[scenoco] this project does not compile yet:\n${first.message}`);
45
+ // The same port as last time whenever it is free: a tab the previous session left behind is
46
+ // retrying this exact URL every second, and reclaiming the port is what lets it come back
47
+ // instead of being stranded while a duplicate opens beside it.
48
+ let chosen = options.port !== undefined ? { port: options.port, reused: false } : await choosePort(root);
49
+ const serve = (port) => createServer({
50
+ root: appRoot,
51
+ configFile: false,
52
+ plugins: [react(), editorApi(root, () => model, compile)],
53
+ server: {
54
+ port,
55
+ // Listen on every interface, so the editor can be opened from a phone on the same
56
+ // network — which is the only way to try the touch controls on real fingers. It is a
57
+ // dev server on a local network, and `fs.allow` below still bounds what it will read.
58
+ host: true,
59
+ // Strict, so losing the race for the remembered port is an error we can fall back from
60
+ // rather than a silent move to port+1 that no stranded tab is watching.
61
+ strictPort: true,
62
+ // The page imports the project's generated modules by absolute path, so the project has
63
+ // to be readable. Nothing outside these two roots is.
64
+ fs: { allow: [appRoot, root] },
65
+ },
66
+ // One `three` across the editor and the project, or `instanceof` quietly fails across two
67
+ // copies and every raycast hit looks like nothing at all.
68
+ resolve: { dedupe: ['three'] },
69
+ optimizeDeps: { include: ['react', 'react-dom/client'] },
70
+ });
71
+ let vite = await serve(chosen.port);
72
+ try {
73
+ await vite.listen();
74
+ }
75
+ catch (error) {
76
+ // A caller that asked for a specific port meant it; anything else falls back to a fresh one
77
+ // (and, with it, a fresh tab — there is nothing waiting on a port we never had).
78
+ if (options.port !== undefined)
79
+ throw error;
80
+ await vite.close().catch(() => { });
81
+ chosen = { port: await freePort(), reused: false };
82
+ vite = await serve(chosen.port);
83
+ await vite.listen();
84
+ }
85
+ const port = vite.httpServer.address().port;
86
+ rememberPort(root, port);
87
+ const bridge = attachBridge(vite, options);
88
+ watchProject(vite, root, compile, bridge);
89
+ // Vite works out which interface is actually reachable; guessing from `os.networkInterfaces()`
90
+ // picks a VPN or a container bridge as often as the Wi-Fi.
91
+ const networkUrl = vite.resolvedUrls?.network[0]?.replace(/\/$/, '');
92
+ return {
93
+ url: `http://localhost:${port}`,
94
+ ...(networkUrl ? { networkUrl } : {}),
95
+ port,
96
+ reusedPort: chosen.reused,
97
+ bridge,
98
+ model: () => model,
99
+ async close() {
100
+ bridge.push({ push: 'shutdown' });
101
+ await vite.close();
102
+ },
103
+ };
28
104
  }
29
105
  /**
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).
106
+ * The port to serve on: last session's if it is free, otherwise a fresh one.
107
+ *
108
+ * Sticky by default rather than on request, because the thing it fixes — a restart stacking up a
109
+ * second tab while the first sits there retrying — happens on every restart.
34
110
  */
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
- }
111
+ async function choosePort(root) {
112
+ const remembered = readPort(root);
113
+ return remembered === undefined ? { port: await freePort(), reused: false } : { port: remembered, reused: true };
114
+ }
115
+ function portFile(root) {
116
+ return join(root, '.scenoco', 'editor-port');
117
+ }
118
+ /** The port the last session bound, if one was recorded. */
119
+ function readPort(root) {
120
+ try {
121
+ const n = Number(readFileSync(portFile(root), 'utf8').trim());
122
+ return Number.isInteger(n) && n > 0 && n < 65536 ? n : undefined;
123
+ }
124
+ catch {
125
+ return undefined; // no file yet, or unreadable — take a fresh port
49
126
  }
50
127
  }
51
- function writeIfChanged(path, content) {
128
+ /** Record the port just bound. Best-effort: sticky reuse is a convenience, not a requirement. */
129
+ function rememberPort(root, port) {
52
130
  try {
53
- if (existsSync(path) && readFileSync(path, 'utf8') === content)
54
- return;
131
+ mkdirSync(dirname(portFile(root)), { recursive: true });
132
+ writeFileSync(portFile(root), String(port));
55
133
  }
56
134
  catch {
57
- /* unreadable — fall through and overwrite */
135
+ /* couldn't write .scenoco/ — the next launch just takes a fresh port */
58
136
  }
59
- writeFileSync(path, content);
60
137
  }
61
138
  /**
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.
139
+ * The handful of endpoints the page needs beyond its own modules.
140
+ *
141
+ * Everything is scoped under `/@scenoco/`, and every path that names a file is checked to be
142
+ * inside the project — a dev server that will read any absolute path is a dev server that will
143
+ * read your SSH key.
65
144
  */
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];
145
+ function editorApi(root, model, compile) {
146
+ const insideProject = (path) => {
147
+ const absolute = resolve(root, path);
148
+ const rel = relative(root, absolute);
149
+ return rel && !rel.startsWith('..') && !rel.startsWith(sep) ? absolute : null;
150
+ };
87
151
  return {
88
- url,
89
- ...(networkUrl ? { networkUrl } : {}),
90
- bridge,
91
- close: async () => {
92
- await watcher.close();
93
- await server.close();
152
+ name: 'scenoco:editor-api',
153
+ configureServer(server) {
154
+ server.middlewares.use('/@scenoco/documents', (_req, res) => {
155
+ // Every document in the project, for the file picker.
156
+ json(res, findAssets(root).map((a) => ({ kind: a.kind, path: a.relativePath, stem: a.stem })));
157
+ });
158
+ server.middlewares.use('/@scenoco/model', (_req, res) => {
159
+ // The inspector's whole schema: field names, types, defaults, doc comments, source
160
+ // links. Read from the project's TypeScript, so it cannot drift from the code.
161
+ const { components, systems, tags } = model();
162
+ json(res, { components, systems, tags });
163
+ });
164
+ server.middlewares.use('/@scenoco/document', (req, res) => {
165
+ const path = new URL(req.url ?? '', 'http://x').searchParams.get('path');
166
+ const absolute = path ? insideProject(path) : null;
167
+ if (!absolute || !existsSync(absolute))
168
+ return fail(res, 404, 'no such document');
169
+ const text = readFileSync(absolute, 'utf8');
170
+ json(res, {
171
+ path,
172
+ // The generated module the page imports to build the live scene.
173
+ module: `/@fs${absolute.replace(/\.xml$/, '.gen.ts')}`,
174
+ text,
175
+ // The element index, from the **compiler's own parser** — the one whose positions the
176
+ // provenance stamps were written with. The page reimplementing this was the one thing
177
+ // guaranteed to drift, and it did: a long or multi-line open tag came out at a
178
+ // different column, so every node but the short ones failed to match its element.
179
+ elements: indexDocument(text).map((e) => ({
180
+ tag: e.tag,
181
+ line: e.line,
182
+ column: e.column,
183
+ // The byte range, so the page can lift an element's source text for a copy.
184
+ start: e.start,
185
+ end: e.end,
186
+ attributes: e.attributes,
187
+ })),
188
+ });
189
+ });
190
+ server.middlewares.use('/@scenoco/save', (req, res) => {
191
+ if (req.method !== 'POST')
192
+ return fail(res, 405, 'POST only');
193
+ readBody(req, (body) => {
194
+ const { path, edits } = body;
195
+ const absolute = path ? insideProject(path) : null;
196
+ if (!absolute || !existsSync(absolute))
197
+ return fail(res, 404, 'no such document');
198
+ const applied = applyEdits(readFileSync(absolute, 'utf8'), edits ?? []);
199
+ if (applied.problems.length > 0)
200
+ return fail(res, 409, applied.problems.join('\n'));
201
+ if (applied.text !== readFileSync(absolute, 'utf8'))
202
+ writeFileSync(absolute, applied.text);
203
+ // Compiled synchronously so a save that breaks the document says so *here*, with the
204
+ // diagnostics, rather than as a blank scene a moment later.
205
+ const built = compile(false);
206
+ if (!built.ok)
207
+ return fail(res, 422, built.message);
208
+ // The fresh index comes back with the text: every position after a splice has moved,
209
+ // and the page's pending edits address positions.
210
+ json(res, {
211
+ text: applied.text,
212
+ elements: indexDocument(applied.text).map((e) => ({
213
+ tag: e.tag,
214
+ line: e.line,
215
+ column: e.column,
216
+ start: e.start,
217
+ end: e.end,
218
+ attributes: e.attributes,
219
+ })),
220
+ });
221
+ });
222
+ });
94
223
  },
95
224
  };
96
225
  }
97
226
  /**
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.
227
+ * Apply a save.
228
+ *
229
+ * One at a time against the running text, because each edit's addresses are computed by the page
230
+ * from the document it is looking at — so a batch has to be built as a sequence of
231
+ * already-consistent steps, not as parallel edits into one snapshot.
102
232
  */
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
- };
233
+ export function applyEdits(source, edits) {
234
+ let text = source;
235
+ const problems = [];
236
+ for (const edit of edits) {
237
+ const result = edit.op === 'setAttribute'
238
+ ? setAttribute(text, edit.at, edit.name, edit.value)
239
+ : edit.op === 'removeElement'
240
+ ? removeElement(text, edit.at)
241
+ : edit.op === 'insertChild'
242
+ ? insertChild(text, edit.at, edit.xml, edit.index)
243
+ : moveElement(text, edit.at, edit.parent, edit.index);
244
+ if (result.problem)
245
+ problems.push(result.problem);
246
+ text = result.text;
247
+ }
248
+ return { text, problems };
167
249
  }
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
- },
250
+ /** Rebuild when the project changes, and tell the page what happened. */
251
+ function watchProject(vite, root, compile, bridge) {
252
+ // The directory, not a glob. chokidar dropped glob support in v4 — which Vite 8 ships — so
253
+ // `add('<root>/**/*.xml')` registered a literal path with asterisks in it, matched nothing,
254
+ // and silently watched nothing at all: editing a document in your own editor changed the file
255
+ // and the running editor never heard about it. What to care about is decided below anyway.
256
+ vite.watcher.add(root);
257
+ let queued;
258
+ let rescan = false;
259
+ const changed = (path) => {
260
+ const isDocument = path.endsWith('.xml');
261
+ const isSource = path.endsWith('.ts') && !path.endsWith('.gen.ts');
262
+ if (!isDocument && !isSource)
263
+ return;
264
+ if (!path.startsWith(root))
265
+ return;
266
+ // Watching the root means seeing installed packages and build output too; neither is
267
+ // something a person edits, and a rescan of either costs a full recompile.
268
+ if (path.includes(`${sep}node_modules${sep}`) || path.includes(`${sep}dist${sep}`))
269
+ return;
270
+ rescan ||= isSource;
271
+ clearTimeout(queued);
272
+ queued = setTimeout(() => {
273
+ const built = compile(rescan);
274
+ rescan = false;
275
+ bridge.push(built.ok
276
+ ? { push: 'documentChanged', file: relative(root, path) }
277
+ : { push: 'buildFailed', message: built.message });
278
+ }, 40);
267
279
  };
280
+ vite.watcher.on('change', changed);
281
+ vite.watcher.on('add', changed);
282
+ vite.watcher.on('unlink', changed);
268
283
  }
269
- /** WebSocket bridge: one editor page ↔ the MCP server, with id-correlated replies. */
270
- function attachBridge(server) {
284
+ /** The WebSocket relay, plus the idle self-halt that keeps a closed tab from leaking a process. */
285
+ function attachBridge(vite, options) {
286
+ const { bridge, register } = createBridgeCore(options);
271
287
  const wss = new WebSocketServer({ noServer: true });
272
- let editor;
288
+ vite.httpServer.on('upgrade', (request, socket, head) => {
289
+ if (!request.url?.startsWith(WS_PATH))
290
+ return;
291
+ // Vite's own HMR upgrades use a different path and fall through to its handler.
292
+ wss.handleUpgrade(request, socket, head, (ws) => register(ws));
293
+ });
294
+ return bridge;
295
+ }
296
+ /**
297
+ * Command routing, failover and the idle self-halt, with no server attached.
298
+ *
299
+ * Separate from `attachBridge` because these are the rules worth pinning down — which tab a
300
+ * command goes to, what happens to a command when that tab closes, when the process gives up —
301
+ * and none of them need a port, a browser, or a compiled project to be true.
302
+ */
303
+ export function createBridgeCore(options = {}) {
304
+ /** Every live page, in connect order — the last one is the tab in front of the user. */
305
+ const sockets = new Set();
273
306
  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))
307
+ const waiters = new Set();
308
+ let idle;
309
+ const armIdle = (after) => {
310
+ if (!options.onIdle)
277
311
  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);
312
+ clearTimeout(idle);
313
+ idle = setTimeout(() => {
314
+ if (sockets.size === 0)
315
+ options.onIdle?.();
316
+ }, after);
317
+ };
318
+ armIdle(options.initialGraceMs ?? 30_000);
319
+ /**
320
+ * The page a command goes to: the most recently connected one still open.
321
+ *
322
+ * Newest rather than oldest because that is the tab the person just opened. Opening a second
323
+ * editor tab and watching the first one answer every tool call is the whole bug.
324
+ */
325
+ const active = () => {
326
+ let latest;
327
+ for (const ws of sockets)
328
+ if (ws.readyState === ws.OPEN)
329
+ latest = ws;
330
+ return latest;
331
+ };
332
+ const register = (ws) => {
333
+ sockets.add(ws);
334
+ clearTimeout(idle);
335
+ for (const wake of [...waiters])
336
+ wake(); // a tab attached — release anyone waiting for one
337
+ ws.on('message', (data) => {
338
+ const response = JSON.parse(String(data));
339
+ const waiting = pending.get(response.id);
340
+ if (!waiting)
341
+ return;
342
+ clearTimeout(waiting.timer);
343
+ pending.delete(response.id);
344
+ waiting.resolve(response);
345
+ });
346
+ ws.on('close', () => {
347
+ sockets.delete(ws);
348
+ // Hand anything this tab still owed to another open one, so closing the tab a tool call
349
+ // happened to be routed to doesn't strand the call for its full timeout.
350
+ for (const [id, waiting] of pending) {
351
+ if (waiting.socket !== ws)
352
+ continue;
353
+ const next = active();
354
+ if (next) {
355
+ waiting.socket = next;
356
+ next.send(JSON.stringify(waiting.command));
285
357
  }
286
- catch {
287
- /* ignore malformed */
358
+ else {
359
+ clearTimeout(waiting.timer);
360
+ pending.delete(id);
361
+ waiting.reject(new Error('the editor tab closed before answering'));
288
362
  }
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
363
  }
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);
364
+ if (sockets.size === 0)
365
+ armIdle(options.idleMs ?? 5_000);
366
+ });
367
+ };
368
+ const bridge = {
369
+ connected: () => active() !== undefined,
370
+ push(message) {
371
+ const text = JSON.stringify(message);
372
+ for (const ws of sockets)
373
+ ws.send(text);
374
+ },
375
+ waitForConnection: (ms) => new Promise((done) => {
376
+ if (active())
377
+ return done(true);
378
+ const wake = () => {
379
+ waiters.delete(wake);
380
+ clearTimeout(timer);
381
+ done(true);
382
+ };
383
+ waiters.add(wake);
384
+ const timer = setTimeout(() => {
385
+ waiters.delete(wake);
386
+ done(false);
387
+ }, ms);
388
+ timer.unref(); // never hold the process open just to time this out
309
389
  }),
310
- push: (msg) => {
311
- if (editor && editor.readyState === editor.OPEN)
312
- editor.send(JSON.stringify(msg));
390
+ send(command) {
391
+ const socket = active();
392
+ if (!socket)
393
+ return Promise.reject(new Error('no editor page is open'));
394
+ return new Promise((resolve, reject) => {
395
+ // A page that never answers must not hang the caller for ever — an agent waiting on a
396
+ // screenshot has no way to tell "slow" from "gone".
397
+ const timer = setTimeout(() => {
398
+ pending.delete(command.id);
399
+ reject(new Error(`the editor did not answer "${command.type}" within 30s`));
400
+ }, 30_000);
401
+ pending.set(command.id, { command, socket, timer, resolve, reject });
402
+ socket.send(JSON.stringify(command));
403
+ });
313
404
  },
314
405
  };
406
+ return { bridge, register };
315
407
  }
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
- });
408
+ // --- plumbing --------------------------------------------------------------------------------
409
+ function json(res, data) {
410
+ res.setHeader('Content-Type', 'application/json');
411
+ res.end(JSON.stringify(data));
331
412
  }
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;
413
+ function fail(res, code, message) {
414
+ res.statusCode = code;
415
+ json(res, { error: message });
416
+ }
417
+ function readBody(req, then) {
418
+ let raw = '';
419
+ req.on('data', (chunk) => (raw += chunk));
420
+ req.on('end', () => {
342
421
  try {
343
- entries = readdirSync(dir, { withFileTypes: true });
422
+ then(JSON.parse(raw || '{}'));
344
423
  }
345
424
  catch {
346
- return; // missing/unreadable root — nothing to list
425
+ then({});
347
426
  }
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));
427
+ });
363
428
  }
364
- /** Ask the OS for a free TCP port (avoids Vite's 5173-default collisions). */
365
429
  function freePort() {
366
- return new Promise((res, rej) => {
430
+ return new Promise((done) => {
367
431
  const probe = createNetServer();
368
- probe.on('error', rej);
369
432
  probe.listen(0, () => {
370
- const port = probe.address().port;
371
- probe.close(() => res(port));
433
+ const { port } = probe.address();
434
+ probe.close(() => done(port));
372
435
  });
373
436
  });
374
437
  }