@jxsuite/server 2.2.7 → 4.0.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 +15 -13
- package/src/import-api.ts +118 -3
- package/src/live-preview.ts +616 -0
- package/src/net-guard.ts +86 -10
- 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 +10 -0
- package/src/refactor/find-refs.ts +0 -0
- package/src/refactor/mounts.ts +49 -0
- package/src/refactor/paths.ts +133 -12
- package/src/refactor/refs.ts +51 -3
- package/src/refactor/scan.ts +22 -2
- package/src/server.ts +1 -0
- package/src/sse.ts +126 -0
- package/src/studio-api.ts +130 -10
- package/src/watch.ts +9 -97
package/src/refactor/refs.ts
CHANGED
|
@@ -16,9 +16,11 @@
|
|
|
16
16
|
*
|
|
17
17
|
* The visitor seam is what stops the read side from being a second, drifting copy of the key list.
|
|
18
18
|
* A new reference-bearing key added below is a key the usage count sees on the same commit.
|
|
19
|
+
*
|
|
20
|
+
* @docs studio/projects/pages-layouts-components
|
|
19
21
|
*/
|
|
20
22
|
|
|
21
|
-
import { classifyRef, rewriteRef } from "./paths.ts";
|
|
23
|
+
import { classifyRef, looksLikeFileRef, rewriteRef } from "./paths.ts";
|
|
22
24
|
import type { RemapCtx } from "./paths.ts";
|
|
23
25
|
|
|
24
26
|
/** A single reference rewrite, for the rename report. */
|
|
@@ -37,7 +39,8 @@ export interface RewriteResult {
|
|
|
37
39
|
* One visit of a reference-bearing string.
|
|
38
40
|
*
|
|
39
41
|
* @param value — the raw authored reference, exactly as written.
|
|
40
|
-
* @param refType — which key carried it (`$ref`, `$layout`, `attr`, `imports`, `$elements`, `url
|
|
42
|
+
* @param refType — which key carried it (`$ref`, `$layout`, `attr`, `imports`, `$elements`, `url`,
|
|
43
|
+
* or `path` for a value matched by shape rather than by key name).
|
|
41
44
|
* @param rootRelativeBare — whether a bare value resolves against the project root (`$layout`).
|
|
42
45
|
* @returns A replacement string to write back, or `null` to leave the document untouched.
|
|
43
46
|
*/
|
|
@@ -50,6 +53,17 @@ export type RefVisitor = (
|
|
|
50
53
|
/** Matches `url(...)` targets inside a CSS string value, capturing the optional quote. */
|
|
51
54
|
const URL_RE = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g;
|
|
52
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Keys whose string values are PROSE, and are therefore never offered to the shape fallback.
|
|
58
|
+
*
|
|
59
|
+
* A page that writes "see layouts/base.json" in its body text names a file without referencing it,
|
|
60
|
+
* and rewriting that sentence during a rename would be vandalism. Almost every such string is
|
|
61
|
+
* already excluded by the whitespace test in `looksLikeFileRef`; these two keys are where a
|
|
62
|
+
* single-token one is plausible, and a whole-repo survey of file-shaped string values found
|
|
63
|
+
* non-reference hits under no other key.
|
|
64
|
+
*/
|
|
65
|
+
const PROSE_KEYS = new Set(["textContent", "innerHTML"]);
|
|
66
|
+
|
|
53
67
|
/** Offer a scalar string reference under `key` to the visitor, writing back any replacement. */
|
|
54
68
|
function visitScalar(
|
|
55
69
|
container: Record<string, unknown>,
|
|
@@ -189,8 +203,42 @@ export function walkDocRefs(node: unknown, visit: RefVisitor): void {
|
|
|
189
203
|
walkStyle(value, visit);
|
|
190
204
|
break;
|
|
191
205
|
}
|
|
206
|
+
/*
|
|
207
|
+
* Everything else, by SHAPE.
|
|
208
|
+
*
|
|
209
|
+
* The cases above are the keys that carry a reference BY NAME, and for years that list was
|
|
210
|
+
* taken to be the whole of it. It is not: the commonest media reference in a real project is
|
|
211
|
+
* a schema-typed component prop (`$props.bg`, `attributes["props.image"]`), and beside it sit
|
|
212
|
+
* a content entry's frontmatter (`cover:`), a prop schema's `default`, `project.json`'s
|
|
213
|
+
* `defaults.layout`, `poster`, `srcset` and `$head` meta `content`. Measured across the
|
|
214
|
+
* committed starters, 73 of 101 files under `public/` were used and reported zero.
|
|
215
|
+
*
|
|
216
|
+
* Naming those keys one at a time would be the same mistake with a longer list — the next
|
|
217
|
+
* extension to define a media-typed prop would reopen it. So an unrecognised string is
|
|
218
|
+
* offered whenever it is SHAPED like a file, and precision stays where it already was: the
|
|
219
|
+
* caller's resolve-and-compare gate, which only counts a value that resolves to the exact
|
|
220
|
+
* file in question.
|
|
221
|
+
*/
|
|
192
222
|
default: {
|
|
193
|
-
|
|
223
|
+
if (typeof value === "string") {
|
|
224
|
+
if (!PROSE_KEYS.has(key) && looksLikeFileRef(value)) {
|
|
225
|
+
visitScalar(obj, key, "path", visit, false);
|
|
226
|
+
}
|
|
227
|
+
} else if (Array.isArray(value)) {
|
|
228
|
+
const arr = value as unknown[];
|
|
229
|
+
for (let i = 0; i < arr.length; i += 1) {
|
|
230
|
+
const item = arr[i];
|
|
231
|
+
if (typeof item === "string") {
|
|
232
|
+
if (!PROSE_KEYS.has(key) && looksLikeFileRef(item)) {
|
|
233
|
+
visitIndexed(arr, i, "path", visit);
|
|
234
|
+
}
|
|
235
|
+
} else {
|
|
236
|
+
walkDocRefs(item, visit);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
walkDocRefs(value, visit);
|
|
241
|
+
}
|
|
194
242
|
}
|
|
195
243
|
}
|
|
196
244
|
}
|
package/src/refactor/scan.ts
CHANGED
|
@@ -22,10 +22,30 @@ export interface LoadedDoc {
|
|
|
22
22
|
|
|
23
23
|
const isJsonPath = (p: string) => p.endsWith(".json");
|
|
24
24
|
|
|
25
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Skip vendored / build / agent directories (mirrors the component-discovery filter), and generated
|
|
27
|
+
* schema artifacts.
|
|
28
|
+
*
|
|
29
|
+
* Every committed `*.schema.json` in a project is a build output — `bun run schema:generate-all`
|
|
30
|
+
* composes the `project.schema.json` / `document.schema.json` pair into each project root — and the
|
|
31
|
+
* generators embed live `examples` blocks. `project.core.schema.json`'s `$head` example carries
|
|
32
|
+
* `/favicon.svg`, and the per-project entry schema enumerates the project's own layouts, so a sweep
|
|
33
|
+
* that reads them makes every project report its own schema as a referrer of its favicon and its
|
|
34
|
+
* base layout. Those are not references anyone authored, and nobody should be told a rename will
|
|
35
|
+
* break one.
|
|
36
|
+
*
|
|
37
|
+
* The stale-schema problem a skipped file leaves behind already has an owner: `bun run
|
|
38
|
+
* schema:verify` catches it and `schema:sync` regenerates it. A rewrite here would be a second
|
|
39
|
+
* writer on a generated file.
|
|
40
|
+
*/
|
|
26
41
|
export function skipScanPath(match: string): boolean {
|
|
27
42
|
const f = fwd(match);
|
|
28
|
-
return
|
|
43
|
+
return (
|
|
44
|
+
match.includes("node_modules") ||
|
|
45
|
+
f.includes("dist/") ||
|
|
46
|
+
f.includes(".claude/") ||
|
|
47
|
+
f.endsWith(".schema.json")
|
|
48
|
+
);
|
|
29
49
|
}
|
|
30
50
|
|
|
31
51
|
/** Build the recursive glob covering JSON plus every registered document/content extension. */
|
package/src/server.ts
CHANGED
|
@@ -421,6 +421,7 @@ export async function createDevServer(options: {
|
|
|
421
421
|
|
|
422
422
|
const res = await handleStudioApi(req, url, absRoot, activeProjectRoot, {
|
|
423
423
|
allowedRoots,
|
|
424
|
+
onFileMoved: (absPath) => collabRegistry.handleExternalChange(absPath),
|
|
424
425
|
onProjectCreated: (created) => createdRoots.push(created),
|
|
425
426
|
});
|
|
426
427
|
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
|
+
}
|
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;
|