@jxsuite/server 2.0.0 → 2.2.1

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.
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Scan.ts — the project document sweep the refactor engines share.
3
+ *
4
+ * `applyRename` (write pass) and `findReferences` (read pass) walk exactly the same file set: every
5
+ * JSON document plus every extension the project's format registry claims, minus the vendored /
6
+ * build / agent directories. Keeping one definition of "the project's documents" is the point — a
7
+ * rename that rewrites a file the usage count never looked at is a lie in both directions.
8
+ */
9
+
10
+ import { extname } from "node:path";
11
+ import { readFile } from "node:fs/promises";
12
+ import type { FormatRegistry } from "@jxsuite/schema/format-registry";
13
+
14
+ /** Normalise a path to forward slashes (Windows `path` returns backslashes). */
15
+ export const fwd = (p: string) => p.replaceAll("\\", "/");
16
+
17
+ /** A parsed document plus the serializer that round-trips it back to disk (null when none). */
18
+ export interface LoadedDoc {
19
+ doc: unknown;
20
+ serialize: ((doc: unknown) => Promise<string>) | null;
21
+ }
22
+
23
+ const isJsonPath = (p: string) => p.endsWith(".json");
24
+
25
+ /** Skip vendored / build / agent directories (mirrors the component-discovery filter). */
26
+ export function skipScanPath(match: string): boolean {
27
+ const f = fwd(match);
28
+ return match.includes("node_modules") || f.includes("dist/") || f.includes(".claude/");
29
+ }
30
+
31
+ /** Build the recursive glob covering JSON plus every registered document/content extension. */
32
+ export function documentGlob(registry: FormatRegistry): Bun.Glob {
33
+ const exts = new Set(["json"]);
34
+ for (const ext of registry.documentExtensions()) {
35
+ exts.add(ext.replace(/^\./, ""));
36
+ }
37
+ return new Bun.Glob(`**/*.{${[...exts].join(",")}}`);
38
+ }
39
+
40
+ /**
41
+ * Read + parse a file, returning the doc and a matching serializer (null when the format can parse
42
+ * but not serialize). Throws when no parser claims the extension.
43
+ */
44
+ export async function loadDoc(fp: string, registry: FormatRegistry): Promise<LoadedDoc> {
45
+ const raw = await readFile(fp, "utf8");
46
+ if (isJsonPath(fp)) {
47
+ const trailingNewline = raw.endsWith("\n");
48
+ return {
49
+ doc: JSON.parse(raw) as unknown,
50
+ serialize: (doc) =>
51
+ Promise.resolve(JSON.stringify(doc, null, 2) + (trailingNewline ? "\n" : "")),
52
+ };
53
+ }
54
+ const ext = extname(fp);
55
+ const parseEntry = registry.byExtension(ext, "parse");
56
+ if (!parseEntry) {
57
+ throw new Error(`No parser for "${ext}"`);
58
+ }
59
+ const doc = await parseEntry.call("parse", raw);
60
+ const serializeEntry = registry.byExtension(ext, "serialize");
61
+ return {
62
+ doc,
63
+ serialize: serializeEntry
64
+ ? async (d) => {
65
+ const out = await serializeEntry.call("serialize", d);
66
+ return typeof out === "string" ? out : String(out);
67
+ }
68
+ : null,
69
+ };
70
+ }
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { watch as chokidarWatch } from "chokidar";
10
10
  import { coalesceFsEvents, toFsEvent } from "./fs-events.ts";
11
+ import { invalidateReferenceCache } from "./find-refs.ts";
11
12
  import type { FsEventPayload } from "./fs-events.ts";
12
13
 
13
14
  const IGNORE_SEGMENTS = ["node_modules", ".git", "dist", ".jx", ".direnv", ".devenv"];
