@iterant/site-runtime 3.7.0 → 3.8.1

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.
@@ -0,0 +1,406 @@
1
+ // @ts-check
2
+ import { createHash } from "node:crypto";
3
+ import { readdirSync, readFileSync } from "node:fs";
4
+ import { join, relative, resolve, sep } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ import { beginRestart, endRestart } from "./dev-restart-state.mjs";
8
+
9
+ // Dev-only restart signals, content re-arm and state probe (2026-09-11).
10
+ //
11
+ // Astro restarts its Vite server IN PLACE when a file in `settings.watchFiles`
12
+ // changes, and `core/config/settings.js` seeds that list with the repo's
13
+ // package.json, so a plain `bun add` mid-session replaces the whole dev
14
+ // server. The preset adds src/site-config.ts to the same list, so a font change
15
+ // takes this path too. Vite's restart builds a new chokidar watcher and closes
16
+ // the old one. The content layer is initialised ONCE at boot
17
+ // (`core/dev/dev.js`, `globalContentLayer.init({ watcher })`) and the restart
18
+ // never re-inits it, so the glob loader for the content tree stays bound to a
19
+ // closed watcher for the rest of the process: .tsx/.astro/.css keep
20
+ // hot-reloading while page entries and chrome.json quietly stop. Only a process
21
+ // restart clears it.
22
+ //
23
+ // `astro:server:setup` runs on EVERY server Astro creates, the in-place restart
24
+ // included, and it hands over `refreshContent()`, which resolves the live
25
+ // content layer and syncs it. That is the seam: on a restarted server this
26
+ // integration watches the content tree itself and drives the sync the dead
27
+ // watcher no longer drives. The sync writes .astro/data-store.json, and the new
28
+ // server's own watcher invalidates the content virtual module off that write
29
+ // (`content/vite-plugin-content-virtual-mod.js`), so the next request serves
30
+ // the new entry and the browser reloads.
31
+ //
32
+ // The watched tree is the one the COLLECTIONS read, not a fixed path: a repo
33
+ // that moved its entries passes the same `pagesDir`/`chromeDir` to
34
+ // `createCollections` and to `iterantStarter`, and the preset threads them here.
35
+ //
36
+ // WHAT THE FIRST REFRESH DOES TO THE DEAD WATCHER, measured on the consumer
37
+ // fixture across three restarts (chokidar 3.6 as Vite 8 bundles it). The glob
38
+ // loader ends its load with `watcher.add(base)` on whatever watcher the content
39
+ // layer holds, and chokidar's `add()` opens with `this.closed = false`. So the
40
+ // first refresh this integration drives brings watcher #1 back from the dead:
41
+ //
42
+ // boot #1 alive, 41 dirs / 123 paths, content listeners 10/14/10
43
+ // after restart #1 closed, 0 dirs / 0 paths, 0 listeners; #2 alive 41/123
44
+ // 1st edit after our sync only. #1 comes back: 3 dirs / 6 paths, 2/2/2
45
+ // 2nd edit after `Reloaded data from home.json` AND our sync: BOTH run
46
+ //
47
+ // Three consequences, all of them measured rather than reasoned:
48
+ //
49
+ // The content layer SELF-HEALS from the second edit on. Watcher #1's revived
50
+ // handlers are the layer's own, so it resumes its incremental per-file
51
+ // update. It heals BECAUSE of this arm, not instead of it: nothing else ever
52
+ // calls add(), and the first edit after a restart is carried by us alone.
53
+ //
54
+ // Nothing ACCUMULATES. Only watcher #1 ever revives, because only watcher #1
55
+ // is the one the layer holds; #2 and #3 stayed closed with 0 paths for the
56
+ // rest of the process. At most two live watchers, whatever the restart count.
57
+ //
58
+ // The revived one is NOT a second full watcher. `close()` wipes its watched
59
+ // set, and the loader re-adds only the collection dirs: 3 dirs / 6 paths
60
+ // against the live watcher's 41 / 123. Under inotify that is a handful of
61
+ // descriptors; under CHOKIDAR_USEPOLLING it polls six paths, not the tree.
62
+ //
63
+ // The redundant work is therefore one full sync per content edit alongside the
64
+ // layer's own single-file update: 7.7ms median over the fixture's four entries,
65
+ // growing with entry count. That is the price of an arm that cannot go stale,
66
+ // and it is why narrowing watcher #1 with `unwatch` was NOT done: it would buy
67
+ // back six watched paths and cost the self-heal.
68
+ //
69
+ // STDOUT SIGNALS. One single-line JSON object per event, every one keyed
70
+ // `iterant` so the preview supervisor (which already matches Astro's `ready in`
71
+ // line) can filter them out of the dev log. Every `at`/timestamp field in this
72
+ // integration, on stdout, over HMR and in the dev-state body alike, is an ISO
73
+ // 8601 string in UTC, as `new Date().toISOString()` writes it
74
+ // (`2026-09-11T15:51:32.589Z`), never an epoch number:
75
+ //
76
+ // {"iterant":"dev-server","event":"restart","phase":"config"}
77
+ // {"iterant":"dev-server","event":"restart","phase":"setup"}
78
+ // {"iterant":"dev-server","event":"ready","restart":true}
79
+ // {"iterant":"dev-server","event":"content-synced","restart":true,"paths":[...]}
80
+ // {"iterant":"dev-server","event":"content-sync-failed","restart":true,"paths":[...],"error":"..."}
81
+ //
82
+ // `paths` are root-relative (`src/content/pages/home.json`), which is what a
83
+ // writer wrote and the only key shape that survives a relocated tree. An empty
84
+ // `paths` is the arm-time sync below, which belongs to no single file.
85
+ //
86
+ // The two `restart` lines are emitted only on a restarted server; `ready`
87
+ // carries the flag instead, so a supervisor reading only `ready` still learns
88
+ // which kind of server it got. Exactly one `ready` per server, boot or restart,
89
+ // emitted when its httpServer starts listening.
90
+ //
91
+ // Those two moments are also the window preview-error-shell reads before it
92
+ // goes and fetches /under-construction for its cache (`dev-restart-state`).
93
+ // Nothing of ours may reach back through this port while the server behind it
94
+ // is being replaced.
95
+ //
96
+ // HMR EVENTS, on this server's channel:
97
+ //
98
+ // iterant:server-ready { restart, at } as each client connects
99
+ // iterant:content-synced { paths, at } after each sync
100
+ //
101
+ // There is deliberately no "restarting" event: by the time any hook on the new
102
+ // server runs the old one is gone, so nothing could have sent it to the client
103
+ // that was connected. `iterant:server-ready` is the arrival half of the same
104
+ // signal, and it goes out on connection rather than at startup because a
105
+ // payload sent before a client attaches is dropped.
106
+ //
107
+ // DEV STATE, mounted on the dev server's own middleware stack. Both routes
108
+ // require the header `x-iterant-dev-state: 1`, which the preview supervisor
109
+ // sends; without it the request falls through to Astro and is answered exactly
110
+ // as it would be if this integration were not installed (404 for the GET, and
111
+ // Astro's own cross-site rejection for the POST). The dev server binds to a
112
+ // host the preview tunnel can reach, so a route that answered any caller would
113
+ // be a content-refresh trigger anyone with the URL could pull:
114
+ //
115
+ // GET /__iterant/dev-state -> restart flag, start time, last sync, digests
116
+ // POST /__iterant/refresh -> forces one refreshContent()
117
+ //
118
+ // The digests are computed here, from the files on disk the content layer would
119
+ // load. What the layer currently HOLDS is not reported: the store is reachable
120
+ // only through `astro/dist/content` internals (`globalContentLayer`,
121
+ // `globalDataStore`), which an integration must not import.
122
+
123
+ const REFRESH_DEBOUNCE_MS = 100;
124
+ const STATE_PATH = "/__iterant/dev-state";
125
+ const REFRESH_PATH = "/__iterant/refresh";
126
+ const STATE_HEADER = "x-iterant-dev-state";
127
+
128
+ /**
129
+ * @param {object} options
130
+ * @param {string} options.pagesDir Page entry dir, relative to the project root.
131
+ * @param {string} options.chromeDir Chrome and links dir, same.
132
+ * @returns {import("astro").AstroIntegration}
133
+ */
134
+ export default function devServerSignals({ pagesDir, chromeDir }) {
135
+ /** Dev only. Set in astro:config:setup, read by every later hook. */
136
+ let enabled = false;
137
+ /** Whether THIS server is an in-place restart rather than the boot server. */
138
+ let restart = false;
139
+ /** Project root, no trailing separator: what the reported paths are relative to. */
140
+ let root = "";
141
+ /** Absolute content dirs, each with a trailing separator, nested ones collapsed. */
142
+ let contentDirs = /** @type {string[]} */ ([]);
143
+ let startedAt = "";
144
+ /** @type {string | null} */
145
+ let lastSyncAt = null;
146
+ /** @type {string[]} */
147
+ let lastSyncPaths = [];
148
+
149
+ return {
150
+ name: "dev-server-signals",
151
+ hooks: {
152
+ "astro:config:setup": ({ command, config, isRestart }) => {
153
+ if (command !== "dev") return;
154
+ enabled = true;
155
+ restart = isRestart;
156
+ root = fileURLToPath(config.root).replace(/[\\/]$/, "");
157
+ // resolve(), not join(): a configured dir may carry a trailing slash
158
+ // (`src/content/`), and join keeps it, so the `+ sep` below would make
159
+ // `src/content//` and no content path would ever match the prefix.
160
+ contentDirs = collapseNested(
161
+ [pagesDir, chromeDir].map((dir) => resolve(root, dir) + sep),
162
+ );
163
+ startedAt = new Date().toISOString();
164
+ if (isRestart) {
165
+ // The same moment, shared with preview-error-shell: from here until
166
+ // this server listens, a fetch back through the port would be aimed
167
+ // at a server still being replaced (see dev-restart-state).
168
+ beginRestart();
169
+ signal({ event: "restart", phase: "config" });
170
+ }
171
+ },
172
+
173
+ "astro:server:setup": ({ server, refreshContent }) => {
174
+ if (!enabled) return;
175
+ if (restart) signal({ event: "restart", phase: "setup" });
176
+
177
+ // Astro declares `refreshContent` optional and takes its options
178
+ // object by value; passing none of its keys syncs every loader.
179
+ const sync = () => refreshContent?.({}) ?? Promise.resolve();
180
+
181
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
182
+ let timer;
183
+ /** @type {Set<string>} */
184
+ const pending = new Set();
185
+
186
+ const refresh = async () => {
187
+ const paths = [...pending].sort();
188
+ pending.clear();
189
+ try {
190
+ await sync();
191
+ } catch (error) {
192
+ // A malformed entry rejects the whole loader chain. Report it as
193
+ // its own event rather than letting the rejection go unhandled:
194
+ // the next write retries, and a supervisor that sees this knows
195
+ // the store is behind the digests the state route reports.
196
+ signal({
197
+ event: "content-sync-failed",
198
+ restart,
199
+ paths,
200
+ error: String(error instanceof Error ? error.message : error),
201
+ });
202
+ return;
203
+ }
204
+ lastSyncAt = new Date().toISOString();
205
+ lastSyncPaths = paths;
206
+ signal({ event: "content-synced", restart, paths });
207
+ server.hot.send({
208
+ type: "custom",
209
+ event: "iterant:content-synced",
210
+ data: { paths, at: lastSyncAt },
211
+ });
212
+ };
213
+
214
+ // Coalesce a burst (a multi-entry write, a branch checkout) into one
215
+ // sync: reset the timer per event and fire once the tree settles.
216
+ /** @param {string} [path] */
217
+ const scheduleRefresh = (path) => {
218
+ if (path) pending.add(path);
219
+ clearTimeout(timer);
220
+ timer = setTimeout(() => void refresh(), REFRESH_DEBOUNCE_MS);
221
+ };
222
+
223
+ // Only on a restarted server. The BOOT server's content layer still
224
+ // holds a live watcher and runs its own incremental update per event;
225
+ // arming here too would make every content save run a second full sync
226
+ // on top of it, for the whole session, to fix nothing.
227
+ if (restart) {
228
+ server.watcher.on("all", (event, path) => {
229
+ if (event !== "add" && event !== "change" && event !== "unlink") {
230
+ return;
231
+ }
232
+ if (!contentDirs.some((dir) => path.startsWith(dir))) return;
233
+ scheduleRefresh(contentKey(path, root));
234
+ });
235
+ // One sync for the gap itself. A write that landed between the old
236
+ // watcher's close and this arm produced an event nobody was
237
+ // listening for, and no later event will mention that file, so
238
+ // waiting for one would strand it until the next unrelated edit.
239
+ // It rides the same debounce, so a restart plus a burst of writes
240
+ // is still one sync.
241
+ scheduleRefresh();
242
+ }
243
+
244
+ // An obsolete generation must not fire after its server is gone: Vite's
245
+ // in-place restart calls close() on the old server (which closes this
246
+ // httpServer) before the new one listens, and a timer still pending
247
+ // then would run one sync and one content-synced event on behalf of a
248
+ // server nobody is talking to. The same listener covers shutdown.
249
+ server.httpServer?.once("close", () => clearTimeout(timer));
250
+
251
+ server.middlewares.use((req, res, next) => {
252
+ const path = String(req.url ?? "").split("?")[0];
253
+ if (path !== STATE_PATH && path !== REFRESH_PATH) return next();
254
+ // Absent or wrong header: behave as if these routes did not exist.
255
+ if (req.headers[STATE_HEADER] !== "1") return next();
256
+ if (path === STATE_PATH && req.method === "GET") {
257
+ return json(res, 200, {
258
+ restart,
259
+ startedAt,
260
+ contentSync: {
261
+ armed: restart,
262
+ lastAt: lastSyncAt,
263
+ lastPaths: lastSyncPaths,
264
+ },
265
+ entries: contentDigests(contentDirs, root),
266
+ });
267
+ }
268
+ if (path === REFRESH_PATH && req.method === "POST") {
269
+ void sync().then(
270
+ () => {
271
+ lastSyncAt = new Date().toISOString();
272
+ json(res, 200, { ok: true, at: lastSyncAt });
273
+ },
274
+ (error) => {
275
+ json(res, 500, {
276
+ ok: false,
277
+ error: String(error instanceof Error ? error.message : error),
278
+ });
279
+ },
280
+ );
281
+ return;
282
+ }
283
+ return next();
284
+ });
285
+ // Astro's own dev handler registers earlier in the connect stack and
286
+ // ends the response before later layers run, the same reason
287
+ // preview-error-shell hoists its layer; these two paths belong to no
288
+ // route, so they have to be answered before it.
289
+ const layer = server.middlewares.stack.pop();
290
+ if (layer) server.middlewares.stack.unshift(layer);
291
+
292
+ // The ready line, from the socket rather than from a hook. Astro runs
293
+ // astro:server:start on the BOOT path only, so a restarted server that
294
+ // waited for it would never announce itself; 'listening' is the moment
295
+ // both paths share, and it is also the first moment the routes above
296
+ // can actually be reached.
297
+ server.httpServer?.once("listening", () => {
298
+ endRestart();
299
+ signal({ event: "ready", restart });
300
+ });
301
+
302
+ // The same news over HMR, as clients arrive. It cannot be sent from a
303
+ // hook: an HMR payload with no client attached is dropped, and on a
304
+ // restart the client that was open has not finished reconnecting yet.
305
+ // This states a FACT about the server rather than announcing a moment,
306
+ // so a client connecting later still wants it, and one already attached
307
+ // can take it twice without harm.
308
+ server.ws.on("connection", () =>
309
+ server.hot.send({
310
+ type: "custom",
311
+ event: "iterant:server-ready",
312
+ data: { restart, at: new Date().toISOString() },
313
+ }),
314
+ );
315
+ },
316
+ },
317
+ };
318
+ }
319
+
320
+ /**
321
+ * One signal line on stdout. Written directly rather than through Astro's
322
+ * logger, which wraps and colorizes: a supervisor parses these, so they have to
323
+ * stay one line of plain JSON.
324
+ *
325
+ * @param {Record<string, unknown>} fields
326
+ */
327
+ function signal(fields) {
328
+ process.stdout.write(
329
+ `${JSON.stringify({ iterant: "dev-server", ...fields })}\n`,
330
+ );
331
+ }
332
+
333
+ /**
334
+ * @param {import("node:http").ServerResponse} res
335
+ * @param {number} status
336
+ * @param {unknown} body
337
+ */
338
+ function json(res, status, body) {
339
+ res.writeHead(status, { "content-type": "application/json" });
340
+ res.end(JSON.stringify(body));
341
+ }
342
+
343
+ /**
344
+ * The dirs to walk and watch, with any dir that sits inside another dropped.
345
+ * The default layout nests the two (`src/content` holds `src/content/pages`),
346
+ * and walking both would digest the page entries twice.
347
+ *
348
+ * @param {string[]} dirs
349
+ * @returns {string[]}
350
+ */
351
+ function collapseNested(dirs) {
352
+ const sorted = [...new Set(dirs)].sort();
353
+ return sorted.filter(
354
+ (dir, index) =>
355
+ !sorted.slice(0, index).some((kept) => dir.startsWith(kept)),
356
+ );
357
+ }
358
+
359
+ /**
360
+ * A content file's reported key: its path relative to the project root, with
361
+ * forward slashes. Root-relative rather than dir-relative because two relocated
362
+ * dirs can hold the same file name, and because it is the path a writer wrote.
363
+ *
364
+ * @param {string} path
365
+ * @param {string} root
366
+ */
367
+ function contentKey(path, root) {
368
+ return relative(root, path).split(sep).join("/");
369
+ }
370
+
371
+ /**
372
+ * sha256 of every content entry on disk, keyed by `contentKey`. Every
373
+ * collection this runtime defines loads `.json` out of these dirs, so the walk
374
+ * needs no per-collection knowledge. An absent dir is a real state (a repo
375
+ * before its first entry lands) and reports as no entries.
376
+ *
377
+ * @param {string[]} dirs
378
+ * @param {string} root
379
+ * @returns {Record<string, string>}
380
+ */
381
+ function contentDigests(dirs, root) {
382
+ /** @type {Record<string, string>} */
383
+ const digests = {};
384
+ /** @param {string} current */
385
+ const walk = (current) => {
386
+ /** @type {import("node:fs").Dirent[]} */
387
+ let entries;
388
+ try {
389
+ entries = readdirSync(current, { withFileTypes: true });
390
+ } catch {
391
+ return;
392
+ }
393
+ for (const entry of entries) {
394
+ const path = join(current, entry.name);
395
+ if (entry.isDirectory()) {
396
+ walk(path);
397
+ } else if (entry.name.endsWith(".json")) {
398
+ digests[contentKey(path, root)] = createHash("sha256")
399
+ .update(readFileSync(path))
400
+ .digest("hex");
401
+ }
402
+ }
403
+ };
404
+ for (const dir of dirs) walk(dir);
405
+ return digests;
406
+ }
@@ -1,4 +1,5 @@
1
1
  // @ts-check
