@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.
package/src/studio-api.ts CHANGED
@@ -22,13 +22,19 @@ import { readBundledProjectSchemas } from "@jxsuite/compiler/schema-command";
22
22
  import { handleDataApi } from "./data-api.ts";
23
23
  import { containedPath } from "./net-guard.ts";
24
24
  import { startSitePreview } from "./site-preview.ts";
25
+ import {
26
+ clearLivePreviewOverlay,
27
+ navigateLivePreview,
28
+ setLivePreviewOverlay,
29
+ startLivePreview,
30
+ } from "./live-preview.ts";
25
31
  import { applyRename } from "./refactor/apply.ts";
26
32
  import { findReferences } from "./refactor/find-refs.ts";
27
33
  import {
28
34
  bunExecutable,
29
35
  dependenciesNeedInstall,
30
36
  installDependencies,
31
- outdatedPackages,
37
+ packageVersions,
32
38
  setPackageVersions,
33
39
  } from "./packages.ts";
34
40
  import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
@@ -288,6 +294,17 @@ export interface StudioApiOptions {
288
294
  * unreachable by the very request that made it.
289
295
  */
290
296
  onProjectCreated?: (projectRoot: string) => void;
297
+ /**
298
+ * Tell the collaboration registry a path changed underneath it — the same hook the file watcher
299
+ * uses (`server.ts`), handed to the one mutation the watcher cannot describe.
300
+ *
301
+ * A rename MOVES a file, and a live room is keyed by the old path. Without this the room survives
302
+ * the move holding pre-rename content, and `stop()`'s graceful-shutdown flush writes it back —
303
+ * RECREATING the file the rename deleted. `pendingPersist` receives a path on the room's seed
304
+ * transaction, so a clean editor does not protect against it either. Passing the OLD absolute
305
+ * path lets the registry reset that room, which deletes it and makes the later flush a no-op.
306
+ */
307
+ onFileMoved?: (absPath: string) => void;
291
308
  }
292
309
 
293
310
  /**
@@ -323,6 +340,23 @@ function conflictingPaths(output: string): string[] {
323
340
  return [...paths];
324
341
  }
325
342
 
343
+ /**
344
+ * Order a directory listing so two identical requests answer identically.
345
+ *
346
+ * `readdir` and `Bun.Glob.scan` both answer in FILESYSTEM order, which is not an order at all: it
347
+ * varies with the directory's internal layout and therefore with the history of writes to it. That
348
+ * reached the screenshots as a picture that changed on its own — `blog-grid.png` photographs a
349
+ * content collection, `content-source.ts` inserts rows in listing order (deliberately, so a
350
+ * concurrent read cannot decide it), and the listing underneath was unsorted. The collection's
351
+ * three posts swapped places between runs and rewrote the image 15 times in ten days.
352
+ *
353
+ * Codepoint order, not `localeCompare`: the comparison has to answer the same on a maintainer's
354
+ * machine and in the capture container, and a locale-aware collation does not promise that.
355
+ */
356
+ function byPathOrder<T extends { path: string }>(entries: T[]): T[] {
357
+ return entries.toSorted((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
358
+ }
359
+
326
360
  export async function handleStudioApi(
327
361
  req: Request,
328
362
  url: URL,
@@ -701,7 +735,7 @@ export async function handleStudioApi(
701
735
  }
702
736
  } catch {}
703
737
  }
704
- return Response.json(files);
738
+ return Response.json(byPathOrder(files));
705
739
  }
706
740
 
707
741
  const entries = await readdir(absDir, { withFileTypes: true });
@@ -720,7 +754,7 @@ export async function handleStudioApi(
720
754
  type: entry.isDirectory() ? "directory" : "file",
721
755
  });
722
756
  }
723
- return Response.json(files);
757
+ return Response.json(byPathOrder(files));
724
758
  } catch (error) {
725
759
  return problem("internalError", errorMessage(error));
726
760
  }
@@ -889,6 +923,76 @@ export async function handleStudioApi(
889
923
  }
890
924
  }
891
925
 
