@scenoco-three/editor 0.4.0 → 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 (49) 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 -304
  12. package/app/meta.ts +24 -0
  13. package/app/picking.ts +47 -0
  14. package/app/session.ts +476 -311
  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/dist/cli/index.js +93 -70
  35. package/dist/index.d.ts +10 -3
  36. package/dist/index.js +9 -5
  37. package/dist/mcp.d.ts +28 -12
  38. package/dist/mcp.js +172 -131
  39. package/dist/protocol.d.ts +74 -65
  40. package/dist/protocol.js +17 -4
  41. package/dist/server.d.ts +106 -39
  42. package/dist/server.js +386 -361
  43. package/package.json +25 -10
  44. package/app/filePicker.ts +0 -64
  45. package/app/panels.ts +0 -553
  46. package/app/toolbar.ts +0 -131
  47. package/app/wsClient.ts +0 -92
  48. package/dist/args.d.ts +0 -8
  49. package/dist/args.js +0 -28
package/dist/server.js CHANGED
@@ -1,412 +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;
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) {
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);
52
72
  try {
53
- if (existsSync(path) && readFileSync(path, 'utf8') === content)
54
- return;
73
+ await vite.listen();
55
74
  }
56
- catch {
57
- /* unreadable — fall through and overwrite */
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();
58
84
  }
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];
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(/\/$/, '');
87
92
  return {
88
- url,
93
+ url: `http://localhost:${port}`,
89
94
  ...(networkUrl ? { networkUrl } : {}),
95
+ port,
96
+ reusedPort: chosen.reused,
90
97
  bridge,
91
- close: async () => {
92
- await watcher.close();
93
- await server.close();
98
+ model: () => model,
99
+ async close() {
100
+ bridge.push({ push: 'shutdown' });
101
+ await vite.close();
94
102
  },
95
103
  };
96
104
  }
97
105
  /**
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.
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.
102
110
  */
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
- };
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
126
+ }
127
+ }
128
+ /** Record the port just bound. Best-effort: sticky reuse is a convenience, not a requirement. */
129
+ function rememberPort(root, port) {
130
+ try {
131
+ mkdirSync(dirname(portFile(root)), { recursive: true });
132
+ writeFileSync(portFile(root), String(port));
133
+ }
134
+ catch {
135
+ /* couldn't write .scenoco/ — the next launch just takes a fresh port */
136
+ }
151
137
  }
152
138
  /**
153
- * Two virtual modules, split so a broken project component can't wipe the core
154
- * tag registry (the P1 "Background not registered" cascade):
155
- * - `virtual:scenoco-runtime` → `@scenoco-three/core/nodes` + tag packages
156
- * (the infrastructure every core scene decodes against; imported first).
157
- * - `virtual:scenoco-components` → only the project's component files (fragile —
158
- * a broken one throws here, isolated from core/package registration).
159
- * The components module is invalidated when a component file is added/removed, so
160
- * a deleted file never lingers as a stale import (the client-cache half of P1).
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.
161
144
  */
