@jxsuite/server 1.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/server",
3
- "version": "1.3.0",
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.5.1",
39
- "@jxsuite/compiler": "^1.4.0",
40
- "@jxsuite/create": "^1.2.1",
41
- "@jxsuite/import": "^0.38.1",
42
- "@jxsuite/protocol": "^0.6.1",
43
- "@jxsuite/runtime": "^1.3.1",
44
- "@jxsuite/schema": "^1.4.0",
45
- "@jxsuite/starters": "^1.2.1",
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.1",
52
- "@jxsuite/connector": "^0.4.1",
53
- "@jxsuite/parser": "^1.4.0"
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/server.ts CHANGED
@@ -13,13 +13,18 @@
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
21
  import { handleResolve, handleServerFunction, projectAssetMounts } from "./resolve.ts";
22
- import { assertAccessible, handleStudioApi } from "./studio-api.ts";
22
+ import {
23
+ assertAccessible,
24
+ assertCreatableParent,
25
+ handleStudioApi,
26
+ isOwnedProjectDir,
27
+ } from "./studio-api.ts";
23
28
  import {
24
29
  containedPath,
25
30
  decodeAndNormalizePath,
@@ -217,6 +222,14 @@ export async function createDevServer(options: {
217
222
  }
218
223
  const absRoot = resolve(root);
219
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
+
220
233
  // ─── Build pipeline ─────────────────────────────────────────────────────────
221
234
 
222
235
  if (builds.length > 0) {
@@ -331,8 +344,10 @@ export async function createDevServer(options: {
331
344
  }
332
345
 
333
346
  // Activate project — tells the server which project root to use for static file fallback.
334
- // Contain the requested root: it must be under absRoot or an explicitly allowed root, so a
335
- // Hostile activate cannot widen the filesystem API to arbitrary directories.
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.
336
351
  if (path === "/__studio/activate" && req.method === "POST") {
337
352
  const body = (await req.json()) as { root?: string };
338
353
  const raw = body.root || null;
@@ -343,7 +358,9 @@ export async function createDevServer(options: {
343
358
  const requested = resolve(absRoot, raw);
344
359
  const permitted =
345
360
  containedPath(requested, absRoot) !== null ||
346
- 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);
347
364
  if (!permitted) {
348
365
  return Response.json({ ok: false, error: "root not permitted" }, { status: 403 });
349
366
  }
@@ -357,14 +374,26 @@ export async function createDevServer(options: {
357
374
  return aiRes;
358
375
  }
359
376
 
360
- // 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.
361
382
  const importRes = await handleImportApi(req, url, {
362
383
  resolveDest: (dir) => {
384
+ if (isAbsolute(dir)) {
385
+ assertCreatableParent(dirname(dir), absRoot, allowedRoots);
386
+ createdRoots.push(dir);
387
+ return dir;
388
+ }
363
389
  const dest = resolve(absRoot, dir);
364
390
  assertAccessible(dest, absRoot, activeProjectRoot);
365
391
  return dest;
366
392
  },
367
- toRoot: (dest) => relative(absRoot, dest).replaceAll("\\", "/"),
393
+ toRoot: (dest) =>
394
+ containedPath(dest, absRoot) === null
395
+ ? dest
396
+ : relative(absRoot, dest).replaceAll("\\", "/"),
368
397
  });
369
398
  if (importRes) {
370
399
  return importRes;
@@ -375,7 +404,10 @@ export async function createDevServer(options: {
375
404
  return codeRes;
376
405
  }
377
406
 
378
- 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
+ });
379
411
  if (res) {
380
412
  return res;
381
413
  }
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
- const destPath = resolve(root, directory);
478
- assertAccessible(destPath, root, activeProjectRoot);
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
- const projectRoot = fwd(relative(root, destPath));
495
- return Response.json({ config, root: projectRoot });
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
  }