2
+ import { restartInFlight } from "./dev-restart-state.mjs";
2
3
 
3
4
  // Dev-only branded fallback for broken routes (2026-07-06): mid-build there
4
5
  // is a window where a route exists but its page component doesn't (the shell
@@ -10,6 +11,33 @@
10
11
  // /under-construction shell (navbar + footer + "in progress"). The 5xx
11
12
  // status is preserved on purpose — verify probes and the save gate must
12
13
  // still see the failure; only the human-facing body changes.
14
+ //
15
+ // NOTHING IS FETCHED FROM INSIDE A FAILING REQUEST (3.8.1). Until 3.8.0 the
16
+ // 5xx path fetched /under-construction back through this same port while it
17
+ // held the failing response open, and that wedged the dev server for good.
18
+ // Measured in Docker, on the site-config edit that restarts the server in
19
+ // place: an in-flight request resolves 500 `fetch failed` (the Cloudflare
20
+ // plugin's miniflare goes down under it), the shell's self-fetch answers 200
21
+ // in 271ms, and from then on every request that reaches workerd renders (the
22
+ // worker logs its own 200) and never reaches the client, for the whole 120s
23
+ // probe window. Node's own middleware still answers in 8ms, so what the
24
+ // nested request leaves broken is the node-to-workerd path, inside
25
+ // @cloudflare/vite-plugin. With the same self-fetch gone the same edit
26
+ // recovered in 6s, and with the whole integration off, in 11s.
27
+ //
28
+ // So the branded body is CACHED instead, and a 5xx answers from memory with no
29
+ // I/O at all. The one fetch that fills the cache runs after a request this
30
+ // server has just ANSWERED, page and all: the only moment it is known to be
31
+ // able to render its own route, and the furthest possible moment from a
32
+ // restart. Nothing waits on that fetch, it carries an abort timeout, and it
33
+ // does not run while `dev-server-signals` says a restart is in flight. One
34
+ // server fetches once, and a restarted server fetches again off its own first
35
+ // page, which is what picks up an edited navbar.
36
+ //
37
+ // The cached body outlives the server that fetched it, so a preview that has
38
+ // shown the branded shell keeps showing it across a restart. Before the first
39
+ // page of a session renders there is nothing to cache, and a break that early
40
+ // gets the static shell below.
13
41
 