162
- function allComponentsPlugin(roots, packages, cwd) {
163
- const runtimeId = 'virtual:scenoco-runtime';
164
- const componentsId = 'virtual:scenoco-components';
165
- const resolvedRuntime = '\0' + runtimeId;
166
- const resolvedComponents = '\0' + componentsId;
167
- const absRoots = roots.map((r) => resolve(cwd, r));
168
- const isComponentFile = (file) => SOURCE_EXT_RE.test(file) && absRoots.some((r) => resolve(file).startsWith(r));
169
- return {
170
- name: 'scenoco:all-components',
171
- resolveId: (source) => source === runtimeId ? resolvedRuntime : source === componentsId ? resolvedComponents : null,
172
- load(source) {
173
- if (source === resolvedRuntime) {
174
- // Core nodes first (so core tags register even if a package throws next), then packages.
175
- return ['import "@scenoco-three/core/nodes";', ...packages.map((p) => `import ${JSON.stringify(p)};`)].join('\n');
176
- }
177
- if (source === resolvedComponents) {
178
- return [...scanComponents(roots, cwd).files.keys()].map((f) => `import ${JSON.stringify(`/@fs${f}`)};`).join('\n') || 'export {};';
179
- }
180
- return null;
181
- },
182
- configureServer(server) {
183
- // Vite's watcher is rooted at the editor app dir, so it only sees component
184
- // files once they're in the module graph (already imported). Watch the project
185
- // component roots explicitly, so a brand-NEW file's `add` fires too — not just
186
- // edits/deletes of already-imported ones.
187
- server.watcher.add(absRoots);
188
- // Adding/removing a component file changes the import list — rebuild the
189
- // components module (not the runtime one) and reload, so a new file gets
190
- // imported and a just-deleted one isn't imported from a stale graph.
191
- const invalidate = (file) => {
192
- if (!isComponentFile(file))
193
- return;
194
- const mod = server.moduleGraph.getModuleById(resolvedComponents);
195
- if (mod)
196
- server.moduleGraph.invalidateModule(mod);
197
- server.ws.send({ type: 'full-reload' });
198
- };
199
- server.watcher.on('add', invalidate);
200
- server.watcher.on('unlink', invalidate);
201
- },
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;
202
150
  };
203
- }
204
- /** Component source extensions the editor imports (mirrors the compiler's scan). */
205
- const SOURCE_EXT_RE = /\.[cm]?[jt]sx?$/;
206
- /** `GET /@scenoco/bundle?file=<abs>` → the compiled runtime bundle (JSON). */
207
- function bundleEndpointPlugin(roots, packages, cwd, watcher) {
208
151
  return {
209
- name: 'scenoco:bundle-endpoint',
210
- async configureServer(server) {
211
- // Register components in this Node process so compilation/validation works,
212
- // and publish the project's XSDs (if it has a schema/ dir).
213
- await refreshComponents(roots, cwd, packages);
214
- // POST /@scenoco/save — surgical, attribute-level write-back to the source.
215
- server.middlewares.use((req, res, next) => {
216
- if (req.method !== 'POST' || !req.url || !req.url.startsWith('/@scenoco/save'))
217
- return next();
218
- void readJsonBody(req)
219
- .then((body) => {
220
- const { file, edits } = (body ?? {});
221
- if (!file || !Array.isArray(edits))
222
- throw new Error('expected { file, edits }');
223
- const abs = resolve(cwd, file);
224
- const { text, changed, unresolved } = patchSceneXml(readFileSync(abs, 'utf8'), edits);
225
- if (changed > 0)
226
- writeFileSync(abs, text);
227
- watcher.note(abs, text); // our own write — don't report it back as an external change
228
- res.setHeader('Content-Type', 'application/json');
229
- res.end(JSON.stringify({ ok: true, changed, unresolved: unresolved.length }));
230
- })
231
- .catch((e) => {
232
- res.statusCode = 400;
233
- res.setHeader('Content-Type', 'application/json');
234
- res.end(JSON.stringify({ error: e instanceof Error ? e.message : String(e) }));
235
- });
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 });
236
163
  });
237
- // GET /@scenoco/files — the scenes/prefabs under the project's asset roots,
238
- // for the start-up file picker (used when the editor is opened with no ?file=).
239
- // Isolated to `roots` (default assets/): nothing outside them is listed.
240
- server.middlewares.use((req, res, next) => {
241
- if (!req.url || !req.url.startsWith('/@scenoco/files'))
242
- return next();
243
- res.setHeader('Content-Type', 'application/json');
244
- res.end(JSON.stringify({ files: listSceneFiles(roots, cwd) }));
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
+ });
245
189
  });
