@jxsuite/server 3.0.0 → 4.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.
@@ -14,11 +14,19 @@
14
14
  * `rewriteRef`'s resolve-and-compare gate in paths.ts: a reference is only rewritten (or counted as
15
15
  * a usage) when it actually resolves to the file in question.
16
16
  *
17
+ * **A reference is not always a value.** Three of the four visit helpers offer a value under a
18
+ * container-plus-position; {@link visitKeys} offers a map's KEYS, because `project.json`'s `copy`
19
+ * map names the files it copies there. Everything downstream is unchanged — a key is renamed by the
20
+ * same visitor returning the same kind of string — but the fourth shape had to exist before the
21
+ * walk could see the reference at all (issue 242).
22
+ *
17
23
  * The visitor seam is what stops the read side from being a second, drifting copy of the key list.
18
24
  * A new reference-bearing key added below is a key the usage count sees on the same commit.
25
+ *
26
+ * @docs studio/projects/pages-layouts-components
19
27
  */
20
28
 
21
- import { classifyRef, rewriteRef } from "./paths.ts";
29
+ import { classifyRef, looksLikeFileRef, rewriteRef } from "./paths.ts";
22
30
  import type { RemapCtx } from "./paths.ts";
23
31
 
24
32
  /** A single reference rewrite, for the rename report. */
