@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/README.md +5 -5
- package/package.json +16 -14
- package/src/import-api.ts +118 -3
- package/src/live-preview.ts +616 -0
- package/src/packages.ts +28 -10
- package/src/preview-client.ts +177 -0
- package/src/project-server.ts +16 -0
- package/src/refactor/apply.ts +31 -3
- package/src/refactor/find-refs.ts +0 -0
- package/src/refactor/mounts.ts +57 -0
- package/src/refactor/paths.ts +147 -13
- package/src/refactor/refs.ts +124 -4
- package/src/refactor/scan.ts +51 -5
- package/src/resolve.ts +17 -0
- package/src/server.ts +26 -1
- package/src/sse.ts +126 -0
- package/src/studio-api.ts +130 -10
- package/src/watch.ts +9 -97
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
|
-
|
|
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
|
-
//
|
|
1050
|
-
if (path === "/__studio/packages/
|
|
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
|
|
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(
|
|
1316
|
+
from: fwd(relative(scanRoot, absFrom)),
|
|
1207
1317
|
ok: true,
|
|
1208
|
-
to: fwd(relative(
|
|
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
|
-
|
|
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(
|
|
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
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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
|
|
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
|
|
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;
|