pixifact-cli 0.3.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.
@@ -0,0 +1 @@
1
+ :root{color:#d9dee7;font-synthesis:none;text-rendering:optimizelegibility;--bg:#111318;--panel:#171a20;--panel-raised:#1d2129;--canvas:#0d0f13;--line:#2a2f39;--line-strong:#39404d;--text:#d9dee7;--muted:#818997;--faint:#596171;--focus:#4c8dff;--focus-soft:#213a63;--success:#51b785;--warning:#d4a64f;--danger:#e36f6f;--control:#11141a;background:#111318;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}*{box-sizing:border-box}html,body,#app{width:100%;height:100%;margin:0;overflow:hidden}button,input,select{color:inherit;font:inherit;letter-spacing:0}button{border:0}.editor-shell{background:var(--bg);grid-template-rows:42px minmax(0,1fr);width:100%;height:100%;display:grid}.editor-session-standby{background:var(--bg);place-items:center;width:100%;height:100%;display:grid}.editor-session-standby>section{gap:18px;width:min(420px,100vw - 40px);display:grid}.standby-product{color:var(--faint);font-size:10px;font-weight:750}.editor-session-standby h1{color:var(--text);letter-spacing:0;margin:0;font-size:18px}.standby-context{border-top:1px solid var(--line);border-bottom:1px solid var(--line);grid-template-columns:76px minmax(0,1fr);gap:8px 12px;padding:14px 0;display:grid}.standby-context span{color:var(--muted);font-size:12px}.standby-context strong{min-width:0;color:var(--text);text-overflow:ellipsis;white-space:nowrap;font-size:12px;font-weight:500;overflow:hidden}.takeover-button{background:var(--focus);color:#fff;cursor:pointer;border:1px solid #659cff;border-radius:4px;justify-self:start;min-width:104px;height:32px;padding:0 14px;font-size:12px;font-weight:650}.takeover-button:hover:not(:disabled){background:#5a96ff}.standby-note,.standby-error{margin:-6px 0 0;font-size:11px;line-height:1.6}.standby-note{color:var(--muted)}.standby-error{color:var(--danger)}.topbar{border-bottom:1px solid var(--line);background:#14171c;grid-template-columns:232px minmax(0,1fr) auto 112px;align-items:center;min-width:0;display:grid}.brand{border-right:1px solid var(--line);color:#f1f4f8;align-items:center;height:100%;padding:0 14px;font-size:11px;font-weight:750;display:flex}.scene-identity{align-items:baseline;gap:10px;min-width:0;padding:0 14px;display:flex}.scene-identity strong{flex:none;font-size:13px}.scene-identity span{min-width:0;color:var(--muted);text-overflow:ellipsis;white-space:nowrap;font-size:11px;overflow:hidden}.history-tools{align-items:center;gap:2px;padding-right:10px;display:flex}.history-tools button,.reset-button{width:28px;height:28px;color:var(--muted);cursor:pointer;background:0 0;border-radius:4px;place-items:center;padding:0;display:inline-grid}.history-tools button:hover:not(:disabled),.reset-button:hover:not(:disabled){background:var(--panel-raised);color:var(--text)}button:disabled{opacity:.35;cursor:default}.sync-state{border-left:1px solid var(--line);height:100%;color:var(--muted);align-items:center;gap:7px;padding:0 13px;font-size:11px;display:flex}.sync-state span{background:var(--success);border-radius:50%;width:7px;height:7px}.sync-state[data-state=saving] span{background:var(--warning)}.sync-state[data-state=conflict] span,.sync-state[data-state=error] span{background:var(--danger)}.workspace{grid-template-columns:minmax(190px,232px) minmax(360px,1fr) minmax(250px,296px);min-width:0;min-height:0;display:grid}.left-panel,.right-panel,.canvas-panel{min-width:0;min-height:0}.left-panel,.right-panel{background:var(--panel)}.left-panel{border-right:1px solid var(--line)}.right-panel{border-left:1px solid var(--line);overflow:auto}.left-tabs{grid-template-rows:34px minmax(0,1fr);height:100%;display:grid}.tab-list{border-bottom:1px solid var(--line);grid-template-columns:1fr 1fr;display:grid}.tab-trigger{color:var(--muted);cursor:pointer;background:0 0;font-size:11px;position:relative}.tab-trigger[data-state=active]{color:var(--text)}.tab-trigger[data-state=active]:after{background:var(--focus);content:"";height:2px;position:absolute;bottom:-1px;left:12px;right:12px}.tab-content{outline:none;min-height:0;overflow:auto}.panel-title,.inspector-section-title,.asset-group-label{color:var(--muted);font-size:10px;font-weight:700}.panel-title{border-bottom:1px solid var(--line);align-items:center;height:34px;padding:0 12px;display:flex}.tree-list{margin:0;padding:0;list-style:none}.hierarchy-panel{min-height:100%;padding:0 0 6px}.hierarchy-toolbar{z-index:2;border-bottom:1px solid var(--line);background:var(--panel);grid-template-columns:minmax(0,1fr) 27px 27px 27px;align-items:center;gap:2px;height:35px;padding:4px 6px;display:grid;position:sticky;top:0}.hierarchy-toolbar select{border:1px solid var(--line-strong);background:var(--control);width:100%;min-width:0;height:25px;color:var(--text);border-radius:3px;outline:none;padding:0 5px;font-size:10px}.hierarchy-toolbar select:focus{border-color:var(--focus)}.hierarchy-toolbar button{width:27px;height:27px;color:var(--muted);cursor:pointer;background:0 0;border-radius:3px;place-items:center;padding:0;display:grid}.hierarchy-toolbar button:hover:not(:disabled){background:var(--panel-raised);color:var(--text)}.tree-row{width:100%;height:27px;color:var(--text);text-align:left;cursor:default;background:0 0;grid-template-columns:15px 16px minmax(0,1fr) auto;align-items:center;gap:4px;padding-top:0;padding-bottom:0;padding-right:9px;display:grid;position:relative}.hierarchy-panel.is-dragging .tree-row{cursor:grabbing}.hierarchy-panel.is-asset-dragging .tree-row{cursor:copy}.tree-row.drop-inside{box-shadow:inset 0 0 0 1px var(--focus);background:#26364d}.tree-row.drop-before:before,.tree-row.drop-after:after{z-index:1;background:var(--focus);content:"";pointer-events:none;height:2px;position:absolute;left:5px;right:5px}.tree-row.drop-before:before{top:-1px}.tree-row.drop-after:after{bottom:-1px}.tree-row:hover{background:#20242c}.tree-row.selected{background:var(--focus-soft);color:#eef5ff}.tree-disclosure{width:15px;height:20px;color:var(--muted);place-items:center;display:grid}.tree-disclosure.empty{visibility:hidden}.tree-icon{color:#8da4c7}.tree-label{text-overflow:ellipsis;white-space:nowrap;min-width:0;font-size:12px;overflow:hidden}.tree-row small{color:var(--faint);font-size:9px}.scene-root{cursor:default;padding-left:8px;font-weight:650}.scene-mark{color:#a9c8f8;border:1px solid #637a9d;border-radius:3px;place-items:center;width:14px;height:14px;font-size:8px;display:grid}.asset-list{padding:7px 0 14px}.asset-group-label{padding:10px 12px 5px}.asset-row{width:100%;height:29px;color:var(--text);text-align:left;background:0 0;grid-template-columns:18px minmax(0,1fr);align-items:center;gap:5px;padding:0 12px;display:grid}button.asset-row{cursor:pointer}.asset-row[data-asset-draggable=true]{cursor:grab;-webkit-user-select:none;user-select:none}.asset-row:hover,.asset-row.selected{background:#20242c}.asset-row.selected{color:#a9ccff}.asset-row span{text-overflow:ellipsis;white-space:nowrap;font-size:12px;overflow:hidden}.asset-row.asset-draggable{color:var(--muted)}.asset-empty,.panel-empty{color:var(--faint);padding:14px 12px;font-size:12px}.panel-empty.compact{padding-top:18px}.canvas-panel{background:var(--canvas);position:relative}.scene-canvas-host{-webkit-user-select:none;user-select:none;position:absolute;inset:0;overflow:hidden}.scene-canvas-host.is-asset-drop-target:after{z-index:2;border:1px solid var(--focus);content:"";pointer-events:none;position:absolute;inset:3px}.canvas-grid{pointer-events:none;background-image:linear-gradient(#20242b 1px,#0000 1px),linear-gradient(90deg,#20242b 1px,#0000 1px);background-size:24px 24px;position:absolute;inset:0}.scene-canvas{display:block;position:absolute;inset:0}.scene-canvas-host.is-pan-ready .scene-canvas{cursor:grab!important}.scene-canvas-host.is-panning .scene-canvas{cursor:grabbing!important}.canvas-tools{z-index:3;position:absolute;top:10px;right:10px}.canvas-tools button{border:1px solid var(--line);background:var(--panel);width:28px;height:28px;color:var(--muted);cursor:pointer;border-radius:4px;place-items:center;padding:0;display:inline-grid}.canvas-tools button:hover:not(:disabled){border-color:var(--line-strong);background:var(--panel-raised);color:var(--text)}.canvas-status{border:1px solid var(--line);max-width:min(520px,100% - 48px);color:var(--muted);background:#15181e;border-radius:4px;padding:7px 10px;font-size:12px;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}.inspector-panel{min-height:0}.inspector-heading{border-bottom:1px solid var(--line);justify-content:space-between;align-items:center;min-height:54px;padding:8px 12px;display:flex}.inspector-heading div{gap:3px;min-width:0;display:grid}.inspector-heading strong{text-overflow:ellipsis;white-space:nowrap;font-size:13px;overflow:hidden}.inspector-heading small,.node-id{color:var(--muted);font-size:10px}.node-id{text-overflow:ellipsis;white-space:nowrap;max-width:90px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;overflow:hidden}.inspector-section-title{border-bottom:1px solid var(--line);height:28px;padding:9px 12px 0}.property-row{border-bottom:1px solid #21252d;grid-template-columns:minmax(70px,.8fr) minmax(120px,1.4fr);align-items:center;min-height:34px;padding:3px 8px 3px 12px;display:grid}.property-row>span{color:var(--text);text-overflow:ellipsis;white-space:nowrap;font-size:11px;overflow:hidden}.property-row>span.inherited{color:var(--muted)}.property-control{grid-template-columns:minmax(0,1fr) 24px;align-items:start;gap:3px;display:grid}.property-value{gap:3px;min-width:0;display:grid}.property-control input[type=text],.property-control input[type=number],.property-control select{border:1px solid var(--line-strong);background:var(--control);width:100%;min-width:0;height:25px;color:var(--text);border-radius:3px;outline:none;padding:0 7px;font-size:11px}.property-control input:focus,.property-control select:focus{border-color:var(--focus)}.property-control input:disabled,.property-control select:disabled{color:#9ca6b5;cursor:default;opacity:1;background:#1b1f26;border-color:#303640}.property-control input[type=checkbox]{width:15px;height:15px;accent-color:var(--focus)}.property-control input[type=color]{border:1px solid var(--line-strong);background:var(--control);border-radius:3px;width:34px;height:24px;padding:2px}.reset-button{width:24px;height:24px}.binding-source{color:#8eb8e8;text-overflow:ellipsis;white-space:nowrap;align-items:center;gap:4px;min-width:0;font-size:10px;display:flex;overflow:hidden}.inline-error,.global-error{color:#ffd1d1;margin:0;font-size:11px}.inline-error{background:#2d1b20;border-bottom:1px solid #4f292f;padding:9px 12px}.global-error{background:#321e23;border:1px solid #66343c;border-radius:4px;max-width:min(520px,100vw - 24px);padding:9px 11px;position:fixed;bottom:12px;right:12px}@media (width<=860px){.workspace{grid-template-columns:190px minmax(320px,1fr) 250px}.topbar{grid-template-columns:190px minmax(0,1fr) auto 100px}}
@@ -0,0 +1 @@
1
+ import{n as e,r as t,t as n,u as r}from"./index-CAe9Mkmr.js";r.add(n,t),r.add(e);
@@ -0,0 +1 @@
1
+ import"./index-CAe9Mkmr.js";import"./init-C32NAhsC.js";
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Pixifact Editor</title>
7
+ <script type="module" crossorigin src="/assets/index-CAe9Mkmr.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-CVrKIfXK.css">
9
+ </head>
10
+ <body>
11
+ <div id="app"></div>
12
+ </body>
13
+ </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pixifact-cli",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Pixifact Scene 自动化 CLI。",
5
5
  "packageManager": "bun@1.3.13",
6
6
  "type": "module",
@@ -8,7 +8,8 @@
8
8
  "pixifact": "src/pixifact-cli.ts"
9
9
  },
