@iterant/site-runtime 3.8.0 → 3.8.2
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/docs/runtime-contract.md +53 -1
- package/package.json +1 -1
- package/src/config/preset.ts +4 -0
- package/src/content/schema.ts +4 -0
- package/src/integrations/dev-restart-state.mjs +46 -0
- package/src/integrations/dev-server-signals.mjs +117 -4
- package/src/integrations/preview-error-shell.mjs +105 -25
package/docs/runtime-contract.md
CHANGED
|
@@ -50,7 +50,7 @@ runtime and says so.
|
|
|
50
50
|
|
|
51
51
|
<!-- generated: available libraries -->
|
|
52
52
|
|
|
53
|
-
_Generated from package.json by scripts/generate-kit-table.mjs. Runtime 3.8.
|
|
53
|
+
_Generated from package.json by scripts/generate-kit-table.mjs. Runtime 3.8.2._
|
|
54
54
|
|
|
55
55
|
**Toolchain** (this package owns the version; do NOT declare these):
|
|
56
56
|
|
|
@@ -720,6 +720,58 @@ one reported, and calls `POST /__iterant/refresh` if the layer is behind. Every
|
|
|
720
720
|
timestamp in these payloads, and in the signal lines, is an ISO 8601 string in
|
|
721
721
|
UTC, never an epoch number.
|
|
722
722
|
|
|
723
|
+
### Restarts and the error shell (3.8.1)
|
|
724
|
+
|
|
725
|
+
An in-place restart answers whatever request was already running with a 500: the
|
|
726
|
+
Cloudflare plugin's miniflare goes down under it. Until 3.8.0 `preview-error-shell`
|
|
727
|
+
answered a 5xx by fetching `/under-construction` back through the same port while
|
|
728
|
+
it held that failing response open, and in Docker that wedged the dev server for
|
|
729
|
+
good. The self-fetch itself came back 200 in 271ms, and from then on every
|
|
730
|
+
request that reached workerd rendered (the worker logged its own 200) and
|
|
731
|
+
never reached the client, for the whole 120 second probe window, while Node's
|
|
732
|
+
own middleware kept answering in 8ms. The same edit with the self-fetch gone
|
|
733
|
+
recovered in 6 seconds.
|
|
734
|
+
|
|
735
|
+
So the shell no longer fetches anything from inside a request. It caches the
|
|
736
|
+
branded body and serves a 5xx from memory. The one fetch that fills the cache
|
|
737
|
+
runs after a page this server has already ANSWERED, which is the only moment it
|
|
738
|
+
is known to be able to render its own route; it is bounded by an abort timeout,
|
|
739
|
+
holds no response open, gives up after three tries, and never runs while a
|
|
740
|
+
restart is in flight (`dev-server-signals` opens that window at
|
|
741
|
+
`restart`/`config` and closes it at `ready`). The cached body outlives the
|
|
742
|
+
server that fetched it, so a restart keeps showing the branded shell, and the
|
|
743
|
+
new server's own first page refetches it, which is what picks up an edited
|
|
744
|
+
navbar. Before any page of a session has rendered there is nothing to cache and
|
|
745
|
+
a break that early gets the static in-progress body.
|
|
746
|
+
|
|
747
|
+
The preset also pre-bundles `astro/logger/json`, which the supervisor's
|
|
748
|
+
`astro dev --json` imports on every boot. Discovered mid-session instead, it
|
|
749
|
+
reloaded the program and split React in two inside workerd, and every island
|
|
750
|
+
render then failed on `useRef`.
|
|
751
|
+
|
|
752
|
+
### The worker's half of a content refresh (3.8.2)
|
|
753
|
+
|
|
754
|
+
Astro syncs a changed page entry on the node side and then invalidates the data
|
|
755
|
+
store module in the ssr module graph. Under the cloudflare adapter that graph
|
|
756
|
+
entry is the only lever that reaches a render: the modules are evaluated inside
|
|
757
|
+
workerd, and Astro cannot clear a runner it does not own. When the entry is
|
|
758
|
+
missing at the moment the store is written (after a program reload, measured in
|
|
759
|
+
the sandbox image), the node side logs `Reloaded data from home.json`, the store
|
|
760
|
+
on disk is fresh, and `/` keeps serving the entry the worker evaluated last,
|
|
761
|
+
until any SSR module changes. A first build showed two sections as "Coming up"
|
|
762
|
+
placeholders for 95 seconds after their real rows had been written and
|
|
763
|
+
committed.
|
|
764
|
+
|
|
765
|
+
Since 3.8.2 the signals integration watches `.astro/data-store.json`, the file
|
|
766
|
+
Astro itself writes on every sync, and hands it to every environment Astro
|
|
767
|
+
cannot reach in process: the content virtual modules are invalidated in that
|
|
768
|
+
environment's graph and the environment is sent a full reload, so the worker
|
|
769
|
+
imports its entrypoints again against the new store. It prints
|
|
770
|
+
`{"iterant":"dev-server","event":"content-reloaded","restart":...,"environments":["ssr"]}`.
|
|
771
|
+
The reload is the same one an `.astro` edit causes and costs the same; a `.tsx`
|
|
772
|
+
edit still hot-reloads, and the boot server is armed the same way as a restarted
|
|
773
|
+
one, because the hole belongs to the adapter and not to the restart.
|
|
774
|
+
|
|
723
775
|
## Verify: the gate
|
|
724
776
|
|
|
725
777
|
`bun run verify` maps to `site-runtime verify` and is the gate. It is silent on
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iterant/site-runtime",
|
|
3
|
-
"version": "3.8.
|
|
3
|
+
"version": "3.8.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The platform layer every Iterant brand site runs on: content grammar, collection schemas, SEO head and JSON-LD, layout core, Astro config preset, dev integrations and the verify gates.",
|
|
6
6
|
"scripts": {
|
package/src/config/preset.ts
CHANGED
|
@@ -283,6 +283,10 @@ export function iterantStarter({
|
|
|
283
283
|
"tailwind-merge",
|
|
284
284
|
"class-variance-authority",
|
|
285
285
|
"@radix-ui/react-slot",
|
|
286
|
+
// The supervisor runs `astro dev --json`, so astro imports this one on
|
|
287
|
+
// every boot: left out, it is discovered mid-session and the reload
|
|
288
|
+
// that follows splits React in two inside workerd (the same crash).
|
|
289
|
+
"astro/logger/json",
|
|
286
290
|
],
|
|
287
291
|
},
|
|
288
292
|
server: {
|
package/src/content/schema.ts
CHANGED
|
@@ -164,6 +164,10 @@ export function createContentSchemas({
|
|
|
164
164
|
// brand asset; alt is the accessible/og:image:alt text.
|
|
165
165
|
ogImage: z
|
|
166
166
|
.object({
|
|
167
|
+
// Every other image leaf in an entry is tagged type: "image",
|
|
168
|
+
// and the model reaches for the same tag here; the tag is
|
|
169
|
+
// accepted so a share image never refuses a whole page write.
|
|
170
|
+
type: z.literal("image").optional(),
|
|
167
171
|
src: z.string().url(),
|
|
168
172
|
assetId: z.string().optional(),
|
|
169
173
|
alt: z.string().optional(),
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
// Dev-only: is an in-place Astro restart in flight? (3.8.1)
|
|
4
|
+
//
|
|
5
|
+
// Astro replaces its Vite server in place when a watched file changes
|
|
6
|
+
// (`src/site-config.ts`, `package.json`), and for that stretch the port still
|
|
7
|
+
// accepts connections while the server behind it is being torn down and built
|
|
8
|
+
// again. A request that was already running is answered 500 `fetch failed`:
|
|
9
|
+
// the Cloudflare vite plugin's miniflare went down under it.
|
|
10
|
+
//
|
|
11
|
+
// preview-error-shell reads this flag before it fetches /under-construction
|
|
12
|
+
// back through that same port for its cache. A fetch aimed into the gap buys
|
|
13
|
+
// nothing (it can only fail or hang) and it reaches back into the plugin at
|
|
14
|
+
// the moment it is least able to answer, which is how the 3.8.0 shell wedged
|
|
15
|
+
// the dev server for a whole 120s probe window in Docker.
|
|
16
|
+
//
|
|
17
|
+
// The window opens in `dev-server-signals` at `astro:config:setup` on the
|
|
18
|
+
// restarted server, the same moment it emits
|
|
19
|
+
// `{"event":"restart","phase":"config"}`, and closes when that server's socket
|
|
20
|
+
// starts listening, the moment of `{"event":"ready","restart":true}`.
|
|
21
|
+
//
|
|
22
|
+
// State, not a timer: nothing here schedules work or holds a handle, and the
|
|
23
|
+
// two integrations share one module instance because the preset imports both
|
|
24
|
+
// from this package. A restart that never lands would otherwise leave the flag
|
|
25
|
+
// up for the rest of the session and strand the cache empty, so the flag is
|
|
26
|
+
// read with a bound on how long a restart can plausibly be in flight rather
|
|
27
|
+
// than trusted forever. A dev boot on a cold cache is around 11s.
|
|
28
|
+
|
|
29
|
+
const RESTART_WINDOW_MS = 30_000;
|
|
30
|
+
|
|
31
|
+
let startedAt = 0;
|
|
32
|
+
|
|
33
|
+
/** The restarted server is being built. Called once per in-place restart. */
|
|
34
|
+
export function beginRestart() {
|
|
35
|
+
startedAt = Date.now();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The restarted server is listening, so a self-fetch can be answered again. */
|
|
39
|
+
export function endRestart() {
|
|
40
|
+
startedAt = 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** @returns {boolean} */
|
|
44
|
+
export function restartInFlight() {
|
|
45
|
+
return startedAt !== 0 && Date.now() - startedAt < RESTART_WINDOW_MS;
|
|
46
|
+
}
|
|
@@ -4,6 +4,8 @@ import { readdirSync, readFileSync } from "node:fs";
|
|
|
4
4
|
import { join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
|
|
7
|
+
import { beginRestart, endRestart } from "./dev-restart-state.mjs";
|
|
8
|
+
|
|
7
9
|
// Dev-only restart signals, content re-arm and state probe (2026-09-11).
|
|
8
10
|
//
|
|
9
11
|
// Astro restarts its Vite server IN PLACE when a file in `settings.watchFiles`
|
|
@@ -64,6 +66,33 @@ import { fileURLToPath } from "node:url";
|
|
|
64
66
|
// and it is why narrowing watcher #1 with `unwatch` was NOT done: it would buy
|
|
65
67
|
// back six watched paths and cost the self-heal.
|
|
66
68
|
//
|
|
69
|
+
// THE WORKER'S HALF OF A CONTENT REFRESH. A synced store still has to reach the
|
|
70
|
+
// environment that renders. Astro does that in
|
|
71
|
+
// `content/vite-plugin-content-virtual-mod.js` (`invalidateDataStore`): it
|
|
72
|
+
// invalidates the data store module in the ssr module graph and, for an
|
|
73
|
+
// environment whose modules THIS process evaluates, drops the runner's copy as
|
|
74
|
+
// well. `astro dev` with the cloudflare adapter has neither shape: the modules
|
|
75
|
+
// live inside workerd, the plugin's environment is a plain DevEnvironment, and
|
|
76
|
+
// the graph entry is the only lever Astro has left. A prune or a program reload
|
|
77
|
+
// can leave that graph without the entry at the moment the store is written,
|
|
78
|
+
// and then nothing reaches the render at all.
|
|
79
|
+
//
|
|
80
|
+
// Measured in the sandbox image on 2026-09-11, with the graph lever removed to
|
|
81
|
+
// hold the state still: the node side logs `Reloaded data from home.json`, the
|
|
82
|
+
// synced store on disk carries the new rows, `astro:content-changed` goes out
|
|
83
|
+
// over the worker's channel, and / keeps serving the entry the worker evaluated
|
|
84
|
+
// at the last program reload for as long as nothing else touches an SSR module.
|
|
85
|
+
// A customer's first build showed two sections as "Coming up:" placeholders for
|
|
86
|
+
// 95 s after their real rows had been written and committed.
|
|
87
|
+
//
|
|
88
|
+
// So the store write drives a reload of every environment Astro cannot reach in
|
|
89
|
+
// process. The graph entries go first, so the re-import reads the new store
|
|
90
|
+
// rather than a cached transform, and the full reload makes the worker clear its
|
|
91
|
+
// evaluated modules and import its entrypoints again. This watches the same file
|
|
92
|
+
// Astro's own plugin watches, so the store is on disk by the time the event
|
|
93
|
+
// arrives, and it is armed on the BOOT server too: the hole belongs to the
|
|
94
|
+
// adapter, not to the restart.
|
|
95
|
+
//
|
|
67
96
|
// STDOUT SIGNALS. One single-line JSON object per event, every one keyed
|
|
68
97
|
// `iterant` so the preview supervisor (which already matches Astro's `ready in`
|
|
69
98
|
// line) can filter them out of the dev log. Every `at`/timestamp field in this
|
|
@@ -76,6 +105,7 @@ import { fileURLToPath } from "node:url";
|
|
|
76
105
|
// {"iterant":"dev-server","event":"ready","restart":true}
|
|
77
106
|
// {"iterant":"dev-server","event":"content-synced","restart":true,"paths":[...]}
|
|
78
107
|
// {"iterant":"dev-server","event":"content-sync-failed","restart":true,"paths":[...],"error":"..."}
|
|
108
|
+
// {"iterant":"dev-server","event":"content-reloaded","restart":true,"environments":["ssr"]}
|
|
79
109
|
//
|
|
80
110
|
// `paths` are root-relative (`src/content/pages/home.json`), which is what a
|
|
81
111
|
// writer wrote and the only key shape that survives a relocated tree. An empty
|
|
@@ -86,6 +116,11 @@ import { fileURLToPath } from "node:url";
|
|
|
86
116
|
// which kind of server it got. Exactly one `ready` per server, boot or restart,
|
|
87
117
|
// emitted when its httpServer starts listening.
|
|
88
118
|
//
|
|
119
|
+
// Those two moments are also the window preview-error-shell reads before it
|
|
120
|
+
// goes and fetches /under-construction for its cache (`dev-restart-state`).
|
|
121
|
+
// Nothing of ours may reach back through this port while the server behind it
|
|
122
|
+
// is being replaced.
|
|
123
|
+
//
|
|
89
124
|
// HMR EVENTS, on this server's channel:
|
|
90
125
|
//
|
|
91
126
|
// iterant:server-ready { restart, at } as each client connects
|
|
@@ -114,6 +149,10 @@ import { fileURLToPath } from "node:url";
|
|
|
114
149
|
// `globalDataStore`), which an integration must not import.
|
|
115
150
|
|
|
116
151
|
const REFRESH_DEBOUNCE_MS = 100;
|
|
152
|
+
/** Astro's dev-time content store, under the project's own `.astro` dir. */
|
|
153
|
+
const DATA_STORE_SEGMENTS = [".astro", "data-store.json"];
|
|
154
|
+
/** The modules an SSR render reads a page entry through, by resolved id. */
|
|
155
|
+
const CONTENT_VIRTUAL_IDS = ["\0astro:data-layer-content", "\0astro:content"];
|
|
117
156
|
const STATE_PATH = "/__iterant/dev-state";
|
|
118
157
|
const REFRESH_PATH = "/__iterant/refresh";
|
|
119
158
|
const STATE_HEADER = "x-iterant-dev-state";
|
|
@@ -154,7 +193,13 @@ export default function devServerSignals({ pagesDir, chromeDir }) {
|
|
|
154
193
|
[pagesDir, chromeDir].map((dir) => resolve(root, dir) + sep),
|
|
155
194
|
);
|
|
156
195
|
startedAt = new Date().toISOString();
|
|
157
|
-
if (isRestart)
|
|
196
|
+
if (isRestart) {
|
|
197
|
+
// The same moment, shared with preview-error-shell: from here until
|
|
198
|
+
// this server listens, a fetch back through the port would be aimed
|
|
199
|
+
// at a server still being replaced (see dev-restart-state).
|
|
200
|
+
beginRestart();
|
|
201
|
+
signal({ event: "restart", phase: "config" });
|
|
202
|
+
}
|
|
158
203
|
},
|
|
159
204
|
|
|
160
205
|
"astro:server:setup": ({ server, refreshContent }) => {
|
|
@@ -228,6 +273,23 @@ export default function devServerSignals({ pagesDir, chromeDir }) {
|
|
|
228
273
|
scheduleRefresh();
|
|
229
274
|
}
|
|
230
275
|
|
|
276
|
+
// Whichever side synced it, a written store has to reach the render.
|
|
277
|
+
// Armed on both kinds of server: the environment that cannot be
|
|
278
|
+
// invalidated is the adapter's, and a boot server has the same one.
|
|
279
|
+
const dataStore = resolve(root, ...DATA_STORE_SEGMENTS);
|
|
280
|
+
server.watcher.on("all", (event, path) => {
|
|
281
|
+
if (event !== "add" && event !== "change") return;
|
|
282
|
+
if (path !== dataStore) return;
|
|
283
|
+
const reloaded = reloadRenderEnvironments(server);
|
|
284
|
+
if (reloaded.length > 0) {
|
|
285
|
+
signal({
|
|
286
|
+
event: "content-reloaded",
|
|
287
|
+
restart,
|
|
288
|
+
environments: reloaded,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
231
293
|
// An obsolete generation must not fire after its server is gone: Vite's
|
|
232
294
|
// in-place restart calls close() on the old server (which closes this
|
|
233
295
|
// httpServer) before the new one listens, and a timer still pending
|
|
@@ -281,9 +343,10 @@ export default function devServerSignals({ pagesDir, chromeDir }) {
|
|
|
281
343
|
// waited for it would never announce itself; 'listening' is the moment
|
|
282
344
|
// both paths share, and it is also the first moment the routes above
|
|
283
345
|
// can actually be reached.
|
|
284
|
-
server.httpServer?.once("listening", () =>
|
|
285
|
-
|
|
286
|
-
|
|
346
|
+
server.httpServer?.once("listening", () => {
|
|
347
|
+
endRestart();
|
|
348
|
+
signal({ event: "ready", restart });
|
|
349
|
+
});
|
|
287
350
|
|
|
288
351
|
// The same news over HMR, as clients arrive. It cannot be sent from a
|
|
289
352
|
// hook: an HMR payload with no client attached is dropped, and on a
|
|
@@ -303,6 +366,56 @@ export default function devServerSignals({ pagesDir, chromeDir }) {
|
|
|
303
366
|
};
|
|
304
367
|
}
|
|
305
368
|
|
|
369
|
+
/**
|
|
370
|
+
* Hand the freshly written store to every environment Astro's own invalidation
|
|
371
|
+
* leaves behind: not the client, which Astro reloads itself, and not one this
|
|
372
|
+
* process evaluates, whose runner Astro already clears. What is left in this
|
|
373
|
+
* stack is the single workerd environment the cloudflare plugin creates, and it
|
|
374
|
+
* is the one that renders.
|
|
375
|
+
*
|
|
376
|
+
* The `runner` property is the test rather than vite's `isRunnableDevEnvironment`
|
|
377
|
+
* (which is `instanceof RunnableDevEnvironment`): that handle is what Astro's
|
|
378
|
+
* own content invalidation reaches for, and this file is loaded as source out of
|
|
379
|
+
* a brand's node_modules, where an import of vite to ask the question would be
|
|
380
|
+
* the only one any integration here has.
|
|
381
|
+
*
|
|
382
|
+
* @typedef {Parameters<
|
|
383
|
+
* NonNullable<import("astro").AstroIntegration["hooks"]["astro:server:setup"]>
|
|
384
|
+
* >[0]["server"]} DevServer
|
|
385
|
+
*/
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* @param {DevServer} server
|
|
389
|
+
* @returns {string[]} The environments reloaded, for the signal line.
|
|
390
|
+
*/
|
|
391
|
+
function reloadRenderEnvironments(server) {
|
|
392
|
+
/** @type {string[]} */
|
|
393
|
+
const reloaded = [];
|
|
394
|
+
for (const environment of Object.values(server.environments)) {
|
|
395
|
+
if (environment.name === "client" || "runner" in environment) continue;
|
|
396
|
+
const timestamp = Date.now();
|
|
397
|
+
for (const id of CONTENT_VIRTUAL_IDS) {
|
|
398
|
+
const module = environment.moduleGraph.getModuleById(id);
|
|
399
|
+
// The graph entry first: a reload without it re-imports the cached
|
|
400
|
+
// transform, which is the store as it stood when that transform ran.
|
|
401
|
+
if (module) {
|
|
402
|
+
environment.moduleGraph.invalidateModule(
|
|
403
|
+
module,
|
|
404
|
+
undefined,
|
|
405
|
+
timestamp,
|
|
406
|
+
true,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
// No `triggeredBy`: the runner then clears every evaluated module and
|
|
411
|
+
// imports each entrypoint again, rather than walking one file's importers
|
|
412
|
+
// to entrypoints the store module has no edge to.
|
|
413
|
+
environment.hot.send({ type: "full-reload", path: "*" });
|
|
414
|
+
reloaded.push(environment.name);
|
|
415
|
+
}
|
|
416
|
+
return reloaded;
|
|
417
|
+
}
|
|
418
|
+
|
|
306
419
|
/**
|
|
307
420
|
* One signal line on stdout. Written directly rather than through Astro's
|
|
308
421
|
* logger, which wraps and colorizes: a supervisor parses these, so they have to
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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 {
|
|
126
|
-
* @param {number} status
|
|
205
|
+
* @param {WarmState} state
|
|
127
206
|
*/
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
}
|