@iterant/site-runtime 3.8.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.
@@ -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.0._
53
+ _Generated from package.json by scripts/generate-kit-table.mjs. Runtime 3.8.1._
54
54
 
55
55
  **Toolchain** (this package owns the version; do NOT declare these):
56
56
 
@@ -720,6 +720,35 @@ 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
+
723
752
  ## Verify: the gate
724
753
 
725
754
  `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.0",
3
+ "version": "3.8.1",
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": {
@@ -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: {
@@ -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`
@@ -86,6 +88,11 @@ import { fileURLToPath } from "node:url";
86
88
  // which kind of server it got. Exactly one `ready` per server, boot or restart,
87
89
  // emitted when its httpServer starts listening.
88
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
+ //
89
96
  // HMR EVENTS, on this server's channel:
90
97
  //
91
98
  // iterant:server-ready { restart, at } as each client connects
@@ -154,7 +161,13 @@ export default function devServerSignals({ pagesDir, chromeDir }) {
154
161
  [pagesDir, chromeDir].map((dir) => resolve(root, dir) + sep),
155
162
  );
156
163
  startedAt = new Date().toISOString();
157
- if (isRestart) signal({ event: "restart", phase: "config" });
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
+ }
158
171
  },
159
172
 
160
173
  "astro:server:setup": ({ server, refreshContent }) => {
@@ -281,9 +294,10 @@ export default function devServerSignals({ pagesDir, chromeDir }) {
281
294
  // waited for it would never announce itself; 'listening' is the moment
282
295
  // both paths share, and it is also the first moment the routes above
283
296
  // can actually be reached.
284
- server.httpServer?.once("listening", () =>
285
- signal({ event: "ready", restart }),
286
- );
297
+ server.httpServer?.once("listening", () => {
298
+ endRestart();
299
+ signal({ event: "ready", restart });
300
+ });
287
301
 
288
302
  // The same news over HMR, as clients arrive. It cannot be sent from a
289
303
  // hook: an HMR payload with no client attached is dropped, and on a
@@ -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
  }