10
10
  "files": [
11
- "src/"
11
+ "src/",
12
+ "editor/"
12
13
  ],
13
14
  "repository": {
14
15
  "type": "git",
@@ -31,6 +32,7 @@
31
32
  "start": "bun src/pixifact-cli.ts"
32
33
  },
33
34
  "dependencies": {
34
- "pixifact": "^0.3.0"
35
+ "esbuild": "^0.27.2",
36
+ "pixifact": "^0.5.0"
35
37
  }
36
38
  }
package/src/automation.ts CHANGED
@@ -5,7 +5,6 @@ import {
5
5
  builtinSceneAssetIds,
6
6
  builtinSceneNameFromAssetId,
7
7
  defaultSceneSourceRoots,
8
- extractSceneScriptInterfaces,
9
8
  findMissingScenePartReferences,
10
9
  inspectSceneTemplate,
11
10
  isIgnoredSceneSourceDirectory,
@@ -17,7 +16,7 @@ import {
17
16
  toPosixPath,
18
17
  validateSceneContent,
19
18
  } from 'pixifact/compiler';
20
- import { readBuiltinSceneScriptSourcesSync } from 'pixifact/compiler-node';
19
+ import { extractSceneScriptInterfaces, readBuiltinSceneScriptSourcesSync } from 'pixifact/compiler-node';
21
20
  import {
22
21
  parsePixifactProjectConfig,
23
22
  pixifactProjectConfigFileName,
@@ -398,20 +397,30 @@ function validateCompilerSceneFile(root: string, scenePath: string) {
398
397
  if (sourceRootFailure) {
399
398
  return sourceRootFailure;
400
399
  }
400
+ let template: SceneTemplate;
401
+ try {
402
+ template = parseSceneTemplate(content);
403
+ } catch {
404
+ return validateSceneContent({
405
+ scene: scenePath,
406
+ content,
407
+ });
408
+ }
409
+ const pair = readCompilerScenePairContract(root, scenePath, template);
410
+ if (pair.diagnostics.length > 0 || !pair.sceneInterface) {
411
+ return compilerSceneValidationFailure(scenePath, content, pair.diagnostics);
412
+ }
401
413
  const result = validateSceneContent({
402
414
  scene: scenePath,
403
415
  content,
404
416
  existingAssets: collectProjectAssets(root),
405
417
  sceneInterfaces: collectCompilerSceneInterfaces(root, scenePath),
418
+ sceneInterface: pair.sceneInterface,
406
419
  normalizeSceneReference: normalizeCompilerSceneReference(scenePath),
407
420
  });
408
421
  if (!result.ok) {
409
422
  return result;
410
423
  }
411
- const pair = readCompilerScenePairContract(root, scenePath, parseSceneTemplate(content));
412
- if (pair.diagnostics.length > 0) {
413
- return compilerSceneValidationFailure(scenePath, content, pair.diagnostics);
414
- }
415
424
  return {
416
425
  ...result,
417
426
  scenePath,
@@ -528,7 +537,7 @@ function createBlankCompilerSceneSource(sceneName: string) {
528
537
  function createBlankCompilerSceneScript(sceneName: string) {
529
538
  return [
530
539
  "import { Group } from 'pixifact/runtime';",
531
- "import { scene } from 'pixifact/compiler';",
540
+ "import { scene } from 'pixifact/scene';",
532
541
  '',
533
542
  '@scene()',
534
543
  `export class ${sceneName} extends Group {}`,
@@ -0,0 +1,375 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { spawn } from 'node:child_process';
5
+ import {
6
+ normalizeSceneAssetId,
7
+ pairedSceneScriptPath,
8
+ } from 'pixifact/compiler';
9
+ import { extractSceneScriptInterfaces } from 'pixifact/compiler-node';
10
+ import {
11
+ claimEditorSession,
12
+ createEditorHostSession,
13
+ editorSessionProtocolVersion,
14
+ findActiveEditorSession,
15
+ removeEditorSessionDescriptor,
16
+ type EditorSessionDescriptor,
17
+ } from './editorSession';
18
+
19
+ export interface EditorProjectServiceOptions {
20
+ projectRoot: string;
21
+ staticRoot: string;
22
+ }
23
+
24
+ export interface StartEditorOptions {
25
+ projectRoot: string;
26
+ staticRoot?: string;
27
+ }
28
+
29
+ export interface EditorServerSession {
30
+ url: string;
31
+ stop: () => void;
32
+ }
33
+
34
+ interface EditorProjectFile {
35
+ kind: 'image' | 'scene' | 'script' | 'file';
36
+ path: string;
37
+ }
38
+
39
+ interface BunServer {
40
+ hostname: string;
41
+ port: number;
42
+ stop(closeActiveConnections?: boolean): void;
43
+ upgrade(request: Request): boolean;
44
+ }
45
+
46
+ interface BunWebSocket {
47
+ send(data: string): void;
48
+ }
49
+
50
+ declare const Bun: {
51
+ serve(options: {
52
+ fetch(request: Request, server: BunServer): Promise<Response> | Response | undefined;
53
+ hostname: string;
54
+ port: number;
55
+ websocket: {
56
+ close(socket: BunWebSocket): void;
57
+ message(socket: BunWebSocket, message: string | ArrayBuffer | Uint8Array): void;
58
+ open(socket: BunWebSocket): void;
59
+ };
60
+ }): BunServer;
61
+ };
62
+
63
+ const ignoredDirectories = new Set(['.git', '.pixifact', 'dist', 'node_modules', 'target']);
64
+ const imageExtensions = new Set(['.gif', '.jpeg', '.jpg', '.png', '.svg', '.webp']);
65
+ const scriptExtensions = new Set(['.cjs', '.js', '.jsx', '.mjs', '.ts', '.tsx']);
66
+ const contentTypes: Record<string, string> = {
67
+ '.css': 'text/css; charset=utf-8',
68
+ '.gif': 'image/gif',
69
+ '.html': 'text/html; charset=utf-8',
70
+ '.jpeg': 'image/jpeg',
71
+ '.jpg': 'image/jpeg',
72
+ '.js': 'text/javascript; charset=utf-8',
73
+ '.json': 'application/json; charset=utf-8',
74
+ '.png': 'image/png',
75
+ '.svg': 'image/svg+xml',
76
+ '.webp': 'image/webp',
77
+ };
78
+
79
+ class EditorRequestError extends Error {
80
+ constructor(readonly status: number, message: string) {
81
+ super(message);
82
+ }
83
+ }
84
+
85
+ function jsonResponse(value: unknown, status = 200) {
86
+ return Response.json(value, { status });
87
+ }
88
+
89
+ function sceneVersion(source: string) {
90
+ return `sha256:${createHash('sha256').update(source).digest('hex')}`;
91
+ }
92
+
93
+ function fileKind(filePath: string): EditorProjectFile['kind'] {
94
+ const extension = path.extname(filePath).toLowerCase();
95
+ if (extension === '.scene') {
96
+ return 'scene';
97
+ }
98
+ if (imageExtensions.has(extension)) {
99
+ return 'image';
100
+ }
101
+ if (scriptExtensions.has(extension)) {
102
+ return 'script';
103
+ }
104
+ return 'file';
105
+ }
106
+
107
+ function listProjectFiles(projectRoot: string) {
108
+ const files: EditorProjectFile[] = [];
109
+
110
+ function visit(directory: string) {
111
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
112
+ if (entry.isSymbolicLink()) {
113
+ continue;
114
+ }
115
+ const absolutePath = path.join(directory, entry.name);
116
+ if (entry.isDirectory()) {
117
+ if (!ignoredDirectories.has(entry.name)) {
118
+ visit(absolutePath);
119
+ }
120
+ continue;
121
+ }
122
+ if (entry.isFile()) {
123
+ const relativePath = path.relative(projectRoot, absolutePath).split(path.sep).join('/');
124
+ files.push({ path: relativePath, kind: fileKind(relativePath) });
125
+ }
126
+ }
127
+ }
128
+
129
+ visit(projectRoot);
130
+ return files.sort((left, right) => left.path.localeCompare(right.path));
131
+ }
132
+
133
+ function readProjectSceneBindings(projectRoot: string) {
134
+ const scenePaths = listProjectFiles(projectRoot)
135
+ .filter((file) => file.kind === 'scene')
136
+ .map((file) => normalizeSceneAssetId(file.path));
137
+ return extractSceneScriptInterfaces(scenePaths.map((scene) => {
138
+ const scriptPath = pairedSceneScriptPath(scene);
139
+ return {
140
+ scene,
141
+ fileName: path.resolve(projectRoot, scriptPath),
142
+ source: fs.readFileSync(resolveInsideRoot(projectRoot, scriptPath), 'utf8'),
143
+ };
144
+ }));
145
+ }
146
+
147
+ function resolveInsideRoot(root: string, requestedPath: string) {
148
+ if (!requestedPath || path.isAbsolute(requestedPath)) {
149
+ throw new EditorRequestError(400, 'Project path must stay inside the project root.');
150
+ }
151
+ const absolutePath = path.resolve(root, requestedPath);
152
+ if (absolutePath !== root && !absolutePath.startsWith(`${root}${path.sep}`)) {
153
+ throw new EditorRequestError(400, 'Project path must stay inside the project root.');
154
+ }
155
+ const realPath = fs.realpathSync(absolutePath);
156
+ if (realPath !== root && !realPath.startsWith(`${root}${path.sep}`)) {
157
+ throw new EditorRequestError(400, 'Project path must stay inside the project root.');
158
+ }
159
+ return realPath;
160
+ }
161
+
162
+ function requestPath(url: URL) {
163
+ return url.searchParams.get('path') ?? '';
164
+ }
165
+
166
+ async function readJsonBody(request: Request) {
167
+ const value = await request.json();
168
+ if (!value || typeof value !== 'object') {
169
+ throw new EditorRequestError(400, 'Request body must be a JSON object.');
170
+ }
171
+ return value as Record<string, unknown>;
172
+ }
173
+
174
+ function serveStaticFile(staticRoot: string, pathname: string) {
175
+ const relativePath = pathname === '/' ? 'index.html' : decodeURIComponent(pathname.slice(1));
176
+ const requestedPath = path.resolve(staticRoot, relativePath);
177
+ if (requestedPath !== staticRoot && !requestedPath.startsWith(`${staticRoot}${path.sep}`)) {
178
+ throw new EditorRequestError(400, 'Static path must stay inside the Editor root.');
179
+ }
180
+ const filePath = fs.existsSync(requestedPath) && fs.statSync(requestedPath).isFile()
181
+ ? requestedPath
182
+ : path.join(staticRoot, 'index.html');
183
+ const extension = path.extname(filePath).toLowerCase();
184
+ return new Response(fs.readFileSync(filePath), {
185
+ headers: {
186
+ 'content-type': contentTypes[extension] ?? 'application/octet-stream',
187
+ },
188
+ });
189
+ }
190
+
191
+ export function createEditorProjectService(options: EditorProjectServiceOptions) {
192
+ const projectRoot = fs.realpathSync(options.projectRoot);
193
+ const staticRoot = fs.realpathSync(options.staticRoot);
194
+
195
+ async function fetch(request: Request): Promise<Response> {
196
+ try {
197
+ const url = new URL(request.url);
198
+ if (url.pathname === '/api/project' && request.method === 'GET') {
199
+ const files = listProjectFiles(projectRoot);
200
+ return jsonResponse({
201
+ name: path.basename(projectRoot),
202
+ root: projectRoot,
203
+ files,
204
+ scenes: files.filter((file) => file.kind === 'scene').map((file) => file.path),
205
+ images: files.filter((file) => file.kind === 'image').map((file) => file.path),
206
+ });
207
+ }
208
+ if (url.pathname === '/api/scene' && request.method === 'GET') {
209
+ const scenePath = requestPath(url);
210
+ if (path.extname(scenePath).toLowerCase() !== '.scene') {
211
+ throw new EditorRequestError(400, 'Scene path must end with .scene.');
212
+ }
213
+ const source = fs.readFileSync(resolveInsideRoot(projectRoot, scenePath), 'utf8');
214
+ return jsonResponse({ path: scenePath, source, version: sceneVersion(source) });
215
+ }
216
+ if (url.pathname === '/api/scene-bindings' && request.method === 'GET') {
217
+ return jsonResponse(readProjectSceneBindings(projectRoot));
218
+ }
219
+ if (url.pathname === '/api/scene' && request.method === 'PUT') {
220
+ const scenePath = requestPath(url);
221
+ if (path.extname(scenePath).toLowerCase() !== '.scene') {
222
+ throw new EditorRequestError(400, 'Scene path must end with .scene.');
223
+ }
224
+ const filePath = resolveInsideRoot(projectRoot, scenePath);
225
+ const body = await readJsonBody(request);
226
+ if (typeof body.source !== 'string' || typeof body.expectedVersion !== 'string') {
227
+ throw new EditorRequestError(400, 'Scene write requires source and expectedVersion.');
228
+ }
229
+ const currentSource = fs.readFileSync(filePath, 'utf8');
230
+ if (sceneVersion(currentSource) !== body.expectedVersion) {
231
+ throw new EditorRequestError(409, 'Scene file version changed.');
232
+ }
233
+ fs.writeFileSync(filePath, body.source, 'utf8');
234
+ return jsonResponse({ path: scenePath, version: sceneVersion(body.source) });
235
+ }
236
+ if (url.pathname === '/api/file' && request.method === 'GET') {
237
+ const filePath = resolveInsideRoot(projectRoot, requestPath(url));
238
+ const extension = path.extname(filePath).toLowerCase();
239
+ return new Response(fs.readFileSync(filePath), {
240
+ headers: {
241
+ 'content-type': contentTypes[extension] ?? 'application/octet-stream',
242
+ },
243
+ });
244
+ }
245
+ if (url.pathname.startsWith('/api/')) {
246
+ return jsonResponse({ ok: false, error: 'Editor API route was not found.' }, 404);
247
+ }
248
+ return serveStaticFile(staticRoot, url.pathname);
249
+ } catch (error) {
250
+ if (error instanceof EditorRequestError) {
251
+ return jsonResponse({ ok: false, error: error.message }, error.status);
252
+ }
253
+ throw error;
254
+ }
255
+ }
256
+
257
+ return { fetch };
258
+ }
259
+
260
+ function defaultEditorStaticRoot() {
261
+ return path.resolve(import.meta.dir, '..', 'editor');
262
+ }
263
+
264
+ function openBrowser(url: string) {
265
+ const command = process.platform === 'darwin'
266
+ ? ['open', url]
267
+ : process.platform === 'win32'
268
+ ? ['cmd', '/c', 'start', '', url]
269
+ : ['xdg-open', url];
270
+ spawn(command[0], command.slice(1), { detached: true, stdio: 'ignore' }).unref();
271
+ }
272
+
273
+ export function watchEditorProject(projectRoot: string, onProjectFileChanged: (path: string) => void) {
274
+ const changedPathTimers = new Map<string, ReturnType<typeof setTimeout>>();
275
+ const watcher = fs.watch(projectRoot, { recursive: true }, (_event, fileName) => {
276
+ if (!fileName) return;
277
+ const changedPath = String(fileName).split(path.sep).join('/');
278
+ const pending = changedPathTimers.get(changedPath);
279
+ if (pending) clearTimeout(pending);
280
+ changedPathTimers.set(changedPath, setTimeout(() => {
281
+ changedPathTimers.delete(changedPath);
282
+ onProjectFileChanged(changedPath);
283
+ }, 30));
284
+ });
285
+ return () => {
286
+ for (const timer of changedPathTimers.values()) clearTimeout(timer);
287
+ watcher.close();
288
+ };
289
+ }
290
+
291
+ export async function startEditorServer(options: StartEditorOptions): Promise<EditorServerSession> {
292
+ const staticRoot = options.staticRoot ?? defaultEditorStaticRoot();
293
+ if (!fs.existsSync(path.join(staticRoot, 'index.html'))) {
294
+ throw new Error('Editor frontend is not built. Run "bun run editor:frontend:build" first.');
295
+ }
296
+ const projectRoot = fs.realpathSync(options.projectRoot);
297
+ const activeSession = await findActiveEditorSession({ projectRoot });
298
+ if (activeSession) {
299
+ openBrowser(activeSession.origin);
300
+ return {
301
+ url: activeSession.origin,
302
+ stop() {},
303
+ };
304
+ }
305
+
306
+ const service = createEditorProjectService({ projectRoot, staticRoot });
307
+ const token = randomBytes(32).toString('hex');
308
+ const hostSession = createEditorHostSession({ projectRoot, token });
309
+ const server = Bun.serve({
310
+ hostname: '127.0.0.1',
311
+ port: 0,
312
+ fetch(request, bunServer) {
313
+ const sessionResponse = hostSession.fetch(request);
314
+ if (sessionResponse) {
315
+ return sessionResponse;
316
+ }
317
+ const requestUrl = new URL(request.url);
318
+ if (requestUrl.pathname === '/api/events') {
319
+ if (request.headers.get('origin') !== requestUrl.origin) {
320
+ return Response.json({ ok: false, error: 'Editor WebSocket origin is invalid.' }, { status: 403 });
321
+ }
322
+ if (bunServer.upgrade(request)) {
323
+ return undefined;
324
+ }
325
+ }
326
+ return service.fetch(request);
327
+ },
328
+ websocket: {
329
+ open(socket) {
330
+ hostSession.open(socket);
331
+ },
332
+ close(socket) {
333
+ hostSession.close(socket);
334
+ },
335
+ message(socket, message) {
336
+ hostSession.message(socket, message);
337
+ },
338
+ },
339
+ });
340
+ const stopWatcher = watchEditorProject(projectRoot, hostSession.notifyProjectFileChanged);
341
+ const url = `http://${server.hostname}:${server.port}`;
342
+ const descriptor: EditorSessionDescriptor = {
343
+ protocolVersion: editorSessionProtocolVersion,
344
+ projectRoot,
345
+ pid: process.pid,
346
+ origin: url,
347
+ token,
348
+ };
349
+ const claim = await claimEditorSession({ projectRoot, descriptor });
350
+ if (!claim.owned) {
351
+ stopWatcher();
352
+ server.stop(true);
353
+ openBrowser(claim.descriptor.origin);
354
+ return {
355
+ url: claim.descriptor.origin,
356
+ stop() {},
357
+ };
358
+ }
359
+
360
+ let stopped = false;
361
+ const removeDescriptor = () => removeEditorSessionDescriptor(descriptor);
362
+ process.once('exit', removeDescriptor);
363
+ openBrowser(url);
364
+ return {
365
+ url,
366
+ stop() {
367
+ if (stopped) return;
368
+ stopped = true;
369
+ process.off('exit', removeDescriptor);
370
+ removeDescriptor();
371
+ stopWatcher();
372
+ server.stop(true);
373
+ },
374
+ };
375
+ }