@@ -45,6 +46,9 @@ export function createFsWatcher(
45
46
  if (!event) {
46
47
  return;
47
48
  }
49
+ // Any move under the root can change who references what, so the usage cache drops the whole
50
+ // Project rather than trying to derive which queries this one path could have affected.
51
+ invalidateReferenceCache(root);
48
52
  buffer = coalesceFsEvents(buffer, event);
49
53
  if (timer) {
50
54
  clearTimeout(timer);
package/src/resolve.ts CHANGED
@@ -11,6 +11,7 @@ import { loadAssetMounts } from "@jxsuite/compiler/asset-mounts";
11
11
  import type { AssetMount } from "@jxsuite/schema/asset-paths";
12
12
  import type { DynamicClass } from "@jxsuite/runtime/types";
13
13
  import type { JxClassDef, ProjectConfig } from "@jxsuite/schema/types";
14
+ import { problem } from "./problem.ts";
14
15
 
15
16
  interface ModuleNamespace {
16
17
  default?: Record<string, unknown>;
@@ -126,12 +127,12 @@ export async function handleResolve(
126
127
  try {
127
128
  body = (await req.json()) as ResolveBody;
128
129
  } catch {
129
- return new Response("Invalid JSON body", { status: 400 });
130
+ return problem("invalidRequest", "Invalid JSON body");
130
131
  }
131
132
 
132
133
  const { $src, $prototype, $export: xport, $base, ...config } = body;
133
134
  if (!$src) {
134
- return new Response("Missing $src", { status: 400 });
135
+ return problem("invalidRequest", "Missing $src");
135
136
  }
136
137
 
137
138
  let moduleAbsPath;
@@ -200,7 +201,7 @@ export async function handleResolve(
200
201
  // A trusted (bare-specifier) class file names its own sibling implementation; only a
201
202
  // Project-local relative $src is held to the root-escape guard.
202
203
  if (!trustedResolution && !isImportable(implPath, root, activeProjectRoot)) {
203
- return new Response(`$implementation escapes the project root`, { status: 403 });
204
+ return problem("pathOutsideProject", "$implementation escapes the project root");
204
205
  }
205
206
  const exportName = xport ?? classDef.title ?? $prototype;
206
207
  const mod = (await import(implPath)) as ModuleNamespace;
@@ -236,7 +237,7 @@ export async function handleResolve(
236
237
  : instance;
237
238
  return Response.json(value);
238
239
  } catch (error) {
239
- return Response.json({ error: errorMessage(error) }, { status: 500 });
240
+ return problem("internalError", errorMessage(error));
240
241
  }
241
242
  }
242
243
 
@@ -260,12 +261,12 @@ export async function handleServerFunction(req: Request, root: string) {
260
261
  try {
261
262
  body = (await req.json()) as ServerFunctionBody;
262
263
  } catch {
263
- return new Response("Invalid JSON body", { status: 400 });
264
+ return problem("invalidRequest", "Invalid JSON body");
264
265
  }
265
266
 
266
267
  const { $src, $export: xport, $base, arguments: args = {} } = body;
267
268
  if (!$src || !xport) {
268
- return new Response("Missing $src or $export", { status: 400 });
269
+ return problem("invalidRequest", "Missing $src or $export");
269
270
  }
270
271
 
271
272
  let moduleAbsPath;
@@ -278,9 +279,7 @@ export async function handleServerFunction(req: Request, root: string) {
278
279
  moduleAbsPath = resolve(root, $src);
279
280
  }
280
281
  } catch (error) {
281
- return new Response(`Cannot resolve $src: ${errorMessage(error)}`, {
282
- status: 400,
283
- });
282
+ return problem("invalidRequest", `Cannot resolve $src: ${errorMessage(error)}`);
284
283
  }
285
284
 
286
285
  if (!isImportable(moduleAbsPath, root, null)) {
@@ -312,7 +311,7 @@ export async function handleServerFunction(req: Request, root: string) {
312
311
  );
313
312
  return Response.json(result ?? null);
314
313
  } catch (error) {
315
- return Response.json({ error: errorMessage(error) }, { status: 500 });
314
+ return problem("internalError", errorMessage(error));
316
315
  }
317
316
  }
318
317
 
package/src/server.ts CHANGED
@@ -34,7 +34,9 @@ import {
34
34
  import { handleCodeApi } from "./code-api.ts";
35
35
  import { handleAiApi } from "./ai-api.ts";
36
36
  import { handleImportApi } from "./import-api.ts";
37
+ import { mediaTypeForPath } from "@jxsuite/schema/media-type";
37
38
  import { existsSync, readFileSync } from "node:fs";
39
+ import { problem } from "./problem.ts";
38
40
 
39
41
  /**
40
42
  * Resolve an npm-style bare specifier from a URL path via node_modules. Handles scoped packages
@@ -255,8 +257,19 @@ export async function createDevServer(options: {
255
257
  // Studio.js/canvas bundle — which shows up as half-updated UI after a rebuild (the iframe assets
256
258
  // Are query-cache-busted per mount, the parent bundle is not).
257
259
  const NO_CACHE = { "Cache-Control": "no-cache" };
258
- const fileResponse = (file: ReturnType<typeof Bun.file>) =>
259
- 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
+ };
260
273
 
261
274
  // Active studio project root (set via /__studio/activate, used for static file fallback)
262
275
  let activeProjectRoot: string | null = null;
@@ -296,7 +309,7 @@ export async function createDevServer(options: {
296
309
 
297
310
  // SSE live reload
298
311
  if (handleSSE && path === "/__reload") {
299
- return handleSSE();
312
+ return handleSSE(req);
300
313
  }
301
314
 
302
315
  /*
@@ -362,7 +375,9 @@ export async function createDevServer(options: {
362
375
  createdRoots.some((created) => containedPath(requested, created) !== null) ||
363
376
  isOwnedProjectDir(requested);
364
377
  if (!permitted) {
365
- 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 });
366
381
  }
367
382
  activeProjectRoot = requested;
368
383
  return Response.json({ ok: true, root: activeProjectRoot });
@@ -450,7 +465,15 @@ export async function createDevServer(options: {
450
465
  await projectAssetMounts(activeProjectRoot),
451
466
  );
452
467
  if (mountRes) {
453
- 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 });
454
477
  }
455
478
  }
456
479
 
@@ -511,7 +534,14 @@ export async function createDevServer(options: {
511
534
  });
512
535
  }
513
536
  }
514
- 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");
515
545
  }
516
546
 
517
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
+ }