14
42
  // vite is astro's dependency, not this package's, so the dev-server type is
15
43
  // derived from the hook astro hands it to. One vite in the type graph, whichever
@@ -20,9 +48,41 @@
20
48
  * >[0]["server"]} ViteDevServer
21
49
  */
22
50
 
51
+ /**
52
+ * Whether THIS server has the branded body yet, whether a fetch for it is
53
+ * already out, and how many it has spent trying.
54
+ *
55
+ * @typedef {{ warmed: boolean, warming: boolean, attempts: number }} WarmState
56
+ */
57
+
23
58
  const FALLBACK_PATH = "/under-construction";
24
59
 
25
- // Served when /under-construction itself can't render (e.g. Layout broken).
60
+ /**
61
+ * How long a warm fetch may take before it is abandoned. Nothing waits on it,
62
+ * so it can be patient: a cold server rendered this route in 5.3s under
63
+ * Docker. It is bounded all the same, because a fetch that never settles would
64
+ * hold `warming` up and leave the cache unfillable for the rest of the server.
65
+ */
66
+ const WARM_TIMEOUT_MS = 10_000;
67
+
68
+ /**
69
+ * The branded body, once some server has managed to fetch it. Module scope on
70
+ * purpose: an in-place restart builds a new server, and a preview that has
71
+ * been showing the branded shell should not drop back to the static one while
72
+ * the new server warms up.
73
+ */
74
+ let cachedShell = "";
75
+
76
+ /**
77
+ * How many times one server tries. A repo whose /under-construction route is
78
+ * gone answers 404 forever, and without a cap every page view would spend a
79
+ * fetch on it.
80
+ */
81
+ const WARM_ATTEMPTS = 3;
82
+
83
+ // Served until the branded body is cached, and after that only if the branded
84
+ // fetch keeps failing: /under-construction not rendering (a broken Layout), or
85
+ // a break before this session has rendered any page at all.
26
86
  const MINIMAL_FALLBACK = `<!doctype html>
27
87
  <html lang="en"><head><meta charset="utf-8"><title>Page in progress</title></head>
28
88
  <body style="display:flex;min-height:100vh;align-items:center;justify-content:center;font-family:system-ui,sans-serif">
@@ -42,6 +102,14 @@ export default function previewErrorShell() {
42
102
  {
43
103
  name: "preview-error-shell",
44
104
  configureServer(server) {
105
+ // Per server, so a restarted one warms again. The body it
106
+ // fetches is shared; whether THIS server has fetched it is
107
+ // not.
108
+ const state = {
109
+ warmed: false,
110
+ warming: false,
111
+ attempts: 0,
112
+ };
45
113
  server.middlewares.use((req, res, next) => {
46
114
  const accept = String(req.headers.accept ?? "");
47
115
  if (
@@ -51,7 +119,7 @@ export default function previewErrorShell() {
51
119
  ) {
52
120
  return next();
53
121
  }
54
- intercept(server, res);
122
+ intercept(server, state, res);
55
123
  next();
56
124
  });
57
125
  // Astro's own dev handler registers earlier in the connect
@@ -72,13 +140,14 @@ export default function previewErrorShell() {
72
140
 
73
141
  /**
74
142
  * Buffer the response; replay it verbatim unless it resolves to a 5xx, in
75
- * which case serve the branded fallback body instead. Buffering trades dev
143
+ * which case serve the cached fallback body instead. Buffering trades dev
76
144
  * streaming for the swap — fine for a preview.
77
145
  *
78
146
  * @param {ViteDevServer} server
147
+ * @param {WarmState} state
79
148
  * @param {import("node:http").ServerResponse} res
80
149
  */
81
- function intercept(server, res) {
150
+ function intercept(server, state, res) {
82
151
  /** @type {Array<string | Buffer>} */
83
152
  const chunks = [];
84
153
  /** @type {unknown[] | null} */
@@ -112,34 +181,45 @@ function intercept(server, res) {
112
181
  res.end = original.end;
113
182
  if (headArgs) original.writeHead(.../** @type {[number]} */ (headArgs));
114
183
  for (const c of chunks) original.write(c);
115
- return original.end();
184
+ const answered = original.end();
185
+ // This server just proved it can render a page of its own. That is the
186
+ // moment, and the only one, to go and get the branded body.
187
+ if (!state.warmed) warm(server, state);
188
+ return answered;
116
189
  }
117
- void serveFallback(server, original, status);
118
- return res;
190
+ original.writeHead(status, {
191
+ "content-type": "text/html",
192
+ "x-preview-fallback": "1",
193
+ });
194
+ original.write(cachedShell || MINIMAL_FALLBACK);
195
+ return original.end();
119
196
  }
120
197
  );
121
198
  }
122
199
 
123
200
  /**
201
+ * Fetch the branded body into the cache. Fire and forget: nothing waits on it,
202
+ * no response is open on it, and a failure leaves the previous body in place.
203
+ *
124
204
  * @param {ViteDevServer} server
125
- * @param {{ writeHead: Function, write: Function, end: Function }} original
126
- * @param {number} status
205
+ * @param {WarmState} state
127
206
  */
128
- async function serveFallback(server, original, status) {
129
- let html = MINIMAL_FALLBACK;
130
- try {
131
- const port = server.config.server.port;
132
- const resp = await fetch(`http://127.0.0.1:${port}${FALLBACK_PATH}`, {
133
- headers: { accept: "text/html" },
207
+ function warm(server, state) {
208
+ if (state.warming || state.attempts >= WARM_ATTEMPTS) return;
209
+ if (restartInFlight()) return;
210
+ state.warming = true;
211
+ state.attempts += 1;
212
+ const port = server.config.server.port;
213
+ void fetch(`http://127.0.0.1:${port}${FALLBACK_PATH}`, {
214
+ headers: { accept: "text/html" },
215
+ signal: AbortSignal.timeout(WARM_TIMEOUT_MS),
216
+ })
217
+ .then((resp) => (resp.ok ? resp.text() : ""))
218
+ .catch(() => "")
219
+ .then((html) => {
220
+ state.warming = false;
221
+ if (!html) return;
222
+ state.warmed = true;
223
+ cachedShell = html;
134
224
  });
135
- if (resp.ok) html = await resp.text();
136
- } catch {
137
- // fall through to the minimal shell
138
- }
139
- original.writeHead(status, {
140
- "content-type": "text/html",
141
- "x-preview-fallback": "1",
142
- });
143
- original.write(html);
144
- original.end();
145
225
  }
