@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/refactor/refs.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
}
|
package/src/refactor/scan.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
42
|
-
*
|
|
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 {
|
|
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
|
+
}
|