@jxsuite/server 0.34.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.34.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,6 +15,7 @@
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",
19
20
  "./project-server": "./src/project-server.ts",
20
21
  "./refactor": "./src/refactor/index.ts",
@@ -30,11 +31,13 @@
30
31
  "test:coverage": "bun test --isolate --coverage"
31
32
  },
32
33
  "dependencies": {
33
- "@jxsuite/compiler": "^0.34.0",
34
- "@jxsuite/create": "^0.34.0",
35
- "@jxsuite/parser": "^0.34.0",
36
- "@jxsuite/runtime": "^0.34.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",
37
39
  "@jxsuite/schema": "^0.34.0",
40
+ "@jxsuite/starters": "^0.35.0",
38
41
  "chokidar": "^5.0.0",
39
42
  "zod": "^4.4.3"
40
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
+ }
@@ -29,7 +29,9 @@ import { isAbsolute, relative, resolve, sep } from "node:path";
29
29
  import { realpathSync } from "node:fs";
30
30
  import { handleResolve, handleServerFunction } from "./resolve.ts";
31
31
  import { handleAiApi } from "./ai-api.ts";
32
+ import { handleImportApi } from "./import-api.ts";
32
33
  import { resolveNpmPath } from "./server.ts";
34
+ import type { ImportApiOptions } from "./import-api.ts";
33
35
 
34
36
  /** A resolved per-window session: its project root plus its RPC handler map. */
35
37
  export interface ProjectServerSession {
@@ -52,6 +54,11 @@ export interface CreateProjectServerOptions {
52
54
  hostname?: string;
53
55
  /** Bind port. Defaults to 0 (ephemeral). */
54
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;
55
62
  }
56
63
 
57
64
  export interface ProjectServerHandle {
@@ -273,6 +280,25 @@ export function createProjectServer(options: CreateProjectServerOptions): Projec
273
280
  }
274
281
  }
275
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
+
276
302
  // 3. Studio assets under the non-colliding /__studio__/ namespace.
277
303
  if (normPath.startsWith("/__studio__/")) {
278
304
  const assetRel = normPath.slice("/__studio__/".length);
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
  /**
@@ -267,6 +268,19 @@ export async function createDevServer(options: {
267
268
  return aiRes;
268
269
  }
269
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
+
270
284
  const codeRes = await handleCodeApi(req, url);
271
285
  if (codeRes) {
272
286
  return codeRes;
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;