@@ -37,8 +45,11 @@ export interface RewriteResult {
37
45
  * One visit of a reference-bearing string.
38
46
  *
39
47
  * @param value — the raw authored reference, exactly as written.
40
- * @param refType — which key carried it (`$ref`, `$layout`, `attr`, `imports`, `$elements`, `url`).
41
- * @param rootRelativeBare — whether a bare value resolves against the project root (`$layout`).
48
+ * @param refType — which key carried it (`$ref`, `$layout`, `attr`, `imports`, `$elements`, `url`,
49
+ * `source`, `copy` for a map KEY naming a copied file, or `path` for a value matched by shape
50
+ * rather than by key name).
51
+ * @param rootRelativeBare — whether a bare value resolves against the project root (`$layout`,
52
+ * `copy`).
42
53
  * @returns A replacement string to write back, or `null` to leave the document untouched.
43
54
  */
44
55
  export type RefVisitor = (
@@ -50,6 +61,17 @@ export type RefVisitor = (
50
61
  /** Matches `url(...)` targets inside a CSS string value, capturing the optional quote. */
51
62
  const URL_RE = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g;
52
63
 
64
+ /**
65
+ * Keys whose string values are PROSE, and are therefore never offered to the shape fallback.
66
+ *
67
+ * A page that writes "see layouts/base.json" in its body text names a file without referencing it,
68
+ * and rewriting that sentence during a rename would be vandalism. Almost every such string is
69
+ * already excluded by the whitespace test in `looksLikeFileRef`; these two keys are where a
70
+ * single-token one is plausible, and a whole-repo survey of file-shaped string values found
71
+ * non-reference hits under no other key.
72
+ */
73
+ const PROSE_KEYS = new Set(["textContent", "innerHTML"]);
74
+
53
75
  /** Offer a scalar string reference under `key` to the visitor, writing back any replacement. */
54
76
  function visitScalar(
55
77
  container: Record<string, unknown>,
@@ -68,6 +90,41 @@ function visitScalar(
68
90
  }
69
91
  }
70
92
 
93
+ /**
94
+ * Offer every KEY of a map to the visitor, renaming in place and preserving insertion order.
95
+ *
96
+ * The fourth visit helper, and the only one whose reference is not a value. `project.json`'s `copy`
97
+ * map names its sources in its keys (`{"assets/brochure.pdf": "brochure.pdf"}`), so a walk that
98
+ * only ever offered values could not see them at all — renaming a copied file reported zero
99
+ * references and rewrote nothing (issue 242).
100
+ *
101
+ * Order is preserved by rebuilding rather than by deleting one key and re-adding it: a plain
102
+ * `delete` + set moves the renamed entry to the end, which is a diff on a file nobody edited. When
103
+ * a rename collides with an existing key the later entry wins, exactly as an authored duplicate
104
+ * would.
105
+ */
106
+ function visitKeys(
107
+ container: Record<string, unknown>,
108
+ refType: string,
109
+ visit: RefVisitor,
110
+ rootRelativeBare: boolean,
111
+ ): void {
112
+ const entries = Object.entries(container);
113
+ const renamed = entries.map(([key, value]): [string, unknown] => {
114
+ const out = visit(key, refType, rootRelativeBare);
115
+ return [out === null ? key : out, value];
116
+ });
117
+ if (renamed.every(([key], i) => key === entries[i]![0])) {
118
+ return;
119
+ }
120
+ for (const [key] of entries) {
121
+ delete container[key];
122
+ }
123
+ for (const [key, value] of renamed) {
124
+ container[key] = value;
125
+ }
126
+ }
127
+
71
128
  /** Offer a string array member (e.g. an `$elements` entry) to the visitor. */
72
129
  function visitIndexed(arr: unknown[], index: number, refType: string, visit: RefVisitor): void {
73
130
  const value = arr[index];
@@ -189,8 +246,71 @@ export function walkDocRefs(node: unknown, visit: RefVisitor): void {
189
246
  walkStyle(value, visit);
190
247
  break;
191
248
  }
249
+ case "copy": {
250
+ /*
251
+ * `project.json`'s declarative copy map (site-architecture.md §5). Its KEYS are project
252
+ * files and its VALUES are destinations inside `outDir` — a directory the project does not
253
+ * contain and a rename can never move. So the key is visited and the value deliberately is
254
+ * not: offering the value to the shape fallback, which is what happened before, meant
255
+ * renaming a file at the project root could rewrite an unrelated copy DESTINATION that
256
+ * merely shared its name.
257
+ */
258
+ if (value && typeof value === "object" && !Array.isArray(value)) {
259
+ visitKeys(value as Record<string, unknown>, "copy", visit, true);
260
+ }
261
+ break;
262
+ }
263
+ case "source": {
264
+ /*
265
+ * A content collection's source (site-architecture.md §6), which may name a single file
266
+ * (`./content/listings.csv`) or a DIRECTORY (`./content/posts/`).
267
+ *
268
+ * Named rather than left to the shape fallback because a directory has no extension, and
269
+ * the extension test is exactly what keeps that fallback from admitting every route and
270
+ * bare word. Here the key is known and the value's shape is not diagnostic, so the value is
271
+ * offered unconditionally and precision stays where it always was — the caller's
272
+ * resolve-and-compare gate, which only matches a value resolving to the file or directory
273
+ * in question. A remote `https://…` source is filtered by `classifyRef` before that.
274
+ */
275
+ visitScalar(obj, key, "source", visit, false);
276
+ break;
277
+ }
278
+ /*
279
+ * Everything else, by SHAPE.
280
+ *
281
+ * The cases above are the keys that carry a reference BY NAME, and for years that list was
282
+ * taken to be the whole of it. It is not: the commonest media reference in a real project is
283
+ * a schema-typed component prop (`$props.bg`, `attributes["props.image"]`), and beside it sit
284
+ * a content entry's frontmatter (`cover:`), a prop schema's `default`, `project.json`'s
285
+ * `defaults.layout`, `poster`, `srcset` and `$head` meta `content`. Measured across the
286
+ * committed starters, 73 of 101 files under `public/` were used and reported zero.
287
+ *
288
+ * Naming those keys one at a time would be the same mistake with a longer list — the next
289
+ * extension to define a media-typed prop would reopen it. So an unrecognised string is
290
+ * offered whenever it is SHAPED like a file, and precision stays where it already was: the
291
+ * caller's resolve-and-compare gate, which only counts a value that resolves to the exact
292
+ * file in question.
293
+ */
192
294
  default: {
193
- walkDocRefs(value, visit);
295
+ if (typeof value === "string") {
296
+ if (!PROSE_KEYS.has(key) && looksLikeFileRef(value)) {
297
+ visitScalar(obj, key, "path", visit, false);
298
+ }
299
+ } else if (Array.isArray(value)) {
300
+ const arr = value as unknown[];
301
+ for (let i = 0; i < arr.length; i += 1) {
302
+ const item = arr[i];
303
+ if (typeof item === "string") {
304
+ if (!PROSE_KEYS.has(key) && looksLikeFileRef(item)) {
305
+ visitIndexed(arr, i, "path", visit);
306
+ }
307
+ } else {
308
+ walkDocRefs(item, visit);
309
+ }
310
+ }
311
+ } else {
312
+ walkDocRefs(value, visit);
313
+ }
194
314
  }
195
315
  }
196
316
  }
@@ -14,18 +14,56 @@ import type { FormatRegistry } from "@jxsuite/schema/format-registry";
14
14
  /** Normalise a path to forward slashes (Windows `path` returns backslashes). */
15
15
  export const fwd = (p: string) => p.replaceAll("\\", "/");
16
16
 
17
- /** A parsed document plus the serializer that round-trips it back to disk (null when none). */
17
+ /** One authored value to replace in a document's own source text. */
18
+ export interface ValueEdit {
19
+ from: string;
20
+ to: string;
21
+ }
22
+
23
+ /** A parsed document and the two ways it might be written back. */
18
24
  export interface LoadedDoc {
19
25
  doc: unknown;
26
+ /** Round-trips the whole document. Null when the format declares no `serialize`. */
20
27
  serialize: ((doc: unknown) => Promise<string>) | null;
28
+ /**
29
+ * Replaces authored values in the file's own bytes, changing nothing else. Null when the format
30
+ * declares no `rewrite`.
31
+ *
32
+ * The narrower of the two, and the only one a load-only format can offer: a CSV collection is a
33
+ * data source entries are read FROM, so there is no document to serialize — but one cell's text
34
+ * can be changed without inventing a quoting style. `applyRename` prefers `serialize` where it
35
+ * exists, because a full round trip can express a change (a tag rename) that a list of value
36
+ * edits cannot.
37
+ */
38
+ rewrite: ((edits: readonly ValueEdit[]) => Promise<string>) | null;
21
39
  }
22
40
 
23
41
  const isJsonPath = (p: string) => p.endsWith(".json");
24
42
 
25
- /** Skip vendored / build / agent directories (mirrors the component-discovery filter). */
43
+ /**
44
+ * Skip vendored / build / agent directories (mirrors the component-discovery filter), and generated
45
+ * schema artifacts.
46
+ *
47
+ * Every committed `*.schema.json` in a project is a build output — `bun run schema:generate-all`
48
+ * composes the `project.schema.json` / `document.schema.json` pair into each project root — and the
49
+ * generators embed live `examples` blocks. `project.core.schema.json`'s `$head` example carries
50
+ * `/favicon.svg`, and the per-project entry schema enumerates the project's own layouts, so a sweep
51
+ * that reads them makes every project report its own schema as a referrer of its favicon and its
52
+ * base layout. Those are not references anyone authored, and nobody should be told a rename will
53
+ * break one.
54
+ *
55
+ * The stale-schema problem a skipped file leaves behind already has an owner: `bun run
56
+ * schema:verify` catches it and `schema:sync` regenerates it. A rewrite here would be a second
57
+ * writer on a generated file.
58
+ */
26
59
  export function skipScanPath(match: string): boolean {
27
60
  const f = fwd(match);
28
- return match.includes("node_modules") || f.includes("dist/") || f.includes(".claude/");
61
+ return (
62
+ match.includes("node_modules") ||
63
+ f.includes("dist/") ||
64
+ f.includes(".claude/") ||
65
+ f.endsWith(".schema.json")
66
+ );
29
67
  }
30
68
 
31
69
  /** Build the recursive glob covering JSON plus every registered document/content extension. */
@@ -38,8 +76,8 @@ export function documentGlob(registry: FormatRegistry): Bun.Glob {
38
76
  }
39
77
 
40
78
  /**
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.
79
+ * Read + parse a file, returning the doc and whichever write-back paths the format declares. Throws
80
+ * when no parser claims the extension.
43
81
  */
44
82
  export async function loadDoc(fp: string, registry: FormatRegistry): Promise<LoadedDoc> {
45
83
  const raw = await readFile(fp, "utf8");
@@ -47,6 +85,7 @@ export async function loadDoc(fp: string, registry: FormatRegistry): Promise<Loa
47
85
  const trailingNewline = raw.endsWith("\n");
48
86
  return {
49
87
  doc: JSON.parse(raw) as unknown,
88
+ rewrite: null,
50
89
  serialize: (doc) =>
51
90
  Promise.resolve(JSON.stringify(doc, null, 2) + (trailingNewline ? "\n" : "")),
52
91
  };
@@ -58,8 +97,15 @@ export async function loadDoc(fp: string, registry: FormatRegistry): Promise<Loa
58
97
  }
59
98
  const doc = await parseEntry.call("parse", raw);
60
99
  const serializeEntry = registry.byExtension(ext, "serialize");
100
+ const rewriteEntry = registry.byExtension(ext, "rewrite");
61
101
  return {
62
102
  doc,
103
+ rewrite: rewriteEntry
104
+ ? async (edits) => {
105
+ const out = await rewriteEntry.call("rewrite", raw, edits);
106
+ return typeof out === "string" ? out : String(out);
107
+ }
108
+ : null,
63
109
  serialize: serializeEntry
64
110
  ? async (d) => {
65
111
  const out = await serializeEntry.call("serialize", d);
package/src/resolve.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /** Resolve.js — Generic $src module proxy + timing: "server" function proxy */
2
2
 
3
+ import { siteBasePath } from "@jxsuite/schema/asset-paths";
3
4
  import { dirname, relative, resolve } from "node:path";
4
5
  import { errorMessage, parseClassDef } from "@jxsuite/schema/parse";
5
6
  import { existsSync, readFileSync, statSync } from "node:fs";
@@ -99,6 +100,22 @@ async function loadProjectEntry(projectRoot: string) {
99
100
  }
100
101
  }
101
102
 
103
+ /**
104
+ * The path a project says it is deployed under, or `""` for a site at an origin root.
105
+ *
106
+ * Read off `project.json`'s `url` (`@jxsuite/schema/asset-paths`), through the same mtime-keyed
107
+ * cache the asset mounts use — a second cache on the same file would be a second thing to
108
+ * invalidate, and this is asked on every request.
109
+ *
110
+ * @param {string} projectRoot
111
+ * @returns {Promise<string>} A path with a leading slash and no trailing one, or `""`
112
+ */
113
+ export async function projectSiteBase(projectRoot: string): Promise<string> {
114
+ const entry = await loadProjectEntry(projectRoot);
115
+ const config = entry?.context.config as ProjectConfig | undefined;
116
+ return siteBasePath(config?.url);
117
+ }
118
+
102
119
  /**
103
120
  * Extension asset mounts for a project (specs/extensions.md §8.5) — the directories the dev server
104
121
  * serves at their site URLs, so content-relative images resolve exactly as they will once built.
package/src/server.ts CHANGED
@@ -18,7 +18,12 @@ 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, projectAssetMounts } from "./resolve.ts";
21
+ import {
22
+ handleResolve,
23
+ handleServerFunction,
24
+ projectAssetMounts,
25
+ projectSiteBase,
26
+ } from "./resolve.ts";
22
27
  import {
23
28
  assertAccessible,
24
29
  assertCreatableParent,
@@ -301,6 +306,25 @@ export async function createDevServer(options: {
301
306
  }
302
307
  // Keep the un-collapsed decoded path: the static branch detects a POSIX-absolute "//abs" prefix.
303
308
  let { path } = decoded;
309
+
310
+ /*
311
+ * A subpath deployment's base, stripped once at the edge (site-architecture.md §5).
312
+ *
313
+ * A project whose `url` carries a path is SERVED from that path, and a build emits every URL
314
+ * under it — so a preview that only answered the bare path would exercise URLs the deployed
315
+ * site never uses, which is the class of bug that is only found in production. Stripping
316
+ * rather than moving the dev root means both spellings work: `localhost:3000/` is unchanged,
317
+ * and `localhost:3000/m/my-site/assets/x.js` resolves to the same file.
318
+ *
319
+ * `url` is mutated too, because everything downstream routes on it — the extension mounts
320
+ * match `url.pathname` against their own basePath, which is declared bare.
321
+ */
322
+ const siteBase = await projectSiteBase(activeProjectRoot ?? absRoot);
323
+ if (siteBase !== "" && (path === siteBase || path.startsWith(`${siteBase}/`))) {
324
+ path = path.slice(siteBase.length) || "/";
325
+ url.pathname = url.pathname.slice(siteBase.length) || "/";
326
+ }
327
+
304
328
  if (path.endsWith("/")) {
305
329
  path += "index.html";
306
330
  } else if (path === "") {
@@ -421,6 +445,7 @@ export async function createDevServer(options: {
421
445
 
422
446
  const res = await handleStudioApi(req, url, absRoot, activeProjectRoot, {
423
447
  allowedRoots,
448
+ onFileMoved: (absPath) => collabRegistry.handleExternalChange(absPath),
424
449
  onProjectCreated: (created) => createdRoots.push(created),
425
450
  });
426
451
  if (res) {
package/src/sse.ts ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The live-reload stream contract, in one place.
3
+ *
4
+ * Two surfaces speak it — the dev server's `/__reload` ({@link ./watch.ts}) and the live preview
5
+ * origin's own channel ({@link ./live-preview.ts}) — and what they share is not "an SSE endpoint"
6
+ * but a specific reading of the HTML Standard's EventSource contract that took a defect report to
7
+ * arrive at (specs/server.md §3.1). Re-deriving it per surface is how one of them ends up with a
8
+ * reconnect that silently never fires.
9
+ *
10
+ * The two halves that are easy to omit and expensive to omit:
11
+ *
12
+ * - **`retry:`** sets the reconnection time. The default is user-agent defined and is measured in
13
+ * seconds; during a server restart that is long enough to watch a save do nothing. 500 ms is
14
+ * loopback, so the reconnect costs nothing and lands inside the restart.
15
+ * - **`Last-Event-ID`** is how the browser says "I was here before", and it is the ONLY way the
16
+ * server can tell a reconnection from a first connection. A reconnecting client gets exactly one
17
+ * reload and no replay; a first connection gets none at all, because reloading a page that just
18
+ * loaded is a reload loop.
19
+ *
20
+ * A hub broadcasts to every client it holds. Membership is the socket's whole lifetime — added on
21
+ * open, dropped on cancel — so a broadcast after a tab closes reaches nobody rather than throwing.
22
+ */
23
+
24
+ /**
25
+ * Reconnection time advertised on the stream, in milliseconds (HTML Standard, `retry:`).
26
+ *
27
+ * Both endpoints are on loopback, so a reconnect costs nothing and half a second lands inside a
28
+ * typical restart.
29
+ */
30
+ export const RECONNECT_MS = 500;
31
+
32
+ /** How often a comment frame is sent to keep an idle connection from being reaped. */
33
+ const HEARTBEAT_MS = 15_000;
34
+
35
+ /** A broadcast hub for one stream. */
36
+ export interface SseHub {
37
+ /** Send the default (unnamed) `reload` message, stamped with the next `id:`. */
38
+ broadcast: () => void;
39
+ /**
40
+ * Send a _named_ event.
41
+ *
42
+ * A consumer wired as `onmessage` sees only the unnamed message, so a named event reaches a
43
+ * listener that asked for it without triggering everyone else's reload. That is what lets the
44
+ * studio shell subscribe to `fs` on the same connection the preview reloads on.
45
+ */
46
+ broadcastEvent: (event: string, payload: unknown) => void;
47
+ /** Open the stream. Pass the request so `Last-Event-ID` can be read. */
48
+ handleSSE: (request?: Request) => Response;
49
+ /** How many clients are connected right now. */
50
+ clientCount: () => number;
51
+ }
52
+
53
+ /** Create a hub. Nothing is shared between hubs — a project's clients are its own. */
54
+ export function createSseHub(): SseHub {
55
+ const clients = new Set<(msg: string) => void>();
56
+ const encoder = new TextEncoder();
57
+
58
+ /*
59
+ * Monotonic reload counter, sent as the SSE `id:` of every reload frame.
60
+ *
61
+ * Its only job is to arm the browser: a stream that has never sent an `id:` makes the client omit
62
+ * `Last-Event-ID` on reconnect, and then the server cannot tell a first connection from a
63
+ * reconnection. **Nothing is buffered against it and nothing is replayed.** A reconnecting client
64
+ * gets exactly one reload, because the page it is holding was built before the disconnect and a
65
+ * full reload subsumes every event it missed — one is as correct as a hundred and finishes
66
+ * sooner. Do not "complete" this into a replay buffer; there is no per-event state to replay.
67
+ */
68
+ let lastEventId = 0;
69
+
70
+ function broadcast() {
71
+ lastEventId += 1;
72
+ const frame = `id: ${lastEventId}\ndata: reload\n\n`;
73
+ for (const send of clients) {
74
+ send(frame);
75
+ }
76
+ }
77
+
78
+ function broadcastEvent(event: string, payload: unknown) {
79
+ const frame = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
80
+ for (const send of clients) {
81
+ send(frame);
82
+ }
83
+ }
84
+
85
+ function handleSSE(request?: Request) {
86
+ const resuming = (request?.headers.get("Last-Event-ID") ?? "") !== "";
87
+ let send: ((msg: string) => void) | undefined;
88
+ const stream = new ReadableStream({
89
+ cancel() {
90
+ if (send) {
91
+ clients.delete(send);
92
+ }
93
+ },
94
+ start(c) {
95
+ send = (msg: string) => {
96
+ try {
97
+ c.enqueue(encoder.encode(msg));
98
+ } catch {}
99
+ };
100
+ clients.add(send);
101
+ // The retry interval is a stream-level field, so it is set once, before any event.
102
+ send(`retry: ${RECONNECT_MS}\n\n`);
103
+ if (resuming) {
104
+ lastEventId += 1;
105
+ send(`id: ${lastEventId}\ndata: reload\n\n`);
106
+ }
107
+ const hb = setInterval(() => {
108
+ try {
109
+ c.enqueue(encoder.encode(": heartbeat\n\n"));
110
+ } catch {
111
+ clearInterval(hb);
112
+ }
113
+ }, HEARTBEAT_MS);
114
+ },
115
+ });
116
+ return new Response(stream, {
117
+ headers: {
118
+ "Cache-Control": "no-cache",
119
+ Connection: "keep-alive",
120
+ "Content-Type": "text/event-stream",
121
+ },
122
+ });
123
+ }
124
+
125
+ return { broadcast, broadcastEvent, clientCount: () => clients.size, handleSSE };
126
+ }