@jxsuite/server 1.2.0 → 2.0.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 +12 -12
- package/src/build.ts +20 -17
- package/src/code-api.ts +35 -7
- package/src/net-guard.ts +19 -3
- package/src/project-server.ts +4 -3
- package/src/resolve.ts +29 -4
- package/src/server.ts +61 -10
- package/src/studio-api.ts +166 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/server",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Jx development server with live reload, server-side proxy, and studio integration",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -35,21 +35,21 @@
|
|
|
35
35
|
"test:coverage": "bun test --isolate --coverage"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@jxsuite/collab": "^0.
|
|
39
|
-
"@jxsuite/compiler": "^1.
|
|
40
|
-
"@jxsuite/create": "^1.2.
|
|
41
|
-
"@jxsuite/import": "^0.38.
|
|
42
|
-
"@jxsuite/protocol": "^0.
|
|
43
|
-
"@jxsuite/runtime": "^1.3.
|
|
44
|
-
"@jxsuite/schema": "^1.
|
|
45
|
-
"@jxsuite/starters": "^1.2.
|
|
38
|
+
"@jxsuite/collab": "^0.6.0",
|
|
39
|
+
"@jxsuite/compiler": "^1.5.0",
|
|
40
|
+
"@jxsuite/create": "^1.2.2",
|
|
41
|
+
"@jxsuite/import": "^0.38.2",
|
|
42
|
+
"@jxsuite/protocol": "^1.0.0",
|
|
43
|
+
"@jxsuite/runtime": "^1.3.2",
|
|
44
|
+
"@jxsuite/schema": "^1.5.0",
|
|
45
|
+
"@jxsuite/starters": "^1.2.2",
|
|
46
46
|
"chokidar": "^5.0.0",
|
|
47
47
|
"kysely": "^0.29.4",
|
|
48
48
|
"zod": "^4.4.3"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@jxsuite/auth": "^0.4.
|
|
52
|
-
"@jxsuite/connector": "^0.4.
|
|
53
|
-
"@jxsuite/parser": "^1.
|
|
51
|
+
"@jxsuite/auth": "^0.4.2",
|
|
52
|
+
"@jxsuite/connector": "^0.4.2",
|
|
53
|
+
"@jxsuite/parser": "^1.4.1"
|
|
54
54
|
}
|
|
55
55
|
}
|
package/src/build.ts
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
/** Build.js — Configurable Bun.build pipeline */
|
|
2
2
|
|
|
3
|
+
import type { BuildConfig } from "bun";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One watched build entry.
|
|
7
|
+
*
|
|
8
|
+
* `match` and `label` belong to the watcher; every other key is forwarded verbatim to `Bun.build`,
|
|
9
|
+
* so a consumer can supply `plugins`, `splitting`, `naming`, `define` and the rest. That forwarding
|
|
10
|
+
* was always the behaviour (the implementation spreads `...opts`) but was not in the type, so a
|
|
11
|
+
* caller had no way to know it could keep a watched rebuild byte-identical to its release build —
|
|
12
|
+
* and the repo's own dev server silently did not.
|
|
13
|
+
*/
|
|
14
|
+
export interface BuildEntry extends Partial<BuildConfig> {
|
|
15
|
+
entrypoints: string[];
|
|
16
|
+
outdir: string;
|
|
17
|
+
match?: ((path: string) => boolean) | RegExp;
|
|
18
|
+
label?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
3
21
|
/**
|
|
4
22
|
* Build all entries with sensible defaults (browser target, ESM, linked sourcemaps).
|
|
5
23
|
*
|
|
@@ -10,14 +28,7 @@
|
|
|
10
28
|
* label?: string;
|
|
11
29
|
* }[]} builds
|
|
12
30
|
*/
|
|
13
|
-
export async function buildAll(
|
|
14
|
-
builds: {
|
|
15
|
-
entrypoints: string[];
|
|
16
|
-
outdir: string;
|
|
17
|
-
match?: ((path: string) => boolean) | RegExp;
|
|
18
|
-
label?: string;
|
|
19
|
-
}[],
|
|
20
|
-
) {
|
|
31
|
+
export async function buildAll(builds: BuildEntry[]) {
|
|
21
32
|
for (const entry of builds) {
|
|
22
33
|
const { match: _match, label, ...opts } = entry;
|
|
23
34
|
const result = await Bun.build({
|
|
@@ -48,15 +59,7 @@ export async function buildAll(
|
|
|
48
59
|
* @param {string} changedFile
|
|
49
60
|
* @returns {Promise<{ rebuilt: string[]; success: boolean }>}
|
|
50
61
|
*/
|
|
51
|
-
export async function rebuild(
|
|
52
|
-
builds: {
|
|
53
|
-
entrypoints: string[];
|
|
54
|
-
outdir: string;
|
|
55
|
-
match?: ((path: string) => boolean) | RegExp;
|
|
56
|
-
label?: string;
|
|
57
|
-
}[],
|
|
58
|
-
changedFile: string,
|
|
59
|
-
) {
|
|
62
|
+
export async function rebuild(builds: BuildEntry[], changedFile: string) {
|
|
60
63
|
const rebuilt = [];
|
|
61
64
|
let ok = true;
|
|
62
65
|
for (const entry of builds) {
|
package/src/code-api.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
9
|
import { errorMessage } from "@jxsuite/schema/parse";
|
|
10
|
-
import {
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
11
12
|
import { unlink } from "node:fs/promises";
|
|
12
13
|
import { format } from "oxfmt";
|
|
13
14
|
|
|
@@ -26,11 +27,32 @@ interface CodeApiBody {
|
|
|
26
27
|
args?: string[];
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
const OXLINT_NAME = process.platform === "win32" ? "oxlint.exe" : "oxlint";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Locate the oxlint CLI: explicit JX_OXLINT_BIN override (empty value = disable lint outright),
|
|
34
|
+
* then a walk up from this module probing node_modules/.bin (finds the workspace root in the
|
|
35
|
+
* monorepo and the hoisted bin for npm consumers), then PATH. Null when oxlint is unavailable —
|
|
36
|
+
* e.g. the packaged desktop app, where the lint endpoint quietly returns no diagnostics.
|
|
37
|
+
*/
|
|
38
|
+
export function resolveOxlintBin(startDir: string = import.meta.dir): string | null {
|
|
39
|
+
const override = process.env.JX_OXLINT_BIN;
|
|
40
|
+
if (override !== undefined) {
|
|
41
|
+
return override || null;
|
|
42
|
+
}
|
|
43
|
+
let dir = startDir;
|
|
44
|
+
for (;;) {
|
|
45
|
+
const candidate = join(dir, "node_modules", ".bin", OXLINT_NAME);
|
|
46
|
+
if (existsSync(candidate)) {
|
|
47
|
+
return candidate;
|
|
48
|
+
}
|
|
49
|
+
const parent = dirname(dir);
|
|
50
|
+
if (parent === dir) {
|
|
51
|
+
return Bun.which("oxlint", { PATH: process.env.PATH ?? "" });
|
|
52
|
+
}
|
|
53
|
+
dir = parent;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
34
56
|
|
|
35
57
|
// ─── Wrapper utilities ───────────────────────────────────────────────────────
|
|
36
58
|
|
|
@@ -151,6 +173,12 @@ export async function handleCodeApi(req: Request, url: URL) {
|
|
|
151
173
|
return Response.json({ diagnostics: [] });
|
|
152
174
|
}
|
|
153
175
|
|
|
176
|
+
const oxlintBin = resolveOxlintBin();
|
|
177
|
+
if (!oxlintBin) {
|
|
178
|
+
// No oxlint anywhere (packaged desktop app) — lint quietly disables.
|
|
179
|
+
return Response.json({ diagnostics: [] });
|
|
180
|
+
}
|
|
181
|
+
|
|
154
182
|
const wrapped = wrapBody(code, args);
|
|
155
183
|
const headerLen = wrapped.indexOf("\n") + 1;
|
|
156
184
|
const tmpFile = join(
|
|
@@ -160,7 +188,7 @@ export async function handleCodeApi(req: Request, url: URL) {
|
|
|
160
188
|
|
|
161
189
|
try {
|
|
162
190
|
await Bun.write(tmpFile, wrapped);
|
|
163
|
-
const proc = Bun.spawn([
|
|
191
|
+
const proc = Bun.spawn([oxlintBin, "--format=json", "-A", "no-unused-vars", tmpFile], {
|
|
164
192
|
stderr: "pipe",
|
|
165
193
|
stdout: "pipe",
|
|
166
194
|
});
|
package/src/net-guard.ts
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
|
|
19
19
|
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
20
20
|
import { realpathSync } from "node:fs";
|
|
21
|
+
import { resolveAssetUrl } from "@jxsuite/schema/asset-paths";
|
|
22
|
+
import type { AssetMount } from "@jxsuite/schema/asset-paths";
|
|
21
23
|
|
|
22
24
|
export const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
|
|
23
25
|
|
|
@@ -151,14 +153,28 @@ export async function serveContained(absPath: string, root: string): Promise<Res
|
|
|
151
153
|
}
|
|
152
154
|
|
|
153
155
|
/**
|
|
154
|
-
* Try to serve a project file at its natural URL:
|
|
155
|
-
* public/. Each candidate goes through containedPath
|
|
156
|
-
* URL pathname (leading slash, forward slashes).
|
|
156
|
+
* Try to serve a project file at its natural URL: an extension asset mount, then
|
|
157
|
+
* absolute-under-root, then root-relative, then public/. Each candidate goes through containedPath
|
|
158
|
+
* (realpath). `decodedPath` is the once-decoded URL pathname (leading slash, forward slashes).
|
|
159
|
+
*
|
|
160
|
+
* Mounts come first and are contained against their own directory rather than the project root:
|
|
161
|
+
* they exist precisely to publish directories that may sit outside it (extensions.md §8.5).
|
|
157
162
|
*/
|
|
158
163
|
export async function serveProjectFile(
|
|
159
164
|
decodedPath: string,
|
|
160
165
|
root: string,
|
|
166
|
+
mounts: readonly AssetMount[] = [],
|
|
161
167
|
): Promise<Response | null> {
|
|
168
|
+
for (const mount of [...mounts].toSorted((a, b) => b.urlPrefix.length - a.urlPrefix.length)) {
|
|
169
|
+
const mounted = resolveAssetUrl([mount], decodedPath);
|
|
170
|
+
if (!mounted) {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const res = await serveContained(mounted, mount.dir);
|
|
174
|
+
if (res) {
|
|
175
|
+
return res;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
162
178
|
// Map the URL pathname back to a filesystem path. On Windows the browser requests an absolute
|
|
163
179
|
// Path as /C:/Users/… (leading slash + forward slashes); drop the slash before the drive letter.
|
|
164
180
|
// A POSIX absolute path arrives as //abs/path.
|
package/src/project-server.ts
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
import { resolve, sep } from "node:path";
|
|
29
29
|
import { handleJxMounts } from "./jx-mounts.ts";
|
|
30
|
-
import { handleResolve, handleServerFunction } from "./resolve.ts";
|
|
30
|
+
import { handleResolve, handleServerFunction, projectAssetMounts } from "./resolve.ts";
|
|
31
31
|
import { handleAiApi } from "./ai-api.ts";
|
|
32
32
|
import { handleImportApi } from "./import-api.ts";
|
|
33
33
|
import { resolveNpmPath } from "./server.ts";
|
|
@@ -206,8 +206,9 @@ export function createProjectServer(options: CreateProjectServerOptions): Projec
|
|
|
206
206
|
return handleServerFunction(req, root);
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
-
// 6. Project files at natural URLs.
|
|
210
|
-
|
|
209
|
+
// 6. Project files at natural URLs, including extension asset mounts (§8.5) — that is what
|
|
210
|
+
// Lets a canvas preview show an image a content entry references relative to itself.
|
|
211
|
+
const fileRes = await serveProjectFile(normPath, root, await projectAssetMounts(root));
|
|
211
212
|
if (fileRes) {
|
|
212
213
|
return fileRes;
|
|
213
214
|
}
|
package/src/resolve.ts
CHANGED
|
@@ -7,6 +7,8 @@ import { createRequire } from "node:module";
|
|
|
7
7
|
import { containedPath } from "./net-guard.ts";
|
|
8
8
|
import { buildProjectExtensionRegistry } from "@jxsuite/compiler/format-host";
|
|
9
9
|
import { loadProjectSections } from "@jxsuite/compiler/project-sections";
|
|
10
|
+
import { loadAssetMounts } from "@jxsuite/compiler/asset-mounts";
|
|
11
|
+
import type { AssetMount } from "@jxsuite/schema/asset-paths";
|
|
10
12
|
import type { DynamicClass } from "@jxsuite/runtime/types";
|
|
11
13
|
import type { JxClassDef, ProjectConfig } from "@jxsuite/schema/types";
|
|
12
14
|
|
|
@@ -53,7 +55,10 @@ function isImportable(p: string, root: string, activeProjectRoot: string | null)
|
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
/** Per-project context cache, invalidated when project.json changes on disk. */
|
|
56
|
-
const projectContextCache = new Map<
|
|
58
|
+
const projectContextCache = new Map<
|
|
59
|
+
string,
|
|
60
|
+
{ mtime: number; context: Record<string, unknown>; mounts: AssetMount[] }
|
|
61
|
+
>();
|
|
57
62
|
|
|
58
63
|
/**
|
|
59
64
|
* Lazy-load project context (project.json + extension-loaded sections) for class instantiation.
|
|
@@ -64,6 +69,12 @@ const projectContextCache = new Map<string, { mtime: number; context: Record<str
|
|
|
64
69
|
* @returns {Promise<Record<string, unknown> | null>} `{ config, root, ...sections }` or null
|
|
65
70
|
*/
|
|
66
71
|
async function loadProjectContext(projectRoot: string) {
|
|
72
|
+
const entry = await loadProjectEntry(projectRoot);
|
|
73
|
+
return entry?.context ?? null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Load (or reuse) the cached project context plus its extension asset mounts. */
|
|
77
|
+
async function loadProjectEntry(projectRoot: string) {
|
|
67
78
|
const projectJsonPath = resolve(projectRoot, "project.json");
|
|
68
79
|
if (!existsSync(projectJsonPath)) {
|
|
69
80
|
return null;
|
|
@@ -72,19 +83,33 @@ async function loadProjectContext(projectRoot: string) {
|
|
|
72
83
|
const { mtimeMs } = statSync(projectJsonPath);
|
|
73
84
|
const cached = projectContextCache.get(projectRoot);
|
|
74
85
|
if (cached && cached.mtime === mtimeMs) {
|
|
75
|
-
return cached
|
|
86
|
+
return cached;
|
|
76
87
|
}
|
|
77
88
|
const config = JSON.parse(readFileSync(projectJsonPath, "utf8")) as ProjectConfig;
|
|
78
89
|
const registry = await buildProjectExtensionRegistry(projectRoot, config);
|
|
79
90
|
const sections = await loadProjectSections(projectRoot, config, registry);
|
|
91
|
+
const { mounts } = await loadAssetMounts(registry, config, projectRoot);
|
|
80
92
|
const context = { config, root: projectRoot, ...sections };
|
|
81
|
-
|
|
82
|
-
|
|
93
|
+
const entry = { context, mounts, mtime: mtimeMs };
|
|
94
|
+
projectContextCache.set(projectRoot, entry);
|
|
95
|
+
return entry;
|
|
83
96
|
} catch {
|
|
84
97
|
return null;
|
|
85
98
|
}
|
|
86
99
|
}
|
|
87
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Extension asset mounts for a project (specs/extensions.md §8.5) — the directories the dev server
|
|
103
|
+
* serves at their site URLs, so content-relative images resolve exactly as they will once built.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} projectRoot
|
|
106
|
+
* @returns {Promise<AssetMount[]>} Mounts, or an empty list when there is no project here
|
|
107
|
+
*/
|
|
108
|
+
export async function projectAssetMounts(projectRoot: string): Promise<AssetMount[]> {
|
|
109
|
+
const entry = await loadProjectEntry(projectRoot);
|
|
110
|
+
return entry?.mounts ?? [];
|
|
111
|
+
}
|
|
112
|
+
|
|
88
113
|
/**
|
|
89
114
|
* Handle POST /**jx_resolve** — proxy $prototype + $src entries.
|
|
90
115
|
*
|
package/src/server.ts
CHANGED
|
@@ -13,14 +13,24 @@
|
|
|
13
13
|
* proxying, and studio filesystem integration as a single createDevServer() call.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { join, relative, resolve } from "node:path";
|
|
16
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
17
17
|
import { buildAll } from "./build.ts";
|
|
18
18
|
import { createCollabRegistry } from "./collab.ts";
|
|
19
19
|
import { createWatcher, injectSSE } from "./watch.ts";
|
|
20
20
|
import { handleJxMounts } from "./jx-mounts.ts";
|
|
21
|
-
import { handleResolve, handleServerFunction } from "./resolve.ts";
|
|
22
|
-
import {
|
|
23
|
-
|
|
21
|
+
import { handleResolve, handleServerFunction, projectAssetMounts } from "./resolve.ts";
|
|
22
|
+
import {
|
|
23
|
+
assertAccessible,
|
|
24
|
+
assertCreatableParent,
|
|
25
|
+
handleStudioApi,
|
|
26
|
+
isOwnedProjectDir,
|
|
27
|
+
} from "./studio-api.ts";
|
|
28
|
+
import {
|
|
29
|
+
containedPath,
|
|
30
|
+
decodeAndNormalizePath,
|
|
31
|
+
originHostGate,
|
|
32
|
+
serveProjectFile,
|
|
33
|
+
} from "./net-guard.ts";
|
|
24
34
|
import { handleCodeApi } from "./code-api.ts";
|
|
25
35
|
import { handleAiApi } from "./ai-api.ts";
|
|
26
36
|
import { handleImportApi } from "./import-api.ts";
|
|
@@ -212,6 +222,14 @@ export async function createDevServer(options: {
|
|
|
212
222
|
}
|
|
213
223
|
const absRoot = resolve(root);
|
|
214
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Projects created through Studio's New Project modal, which land wherever the user pointed the
|
|
227
|
+
* Location field — normally outside `absRoot`. Remembering them lets the very next
|
|
228
|
+
* `/__studio/activate` open what was just created without widening the filesystem API to
|
|
229
|
+
* arbitrary directories.
|
|
230
|
+
*/
|
|
231
|
+
const createdRoots: string[] = [];
|
|
232
|
+
|
|
215
233
|
// ─── Build pipeline ─────────────────────────────────────────────────────────
|
|
216
234
|
|
|
217
235
|
if (builds.length > 0) {
|
|
@@ -326,8 +344,10 @@ export async function createDevServer(options: {
|
|
|
326
344
|
}
|
|
327
345
|
|
|
328
346
|
// Activate project — tells the server which project root to use for static file fallback.
|
|
329
|
-
// Contain the requested root: it must be under absRoot
|
|
330
|
-
//
|
|
347
|
+
// Contain the requested root: it must be under absRoot, an explicitly allowed root, a
|
|
348
|
+
// Project this server just created, or an existing project of the account's own
|
|
349
|
+
// (`isOwnedProjectDir`), so a hostile activate cannot widen the filesystem API to arbitrary
|
|
350
|
+
// Directories.
|
|
331
351
|
if (path === "/__studio/activate" && req.method === "POST") {
|
|
332
352
|
const body = (await req.json()) as { root?: string };
|
|
333
353
|
const raw = body.root || null;
|
|
@@ -338,7 +358,9 @@ export async function createDevServer(options: {
|
|
|
338
358
|
const requested = resolve(absRoot, raw);
|
|
339
359
|
const permitted =
|
|
340
360
|
containedPath(requested, absRoot) !== null ||
|
|
341
|
-
allowedRoots.some((allowed) => containedPath(requested, resolve(allowed)) !== null)
|
|
361
|
+
allowedRoots.some((allowed) => containedPath(requested, resolve(allowed)) !== null) ||
|
|
362
|
+
createdRoots.some((created) => containedPath(requested, created) !== null) ||
|
|
363
|
+
isOwnedProjectDir(requested);
|
|
342
364
|
if (!permitted) {
|
|
343
365
|
return Response.json({ ok: false, error: "root not permitted" }, { status: 403 });
|
|
344
366
|
}
|
|
@@ -352,14 +374,26 @@ export async function createDevServer(options: {
|
|
|
352
374
|
return aiRes;
|
|
353
375
|
}
|
|
354
376
|
|
|
355
|
-
// AI-guided site import (/__studio/import-site) — NDJSON progress stream
|
|
377
|
+
// AI-guided site import (/__studio/import-site) — NDJSON progress stream. An ABSOLUTE
|
|
378
|
+
// Directory is a destination the user chose in the New Project modal, so it is vetted the
|
|
379
|
+
// Same way a create is (specs/server.md §4.2). A RELATIVE one predates that field and stays
|
|
380
|
+
// Contained to the server root — resolving it there and then widening the check to the
|
|
381
|
+
// Creatable roots would let "../escape" out.
|
|
356
382
|
const importRes = await handleImportApi(req, url, {
|
|
357
383
|
resolveDest: (dir) => {
|
|
384
|
+
if (isAbsolute(dir)) {
|
|
385
|
+
assertCreatableParent(dirname(dir), absRoot, allowedRoots);
|
|
386
|
+
createdRoots.push(dir);
|
|
387
|
+
return dir;
|
|
388
|
+
}
|
|
358
389
|
const dest = resolve(absRoot, dir);
|
|
359
390
|
assertAccessible(dest, absRoot, activeProjectRoot);
|
|
360
391
|
return dest;
|
|
361
392
|
},
|
|
362
|
-
toRoot: (dest) =>
|
|
393
|
+
toRoot: (dest) =>
|
|
394
|
+
containedPath(dest, absRoot) === null
|
|
395
|
+
? dest
|
|
396
|
+
: relative(absRoot, dest).replaceAll("\\", "/"),
|
|
363
397
|
});
|
|
364
398
|
if (importRes) {
|
|
365
399
|
return importRes;
|
|
@@ -370,7 +404,10 @@ export async function createDevServer(options: {
|
|
|
370
404
|
return codeRes;
|
|
371
405
|
}
|
|
372
406
|
|
|
373
|
-
const res = await handleStudioApi(req, url, absRoot, activeProjectRoot
|
|
407
|
+
const res = await handleStudioApi(req, url, absRoot, activeProjectRoot, {
|
|
408
|
+
allowedRoots,
|
|
409
|
+
onProjectCreated: (created) => createdRoots.push(created),
|
|
410
|
+
});
|
|
374
411
|
if (res) {
|
|
375
412
|
return res;
|
|
376
413
|
}
|
|
@@ -403,6 +440,20 @@ export async function createDevServer(options: {
|
|
|
403
440
|
}
|
|
404
441
|
}
|
|
405
442
|
|
|
443
|
+
// Extension asset mounts (§8.5) publish directories that may sit outside the project root,
|
|
444
|
+
// Such as an external content source's co-located images. They resolve here exactly as they
|
|
445
|
+
// Will once the site is built, so dev and production render the same URLs.
|
|
446
|
+
if (activeProjectRoot) {
|
|
447
|
+
const mountRes = await serveProjectFile(
|
|
448
|
+
path,
|
|
449
|
+
activeProjectRoot,
|
|
450
|
+
await projectAssetMounts(activeProjectRoot),
|
|
451
|
+
);
|
|
452
|
+
if (mountRes) {
|
|
453
|
+
return new Response(mountRes.body, { headers: NO_CACHE });
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
406
457
|
const rootSafe = containedPath(resolve(absRoot, `.${path}`), absRoot);
|
|
407
458
|
const file = rootSafe ? Bun.file(rootSafe) : null;
|
|
408
459
|
if (!file || !(await file.exists())) {
|
package/src/studio-api.ts
CHANGED
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
11
11
|
import { errorMessage } from "@jxsuite/schema/parse";
|
|
12
12
|
import { mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
13
|
-
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
13
|
+
import { existsSync, readFileSync, statSync, unlinkSync } from "node:fs";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { LOCATION_ID_FILE } from "@jxsuite/protocol/routes";
|
|
14
16
|
import {
|
|
15
17
|
buildExtensionsPayload,
|
|
16
18
|
buildProjectExtensionRegistry,
|
|
@@ -175,6 +177,57 @@ export function assertAccessible(filePath: string, root: string, activeProjectRo
|
|
|
175
177
|
throw new Error("Path outside project root");
|
|
176
178
|
}
|
|
177
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Check that a NEW project may be scaffolded under `parent` (specs/server.md §4.2).
|
|
182
|
+
*
|
|
183
|
+
* Deliberately wider than {@link assertAccessible}: a new project is normally created _outside_ the
|
|
184
|
+
* server root — the whole point of the modal's Location field — so root containment cannot apply.
|
|
185
|
+
* What it still refuses is a destination the user did not name (relative paths) and anything
|
|
186
|
+
* outside the places a person keeps projects: the server root, any configured `allowedRoots`, and
|
|
187
|
+
* the account's home directory. That keeps a hostile page on the loopback origin from scaffolding
|
|
188
|
+
* into system paths while leaving every realistic location reachable.
|
|
189
|
+
*
|
|
190
|
+
* @param {string} parent
|
|
191
|
+
* @param {string} root
|
|
192
|
+
* @param {string[]} allowedRoots
|
|
193
|
+
*/
|
|
194
|
+
export function assertCreatableParent(parent: string, root: string, allowedRoots: string[] = []) {
|
|
195
|
+
if (!parent || !isAbsolute(parent)) {
|
|
196
|
+
throw new Error("Destination folder must be an absolute path");
|
|
197
|
+
}
|
|
198
|
+
const bases = [root, ...allowedRoots, homedir()].filter(Boolean);
|
|
199
|
+
if (bases.some((base) => containedPath(parent, resolve(base)) !== null)) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
throw new Error(
|
|
203
|
+
"Destination folder is outside the permitted roots (the server root, an allowed root, or your home directory)",
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* True when `dir` is a Jx project this account already owns: an absolute directory holding a
|
|
209
|
+
* `project.json`, somewhere under the user's home directory (specs/server.md §4.2).
|
|
210
|
+
*
|
|
211
|
+
* Existing projects live outside the server root as a matter of course — the dev server serving
|
|
212
|
+
* this monorepo is not where anyone keeps their sites — so root containment alone would leave
|
|
213
|
+
* `?project=/abs/path`, the Open Project picker, and the recent-projects list unable to activate
|
|
214
|
+
* anything but a project inside the checkout. This admits those without opening the filesystem API
|
|
215
|
+
* to arbitrary directories: a hostile page on the loopback origin still cannot bind the server to
|
|
216
|
+
* `/etc` or another account's files, only to a project the account already has on disk.
|
|
217
|
+
*
|
|
218
|
+
* @param {string} dir
|
|
219
|
+
*/
|
|
220
|
+
export function isOwnedProjectDir(dir: string): boolean {
|
|
221
|
+
const home = homedir();
|
|
222
|
+
if (!home || !dir || !isAbsolute(dir)) {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
if (containedPath(dir, resolve(home)) === null) {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
return existsSync(resolve(dir, "project.json"));
|
|
229
|
+
}
|
|
230
|
+
|
|
178
231
|
const statusMap: Record<string, string> = {
|
|
179
232
|
A: "A",
|
|
180
233
|
C: "C",
|
|
@@ -253,6 +306,18 @@ export function parseGitStatus(out: string) {
|
|
|
253
306
|
return { ahead, behind, branch, files };
|
|
254
307
|
}
|
|
255
308
|
|
|
309
|
+
/** Host hooks for the routes that reach outside the server root (project creation). */
|
|
310
|
+
export interface StudioApiOptions {
|
|
311
|
+
/** Extra roots a new project may be created under, mirroring `createDevServer.allowedRoots`. */
|
|
312
|
+
allowedRoots?: string[];
|
|
313
|
+
/**
|
|
314
|
+
* Called with the absolute root of a project this API just created, so the host can permit a
|
|
315
|
+
* subsequent `/__studio/activate` on it — a project created outside the server root is otherwise
|
|
316
|
+
* unreachable by the very request that made it.
|
|
317
|
+
*/
|
|
318
|
+
onProjectCreated?: (projectRoot: string) => void;
|
|
319
|
+
}
|
|
320
|
+
|
|
256
321
|
/**
|
|
257
322
|
* Handle /__studio/* requests.
|
|
258
323
|
*
|
|
@@ -260,6 +325,7 @@ export function parseGitStatus(out: string) {
|
|
|
260
325
|
* @param {URL} url
|
|
261
326
|
* @param {string} root
|
|
262
327
|
* @param {string | null} [activeProjectRoot]
|
|
328
|
+
* @param {StudioApiOptions} [opts]
|
|
263
329
|
* @returns {Promise<Response | null>}
|
|
264
330
|
*/
|
|
265
331
|
export async function handleStudioApi(
|
|
@@ -267,6 +333,7 @@ export async function handleStudioApi(
|
|
|
267
333
|
url: URL,
|
|
268
334
|
root: string,
|
|
269
335
|
activeProjectRoot: string | null = null,
|
|
336
|
+
opts: StudioApiOptions = {},
|
|
270
337
|
) {
|
|
271
338
|
const path = url.pathname;
|
|
272
339
|
|
|
@@ -416,6 +483,74 @@ export async function handleStudioApi(
|
|
|
416
483
|
}
|
|
417
484
|
}
|
|
418
485
|
|
|
486
|
+
/*
|
|
487
|
+
* Recover the absolute path of a directory the browser picked with `showDirectoryPicker()`.
|
|
488
|
+
*
|
|
489
|
+
* A `FileSystemDirectoryHandle` exposes only `.name`, never a filesystem path (§8.2), and unlike
|
|
490
|
+
* /__studio/find-project there is no `project.json` to search for — the whole point is that the
|
|
491
|
+
* folder is empty. So the client tags the folder with a hidden LOCATION_ID_FILE holding a random
|
|
492
|
+
* id and asks us which directory carries that id.
|
|
493
|
+
*
|
|
494
|
+
* Identity lives in the file's CONTENTS, not its name: matching the id is exact where matching a
|
|
495
|
+
* path shape is only probable — two folders can share a basename, and a file left behind by a
|
|
496
|
+
* crashed session can still be on disk. A candidate whose contents do not equal `id` is skipped,
|
|
497
|
+
* so a stale tag can never redirect a create. The winning file is deleted here, the moment it has
|
|
498
|
+
* served its purpose, rather than being left for the client to tidy up.
|
|
499
|
+
*/
|
|
500
|
+
if (path === "/__studio/locate-directory" && req.method === "GET") {
|
|
501
|
+
const name = url.searchParams.get("name");
|
|
502
|
+
const id = url.searchParams.get("id");
|
|
503
|
+
// `name` is interpolated into a glob; the id is only ever compared, never used as a pattern.
|
|
504
|
+
if (!name || !id || !/^[a-f0-9]{32}$/.test(id) || !/^[^/\\]+$/.test(name)) {
|
|
505
|
+
return Response.json({ error: "Missing or invalid name/id" }, { status: 400 });
|
|
506
|
+
}
|
|
507
|
+
try {
|
|
508
|
+
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
509
|
+
if (!home) {
|
|
510
|
+
return Response.json({ path: null });
|
|
511
|
+
}
|
|
512
|
+
/** The tagged directory when this candidate holds our id, else null. */
|
|
513
|
+
const claim = (dir: string): string | null => {
|
|
514
|
+
const file = resolve(dir, LOCATION_ID_FILE);
|
|
515
|
+
try {
|
|
516
|
+
if (readFileSync(file, "utf8").trim() !== id) {
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
} catch {
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
try {
|
|
523
|
+
unlinkSync(file);
|
|
524
|
+
} catch {
|
|
525
|
+
// Read-only or already gone; the client's own cleanup is the backstop.
|
|
526
|
+
}
|
|
527
|
+
return dir;
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
// The home directory itself is a legitimate pick, and no `**/<name>/` glob can match it.
|
|
531
|
+
const atHome = claim(home);
|
|
532
|
+
if (atHome) {
|
|
533
|
+
return Response.json({ path: atHome });
|
|
534
|
+
}
|
|
535
|
+
// `dot: true` — the tag is a dotfile, so a dot-blind scan would never see it.
|
|
536
|
+
const glob = new Bun.Glob(`**/${name}/${LOCATION_ID_FILE}`);
|
|
537
|
+
try {
|
|
538
|
+
for await (const match of glob.scan({ cwd: home, dot: true })) {
|
|
539
|
+
if (match.includes("node_modules") || match.includes(".Trash")) {
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
const claimed = claim(resolve(home, dirname(match)));
|
|
543
|
+
if (claimed) {
|
|
544
|
+
return Response.json({ path: claimed });
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
} catch {}
|
|
548
|
+
return Response.json({ path: null });
|
|
549
|
+
} catch (error) {
|
|
550
|
+
return Response.json({ error: errorMessage(error) }, { status: 500 });
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
419
554
|
// Discover site projects — find all project.json files under root
|
|
420
555
|
if (path === "/__studio/sites" && req.method === "GET") {
|
|
421
556
|
try {
|
|
@@ -453,6 +588,7 @@ export async function handleStudioApi(
|
|
|
453
588
|
url?: string;
|
|
454
589
|
adapter?: "static" | "cloudflare-pages" | "cloudflare-workers" | "node" | "bun";
|
|
455
590
|
directory?: string;
|
|
591
|
+
destination?: { kind?: string; parent?: string };
|
|
456
592
|
starter?: string;
|
|
457
593
|
template?: string;
|
|
458
594
|
design?: DesignOptions;
|
|
@@ -463,6 +599,7 @@ export async function handleStudioApi(
|
|
|
463
599
|
url: siteUrl,
|
|
464
600
|
adapter,
|
|
465
601
|
directory,
|
|
602
|
+
destination,
|
|
466
603
|
starter,
|
|
467
604
|
template,
|
|
468
605
|
design,
|
|
@@ -470,12 +607,32 @@ export async function handleStudioApi(
|
|
|
470
607
|
if (!name || !directory) {
|
|
471
608
|
return Response.json({ error: "name and directory are required" }, { status: 400 });
|
|
472
609
|
}
|
|
610
|
+
// The destination is the user's to choose (specs/server.md §4.2). Without one the project
|
|
611
|
+
// Would silently land under the server root — which, when the dev server is the jx checkout,
|
|
612
|
+
// Means scaffolding into the monorepo. Refuse instead of guessing.
|
|
613
|
+
if (destination?.kind !== "path" || !destination.parent) {
|
|
614
|
+
return Response.json({ error: "A destination folder is required." }, { status: 400 });
|
|
615
|
+
}
|
|
616
|
+
// `directory` names the project FOLDER, not a path: a separator or dot-segment would walk out
|
|
617
|
+
// Of the parent that was just vetted below, which is exactly what the vetting is for.
|
|
618
|
+
if (/[/\\]/.test(directory) || directory === "." || directory === "..") {
|
|
619
|
+
return Response.json(
|
|
620
|
+
{ error: "Directory must be a folder name, not a path" },
|
|
621
|
+
{ status: 400 },
|
|
622
|
+
);
|
|
623
|
+
}
|
|
473
624
|
const { isTemplateId } = await import("@jxsuite/create/templates");
|
|
474
625
|
if (template !== undefined && !isTemplateId(template)) {
|
|
475
626
|
return Response.json({ error: `Unknown template: "${template}"` }, { status: 400 });
|
|
476
627
|
}
|
|
477
|
-
|
|
478
|
-
|
|
628
|
+
// A bad destination is client input, so answer 400 rather than letting the guard's throw fall
|
|
629
|
+
// Through to the catch-all 500 below.
|
|
630
|
+
try {
|
|
631
|
+
assertCreatableParent(destination.parent, root, opts.allowedRoots);
|
|
632
|
+
} catch (error) {
|
|
633
|
+
return Response.json({ error: errorMessage(error) }, { status: 400 });
|
|
634
|
+
}
|
|
635
|
+
const destPath = resolve(destination.parent, directory);
|
|
479
636
|
|
|
480
637
|
const { generateProject } = await import("@jxsuite/create/generate");
|
|
481
638
|
await generateProject(destPath, {
|
|
@@ -491,8 +648,12 @@ export async function handleStudioApi(
|
|
|
491
648
|
const config = JSON.parse(
|
|
492
649
|
await readFile(resolve(destPath, "project.json"), "utf8"),
|
|
493
650
|
) as ProjectConfig;
|
|
494
|
-
|
|
495
|
-
|
|
651
|
+
// Root-relative while the project happens to sit under the server root (the historical
|
|
652
|
+
// Shape every caller already handles); absolute otherwise, which is the same shape
|
|
653
|
+
// /__studio/find-project returns for an external project.
|
|
654
|
+
const inRoot = containedPath(destPath, root) !== null;
|
|
655
|
+
opts.onProjectCreated?.(destPath);
|
|
656
|
+
return Response.json({ config, root: inRoot ? fwd(relative(root, destPath)) : destPath });
|
|
496
657
|
} catch (error) {
|
|
497
658
|
return Response.json({ error: errorMessage(error) }, { status: 500 });
|
|
498
659
|
}
|