@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 +8 -5
- package/src/import-api.ts +202 -0
- package/src/project-server.ts +26 -0
- package/src/server.ts +16 -2
- 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,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.
|
|
34
|
-
"@jxsuite/create": "^0.
|
|
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",
|
|
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
|
+
}
|
package/src/project-server.ts
CHANGED
|
@@ -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
|
|
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;
|