926
+ /**
927
+ * Preview the site LIVE — the working tree browsable at real routes, with no build on the path.
928
+ *
929
+ * The sibling of `/__studio/build` and the one Open in Browser reaches first. What it answers
930
+ * with is not compiler output: `@jxsuite/site` composes each page on demand and
931
+ * `@jxsuite/runtime` assembles it in the reader's browser, so what opens is the tree as it stands
932
+ * — the author's unsaved edits included, because Studio publishes those as an overlay this origin
933
+ * reads first.
934
+ *
935
+ * `reused` is the second answer and the caller must honour it. A tab already holding this
936
+ * project's reload stream is retargeted in place, and opening another would give the author two
937
+ * tabs on one project — which is the thing the retarget exists to prevent.
938
+ */
939
+ if (path === "/__studio/preview" && req.method === "POST") {
940
+ const dir = activeProjectRoot ?? root;
941
+ if (!existsSync(resolve(dir, "project.json"))) {
942
+ return problem("invalidRequest", "Not a site project");
943
+ }
944
+ let body: { route?: string };
945
+ try {
946
+ body = (await req.json()) as { route?: string };
947
+ } catch {
948
+ return problem("invalidRequest", "Invalid JSON body");
949
+ }
950
+ try {
951
+ const preview = await startLivePreview(dir);
952
+ const reused = body.route ? await navigateLivePreview(dir, body.route) : false;
953
+ return Response.json({
954
+ errors: preview.errors,
955
+ /* A live preview writes nothing, so there are no files to count. The field is part of the
956
+ shared shape and saying zero is the honest answer rather than an omission. */
957
+ files: 0,
958
+ mode: "live",
959
+ reused,
960
+ routes: preview.routes,
961
+ url: preview.origin,
962
+ });
963
+ } catch (error) {
964
+ return problem("internalError", errorMessage(error));
965
+ }
966
+ }
967
+
968
+ /**
969
+ * The unsaved bytes a live preview reads before the disk.
970
+ *
971
+ * POST publishes one document, DELETE retracts one or all of them. Studio owns the lifecycle — it
972
+ * knows which documents are dirty and when a save, a close or a discard ends that — so this route
973
+ * stores what it is told and decides nothing.
974
+ */
975
+ if (path === "/__studio/preview/overlay") {
976
+ const dir = activeProjectRoot ?? root;
977
+ let body: { path?: string; contents?: string };
978
+ try {
979
+ body = (await req.json()) as { path?: string; contents?: string };
980
+ } catch {
981
+ return problem("invalidRequest", "Invalid JSON body");
982
+ }
983
+ if (req.method === "POST") {
984
+ if (typeof body.path !== "string" || typeof body.contents !== "string") {
985
+ return problem("invalidRequest", "Missing path or contents");
986
+ }
987
+ setLivePreviewOverlay(dir, body.path, body.contents);
988
+ return new Response(null, { status: 204 });
989
+ }
990
+ if (req.method === "DELETE") {
991
+ clearLivePreviewOverlay(dir, typeof body.path === "string" ? body.path : undefined);
992
+ return new Response(null, { status: 204 });
993
+ }
994
+ }
995
+
892
996
  // ─── Package management ──────────────────────────────────────────────────────
893
997
 
894
998
  // List CEM-bearing npm packages
@@ -1046,12 +1150,12 @@ export async function handleStudioApi(
1046
1150
  return Response.json({ needsInstall: dependenciesNeedInstall(scanRoot) });
1047
1151
  }
1048
1152
 
