@jxsuite/server 1.3.0 → 2.1.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/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,
@@ -29,7 +34,9 @@ import {
29
34
  import { handleCodeApi } from "./code-api.ts";
30
35
  import { handleAiApi } from "./ai-api.ts";
31
36
  import { handleImportApi } from "./import-api.ts";
37
+ import { mediaTypeForPath } from "@jxsuite/schema/media-type";
32
38
  import { existsSync, readFileSync } from "node:fs";
39
+ import { problem } from "./problem.ts";
33
40
 
34
41
  /**
35
42
  * Resolve an npm-style bare specifier from a URL path via node_modules. Handles scoped packages
@@ -217,6 +224,14 @@ export async function createDevServer(options: {
217
224
  }
218
225
  const absRoot = resolve(root);
219
226
 
227
+ /**
228
+ * Projects created through Studio's New Project modal, which land wherever the user pointed the
229
+ * Location field — normally outside `absRoot`. Remembering them lets the very next
230
+ * `/__studio/activate` open what was just created without widening the filesystem API to
231
+ * arbitrary directories.
232
+ */
233
+ const createdRoots: string[] = [];
234
+
220
235
  // ─── Build pipeline ─────────────────────────────────────────────────────────
221
236
 
222
237
  if (builds.length > 0) {
@@ -242,8 +257,19 @@ export async function createDevServer(options: {
242
257
  // Studio.js/canvas bundle — which shows up as half-updated UI after a rebuild (the iframe assets
243
258
  // Are query-cache-busted per mount, the parent bundle is not).
244
259
  const NO_CACHE = { "Cache-Control": "no-cache" };
245
- const fileResponse = (file: ReturnType<typeof Bun.file>) =>
246
- new Response(file, { headers: NO_CACHE });
260
+ /*
261
+ * `Bun.file` already infers a type from the extension, and for almost everything it is right. The
262
+ * exceptions are the two RFCs Jx has an opinion about: `.md` comes back as bare `text/markdown`
263
+ * with no `variant`, and `.yaml` comes back as `text/yaml`, the spelling RFC 9512 §5 asks
264
+ * implementations to stop using. `mediaTypeForPath` answers null everywhere else, so the
265
+ * inference stays in charge of the ordinary cases.
266
+ */
267
+ const fileResponse = (file: ReturnType<typeof Bun.file>) => {
268
+ const corrected = file.name === undefined ? null : mediaTypeForPath(file.name);
269
+ return new Response(file, {
270
+ headers: corrected === null ? NO_CACHE : { ...NO_CACHE, "Content-Type": corrected },
271
+ });
272
+ };
247
273
 
248
274
  // Active studio project root (set via /__studio/activate, used for static file fallback)
249
275
  let activeProjectRoot: string | null = null;
@@ -283,7 +309,7 @@ export async function createDevServer(options: {
283
309
 
284
310
  // SSE live reload
285
311
  if (handleSSE && path === "/__reload") {
286
- return handleSSE();
312
+ return handleSSE(req);
287
313
  }
288
314
 
289
315
  /*
@@ -331,8 +357,10 @@ export async function createDevServer(options: {
331
357
  }
332
358
 
333
359
  // 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.
360
+ // Contain the requested root: it must be under absRoot, an explicitly allowed root, a
361
+ // Project this server just created, or an existing project of the account's own
362
+ // (`isOwnedProjectDir`), so a hostile activate cannot widen the filesystem API to arbitrary
363
+ // Directories.
336
364
  if (path === "/__studio/activate" && req.method === "POST") {
337
365
  const body = (await req.json()) as { root?: string };
338
366
  const raw = body.root || null;
@@ -343,9 +371,13 @@ export async function createDevServer(options: {
343
371
  const requested = resolve(absRoot, raw);
344
372
  const permitted =
345
373
  containedPath(requested, absRoot) !== null ||
346
- allowedRoots.some((allowed) => containedPath(requested, resolve(allowed)) !== null);
374
+ allowedRoots.some((allowed) => containedPath(requested, resolve(allowed)) !== null) ||
375
+ createdRoots.some((created) => containedPath(requested, created) !== null) ||
376
+ isOwnedProjectDir(requested);
347
377
  if (!permitted) {
348
- return Response.json({ ok: false, error: "root not permitted" }, { status: 403 });
378
+ // `ok: false` rides along as an extension member: the Studio client branches on it,
379
+ // And RFC 9457 §3.2 exists precisely so a problem can carry what a type documents.
380
+ return problem("forbidden", "root not permitted", { ok: false });
349
381
  }
350
382
  activeProjectRoot = requested;
351
383
  return Response.json({ ok: true, root: activeProjectRoot });
@@ -357,14 +389,26 @@ export async function createDevServer(options: {
357
389
  return aiRes;
358
390
  }
359
391
 
360
- // AI-guided site import (/__studio/import-site) — NDJSON progress stream
392
+ // AI-guided site import (/__studio/import-site) — NDJSON progress stream. An ABSOLUTE
393
+ // Directory is a destination the user chose in the New Project modal, so it is vetted the
394
+ // Same way a create is (specs/server.md §4.2). A RELATIVE one predates that field and stays
395
+ // Contained to the server root — resolving it there and then widening the check to the
396
+ // Creatable roots would let "../escape" out.
361
397
  const importRes = await handleImportApi(req, url, {
362
398
  resolveDest: (dir) => {
399
+ if (isAbsolute(dir)) {
400
+ assertCreatableParent(dirname(dir), absRoot, allowedRoots);
401
+ createdRoots.push(dir);
402
+ return dir;
403
+ }
363
404
  const dest = resolve(absRoot, dir);
364
405
  assertAccessible(dest, absRoot, activeProjectRoot);
365
406
  return dest;
366
407
  },
367
- toRoot: (dest) => relative(absRoot, dest).replaceAll("\\", "/"),
408
+ toRoot: (dest) =>
409
+ containedPath(dest, absRoot) === null
410
+ ? dest
411
+ : relative(absRoot, dest).replaceAll("\\", "/"),
368
412
  });
369
413
  if (importRes) {
370
414
  return importRes;
@@ -375,7 +419,10 @@ export async function createDevServer(options: {
375
419
  return codeRes;
376
420
  }
377
421
 
378
- const res = await handleStudioApi(req, url, absRoot, activeProjectRoot);
422
+ const res = await handleStudioApi(req, url, absRoot, activeProjectRoot, {
423
+ allowedRoots,
424
+ onProjectCreated: (created) => createdRoots.push(created),
425
+ });
379
426
  if (res) {
380
427
  return res;
381
428
  }
@@ -418,7 +465,15 @@ export async function createDevServer(options: {
418
465
  await projectAssetMounts(activeProjectRoot),
419
466
  );
420
467
  if (mountRes) {
421
- return new Response(mountRes.body, { headers: NO_CACHE });
468
+ // Carry the resolved response's own headers over. Re-wrapping just the BODY discards the
469
+ // Content-Type Bun inferred from the file, and a JavaScript file that arrives without one
470
+ // Is refused outright by strict MIME checking the moment a page loads it as a MODULE — so
471
+ // A previewed site kept its styles (link elements sniff) and silently lost its scripts.
472
+ const headers = new Headers(mountRes.headers);
473
+ for (const [name, value] of Object.entries(NO_CACHE)) {
474
+ headers.set(name, value);
475
+ }
476
+ return new Response(mountRes.body, { headers });
422
477
  }
423
478
  }
424
479
 
@@ -479,7 +534,14 @@ export async function createDevServer(options: {
479
534
  });
480
535
  }
481
536
  }
482
- return new Response("Not found", { status: 404 });
537
+ /* The project's BUILT SITE is deliberately NOT served here, at any position in this chain.
538
+ This server's URL space means the project's SOURCES — that is what the canvas reads —
539
+ and a built page addresses the same paths meaning its own output
540
+ (`/components/fetch-demo.js` is the formulas here and the custom element there). Filling
541
+ 404s from the output looked like it worked and handed the reader whichever of the two
542
+ happened to be missing. `site-preview.ts` gives the built site its own origin instead,
543
+ where every path has exactly one meaning. */
544
+ return problem("notFound", "Not found");
483
545
  }
484
546
 
485
547
  // Inject the live-reload script into served HTML — but NOT into the Studio editor.
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Serve a project's BUILT output at the routes the built pages actually reference.
3
+ *
4
+ * A built page is written for its published origin: `dist/basics/counter/index.html` links to
5
+ * `/basics/counter` and pulls `/components/fetch-demo.css`, both root-absolute. Studio's `View:
6
+ * Open in Browser` used to hand the browser `…/dist/basics/counter/index.html` — the file's path
7
+ * rather than the page's URL — and the consequences were exactly what those two absolutes predict:
8
+ * the HTML arrived, every stylesheet and script 404'd against the server root, and the first link
9
+ * the reader clicked left the built site entirely. The page loaded and nothing else worked.
10
+ *
11
+ * So the fix is not in the URL alone. A site is only "as if published" when it is served AT A ROOT,
12
+ * which is what this module does for whichever project a server is showing: `/basics/counter` and
13
+ * `/basics/counter/` both resolve to `<outDir>/basics/counter/index.html`, `/` to
14
+ * `<outDir>/index.html`, and every asset the page names resolves the same way it will in
15
+ * production.
16
+ *
17
+ * **It is a LAST resort, deliberately.** `serveProjectFile` tries the mounts, the absolute path,
18
+ * the root-relative source file and `public/` before it gets here, so this step can only answer
19
+ * requests that were already going to 404 — it can never shadow a source file that resolves today.
20
+ * That property is what makes it safe to mount for every project on every server rather than behind
21
+ * a flag.
22
+ *
23
+ * `jx dev`'s own `createDistMiddleware` is a different thing and stays: it runs BEFORE the source
24
+ * files (a site project's dev server is showing the built site, not its sources) and it injects the
25
+ * live-reload client. This one is the fallback for the servers that are showing a project's sources
26
+ * — the monorepo dev server and the desktop's project server — where the built site is something a
27
+ * reader is visiting, not the thing being edited.
28
+ */
29
+
30
+ import { existsSync, readFileSync, statSync } from "node:fs";
31
+ import { join, resolve } from "node:path";
32
+
33
+ import { serveContained } from "./net-guard.ts";
34
+
35
+ /** What `project.json` says the build writes to, cached per root against its mtime. */
36
+ const outDirCache = new Map<string, { mtime: number; outDir: string | null }>();
37
+
38
+ /** Reset the output-directory cache (test hook). */
39
+ export function resetSiteOutput(): void {
40
+ outDirCache.clear();
41
+ }
42
+
43
+ /**
44
+ * The project's absolute output directory, or `null` when it is not a site project.
45
+ *
46
+ * Read from `project.json` rather than assumed to be `dist`, because `build.outDir` is a documented
47
+ * setting and a project that moved it would otherwise be served its stale old output for ever. The
48
+ * mtime guard is the same idiom `jx-mounts.ts` uses: a config edit is picked up, a request is not a
49
+ * file read.
50
+ */
51
+ export function siteOutDir(projectRoot: string): string | null {
52
+ const configPath = resolve(projectRoot, "project.json");
53
+ if (!existsSync(configPath)) {
54
+ return null;
55
+ }
56
+ const { mtimeMs } = statSync(configPath);
57
+ const cached = outDirCache.get(projectRoot);
58
+ if (cached && cached.mtime === mtimeMs) {
59
+ return cached.outDir;
60
+ }
61
+ let outDir: string | null = null;
62
+ try {
63
+ const config = JSON.parse(readFileSync(configPath, "utf8")) as {
64
+ build?: { outDir?: unknown };
65
+ };
66
+ const declared = config.build?.outDir;
67
+ outDir = resolve(projectRoot, typeof declared === "string" && declared ? declared : "dist");
68
+ } catch {
69
+ // An unparseable project.json is the editor's problem to report, not this server's to guess
70
+ // At. No output directory means this fallback simply declines, and the request 404s as before.
71
+ outDir = null;
72
+ }
73
+ outDirCache.set(projectRoot, { mtime: mtimeMs, outDir });
74
+ return outDir;
75
+ }
76
+
77
+ /**
78
+ * Serve `decodedPath` from the project's built output, or `null` when it names nothing there.
79
+ *
80
+ * The mapping is the compiler's own, read backwards: a request for a route gets that route's
81
+ * `index.html` whether or not the reader typed the trailing slash, because a built site is browsed
82
+ * by its routes and a reader does not know which spelling the `trailingSlash` setting produced.
83
+ * `<route>.html` is tried too, which is what `trailingSlash: "never"` writes.
84
+ */
85
+ export async function serveSiteOutput(
86
+ decodedPath: string,
87
+ projectRoot: string,
88
+ ): Promise<Response | null> {
89
+ const outDir = siteOutDir(projectRoot);
90
+ if (!outDir || !existsSync(outDir)) {
91
+ return null;
92
+ }
93
+ const rel = decodedPath.replace(/^\/+/, "");
94
+ const direct = resolve(outDir, `./${rel}`);
95
+
96
+ // A file (an asset, or a page under `trailingSlash: "never"`).
97
+ if (existsSync(direct) && statSync(direct).isFile()) {
98
+ return serveContained(direct, outDir);
99
+ }
100
+ // A route: its directory's index.html, with or without the trailing slash the reader typed.
101
+ const asIndex = join(direct, "index.html");
102
+ if (existsSync(asIndex)) {
103
+ return serveContained(asIndex, outDir);
104
+ }
105
+ // `trailingSlash: "never"` writes `<route>.html` beside its siblings.
106
+ const asHtml = `${direct.replace(/\/+$/, "")}.html`;
107
+ if (rel !== "" && existsSync(asHtml)) {
108
+ return serveContained(asHtml, outDir);
109
+ }
110
+ return null;
111
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The built site on its own origin — what `View: Open in Browser` opens.
3
+ *
4
+ * A built page is written for its published origin and addresses everything ROOT-absolutely:
5
+ * `dist/basics/fetch/index.html` links to `/basics/counter` and loads `/components/fetch-demo.js`.
6
+ * So it is only browsable when those paths resolve against the OUTPUT — and on the editor's server
7
+ * they resolve against the project's SOURCES, because that is the editor's whole job. The two are
8
+ * the same URLs holding different bytes: `/components/fetch-demo.js` is the module of formulas the
9
+ * compiler reads in the sources, and the custom-element definition the page needs in the output.
10
+ *
11
+ * Serving the output as a 404 fallback on the editor's server looks like it works — the page and
12
+ * the assets that exist in only one of the two spaces arrive — and then the reader gets the source
13
+ * module for anything that exists in both. Measured: the page rendered, `customElements.get(...)`
14
+ * was null, and nothing on it did anything. No ordering fixes that; one origin cannot hold two
15
+ * meanings for one path.
16
+ *
17
+ * Hence a second server, rooted AT the output directory, where every path has exactly the meaning
18
+ * the published site gives it. Nothing is injected — no live-reload client, no rewriting — because
19
+ * the point is to hand the reader the bytes that will be deployed. `View: Open in Browser` builds,
20
+ * then opens a URL here.
21
+ *
22
+ * It is bound to loopback and serves only files under the output directory, which is the artefact
23
+ * the author is about to publish; it carries none of the editor's privileged routes and needs no
24
+ * token. Servers are reused per project root — opening ten pages opens one port — and they live for
25
+ * the life of the process: the map is keyed by root and two windows on one project share an origin,
26
+ * so no single window's teardown may close it.
27
+ */
28
+
29
+ import { serveSiteOutput, siteOutDir } from "./site-output.ts";
30
+ import { problem } from "./problem.ts";
31
+
32
+ /** A running preview server for one project. */
33
+ export interface SitePreview {
34
+ /** Origin to prefix a route with, e.g. `http://127.0.0.1:41234`. */
35
+ origin: string;
36
+ port: number;
37
+ }
38
+
39
+ interface Running extends SitePreview {
40
+ server: { stop: (closeActiveConnections?: boolean) => void };
41
+ }
42
+
43
+ /** One server per project root — a second `Open in Browser` reuses the first one's port. */
44
+ const running = new Map<string, Running>();
45
+
46
+ /**
47
+ * Start (or reuse) the preview server for `projectRoot`, or `null` when there is nothing to serve:
48
+ * not a site project, or a site nobody has built yet. A caller that has just built gets a server;
49
+ * one that has not gets the honest answer rather than a port that 404s everything.
50
+ */
51
+ export function startSitePreview(projectRoot: string): SitePreview | null {
52
+ const outDir = siteOutDir(projectRoot);
53
+ if (!outDir) {
54
+ return null;
55
+ }
56
+ const existing = running.get(projectRoot);
57
+ if (existing) {
58
+ return { origin: existing.origin, port: existing.port };
59
+ }
60
+ const server = Bun.serve({
61
+ fetch: async (req: Request) => {
62
+ const url = new URL(req.url);
63
+ let decoded: string;
64
+ try {
65
+ decoded = decodeURIComponent(url.pathname);
66
+ } catch {
67
+ return problem("invalidRequest", "Bad request");
68
+ }
69
+ const res = await serveSiteOutput(decoded, projectRoot);
70
+ if (res) {
71
+ return res;
72
+ }
73
+ /* The site's OWN 404 page when it has one, at the status the published site would send.
74
+ A generated `404.html` is a page the author wrote; showing it is more faithful than
75
+ showing this server's opinion, and it is how the static hosts serve it. */
76
+ const notFound = await serveSiteOutput("/404.html", projectRoot);
77
+ return notFound
78
+ ? new Response(notFound.body, { headers: notFound.headers, status: 404 })
79
+ : problem("notFound", "Not found");
80
+ },
81
+ hostname: "127.0.0.1",
82
+ port: 0,
83
+ });
84
+ // `server.url` rather than `server.port`: the origin is the thing being handed to a browser, and
85
+ // Reading it from the server that is actually listening cannot disagree with it.
86
+ const entry: Running = {
87
+ origin: server.url.origin,
88
+ port: Number(server.url.port),
89
+ server,
90
+ };
91
+ running.set(projectRoot, entry);
92
+ return { origin: entry.origin, port: entry.port };
93
+ }
94
+
95
+ /** The origin already serving `projectRoot`, without starting one. */
96
+ export function sitePreviewOrigin(projectRoot: string): string | null {
97
+ return running.get(projectRoot)?.origin ?? null;
98
+ }
99
+
100
+ /** Stop every preview server (process teardown, and the hook tests close their ports with). */
101
+ export function stopSitePreviews(): void {
102
+ for (const entry of running.values()) {
103
+ entry.server.stop(true);
104
+ }
105
+ running.clear();
106
+ }