@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/server",
3
- "version": "0.33.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.0",
33
- "@jxsuite/create": "^0.33.0",
34
- "@jxsuite/parser": "^0.33.0",
35
- "@jxsuite/runtime": "^0.33.0",
36
- "@jxsuite/schema": "^0.33.0",
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 new Response(file);
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 new Response(projectFile);
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 new Response(publicFile);
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 new Response(file);
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 from file's directory looking for project.json
335
- const absFile = expandTilde(filePath);
336
- let dir = dirname(absFile);
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 { name, description, url: siteUrl, adapter, directory } = body;
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;