pixifact-cli 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.
- package/README.md +15 -1
- package/editor/assets/browserAll-CdyZj0BN.js +1 -0
- package/editor/assets/index-CAe9Mkmr.js +1130 -0
- package/editor/assets/index-CVrKIfXK.css +1 -0
- package/editor/assets/init-C32NAhsC.js +1 -0
- package/editor/assets/webworkerAll-BEmxAAYf.js +1 -0
- package/editor/index.html +13 -0
- package/package.json +4 -3
- package/src/automation.ts +15 -6
- package/src/editorServer.ts +375 -0
- package/src/editorSession.ts +490 -0
- package/src/pixifact-cli.ts +38 -50
- package/src/liveBridge.ts +0 -28
- package/src/liveBridgeServer.ts +0 -163
|
@@ -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
|
+
"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",
|
|
@@ -32,6 +33,6 @@
|
|
|
32
33
|
},
|
|
33
34
|
"dependencies": {
|
|
34
35
|
"esbuild": "^0.27.2",
|
|
35
|
-
"pixifact": "^0.
|
|
36
|
+
"pixifact": "^0.5.0"
|
|
36
37
|
}
|
|
37
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,
|
|
@@ -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
|
+
}
|