1049
- // Dependencies with a newer version available
1050
- if (path === "/__studio/packages/outdated" && req.method === "GET") {
1153
+ // The newest published version of each dependency (whether or not it is behind)
1154
+ if (path === "/__studio/packages/versions" && req.method === "GET") {
1051
1155
  try {
1052
1156
  const dir = url.searchParams.get("dir") || activeProjectRoot || root;
1053
1157
  const scanRoot = isAbsolute(dir) ? dir : resolve(root, dir);
1054
- return Response.json(await outdatedPackages(scanRoot));
1158
+ return Response.json(await packageVersions(scanRoot));
1055
1159
  } catch (error) {
1056
1160
  return problem("internalError", errorMessage(error));
1057
1161
  }
@@ -1193,6 +1297,9 @@ export async function handleStudioApi(
1193
1297
  } catch (error) {
1194
1298
  return problem("internalError", errorMessage(error));
1195
1299
  }
1300
+ // Immediately, and before the refactor pass: a room still keyed to the old path would be
1301
+ // Flushed back onto disk at shutdown, recreating the file this rename just moved.
1302
+ opts.onFileMoved?.(absFrom);
1196
1303
  // Refactor pass: rewrite path references project-wide and, for a component, auto-rename its tag.
1197
1304
  // The move already succeeded, so a refactor failure is reported but never fails the rename.
1198
1305
  const scanRoot = activeProjectRoot ?? root;
@@ -1201,29 +1308,42 @@ export async function handleStudioApi(
1201
1308
  const report = await applyRename({ absFrom, absTo, registry, root: scanRoot });
1202
1309
  return Response.json(report);
1203
1310
  } catch (error) {
1311
+ /* Project-relative, like the report this stands in for. The route takes its `from`/`to` in
1312
+ server space and answers in project space; emitting two spaces from one route is what
1313
+ made the sibling `references` route's own confusion survive review (issue 239). */
1204
1314
  return Response.json({
1205
1315
  error: errorMessage(error),
1206
- from: fwd(relative(root, absFrom)),
1316
+ from: fwd(relative(scanRoot, absFrom)),
1207
1317
  ok: true,
1208
- to: fwd(relative(root, absTo)),
1318
+ to: fwd(relative(scanRoot, absTo)),
1209
1319
  });
1210
1320
  }
1211
1321
  }
1212
1322
 
1213
1323
  // Where a file / component tag is used (the read side of the rename refactor's walker)
1214
1324
  if (path === "/__studio/references" && req.method === "GET") {
1215
- const target = url.searchParams.get("path");
1325
+ let target = url.searchParams.get("path");
1216
1326
  const tag = url.searchParams.get("tag");
1217
1327
  if (!target && !tag) {
1218
1328
  return problem("invalidRequest", "Missing path or tag");
1219
1329
  }
1220
1330
  const scanRoot = activeProjectRoot ?? root;
1221
1331
  if (target) {
1332
+ /* Server-root-relative in (like every sibling route, and like the adapter's `serverPath()`
1333
+ sends), project-relative out. The sweep runs in the project's space, so the target has to
1334
+ be re-expressed into it: resolving against `scanRoot` directly doubles the prefix, and a
1335
+ doubled prefix names a file that does not exist — which reads as a confident zero (issue 239). */
1336
+ const abs = resolve(root, target);
1222
1337
  try {
1223
- assertAccessible(resolve(scanRoot, target), root, activeProjectRoot);
1338
+ assertAccessible(abs, root, activeProjectRoot);
1224
1339
  } catch (error) {
1225
1340
  return new Response(errorMessage(error), { status: 400 });
1226
1341
  }
1342
+ const inProject = fwd(relative(scanRoot, abs));
1343
+ if (inProject === "" || inProject.startsWith("../") || isAbsolute(inProject)) {
1344
+ return problem("invalidRequest", "path is outside the active project");
1345
+ }
1346
+ target = inProject;
1227
1347
  }
1228
1348
  try {
1229
1349
  const registry = await getFormatRegistry(scanRoot);
package/src/watch.ts CHANGED
@@ -5,6 +5,7 @@ import { relative } from "node:path";
5
5
  import { rebuild } from "./build.ts";
6
6
  import { coalesceFsEvents, toFsEvent } from "./refactor/fs-events.ts";
7
7
  import { createWatchIgnore } from "./watch-policy.ts";
8
+ import { createSseHub } from "./sse.ts";
8
9
  import { invalidateReferenceCache } from "./refactor/find-refs.ts";
9
10
  import type { BuildEntry } from "./types.ts";
10
11
  import type { FsEventPayload } from "./refactor/fs-events.ts";
@@ -51,14 +52,14 @@ function shouldIgnore(pathname: string, ignore: string[]) {
51
52
 
52
53
  export { shouldIgnore };
53
54
 
54
- /**
55
- * Reconnection time advertised on the stream, in milliseconds (HTML Standard, `retry:`).
56
- *
57
- * The default is user-agent defined and measured in seconds, which on a dev server restart is long
58
- * enough for a save to look like it did nothing. Both endpoints are on loopback, so a reconnect
59
- * costs nothing and half a second lands inside a typical restart.
55
+ /*
56
+ * The stream itself lives in `sse.ts`, shared with the live preview origin's channel — the reading
57
+ * of the EventSource contract it encodes (`retry:`, the `id:` that arms `Last-Event-ID`, one reload
58
+ * on resume and none on a first connection) is specified once, in specs/server.md §3.1, and having
59
+ * two of it is how one of them silently stops reconnecting. Re-exported here because `RECONNECT_MS`
60
+ * is part of what this module's consumers already read.
60
61
  */
61
- export const RECONNECT_MS = 500;
62
+ export { RECONNECT_MS } from "./sse.ts";
62
63
 
63
64
  export const SSE_SCRIPT = `\n<script>new EventSource('/__reload').onmessage=()=>location.reload()</script>`;
64
65
 
@@ -102,96 +103,7 @@ export function createWatcher(
102
103
  const reloadOnAnyChange = opts.reloadOnAnyChange ?? false;
103
104
  const { preReload } = opts;
104
105
 
105
- const clients = new Set<(msg: string) => void>();
106
- const encoder = new TextEncoder();
107
-
108
- /*
109
- * Monotonic reload counter, sent as the SSE `id:` of every reload frame.
110
- *
111
- * Its only job is to arm the browser: a stream that has never sent an `id:` makes the client omit
112
- * `Last-Event-ID` on reconnect, and then the server cannot tell a first connection from a
113
- * reconnection. **Nothing is buffered against it and nothing is replayed.** A reconnecting client
114
- * gets exactly one reload, because the page it is holding was built before the disconnect and a
115
- * full reload subsumes every event it missed — one is as correct as a hundred and finishes
116
- * sooner. Do not "complete" this into a replay buffer; there is no per-event state to replay.
117
- */
118
- let lastEventId = 0;
119
-
120
- function broadcast() {
121
- lastEventId += 1;
122
- const frame = `id: ${lastEventId}\ndata: reload\n\n`;
123
- for (const send of clients) {
124
- send(frame);
125
- }
126
- }
127
-
128
- /**
129
- * Send a _named_ SSE event. The preview iframe only listens to the default (unnamed) `onmessage`,
130
- * so named events (e.g. "fs") reach the studio shell without triggering a preview reload.
131
- */
132
- function broadcastEvent(event: string, payload: unknown) {
133
- const frame = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
134
- for (const send of clients) {
135
- send(frame);
136
- }
137
- }
138
-
139
- /**
140
- * Open the live-reload stream.
141
- *
142
- * Two halves of the HTML Standard's EventSource contract that were missing, and are the whole of
143
- * `gap:sse-reconnect`:
144
- *
145
- * - **`retry:`** sets the reconnection time. The default is user-agent defined and is measured in
146
- * seconds; during a dev-server restart that is long enough to watch a save do nothing. 500ms is
147
- * loopback, so the reconnect costs nothing and lands inside the restart.
148
- * - **`Last-Event-ID`** is how the browser says "I was here before". It gets **one** reload and no
149
- * replay — see the note on `lastEventId`. A dev server has no event log and needs none: the
150
- * reload is idempotent and total.
151
- *
152
- * @param {Request} [request] - The stream request; read only for its `Last-Event-ID`
153
- * @returns {Response}
154
- */
155
- function handleSSE(request?: Request) {
156
- const resuming = (request?.headers.get("Last-Event-ID") ?? "") !== "";
157
- /** @type {((msg: string) => void) | undefined} */
158
- let send: ((msg: string) => void) | undefined;
159
- const stream = new ReadableStream({
160
- cancel() {
161
- if (send) {
162
- clients.delete(send);
163
- }
164
- },
165
- start(c) {
166
- send = (msg: string) => {
167
- try {
168
- c.enqueue(encoder.encode(msg));
169
- } catch {}
170
- };
171
- clients.add(send);
172
- // The retry interval is a stream-level field, so it is set once, before any event.
173
- send(`retry: ${RECONNECT_MS}\n\n`);
174
- if (resuming) {
175
- lastEventId += 1;
176
- send(`id: ${lastEventId}\ndata: reload\n\n`);
177
- }
178
- const hb = setInterval(() => {
179
- try {
180
- c.enqueue(encoder.encode(": heartbeat\n\n"));
181
- } catch {
182
- clearInterval(hb);
183
- }
184
- }, 15_000);
185
- },
186
- });
187
- return new Response(stream, {
188
- headers: {
189
- "Cache-Control": "no-cache",
190
- Connection: "keep-alive",
191
- "Content-Type": "text/event-stream",
192
- },
193
- });
194
- }
106
+ const { broadcast, broadcastEvent, handleSSE } = createSseHub();
195
107
 
196
108
  let timer: ReturnType<typeof setTimeout> | null = null;
197
109
  let fsTimer: ReturnType<typeof setTimeout> | null = null;