246
- server.middlewares.use((req, res, next) => {
247
- if (!req.url || !req.url.startsWith('/@scenoco/bundle'))
248
- return next();
249
- void (async () => {
250
- // Pick up any component edits (and re-sync schema/) before compiling, with
251
- // last-good fallback — a broken component is reported, not fatal.
252
- const compError = await refreshComponents(roots, cwd, packages);
253
- try {
254
- const file = new URL(req.url, 'http://x').searchParams.get('file');
255
- if (!file)
256
- throw new Error('missing ?file=');
257
- const abs = resolve(cwd, file);
258
- const xml = readFileSync(abs, 'utf8');
259
- const isPrefab = abs.endsWith('.prefab.xml');
260
- // Track which src= files the compile touches, so we can watch them too.
261
- const deps = [];
262
- const trackingResolver = (request, referrer) => {
263
- const r = fsResolver(request, referrer);
264
- if (r)
265
- deps.push({ path: r.path, contents: r.contents });
266
- return r;
267
- };
268
- const result = isPrefab
269
- ? compilePrefabXml(xml, { path: abs, resolve: trackingResolver })
270
- : compileSceneXml(xml, { path: abs, resolve: trackingResolver });
271
- if (!result.ok) {
272
- res.statusCode = 400;
273
- res.setHeader('Content-Type', 'application/json');
274
- // A component load failure is the likely root cause of an "unknown tag"
275
- // compile error — pass it along so the editor can explain it.
276
- res.end(JSON.stringify({ error: 'compile failed', diagnostics: result.diagnostics, componentError: compError?.message }));
277
- return;
278
- }
279
- // A scene's sibling `*.ui.html` (its DOM UI overlay) is embedded in the
280
- // bundle and watched as a dep, so editing it live-refreshes the overlay
281
- // — visible statically even in edit mode (engine.overlay).
282
- if (!isPrefab) {
283
- const uiFile = abs.replace(/\.(scene\.)?xml$/, '.ui.html');
284
- if (existsSync(uiFile)) {
285
- const uiHtml = readFileSync(uiFile, 'utf8');
286
- result.scene.ui = uiHtml;
287
- deps.push({ path: uiFile, contents: uiHtml });
288
- }
289
- }
290
- watcher.arm(abs, xml, deps); // watch this file + its src/ui deps for changes
291
- const bundle = isPrefab ? bundlePrefab(result.scene) : bundleScene(result.scene);
292
- res.setHeader('Content-Type', 'application/json');
293
- // `componentError` is present when the scene still compiled (stale last-good
294
- // registry) but a component file is currently broken — a warning, not fatal.
295
- res.end(JSON.stringify({ bundle, provenance: result.provenance, componentError: compError?.message }));
296
- }
297
- catch (e) {
298
- res.statusCode = 400;
299
- res.setHeader('Content-Type', 'application/json');
300
- res.end(JSON.stringify({ error: e instanceof Error ? e.message : String(e), componentError: compError?.message }));
301
- }
302
- })();
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
+ });
303
222
  });
304
223
  },
305
224
  };
306
225
  }
307
- /** WebSocket bridge: one editor page ↔ the MCP server, with id-correlated replies. */
308
- function attachBridge(server) {
226
+ /**
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.
232
+ */
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 };
249
+ }
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);
279
+ };
280
+ vite.watcher.on('change', changed);
281
+ vite.watcher.on('add', changed);
282
+ vite.watcher.on('unlink', changed);
283
+ }
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);
309
287
  const wss = new WebSocketServer({ noServer: true });
310
- 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();
311
306
  const pending = new Map();
