@jxsuite/server 0.33.0 → 0.35.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/package.json +10 -6
- package/src/import-api.ts +202 -0
- package/src/project-server.ts +434 -0
- package/src/server.ts +30 -7
- package/src/studio-api.ts +47 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"description": "Jx development server with live reload, server-side proxy, and studio integration",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
"exports": {
|
|
16
16
|
".": "./src/server.ts",
|
|
17
17
|
"./ai-api": "./src/ai-api.ts",
|
|
18
|
+
"./import-api": "./src/import-api.ts",
|
|
18
19
|
"./packages": "./src/packages.ts",
|
|
20
|
+
"./project-server": "./src/project-server.ts",
|
|
19
21
|
"./refactor": "./src/refactor/index.ts",
|
|
20
22
|
"./resolve": "./src/resolve.ts",
|
|
21
23
|
"./types": "./src/types.ts"
|
|
@@ -29,11 +31,13 @@
|
|
|
29
31
|
"test:coverage": "bun test --isolate --coverage"
|
|
30
32
|
},
|
|
31
33
|
"dependencies": {
|
|
32
|
-
"@jxsuite/compiler": "^0.
|
|
33
|
-
"@jxsuite/create": "^0.
|
|
34
|
-
"@jxsuite/
|
|
35
|
-
"@jxsuite/
|
|
36
|
-
"@jxsuite/
|
|
34
|
+
"@jxsuite/compiler": "^0.34.1",
|
|
35
|
+
"@jxsuite/create": "^0.35.0",
|
|
36
|
+
"@jxsuite/import": "^0.35.0",
|
|
37
|
+
"@jxsuite/parser": "^0.35.0",
|
|
38
|
+
"@jxsuite/runtime": "^0.34.1",
|
|
39
|
+
"@jxsuite/schema": "^0.34.0",
|
|
40
|
+
"@jxsuite/starters": "^0.35.0",
|
|
37
41
|
"chokidar": "^5.0.0",
|
|
38
42
|
"zod": "^4.4.3"
|
|
39
43
|
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import-api.ts — the AI-guided site-import endpoint for Jx Studio.
|
|
3
|
+
*
|
|
4
|
+
* Handles POST /__studio/import-site: runs @jxsuite/import's importSite pipeline in the backend
|
|
5
|
+
* (headless Chrome + filesystem) and streams progress back as NDJSON lines over the POST response —
|
|
6
|
+
* the same streaming-fetch transport the AI chat proxy uses, so every platform (dev server,
|
|
7
|
+
* Electrobun services server, Chromium loopback server) can mount it unchanged.
|
|
8
|
+
*
|
|
9
|
+
* Stream protocol (one JSON object per line):
|
|
10
|
+
* {"type":"progress","phase":"...","message":"...","current":n,"total":n}
|
|
11
|
+
* {"type":"heartbeat"} — keep-alives during silent phases
|
|
12
|
+
* {"type":"done","root":"...","config":{…}} — terminal success
|
|
13
|
+
* {"type":"error","error":"..."} — terminal failure
|
|
14
|
+
*
|
|
15
|
+
* LLM key flow (matches ai-api.ts): X-Api-Key / Authorization: Bearer header, falling back to the
|
|
16
|
+
* OPENAI_API_KEY env var; base URL from X-Api-Base-URL / OPENAI_BASE_URL. When AI component naming
|
|
17
|
+
* is requested but no key resolves, the import proceeds without the AI pass and emits a warning
|
|
18
|
+
* line instead of failing.
|
|
19
|
+
*
|
|
20
|
+
* @license MIT
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { readFile } from "node:fs/promises";
|
|
24
|
+
import { resolve } from "node:path";
|
|
25
|
+
|
|
26
|
+
export interface ImportApiOptions {
|
|
27
|
+
/**
|
|
28
|
+
* Validate and absolutize the request's destination directory. Must throw for paths the host does
|
|
29
|
+
* not allow (the endpoint writes files and drives a browser — never pass through unchecked).
|
|
30
|
+
*/
|
|
31
|
+
resolveDest: (directory: string) => string;
|
|
32
|
+
/** Map the final absolute project dir onto the platform's project-root form (default identity). */
|
|
33
|
+
toRoot?: (destPath: string) => string;
|
|
34
|
+
/** Explicit browser binary for puppeteer (e.g. the desktop launcher's discovered Chromium). */
|
|
35
|
+
chromePath?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ImportRequestBody {
|
|
39
|
+
url?: string;
|
|
40
|
+
directory?: string;
|
|
41
|
+
depth?: number;
|
|
42
|
+
maxPages?: number;
|
|
43
|
+
maxNodesPerPage?: number;
|
|
44
|
+
aiComponents?: boolean;
|
|
45
|
+
aiModel?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Seconds between keep-alive lines while a phase is silent (browser launch is the longest gap). */
|
|
49
|
+
const HEARTBEAT_INTERVAL_MS = 15_000;
|
|
50
|
+
|
|
51
|
+
const MAX_DEPTH = 5;
|
|
52
|
+
const MAX_PAGES = 100;
|
|
53
|
+
|
|
54
|
+
function clamp(value: number, min: number, max: number): number {
|
|
55
|
+
return Math.min(max, Math.max(min, Math.trunc(value)));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getAiConfig(req: Request): { apiKey: string | null; baseUrl: string | undefined } {
|
|
59
|
+
const authHeader = req.headers.get("Authorization") || "";
|
|
60
|
+
const apiKeyHeader = req.headers.get("X-Api-Key") || "";
|
|
61
|
+
const baseUrlHeader = req.headers.get("X-Api-Base-URL") || "";
|
|
62
|
+
|
|
63
|
+
let apiKey: string | null = null;
|
|
64
|
+
if (authHeader.startsWith("Bearer ")) {
|
|
65
|
+
apiKey = authHeader.slice(7).trim();
|
|
66
|
+
}
|
|
67
|
+
if (apiKeyHeader) {
|
|
68
|
+
apiKey = apiKeyHeader.trim();
|
|
69
|
+
}
|
|
70
|
+
if (!apiKey && process.env.OPENAI_API_KEY) {
|
|
71
|
+
apiKey = process.env.OPENAI_API_KEY;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const baseUrl = baseUrlHeader || process.env.OPENAI_BASE_URL || undefined;
|
|
75
|
+
return { apiKey, baseUrl };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Handle POST /__studio/import-site — run the import pipeline, streaming NDJSON progress. */
|
|
79
|
+
async function handleImportSite(req: Request, opts: ImportApiOptions): Promise<Response> {
|
|
80
|
+
let body: ImportRequestBody;
|
|
81
|
+
try {
|
|
82
|
+
body = (await req.json()) as ImportRequestBody;
|
|
83
|
+
} catch {
|
|
84
|
+
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const { url, directory } = body;
|
|
88
|
+
if (!url || !directory) {
|
|
89
|
+
return Response.json({ error: "url and directory are required" }, { status: 400 });
|
|
90
|
+
}
|
|
91
|
+
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
|
92
|
+
return Response.json({ error: "url must start with http:// or https://" }, { status: 400 });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let destPath: string;
|
|
96
|
+
try {
|
|
97
|
+
destPath = opts.resolveDest(directory);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
100
|
+
return Response.json({ error: message }, { status: 400 });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const depth = clamp(body.depth ?? 1, 0, MAX_DEPTH);
|
|
104
|
+
const maxPages = clamp(body.maxPages ?? 20, 1, MAX_PAGES);
|
|
105
|
+
const maxNodesPerPage = clamp(body.maxNodesPerPage ?? 5000, 100, 50_000);
|
|
106
|
+
const { apiKey, baseUrl } = getAiConfig(req);
|
|
107
|
+
const toRoot = opts.toRoot ?? ((p: string) => p);
|
|
108
|
+
|
|
109
|
+
const encoder = new TextEncoder();
|
|
110
|
+
const stream = new ReadableStream({
|
|
111
|
+
async start(controller) {
|
|
112
|
+
let closed = false;
|
|
113
|
+
const writeLine = (obj: unknown) => {
|
|
114
|
+
if (closed) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
controller.enqueue(encoder.encode(`${JSON.stringify(obj)}\n`));
|
|
119
|
+
} catch {
|
|
120
|
+
closed = true;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
const heartbeat = setInterval(() => writeLine({ type: "heartbeat" }), HEARTBEAT_INTERVAL_MS);
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
// Loaded lazily so the server never pays for puppeteer unless an import runs.
|
|
127
|
+
const { importSite } = await import("@jxsuite/import/run");
|
|
128
|
+
|
|
129
|
+
let ai:
|
|
130
|
+
| false
|
|
131
|
+
| { apiKey: string; baseUrl?: string | undefined; model?: string | undefined } = false;
|
|
132
|
+
if (body.aiComponents) {
|
|
133
|
+
if (apiKey) {
|
|
134
|
+
ai = { apiKey, baseUrl, model: body.aiModel };
|
|
135
|
+
} else {
|
|
136
|
+
writeLine({
|
|
137
|
+
type: "progress",
|
|
138
|
+
phase: "ai",
|
|
139
|
+
message:
|
|
140
|
+
"⚠ AI component naming requested but no API key is configured — continuing with heuristic names",
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const result = await importSite(
|
|
146
|
+
{
|
|
147
|
+
url,
|
|
148
|
+
outDir: destPath,
|
|
149
|
+
maxDepth: depth,
|
|
150
|
+
maxPages,
|
|
151
|
+
maxNodesPerPage,
|
|
152
|
+
ai,
|
|
153
|
+
signal: req.signal,
|
|
154
|
+
...(opts.chromePath === undefined ? {} : { chromePath: opts.chromePath }),
|
|
155
|
+
},
|
|
156
|
+
(e) => writeLine({ type: "progress", ...e }),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const config = JSON.parse(
|
|
160
|
+
await readFile(resolve(result.outDir, "project.json"), "utf8"),
|
|
161
|
+
) as Record<string, unknown>;
|
|
162
|
+
writeLine({ type: "done", root: toRoot(result.outDir), config });
|
|
163
|
+
} catch (error) {
|
|
164
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
165
|
+
writeLine({ type: "error", error: message });
|
|
166
|
+
} finally {
|
|
167
|
+
clearInterval(heartbeat);
|
|
168
|
+
if (!closed) {
|
|
169
|
+
closed = true;
|
|
170
|
+
try {
|
|
171
|
+
controller.close();
|
|
172
|
+
} catch {
|
|
173
|
+
/* Already closed by a client abort. */
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
return new Response(stream, {
|
|
181
|
+
headers: {
|
|
182
|
+
"Content-Type": "application/x-ndjson",
|
|
183
|
+
"Cache-Control": "no-cache",
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Main handler for the import route.
|
|
190
|
+
*
|
|
191
|
+
* @returns Response if handled, null if the route doesn't match
|
|
192
|
+
*/
|
|
193
|
+
export async function handleImportApi(
|
|
194
|
+
req: Request,
|
|
195
|
+
url: URL,
|
|
196
|
+
opts: ImportApiOptions,
|
|
197
|
+
): Promise<Response | null> {
|
|
198
|
+
if (url.pathname === "/__studio/import-site" && req.method === "POST") {
|
|
199
|
+
return handleImportSite(req, opts);
|
|
200
|
+
}
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared project server factory. Generalizes the chromium variant's ad-hoc in-process Bun.serve
|
|
3
|
+
* into one createProjectServer() used by the chromium desktop launcher (and, in Phase 7,
|
|
4
|
+
* electrobun).
|
|
5
|
+
*
|
|
6
|
+
* It serves three surfaces from a single loopback-bound Bun.serve:
|
|
7
|
+
*
|
|
8
|
+
* - The studio shell + canvas iframe doc + iframe bundle, under a non-colliding studio namespace (so
|
|
9
|
+
* a project's own asset tree never collides);
|
|
10
|
+
* - Project files at their natural URLs (absolute-under-root, root-relative, and public/), each
|
|
11
|
+
* contained against path traversal and symlink escape;
|
|
12
|
+
* - The canonical resolve routes (the jx-resolve and jx-server POST endpoints) plus the WS-RPC
|
|
13
|
+
* dispatch, both gated by a per-server token.
|
|
14
|
+
*
|
|
15
|
+
* SECURITY MODEL (see the spec's SECURITY block):
|
|
16
|
+
*
|
|
17
|
+
* - Loopback bind (127.0.0.1) is the PRIMARY control: other local processes / LAN pages cannot read a
|
|
18
|
+
* loopback page's location, so they cannot steal the URL token.
|
|
19
|
+
* - The token is the HARD gate on every privileged surface — the WS upgrade and the two HTTP resolve
|
|
20
|
+
* routes (which both do dynamic import() and are therefore RCE-capable).
|
|
21
|
+
* - Origin/Host is BEST-EFFORT defense-in-depth: a loopback Origin host {127.0.0.1, localhost, ::1}
|
|
22
|
+
* or an absent Origin is accepted; the privileged HTTP routes additionally reject a Host header
|
|
23
|
+
* that is not loopback (anti-DNS-rebinding).
|
|
24
|
+
* - File reads and the project write-path are contained with a lexical relative() check plus a
|
|
25
|
+
* realpath re-check (symlink containment).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
29
|
+
import { realpathSync } from "node:fs";
|
|
30
|
+
import { handleResolve, handleServerFunction } from "./resolve.ts";
|
|
31
|
+
import { handleAiApi } from "./ai-api.ts";
|
|
32
|
+
import { handleImportApi } from "./import-api.ts";
|
|
33
|
+
import { resolveNpmPath } from "./server.ts";
|
|
34
|
+
import type { ImportApiOptions } from "./import-api.ts";
|
|
35
|
+
|
|
36
|
+
/** A resolved per-window session: its project root plus its RPC handler map. */
|
|
37
|
+
export interface ProjectServerSession {
|
|
38
|
+
projectRoot: string | null;
|
|
39
|
+
handlers: Record<string, (params: unknown) => Promise<unknown>>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CreateProjectServerOptions {
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the session for a given window id (the `win` query param / stashed socket data).
|
|
45
|
+
* chromium passes `() => defaultSession`; electrobun (Phase 7) swaps it for a per-window lookup.
|
|
46
|
+
* A missing/unknown window returns `null`, which the routes treat as a 404 fail-closed.
|
|
47
|
+
*/
|
|
48
|
+
resolveSession: (winId: string | null) => ProjectServerSession | null;
|
|
49
|
+
/** Absolute dir holding the studio shell + canvas.html + dist/. */
|
|
50
|
+
studioDir: string;
|
|
51
|
+
/** Per-server RPC token. Defaults to a fresh random UUID. */
|
|
52
|
+
rpcToken?: string;
|
|
53
|
+
/** Bind hostname. Defaults to loopback 127.0.0.1. */
|
|
54
|
+
hostname?: string;
|
|
55
|
+
/** Bind port. Defaults to 0 (ephemeral). */
|
|
56
|
+
port?: number;
|
|
57
|
+
/**
|
|
58
|
+
* Enable the AI-guided site-import endpoint (POST `/__studio__/import-site`). Absent disables the
|
|
59
|
+
* route. It writes to the filesystem, so it is gated with the rpcToken like `/__jx_resolve__`.
|
|
60
|
+
*/
|
|
61
|
+
importApi?: ImportApiOptions;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ProjectServerHandle {
|
|
65
|
+
server: ReturnType<typeof Bun.serve>;
|
|
66
|
+
url: string;
|
|
67
|
+
wsUrl: string;
|
|
68
|
+
rpcToken: string;
|
|
69
|
+
canvasUrl: string;
|
|
70
|
+
stop: () => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
|
|
74
|
+
|
|
75
|
+
/** True when a hostname (no port) is a loopback host. Used for best-effort Origin/Host checks. */
|
|
76
|
+
function isLoopbackHost(host: string | null | undefined): boolean {
|
|
77
|
+
if (!host) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
// Strip a trailing :port (but keep bracketed IPv6 intact for the literal set).
|
|
81
|
+
let h = host;
|
|
82
|
+
if (h.startsWith("[")) {
|
|
83
|
+
const end = h.indexOf("]");
|
|
84
|
+
h = end === -1 ? h : h.slice(0, end + 1);
|
|
85
|
+
} else {
|
|
86
|
+
const colon = h.indexOf(":");
|
|
87
|
+
if (colon !== -1) {
|
|
88
|
+
h = h.slice(0, colon);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return LOOPBACK_HOSTS.has(h.toLowerCase());
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Best-effort loopback Origin/Host check (defense-in-depth, not the hard gate).
|
|
96
|
+
*
|
|
97
|
+
* Accept when the Origin's host is loopback OR Origin is absent (Bun-native / test clients send no
|
|
98
|
+
* Origin). Do NOT hardcode-match one literal origin — localhost and 127.0.0.1 are distinct origins,
|
|
99
|
+
* and a too-strict check would 403 legitimate clients.
|
|
100
|
+
*/
|
|
101
|
+
function originIsLoopbackOrAbsent(req: Request): boolean {
|
|
102
|
+
const origin = req.headers.get("origin");
|
|
103
|
+
if (!origin) {
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
return isLoopbackHost(new URL(origin).hostname);
|
|
108
|
+
} catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Reject a Host header that is present and NOT loopback (defeats DNS-rebinding, where Host is the
|
|
115
|
+
* attacker domain). An absent Host (Bun-native clients) is accepted.
|
|
116
|
+
*/
|
|
117
|
+
function hostIsLoopbackOrAbsent(req: Request): boolean {
|
|
118
|
+
const host = req.headers.get("host");
|
|
119
|
+
if (!host) {
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
return isLoopbackHost(host);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Normalize a path for cross-platform containment comparison (separators + case on Windows). */
|
|
126
|
+
function normalizeForCompare(p: string): string {
|
|
127
|
+
const slashed = p.replaceAll("\\", "/");
|
|
128
|
+
return process.platform === "win32" ? slashed.toLowerCase() : slashed;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Contain `absPath` within `root`: a lexical relative() check followed by a realpath re-check so a
|
|
133
|
+
* symlink inside the tree cannot point outside it. Returns the (possibly realpath'd) absolute path
|
|
134
|
+
* when contained, or null otherwise. The caller must already have decoded the URL pathname.
|
|
135
|
+
*/
|
|
136
|
+
function containedPath(absPath: string, root: string): string | null {
|
|
137
|
+
// (1) Lexical containment.
|
|
138
|
+
const rel = relative(root, absPath);
|
|
139
|
+
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
// (2) realpath BOTH and re-check the realpath is still under realRoot (symlink containment).
|
|
143
|
+
let realRoot: string;
|
|
144
|
+
let realPath: string;
|
|
145
|
+
try {
|
|
146
|
+
realRoot = realpathSync(root);
|
|
147
|
+
} catch {
|
|
148
|
+
// Root itself is unresolvable: fall back to lexical-only containment.
|
|
149
|
+
return absPath;
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
realPath = realpathSync(absPath);
|
|
153
|
+
} catch {
|
|
154
|
+
// Target does not exist yet — lexical containment already passed; let the caller's existence
|
|
155
|
+
// Check decide. (Reads will 404; the write-path resolves its own parent separately.)
|
|
156
|
+
return absPath;
|
|
157
|
+
}
|
|
158
|
+
const nRoot = normalizeForCompare(realRoot);
|
|
159
|
+
const nPath = normalizeForCompare(realPath);
|
|
160
|
+
if (nPath === nRoot || nPath.startsWith(nRoot.endsWith("/") ? nRoot : `${nRoot}/`)) {
|
|
161
|
+
return realPath;
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Serve a file under studioDir (or any root) if it both exists and is contained. Returns the
|
|
168
|
+
* Response, or null when missing/traversed (caller decides the fallthrough status).
|
|
169
|
+
*/
|
|
170
|
+
async function serveContained(absPath: string, root: string): Promise<Response | null> {
|
|
171
|
+
const safe = containedPath(absPath, root);
|
|
172
|
+
if (!safe) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
const file = Bun.file(safe);
|
|
176
|
+
if (await file.exists()) {
|
|
177
|
+
return new Response(file);
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Try to serve a project file at its natural URL: absolute-under-root, then root-relative, then
|
|
184
|
+
* public/. Each candidate goes through containedPath (realpath). `decodedPath` is the once-decoded
|
|
185
|
+
* URL pathname (leading slash, forward slashes).
|
|
186
|
+
*/
|
|
187
|
+
async function serveProjectFile(decodedPath: string, root: string): Promise<Response | null> {
|
|
188
|
+
// Map the URL pathname back to a filesystem path. On Windows the browser requests an absolute
|
|
189
|
+
// Path as /C:/Users/… (leading slash + forward slashes); drop the slash before the drive letter.
|
|
190
|
+
// A POSIX absolute path arrives as //abs/path.
|
|
191
|
+
const fsPath = decodedPath.startsWith("//")
|
|
192
|
+
? decodedPath.slice(1)
|
|
193
|
+
: decodedPath.replace(/^\/([A-Za-z]:)/, "$1");
|
|
194
|
+
|
|
195
|
+
// 1. Absolute path that falls under the project root.
|
|
196
|
+
if (normalizeForCompare(fsPath).startsWith(normalizeForCompare(root))) {
|
|
197
|
+
const res = await serveContained(fsPath, root);
|
|
198
|
+
if (res) {
|
|
199
|
+
return res;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 2. Root-relative.
|
|
204
|
+
const relRes = await serveContained(resolve(root, `.${decodedPath}`), root);
|
|
205
|
+
if (relRes) {
|
|
206
|
+
return relRes;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 3. public/ subdirectory.
|
|
210
|
+
const pubRes = await serveContained(resolve(root, "public", `.${decodedPath}`), root);
|
|
211
|
+
if (pubRes) {
|
|
212
|
+
return pubRes;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Create and start a shared project server bound to loopback.
|
|
220
|
+
*
|
|
221
|
+
* @param options See {@link CreateProjectServerOptions}.
|
|
222
|
+
* @returns A handle with the bound url/wsUrl, the rpcToken, the canvas iframe URL, and stop().
|
|
223
|
+
*/
|
|
224
|
+
export function createProjectServer(options: CreateProjectServerOptions): ProjectServerHandle {
|
|
225
|
+
const {
|
|
226
|
+
resolveSession,
|
|
227
|
+
studioDir,
|
|
228
|
+
rpcToken = crypto.randomUUID(),
|
|
229
|
+
hostname = "127.0.0.1",
|
|
230
|
+
port = 0,
|
|
231
|
+
} = options;
|
|
232
|
+
|
|
233
|
+
// Bundle cache for npm bare specifiers (mirrors server.ts).
|
|
234
|
+
const bundleCache = new Map<string, string>();
|
|
235
|
+
|
|
236
|
+
const server = Bun.serve<{ winId: string | null }>({
|
|
237
|
+
hostname,
|
|
238
|
+
port,
|
|
239
|
+
// Keep SSE/AI streams alive (mirrors the dev server's generous idle timeout).
|
|
240
|
+
idleTimeout: 120,
|
|
241
|
+
async fetch(req, srv) {
|
|
242
|
+
const url = new URL(req.url);
|
|
243
|
+
// Decode the pathname ONCE, then run ALL containment on the decoded path. Reject a path that
|
|
244
|
+
// Still contains an encoded dot/slash after one decode (over-encoding bypass attempt).
|
|
245
|
+
let path: string;
|
|
246
|
+
try {
|
|
247
|
+
path = decodeURIComponent(url.pathname);
|
|
248
|
+
} catch {
|
|
249
|
+
return new Response("Bad request", { status: 400 });
|
|
250
|
+
}
|
|
251
|
+
if (/%2e|%2f/i.test(path)) {
|
|
252
|
+
return new Response("Not found", { status: 404 });
|
|
253
|
+
}
|
|
254
|
+
// Collapse runs of leading slashes to a single one. The natural-URL branch re-expands a POSIX
|
|
255
|
+
// //abs prefix explicitly, so a single leading slash here is the right normal form.
|
|
256
|
+
const normPath = path.replace(/^\/{2,}/, "/");
|
|
257
|
+
|
|
258
|
+
const winId = url.searchParams.get("win");
|
|
259
|
+
|
|
260
|
+
// 1. WebSocket upgrade — token + loopback Origin/Host are the hard gate.
|
|
261
|
+
const upgrade = req.headers.get("upgrade");
|
|
262
|
+
if (upgrade && upgrade.toLowerCase() === "websocket") {
|
|
263
|
+
const token = url.searchParams.get("token");
|
|
264
|
+
if (token !== rpcToken || !originIsLoopbackOrAbsent(req) || !hostIsLoopbackOrAbsent(req)) {
|
|
265
|
+
return new Response("Forbidden", { status: 403 });
|
|
266
|
+
}
|
|
267
|
+
if (srv.upgrade(req, { data: { winId } })) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
return new Response("Upgrade failed", { status: 400 });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// 2. AI SSE — keep the existing handleAiApi behavior (rewrite the studio-ai prefix).
|
|
274
|
+
if (normPath.startsWith("/__studio__/ai/")) {
|
|
275
|
+
const aiUrl = new URL(req.url);
|
|
276
|
+
aiUrl.pathname = normPath.replace("/__studio__/ai/", "/__studio/ai/");
|
|
277
|
+
const aiResponse = await handleAiApi(req, aiUrl);
|
|
278
|
+
if (aiResponse) {
|
|
279
|
+
return aiResponse;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// 2b. AI-guided site import — writes to the filesystem and drives a browser, so token +
|
|
284
|
+
// Loopback Origin/Host are the hard gate (same as the RCE-capable routes below).
|
|
285
|
+
if (normPath === "/__studio__/import-site" && req.method === "POST") {
|
|
286
|
+
const { importApi } = options;
|
|
287
|
+
if (!importApi) {
|
|
288
|
+
return new Response("Not found", { status: 404 });
|
|
289
|
+
}
|
|
290
|
+
const token = url.searchParams.get("token");
|
|
291
|
+
if (token !== rpcToken || !originIsLoopbackOrAbsent(req) || !hostIsLoopbackOrAbsent(req)) {
|
|
292
|
+
return new Response("Forbidden", { status: 403 });
|
|
293
|
+
}
|
|
294
|
+
const importUrl = new URL(req.url);
|
|
295
|
+
importUrl.pathname = "/__studio/import-site";
|
|
296
|
+
const importRes = await handleImportApi(req, importUrl, importApi);
|
|
297
|
+
if (importRes) {
|
|
298
|
+
return importRes;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// 3. Studio assets under the non-colliding /__studio__/ namespace.
|
|
303
|
+
if (normPath.startsWith("/__studio__/")) {
|
|
304
|
+
const assetRel = normPath.slice("/__studio__/".length);
|
|
305
|
+
const assetPath = resolve(studioDir, `.${sep}${assetRel}`);
|
|
306
|
+
const res = await serveContained(assetPath, studioDir);
|
|
307
|
+
if (res) {
|
|
308
|
+
// Never leak the tokened URL cross-origin via Referer, while keeping same-origin requests
|
|
309
|
+
// Normal. MUST be `same-origin`, not `no-referrer`: under `no-referrer` Chromium serializes
|
|
310
|
+
// The Origin header of the canvas iframe's own same-origin POSTs as `null` (Fetch spec
|
|
311
|
+
// Origin-serialization respects referrer policy), which fails originIsLoopbackOrAbsent and
|
|
312
|
+
// Self-403s /__jx_resolve__ + /__jx_server__ from the very document this server serves.
|
|
313
|
+
const headers = new Headers(res.headers);
|
|
314
|
+
headers.set("Referrer-Policy", "same-origin");
|
|
315
|
+
return new Response(res.body, { headers, status: res.status });
|
|
316
|
+
}
|
|
317
|
+
return new Response("Not found", { status: 404 });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// 4. Resolve the session (fail-closed).
|
|
321
|
+
const session = resolveSession(winId);
|
|
322
|
+
if (!session) {
|
|
323
|
+
return new Response("Unknown window", { status: 404 });
|
|
324
|
+
}
|
|
325
|
+
const root = session.projectRoot;
|
|
326
|
+
if (!root) {
|
|
327
|
+
return new Response("No project", { status: 404 });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// 5. Privileged HTTP routes (RCE-capable: dynamic import()). Gate with token + loopback
|
|
331
|
+
// Origin/Host BEFORE dispatch.
|
|
332
|
+
if (
|
|
333
|
+
(normPath === "/__jx_resolve__" || normPath === "/__jx_server__") &&
|
|
334
|
+
req.method === "POST"
|
|
335
|
+
) {
|
|
336
|
+
const token = url.searchParams.get("token");
|
|
337
|
+
if (token !== rpcToken || !originIsLoopbackOrAbsent(req) || !hostIsLoopbackOrAbsent(req)) {
|
|
338
|
+
return new Response("Forbidden", { status: 403 });
|
|
339
|
+
}
|
|
340
|
+
if (normPath === "/__jx_resolve__") {
|
|
341
|
+
return handleResolve(req, root, root);
|
|
342
|
+
}
|
|
343
|
+
return handleServerFunction(req, root);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// 6. Project files at natural URLs.
|
|
347
|
+
const fileRes = await serveProjectFile(normPath, root);
|
|
348
|
+
if (fileRes) {
|
|
349
|
+
return fileRes;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// 7. npm bare specifiers via node_modules (on-demand bundle, mirrors server.ts).
|
|
353
|
+
const resolved = resolveNpmPath(root, normPath);
|
|
354
|
+
if (resolved) {
|
|
355
|
+
if (!bundleCache.has(resolved)) {
|
|
356
|
+
try {
|
|
357
|
+
const result = await Bun.build({
|
|
358
|
+
entrypoints: [resolved],
|
|
359
|
+
format: "esm",
|
|
360
|
+
minify: false,
|
|
361
|
+
});
|
|
362
|
+
if (result.success && result.outputs.length > 0) {
|
|
363
|
+
bundleCache.set(resolved, await result.outputs[0]!.text());
|
|
364
|
+
}
|
|
365
|
+
} catch (error) {
|
|
366
|
+
console.error("Bundle failed for", resolved, error);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const bundled = bundleCache.get(resolved);
|
|
370
|
+
if (bundled) {
|
|
371
|
+
return new Response(bundled, {
|
|
372
|
+
headers: { "Content-Type": "application/javascript; charset=utf-8" },
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return new Response("Not found", { status: 404 });
|
|
378
|
+
},
|
|
379
|
+
|
|
380
|
+
websocket: {
|
|
381
|
+
async message(ws, raw) {
|
|
382
|
+
let msg: { id: number; method: string; params?: unknown };
|
|
383
|
+
try {
|
|
384
|
+
msg = JSON.parse(raw as string) as { id: number; method: string; params?: unknown };
|
|
385
|
+
} catch {
|
|
386
|
+
ws.send(JSON.stringify({ error: "Invalid JSON", id: 0 }));
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Re-resolve the session FRESH each message (fail-closed for Phase 7 multi-window).
|
|
391
|
+
const session = resolveSession(ws.data.winId);
|
|
392
|
+
if (!session) {
|
|
393
|
+
ws.send(JSON.stringify({ error: "Unknown window", id: msg.id }));
|
|
394
|
+
ws.close();
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const handler = session.handlers[msg.method];
|
|
399
|
+
if (!handler) {
|
|
400
|
+
ws.send(JSON.stringify({ error: `Unknown method: ${msg.method}`, id: msg.id }));
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
try {
|
|
405
|
+
const result = await handler(msg.params);
|
|
406
|
+
ws.send(JSON.stringify({ id: msg.id, result: result ?? null }));
|
|
407
|
+
} catch (error: unknown) {
|
|
408
|
+
ws.send(
|
|
409
|
+
JSON.stringify({
|
|
410
|
+
error: error instanceof Error ? error.message : String(error),
|
|
411
|
+
id: msg.id,
|
|
412
|
+
}),
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
const boundHost = hostname.includes(":") ? `[${hostname}]` : hostname;
|
|
420
|
+
const url = `http://${boundHost}:${server.port}`;
|
|
421
|
+
const wsUrl = `ws://${boundHost}:${server.port}`;
|
|
422
|
+
const canvasUrl = `${url}/__studio__/canvas.html`;
|
|
423
|
+
|
|
424
|
+
return {
|
|
425
|
+
server,
|
|
426
|
+
url,
|
|
427
|
+
wsUrl,
|
|
428
|
+
rpcToken,
|
|
429
|
+
canvasUrl,
|
|
430
|
+
stop: () => {
|
|
431
|
+
void server.stop(true);
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -13,13 +13,14 @@
|
|
|
13
13
|
* proxying, and studio filesystem integration as a single createDevServer() call.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { join, resolve } from "node:path";
|
|
16
|
+
import { join, relative, resolve } from "node:path";
|
|
17
17
|
import { buildAll } from "./build.ts";
|
|
18
18
|
import { createWatcher, injectSSE } from "./watch.ts";
|
|
19
19
|
import { handleResolve, handleServerFunction } from "./resolve.ts";
|
|
20
|
-
import { handleStudioApi } from "./studio-api.ts";
|
|
20
|
+
import { assertAccessible, handleStudioApi } from "./studio-api.ts";
|
|
21
21
|
import { handleCodeApi } from "./code-api.ts";
|
|
22
22
|
import { handleAiApi } from "./ai-api.ts";
|
|
23
|
+
import { handleImportApi } from "./import-api.ts";
|
|
23
24
|
import { existsSync, readFileSync } from "node:fs";
|
|
24
25
|
|
|
25
26
|
/**
|
|
@@ -212,6 +213,14 @@ export async function createDevServer(options: {
|
|
|
212
213
|
// Bundle cache for npm packages (bare specifier → bundled JS)
|
|
213
214
|
const bundleCache = new Map<string, string>();
|
|
214
215
|
|
|
216
|
+
// Dev-server cache policy: always revalidate. Without any Cache-Control the browser HEURISTICALLY
|
|
217
|
+
// Caches responses (there are no validators either), so a plain reload can serve a stale
|
|
218
|
+
// Studio.js/canvas bundle — which shows up as half-updated UI after a rebuild (the iframe assets
|
|
219
|
+
// Are query-cache-busted per mount, the parent bundle is not).
|
|
220
|
+
const NO_CACHE = { "Cache-Control": "no-cache" };
|
|
221
|
+
const fileResponse = (file: ReturnType<typeof Bun.file>) =>
|
|
222
|
+
new Response(file, { headers: NO_CACHE });
|
|
223
|
+
|
|
215
224
|
// Active studio project root (set via /__studio/activate, used for static file fallback)
|
|
216
225
|
let activeProjectRoot: string | null = null;
|
|
217
226
|
|
|
@@ -259,6 +268,19 @@ export async function createDevServer(options: {
|
|
|
259
268
|
return aiRes;
|
|
260
269
|
}
|
|
261
270
|
|
|
271
|
+
// AI-guided site import (/__studio/import-site) — NDJSON progress stream
|
|
272
|
+
const importRes = await handleImportApi(req, url, {
|
|
273
|
+
resolveDest: (dir) => {
|
|
274
|
+
const dest = resolve(absRoot, dir);
|
|
275
|
+
assertAccessible(dest, absRoot, activeProjectRoot);
|
|
276
|
+
return dest;
|
|
277
|
+
},
|
|
278
|
+
toRoot: (dest) => relative(absRoot, dest).replaceAll("\\", "/"),
|
|
279
|
+
});
|
|
280
|
+
if (importRes) {
|
|
281
|
+
return importRes;
|
|
282
|
+
}
|
|
283
|
+
|
|
262
284
|
const codeRes = await handleCodeApi(req, url);
|
|
263
285
|
if (codeRes) {
|
|
264
286
|
return codeRes;
|
|
@@ -289,7 +311,7 @@ export async function createDevServer(options: {
|
|
|
289
311
|
if (activeProjectRoot && normSep(fsPath).startsWith(normSep(activeProjectRoot))) {
|
|
290
312
|
const file = Bun.file(fsPath);
|
|
291
313
|
if (await file.exists()) {
|
|
292
|
-
return
|
|
314
|
+
return fileResponse(file);
|
|
293
315
|
}
|
|
294
316
|
}
|
|
295
317
|
|
|
@@ -299,12 +321,12 @@ export async function createDevServer(options: {
|
|
|
299
321
|
if (activeProjectRoot) {
|
|
300
322
|
const projectFile = Bun.file(resolve(activeProjectRoot, `.${path}`));
|
|
301
323
|
if (await projectFile.exists()) {
|
|
302
|
-
return
|
|
324
|
+
return fileResponse(projectFile);
|
|
303
325
|
}
|
|
304
326
|
// Mirror production: public/ contents are served at root
|
|
305
327
|
const publicFile = Bun.file(resolve(activeProjectRoot, "public", `.${path}`));
|
|
306
328
|
if (await publicFile.exists()) {
|
|
307
|
-
return
|
|
329
|
+
return fileResponse(publicFile);
|
|
308
330
|
}
|
|
309
331
|
}
|
|
310
332
|
|
|
@@ -331,6 +353,7 @@ export async function createDevServer(options: {
|
|
|
331
353
|
if (bundled) {
|
|
332
354
|
return new Response(bundled, {
|
|
333
355
|
headers: {
|
|
356
|
+
...NO_CACHE,
|
|
334
357
|
"Content-Type": "application/javascript; charset=utf-8",
|
|
335
358
|
},
|
|
336
359
|
});
|
|
@@ -346,11 +369,11 @@ export async function createDevServer(options: {
|
|
|
346
369
|
if (handleSSE && path.endsWith(".html") && !path.startsWith("/packages/studio/")) {
|
|
347
370
|
const html = await file.text();
|
|
348
371
|
return new Response(injectSSE(html), {
|
|
349
|
-
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
372
|
+
headers: { ...NO_CACHE, "Content-Type": "text/html; charset=utf-8" },
|
|
350
373
|
});
|
|
351
374
|
}
|
|
352
375
|
|
|
353
|
-
return
|
|
376
|
+
return fileResponse(file);
|
|
354
377
|
},
|
|
355
378
|
|
|
356
379
|
port,
|
package/src/studio-api.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
} from "./packages.ts";
|
|
23
23
|
import type { FormatRegistry } from "@jxsuite/schema/format-registry";
|
|
24
24
|
import type { ClassJsonDef } from "./types.ts";
|
|
25
|
+
import type { DesignOptions } from "@jxsuite/create/generate";
|
|
25
26
|
import type { ProjectConfig } from "@jxsuite/schema/types";
|
|
26
27
|
|
|
27
28
|
/** Normalise a path to forward slashes (Windows `path` module returns backslashes). */
|
|
@@ -150,7 +151,7 @@ async function getFormatRegistry(projectRoot: string): Promise<FormatRegistry> {
|
|
|
150
151
|
* @param {string} root
|
|
151
152
|
* @param {string | null} activeProjectRoot
|
|
152
153
|
*/
|
|
153
|
-
function assertAccessible(filePath: string, root: string, activeProjectRoot: string | null) {
|
|
154
|
+
export function assertAccessible(filePath: string, root: string, activeProjectRoot: string | null) {
|
|
154
155
|
const rel = relative(root, filePath);
|
|
155
156
|
if (!rel.startsWith("..") && !rel.startsWith("/")) {
|
|
156
157
|
return;
|
|
@@ -331,9 +332,21 @@ export async function handleStudioApi(
|
|
|
331
332
|
return Response.json({ error: "Missing path param" }, { status: 400 });
|
|
332
333
|
}
|
|
333
334
|
try {
|
|
334
|
-
// Walk up
|
|
335
|
-
|
|
336
|
-
|
|
335
|
+
// Walk up looking for project.json. Accept a directory (e.g. the project root itself, as
|
|
336
|
+
// Deep links like ?project=/abs/my-site pass) by starting the walk AT the directory and
|
|
337
|
+
// Treating its project.json as the target file, so callers land on the home page.
|
|
338
|
+
let absFile = expandTilde(filePath);
|
|
339
|
+
let dir: string;
|
|
340
|
+
let isDir = false;
|
|
341
|
+
try {
|
|
342
|
+
isDir = statSync(absFile).isDirectory();
|
|
343
|
+
} catch {}
|
|
344
|
+
if (isDir) {
|
|
345
|
+
dir = absFile;
|
|
346
|
+
absFile = resolve(absFile, "project.json");
|
|
347
|
+
} else {
|
|
348
|
+
dir = dirname(absFile);
|
|
349
|
+
}
|
|
337
350
|
while (dir) {
|
|
338
351
|
const candidate = resolve(dir, "project.json");
|
|
339
352
|
if (existsSync(candidate)) {
|
|
@@ -424,11 +437,27 @@ export async function handleStudioApi(
|
|
|
424
437
|
url?: string;
|
|
425
438
|
adapter?: "static" | "cloudflare-pages" | "cloudflare-workers" | "node" | "bun";
|
|
426
439
|
directory?: string;
|
|
440
|
+
starter?: string;
|
|
441
|
+
template?: string;
|
|
442
|
+
design?: DesignOptions;
|
|
427
443
|
};
|
|
428
|
-
const {
|
|
444
|
+
const {
|
|
445
|
+
name,
|
|
446
|
+
description,
|
|
447
|
+
url: siteUrl,
|
|
448
|
+
adapter,
|
|
449
|
+
directory,
|
|
450
|
+
starter,
|
|
451
|
+
template,
|
|
452
|
+
design,
|
|
453
|
+
} = body;
|
|
429
454
|
if (!name || !directory) {
|
|
430
455
|
return Response.json({ error: "name and directory are required" }, { status: 400 });
|
|
431
456
|
}
|
|
457
|
+
const { isTemplateId } = await import("@jxsuite/create/templates");
|
|
458
|
+
if (template !== undefined && !isTemplateId(template)) {
|
|
459
|
+
return Response.json({ error: `Unknown template: "${template}"` }, { status: 400 });
|
|
460
|
+
}
|
|
432
461
|
const destPath = resolve(root, directory);
|
|
433
462
|
assertAccessible(destPath, root, activeProjectRoot);
|
|
434
463
|
|
|
@@ -438,6 +467,9 @@ export async function handleStudioApi(
|
|
|
438
467
|
...(adapter !== undefined ? { adapter } : {}),
|
|
439
468
|
...(description !== undefined ? { description } : {}),
|
|
440
469
|
...(siteUrl !== undefined ? { url: siteUrl } : {}),
|
|
470
|
+
...(starter !== undefined ? { starter } : {}),
|
|
471
|
+
...(template !== undefined && isTemplateId(template) ? { template } : {}),
|
|
472
|
+
...(design !== undefined ? { design } : {}),
|
|
441
473
|
});
|
|
442
474
|
|
|
443
475
|
const config = JSON.parse(
|
|
@@ -450,6 +482,16 @@ export async function handleStudioApi(
|
|
|
450
482
|
}
|
|
451
483
|
}
|
|
452
484
|
|
|
485
|
+
// List available starter templates (for the New Project picker)
|
|
486
|
+
if (path === "/__studio/starters" && req.method === "GET") {
|
|
487
|
+
try {
|
|
488
|
+
const { listStarters } = await import("@jxsuite/starters");
|
|
489
|
+
return Response.json(listStarters());
|
|
490
|
+
} catch (error) {
|
|
491
|
+
return Response.json({ error: errorMessage(error) }, { status: 500 });
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
453
495
|
// List files
|
|
454
496
|
if (path === "/__studio/files" && req.method === "GET") {
|
|
455
497
|
const dir = url.searchParams.get("dir") || activeProjectRoot || root;
|