@@ -0,0 +1,38 @@
1
+ // @ts-check
2
+
3
+ // The repo's site config, added to the dev server's restart list (2026-09-11).
4
+ //
5
+ // Astro restarts on its own config file, package.json and the tsconfig sources,
6
+ // and on nothing else: `core/dev/restart.js` compares every add/change/unlink
7
+ // against `settings.watchFiles`, and `addWatchFile` is the only way into that
8
+ // list. src/site-config.ts is not on it by default, yet SITE_CONFIG.fonts is
9
+ // read TWICE from two different places: the preset passes it to Astro's font
10
+ // config (resolved once, at server start) and the layout renders `<Font>` from
11
+ // it per request. Without this, adding a family to that array would render a
12
+ // face the server never resolved, for the rest of the session, and the page
13
+ // would fall back to the system stack with no error anywhere.
14
+ //
15
+ // The restart this causes is Astro's in-place kind, which leaves the content
16
+ // layer bound to the closed watcher; `dev-server-signals` re-arms it. That is
17
+ // what makes watching this file a fix rather than a trade.
18
+ //
19
+ // Not gated on `command`: the hook states that this file feeds the config,
20
+ // which is true in every command, and Astro reads watchFiles in dev alone.
21
+
22
+ /**
23
+ * @param {string} siteConfigPath The repo's site config module, relative to the
24
+ * project root. Resolved against `config.root` rather than `process.cwd()`:
25
+ * the two differ whenever Astro runs with an explicit `--root`, and a watch
26
+ * entry that names a path nothing writes to is a watch that never fires.
27
+ * @returns {import("astro").AstroIntegration}
28
+ */
29
+ export default function siteConfigWatch(siteConfigPath) {
30
+ return {
31
+ name: "site-config-watch",
32
+ hooks: {
33
+ "astro:config:setup": ({ config, addWatchFile }) => {
34
+ addWatchFile(new URL(siteConfigPath, config.root));
35
+ },
36
+ },
37
+ };
38
+ }