312
- server.httpServer?.on('upgrade', (req, socket, head) => {
313
- // Only our path; Vite's HMR upgrades (vite-hmr subprotocol) fall through to it.
314
- if (!req.url || !req.url.startsWith(WS_PATH))
307
+ const waiters = new Set();
308
+ let idle;
309
+ const armIdle = (after) => {
310
+ if (!options.onIdle)
315
311
  return;
316
- wss.handleUpgrade(req, socket, head, (ws) => {
317
- editor = ws;
318
- ws.on('message', (data) => {
319
- try {
320
- const r = JSON.parse(String(data));
321
- pending.get(r.id)?.(r);
322
- 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));
323
357
  }
324
- catch {
325
- /* ignore malformed */
358
+ else {
359
+ clearTimeout(waiting.timer);
360
+ pending.delete(id);
361
+ waiting.reject(new Error('the editor tab closed before answering'));
326
362
  }
327
- });
328
- ws.on('close', () => {
329
- if (editor === ws)
330
- editor = undefined;
331
- });
332
- });
333
- });
334
- return {
335
- connected: () => !!editor && editor.readyState === editor.OPEN,
336
- send: (cmd) => new Promise((res, rej) => {
337
- if (!editor || editor.readyState !== editor.OPEN) {
338
- rej(new Error('no editor connected — open the editor page in a browser first'));
339
- return;
340
363
  }
341
- pending.set(cmd.id, res);
342
- editor.send(JSON.stringify(cmd));
343
- setTimeout(() => {
344
- if (pending.delete(cmd.id))
345
- rej(new Error('editor did not respond in time'));
346
- }, 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
347
389
  }),
348
- push: (msg) => {
349
- if (editor && editor.readyState === editor.OPEN)
350
- 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
+ });
351
404
  },
352
405
  };
406
+ return { bridge, register };
353
407
  }
354
- /** Read a request's JSON body (for the save endpoint). */
355
- function readJsonBody(req) {
356
- return new Promise((resolve, reject) => {
357
- let data = '';
358
- req.on('data', (c) => (data += c));
359
- req.on('end', () => {
360
- try {
361
- resolve(data ? JSON.parse(data) : undefined);
362
- }
363
- catch (e) {
364
- reject(e instanceof Error ? e : new Error(String(e)));
365
- }
366
- });
367
- req.on('error', reject);
368
- });
408
+ // --- plumbing --------------------------------------------------------------------------------
409
+ function json(res, data) {
410
+ res.setHeader('Content-Type', 'application/json');
411
+ res.end(JSON.stringify(data));
369
412
  }
370
- /**
371
- * Every `*.scene.xml` / `*.prefab.xml` under the asset `roots` (default assets/),
372
- * relative to `cwd`. Isolated to the roots — the walk never escapes them, and
373
- * build/cache dirs are skipped. Sorted for a stable picker order.
374
- */
375
- export function listSceneFiles(roots, cwd) {
376
- const found = new Map(); // keyed by file, dedupes overlapping roots
377
- const skip = new Set(['node_modules', 'dist', '.scenoco', '.git']);
378
- const walk = (dir) => {
379
- 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', () => {
380
421
  try {
381
- entries = readdirSync(dir, { withFileTypes: true });
422
+ then(JSON.parse(raw || '{}'));
382
423
  }
383
424
  catch {
384
- return; // missing/unreadable root — nothing to list
385
- }
386
- for (const e of entries) {
387
- const abs = resolve(dir, e.name);
388
- if (e.isDirectory()) {
389
- if (!e.name.startsWith('.') && !skip.has(e.name))
390
- walk(abs);
391
- }
392
- else if (e.name.endsWith('.scene.xml') || e.name.endsWith('.prefab.xml')) {
393
- const file = relative(cwd, abs).split(sep).join('/');
394
- found.set(file, { file, kind: e.name.endsWith('.prefab.xml') ? 'prefab' : 'scene' });
395
- }
425
+ then({});
396
426
  }
397
- };
398
- for (const root of roots)
399
- walk(resolve(cwd, root));
400
- return [...found.values()].sort((a, b) => a.file.localeCompare(b.file));
427
+ });
401
428
  }
402
- /** Ask the OS for a free TCP port (avoids Vite's 5173-default collisions). */
403
429
  function freePort() {
404
- return new Promise((res, rej) => {
430
+ return new Promise((done) => {
405
431
  const probe = createNetServer();
406
- probe.on('error', rej);
407
432
  probe.listen(0, () => {
408
- const port = probe.address().port;
409
- probe.close(() => res(port));
433
+ const { port } = probe.address();
434
+ probe.close(() => done(port));
410
435
  });
411
436
  });
412
437
  }