bosia 1.2.4 → 1.2.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosia",
3
- "version": "1.2.4",
3
+ "version": "1.2.5",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bun": ">=1.4"
@@ -7,7 +7,8 @@ import type { RuntimeTarget } from "./types/plugin.ts";
7
7
  import { BOSIA_NODE_PATH, OUT_DIR } from "./paths.ts";
8
8
  import { currentBase } from "./appBase.ts";
9
9
  import { prerenderSkipReason, wantsPrerender } from "./scanner.ts";
10
- import { isDynamic, segmentName, segmentRank } from "./matcher.ts";
10
+ import { findMatch, isDynamic, segmentName, segmentRank } from "./matcher.ts";
11
+ import { sortRoutes } from "./routeFile.ts";
11
12
  import { HEALTH_PATH, isFrameworkPath, isStaticPath } from "./staticPath.ts";
12
13
 
13
14
  /** Acquire an OS-assigned ephemeral port. Tiny TOCTOU race window; acceptable for build-time use. */
@@ -51,8 +52,19 @@ export function needsStatic404(cwd = process.cwd()): boolean {
51
52
  return false;
52
53
  }
53
54
 
54
- const PRERENDER_TIMEOUT = Number(process.env.PRERENDER_TIMEOUT) || 5_000; // 5s default
55
- const PRERENDER_CONCURRENCY = Number(process.env.PRERENDER_CONCURRENCY) || 6;
55
+ // Up to 2^31-1 (about 24 days), the longest delay a timer takes as written.
56
+ const SETTING_MAX = 2_147_483_647;
57
+
58
+ /** A whole number from 1 to SETTING_MAX, `fallback` when unset, null when anything else. */
59
+ export function prerenderSetting(raw: string | undefined, fallback: number): number | null {
60
+ if (raw === undefined || raw.trim() === "") return fallback;
61
+ // Number(), as in 1.2.4: `1e3` and `5000.0` keep working.
62
+ const n = Number(raw);
63
+ return Number.isInteger(n) && n >= 1 && n <= SETTING_MAX ? n : null;
64
+ }
65
+
66
+ const PRERENDER_TIMEOUT = prerenderSetting(process.env.PRERENDER_TIMEOUT, 5_000); // 5s default
67
+ const PRERENDER_CONCURRENCY = prerenderSetting(process.env.PRERENDER_CONCURRENCY, 6);
56
68
 
57
69
  // ─── Prerendering ─────────────────────────────────────────
58
70
 
@@ -150,6 +162,105 @@ export function prerenderApiOutPath(routePath: string): string {
150
162
  return `${OUT_DIR}/prerendered${routePath.replace(/\/$/, "")}.json`;
151
163
  }
152
164
 
165
+ /**
166
+ * Why the server's reply to a prerender fetch can't be saved, or null when it can. A page must
167
+ * answer 2xx HTML; an API any 2xx but 204/205 (no body, saved as an empty file). Anything else (a
168
+ * loader's error(), a redirect to /login, the static 404) would be saved and served as the route.
169
+ * The 404 probe expects exactly 404.
170
+ */
171
+ export function prerenderReplyProblem(
172
+ kind: "page" | "api",
173
+ res: Response,
174
+ expect: "ok" | 404 = "ok",
175
+ ): string | null {
176
+ if (res.status >= 300 && res.status < 400)
177
+ return `answered ${res.status} → ${res.headers.get("location") ?? "(no Location)"}`;
178
+ const ok = expect === "ok" ? res.status >= 200 && res.status < 300 : res.status === expect;
179
+ if (!ok) return `answered ${res.status}`;
180
+ if (res.status === 204 || res.status === 205) return `answered ${res.status}, no content`;
181
+ const type = res.headers.get("content-type") ?? "";
182
+ if (kind === "page" && !type.includes("text/html"))
183
+ return `answered ${type || "no content-type"}, not an HTML page`;
184
+ return null;
185
+ }
186
+
187
+ /**
188
+ * Why a 200 data payload can't be saved, or null when it can. The server answers a data
189
+ * request's redirect (a loader's, or a hook's 3xx) with a 200 JSON `{ redirect, status }`,
190
+ * so the client router can follow it (server.ts data endpoint and handleRequest). A loader
191
+ * payload is `{ pageData, layoutData, … }` and never has `redirect` or `error` at the top.
192
+ */
193
+ export function prerenderDataProblem(body: string): string | null {
194
+ let data: unknown;
195
+ try {
196
+ data = JSON.parse(body);
197
+ } catch {
198
+ return "answered a body that isn't JSON";
199
+ }
200
+ if (!data || typeof data !== "object") return "answered a body that isn't a data payload";
201
+ const { redirect, status, error } = data as Record<string, any>;
202
+ if (redirect != null) return `answered redirect ${status ?? "?"} → ${redirect}`;
203
+ // The server sends a data error with its own status, which prerenderReplyProblem rejects
204
+ // first; this catches a hook that answers a data request with its own 200 `{ error }`.
205
+ if (error != null) {
206
+ const why = typeof error === "string" ? error : (error.status ?? error.message);
207
+ return `answered error ${why ?? "?"}`;
208
+ }
209
+ return null;
210
+ }
211
+
212
+ /**
213
+ * An error's message plus its `code` (EACCES) and `cause` (Bun's fetch puts ECONNRESET there).
214
+ * Reads any object, not just an Error: a cause is often a plain `{ code, message }`. Causes are
215
+ * followed 3 deep, so a cycle can't loop.
216
+ */
217
+ export function describeError(err: unknown, depth = 0): string {
218
+ if (err == null || typeof err !== "object") return String(err);
219
+ const { message, code, errno, cause } = err as Record<string, unknown>;
220
+ // `code` may be a string (ECONNRESET) or a number; a bare `errno` (-54) stands in for it.
221
+ // A DOMException's number code is a legacy constant (AbortError is 20), not a reason.
222
+ const id =
223
+ typeof code === "string" || (typeof code === "number" && !(err instanceof DOMException))
224
+ ? String(code)
225
+ : typeof errno === "number"
226
+ ? String(errno)
227
+ : "";
228
+ let out = typeof message === "string" && message ? message : "";
229
+ if (id && !out.includes(id)) out += out ? ` [${id}]` : id;
230
+ // An Error's own fields aren't enumerable, so JSON would print `{}`: use its name.
231
+ if (!out && err instanceof Error) out = err.name;
232
+ if (!out) {
233
+ try {
234
+ out = JSON.stringify(err) ?? String(err);
235
+ } catch {
236
+ out = String(err);
237
+ }
238
+ }
239
+ if (cause != null && cause !== "" && depth < 3)
240
+ out += ` (cause: ${describeError(cause, depth + 1)})`;
241
+ return out;
242
+ }
243
+
244
+ /** A prerender fetch that can't be saved; its message says why (a reply, a timeout, a reset). */
245
+ class ReplyProblem extends Error {}
246
+
247
+ const isTimeout = (err: unknown) => err instanceof DOMException && err.name === "TimeoutError";
248
+
249
+ /** Why a prerender fetch threw: a timeout names the env var that raises it. */
250
+ function fetchFailure(err: unknown, timeout: number): string {
251
+ if (isTimeout(err))
252
+ return `timed out after ${timeout / 1000}s — increase PRERENDER_TIMEOUT to raise the limit`;
253
+ return `failed: ${describeError(err)}`;
254
+ }
255
+
256
+ /**
257
+ * Cancels a reply's unread body so its socket is freed. cancel() rejects once the stream has
258
+ * errored (a timeout fired), and that must not replace the real problem.
259
+ */
260
+ async function dropBody(res: Response): Promise<void> {
261
+ await res.body?.cancel().catch(() => {});
262
+ }
263
+
153
264
  /** A dynamic prerender route whose entries() can't be used. build.ts prints `message` and exits. */
154
265
  export class PrerenderEntriesError extends Error {
155
266
  override name = "PrerenderEntriesError";
@@ -173,11 +284,14 @@ export function checkPrerenderPaths(
173
284
  const lower = p.toLowerCase();
174
285
  if (isFrameworkPath(kind === "page" ? `${lower}/` : lower))
175
286
  throw new Error(`entries() gives ${p}; dist and __bosia are the framework's folders`);
176
- // The server answers `/_health`, and for pages any static-file ending like `.png`, itself,
177
- // so the saved file would hold its reply, not the route. Checked on the slash-less form:
178
- // `/logo.png` 404s before the 308 to `/logo.png/`, and the `/` form adds only the prefixes
179
- // rejected above (the invariant test checks both forms).
180
- if (p === HEALTH_PATH || (kind === "page" && isStaticPath(p)))
287
+ // The server answers `/_health` before any route. Its JSON looks like a valid API reply,
288
+ // so prerenderReplyProblem can't catch it for an API.
289
+ if (p === HEALTH_PATH) throw new Error(`entries() gives ${p}, a URL the server answers itself`);
290
+ // A static-file ending (`.png`) is answered as a file, in every slash mode. prerenderReplyProblem
291
+ // would catch the fetched URL too (a 404, or public/'s image), but later and less clearly,
292
+ // and an `always` page fetches `/logo.png/`: its slash-less form 404s before the 308, and
293
+ // nothing fetches that one. The `/` form adds only the prefixes rejected above.
294
+ if (kind === "page" && isStaticPath(p))
181
295
  throw new Error(`entries() gives ${p}, a URL the server answers itself`);
182
296
  // generateStaticSite copies prerendered/ over static/, where the 404 probe saves
183
297
  // 404.html. A static src/routes/404 page may replace it on purpose; a slug may not.
@@ -249,11 +363,44 @@ export async function detectPrerenderRoutes(manifest: RouteManifest): Promise<Pr
249
363
  cause: failed,
250
364
  });
251
365
  const targets = results.flatMap((r) => (r.status === "fulfilled" ? r.value : []));
252
- const clash = findPrerenderClash(targets);
253
- if (clash.length) throw new PrerenderEntriesError(clash.join("\n"));
366
+ const misowned = findMisownedTargets(manifest, targets);
367
+ // Mis-owned targets stay out of the clash check, so one problem prints one ❌.
368
+ const clash = findPrerenderClash(targets.filter((t) => !misowned.has(t)));
369
+ const problems = [...misowned.values(), ...clash];
370
+ if (problems.length) throw new PrerenderEntriesError(problems.join("\n"));
254
371
  return targets;
255
372
  }
256
373
 
374
+ /** How the build's ❌ lines name a target: its URL, plus the route a slug came from. */
375
+ function targetLabel(t: PrerenderTarget): string {
376
+ return t.route === t.path ? t.path : `${t.path} (from ${t.route})`;
377
+ }
378
+
379
+ /**
380
+ * Targets whose URL another route serves: `/blog/[slug]` giving `new` while
381
+ * `src/routes/blog/new` exists, or a page slug at a `+server.ts` URL (an API
382
+ * wins, as in server.ts matchRoutes). Saving it would freeze the other route.
383
+ */
384
+ export function findMisownedTargets(
385
+ manifest: Pick<RouteManifest, "pages" | "apis">,
386
+ targets: PrerenderTarget[],
387
+ ): Map<PrerenderTarget, string> {
388
+ // Copies: findMatch caches a compiled regex on each route object it sees.
389
+ const pages = sortRoutes(manifest.pages.map((r) => ({ pattern: r.pattern })));
390
+ const apis = sortRoutes(manifest.apis.map((r) => ({ pattern: r.pattern })));
391
+ const out = new Map<PrerenderTarget, string>();
392
+ for (const t of targets) {
393
+ const api = findMatch(apis, t.path)?.route.pattern;
394
+ let owner = api;
395
+ // A page loses to any API at its URL, even a +server.ts in its own folder.
396
+ if (t.kind === "page")
397
+ owner = api ? `${api} (+server.ts)` : findMatch(pages, t.path)?.route.pattern;
398
+ if (owner && owner !== t.route)
399
+ out.set(t, `❌ Prerender: ${targetLabel(t)} is served by ${owner}, not this route`);
400
+ }
401
+ return out;
402
+ }
403
+
257
404
  /**
258
405
  * Files two targets would both write. macOS/Windows ignore case, so `About` and `about`
259
406
  * (or folders `A/` and `a/`) merge; a file and a folder can't share a path either.
@@ -264,7 +411,7 @@ export function findPrerenderClash(targets: PrerenderTarget[]): string[] {
264
411
  const clashes: string[] = [];
265
412
  for (const t of targets) {
266
413
  // Name the route a slug came from, so the message says which file to fix.
267
- const who = t.route === t.path ? t.path : `${t.path} (from ${t.route})`;
414
+ const who = targetLabel(t);
268
415
  let clash = "";
269
416
  const files =
270
417
  t.kind === "api"
@@ -295,6 +442,24 @@ export async function prerenderStaticRoutes(
295
442
  manifest: RouteManifest,
296
443
  target: RuntimeTarget = "bun",
297
444
  ): Promise<void> {
445
+ // A bad value would leave every page waiting for a request slot, or make every fetch throw.
446
+ const bad = (
447
+ [
448
+ ["PRERENDER_TIMEOUT", PRERENDER_TIMEOUT],
449
+ ["PRERENDER_CONCURRENCY", PRERENDER_CONCURRENCY],
450
+ ] as const
451
+ ).filter(([, value]) => value === null);
452
+ if (bad.length)
453
+ throw new PrerenderEntriesError(
454
+ bad
455
+ .map(
456
+ ([name]) =>
457
+ `❌ ${name} must be a whole number from 1 to ${SETTING_MAX} (got "${process.env[name]}")`,
458
+ )
459
+ .join("\n"),
460
+ );
461
+ const timeout = PRERENDER_TIMEOUT!;
462
+ const concurrency = PRERENDER_CONCURRENCY!;
298
463
  const targets = await detectPrerenderRoutes(manifest);
299
464
  // On Workers the worker answers unknown paths, so 404.html is never required.
300
465
  const require404 = target !== "workers" && needsStatic404();
@@ -319,6 +484,16 @@ export async function prerenderStaticRoutes(
319
484
  ...env,
320
485
  NODE_ENV: "production",
321
486
  PORT: String(port),
487
+ // Live-traffic limits from .env, set rather than removed (Bun would load them back).
488
+ // MAX_INFLIGHT would 503 the build's own requests (a page and its data,
489
+ // PRERENDER_CONCURRENCY at once); IDLE_TIMEOUT (0 = off) and LOAD_TIMEOUT would cut a
490
+ // loader that PRERENDER_TIMEOUT allows, and METADATA_TIMEOUT would save the page without
491
+ // its metadata (a timeout there is swallowed). All off, so PRERENDER_TIMEOUT alone rules
492
+ // the build: an equal server limit would race it, and its 500 would hide the hint.
493
+ MAX_INFLIGHT: "infinity",
494
+ IDLE_TIMEOUT: "0",
495
+ LOAD_TIMEOUT: "Infinity",
496
+ METADATA_TIMEOUT: "Infinity",
322
497
  NODE_PATH: BOSIA_NODE_PATH,
323
498
  },
324
499
  stdout: "ignore",
@@ -329,6 +504,9 @@ export async function prerenderStaticRoutes(
329
504
  // watch `exited` instead.
330
505
  let exited = false;
331
506
  child.exited.then(() => (exited = true));
507
+ const exitsWithin = (ms: number) =>
508
+ Promise.race([child.exited.then(() => true), Bun.sleep(ms).then(() => false)]);
509
+ const stopReason = () => child.signalCode ?? `exit code ${child.exitCode}`;
332
510
 
333
511
  const signals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"];
334
512
  let receivedSignal: NodeJS.Signals | null = null;
@@ -348,7 +526,10 @@ export async function prerenderStaticRoutes(
348
526
  const deadline = Date.now() + 10_000;
349
527
  while (Date.now() < deadline && !exited) {
350
528
  try {
351
- const res = await fetch(`${base}/_health`);
529
+ // Answered after the hooks, so a stuck hook would hang here past the deadline.
530
+ const res = await fetch(`${base}${HEALTH_PATH}`, {
531
+ signal: AbortSignal.timeout(Math.max(1, deadline - Date.now())),
532
+ });
352
533
  if (res.ok) {
353
534
  ready = true;
354
535
  break;
@@ -360,30 +541,82 @@ export async function prerenderStaticRoutes(
360
541
  }
361
542
 
362
543
  if (!ready || exited) {
363
- const why = child.signalCode ?? `exit code ${child.exitCode ?? "none"}`;
364
- if (require404)
365
- throw new Error(
366
- `Prerender server failed to start (${why}), so 404.html could not be built, but ` +
367
- `wrangler sets not_found_handling "404-page". Fix the server, or add public/404.html.`,
368
- );
369
- console.error(`❌ Prerender server failed to start (${why})`);
370
- return;
544
+ // Alive but never healthy (slow hooks init, a bad BASE_PATH) is not a crash.
545
+ const why = exited ? stopReason() : "not ready within 10s";
546
+ // Returning here would ship a build with every prerendered page missing.
547
+ throw new PrerenderEntriesError(
548
+ `❌ Prerender server failed to start (${why})` +
549
+ (require404
550
+ ? `, so 404.html could not be built, but wrangler sets not_found_handling ` +
551
+ `"404-page". Fix the server, or add public/404.html.`
552
+ : ""),
553
+ );
371
554
  }
372
555
 
373
556
  mkdirSync(`${OUT_DIR}/prerendered`, { recursive: true });
374
557
 
375
- const prerenderOne = async ({
376
- path: routePath,
377
- kind,
378
- trailingSlash: ts,
379
- }: PrerenderTarget): Promise<void> => {
558
+ // Every target that can't be saved; the build fails with all of them once the pool drains.
559
+ const failures: string[] = [];
560
+ // Pages whose connection broke (a reset, a refused connect), with their ❌ line. If the
561
+ // server turns out to have died (a loader that exits or runs out of memory), one line
562
+ // names the exit instead. A timeout isn't one: a dead server resets its connections, it
563
+ // doesn't leave them hanging, so a timeout keeps its own line and hint.
564
+ const connLost: { route: string; line: string }[] = [];
565
+ class ConnLost extends ReplyProblem {}
566
+ // A timeout can mean a loader stuck the server, which may die next: counted, so the
567
+ // check after the pages waits for that too.
568
+ class TimedOut extends ReplyProblem {}
569
+ let timedOut = 0;
570
+ // PRERENDER_CONCURRENCY counts requests, not pages: a page's HTML and data each run its
571
+ // loaders, so N slots keep N loader runs at once (a cap users set for rate-limited APIs).
572
+ let free = concurrency;
573
+ const waiting: (() => void)[] = [];
574
+ const acquire = async () => {
575
+ if (free > 0) free--;
576
+ else await new Promise<void>((resolve) => waiting.push(resolve));
577
+ };
578
+ const release = () => {
579
+ const nextInLine = waiting.shift();
580
+ if (nextInLine) nextInLine();
581
+ else free++;
582
+ };
583
+
584
+ const prerenderOne = async (t: PrerenderTarget): Promise<void> => {
585
+ const { path: routePath, kind, trailingSlash: ts } = t;
586
+ // `manual`: a redirect is reported, not followed into another page that gets saved.
587
+ // A new timeout per fetch: a shared one would give the page and its data (two
588
+ // loader runs) one PRERENDER_TIMEOUT between them.
589
+ // `stop` ends a page's data fetch once the page itself has failed.
590
+ const stop = new AbortController();
591
+ const line = (why: string) => `❌ Prerender: ${targetLabel(t)} ${why}`;
592
+ const fail = (why: string) => failures.push(line(why));
593
+ // Fetches `url` and reads its body. Any failure (a bad reply, a timeout, a reset) is
594
+ // thrown as a ReplyProblem carrying `label`, so a data fetch's says "data".
595
+ // The timeout starts once a slot is free, so time spent queued isn't counted.
596
+ const fetchText = async (url: string, as: "page" | "api", label = "") => {
597
+ await acquire();
598
+ try {
599
+ stop.signal.throwIfAborted();
600
+ const res = await fetch(url, {
601
+ redirect: "manual",
602
+ signal: AbortSignal.any([AbortSignal.timeout(timeout), stop.signal]),
603
+ });
604
+ const problem = prerenderReplyProblem(as, res);
605
+ if (!problem) return await res.text();
606
+ await dropBody(res);
607
+ throw new ReplyProblem(label + problem);
608
+ } catch (err) {
609
+ if (err instanceof ReplyProblem) throw err;
610
+ const why = label + fetchFailure(err, timeout);
611
+ throw isTimeout(err) ? new TimedOut(why) : new ConnLost(why);
612
+ } finally {
613
+ release();
614
+ }
615
+ };
380
616
  try {
381
617
  if (kind === "api") {
382
618
  // APIs: fetch the bare route URL, write body to `<path>.json`.
383
- const res = await fetch(`${base}${routePath.replace(/\/$/, "")}`, {
384
- signal: AbortSignal.timeout(PRERENDER_TIMEOUT),
385
- });
386
- const body = await res.text();
619
+ const body = await fetchText(`${base}${routePath.replace(/\/$/, "")}`, kind);
387
620
  const outPath = prerenderApiOutPath(routePath);
388
621
  mkdirSync(outPath.substring(0, outPath.lastIndexOf("/")), { recursive: true });
389
622
  writeFileSync(outPath, body);
@@ -394,10 +627,22 @@ export async function prerenderStaticRoutes(
394
627
  // Hit the canonical URL so the server doesn't 308 us mid-prerender
395
628
  const canonicalRoute = canonicalRouteFor(routePath, ts);
396
629
 
397
- const res = await fetch(`${base}${canonicalRoute}`, {
398
- signal: AbortSignal.timeout(PRERENDER_TIMEOUT),
399
- });
400
- const html = await res.text();
630
+ // The data payload (filename matches dataUrl() — strips trailing slash). Client
631
+ // navigation on a static host fetches it, so a page saved without it is broken:
632
+ // both are checked before either is written. Fetched side by side, each with its
633
+ // own timeout. One ❌ per page, the page's own problem first: a failed page stops
634
+ // its data fetch, and a failed data fetch still waits for the page.
635
+ const dataPath = prerenderDataPath(routePath);
636
+ const htmlP = fetchText(`${base}${canonicalRoute}`, kind);
637
+ htmlP.catch(() => stop.abort());
638
+ const dataP = fetchText(`${base}/__bosia/data${dataPath}`, "api", "data ");
639
+ const [htmlGot, dataGot] = await Promise.allSettled([htmlP, dataP]);
640
+ if (htmlGot.status === "rejected") throw htmlGot.reason;
641
+ if (dataGot.status === "rejected") throw dataGot.reason;
642
+ const html = htmlGot.value;
643
+ const dataJson = dataGot.value;
644
+ const payloadProblem = prerenderDataProblem(dataJson);
645
+ if (payloadProblem) return void fail(`data ${payloadProblem}`);
401
646
 
402
647
  // Filename strategy:
403
648
  // never → about.html (canonical /about, served by static host as /about → about.html)
@@ -405,47 +650,35 @@ export async function prerenderStaticRoutes(
405
650
  // ignore → about/index.html (single emit; both URLs resolve via server canonicalize=off)
406
651
  // root → index.html
407
652
  const outPath = prerenderOutPath(routePath, ts);
653
+
408
654
  mkdirSync(outPath.substring(0, outPath.lastIndexOf("/")), { recursive: true });
409
655
  writeFileSync(outPath, html);
410
-
411
- // Also prerender the data payload (filename matches dataUrl() — strips trailing slash)
412
- const dataPath = prerenderDataPath(routePath);
413
- const dataRes = await fetch(`${base}/__bosia/data${dataPath}`, {
414
- signal: AbortSignal.timeout(PRERENDER_TIMEOUT),
415
- });
416
- if (dataRes.ok) {
417
- const dataJson = await dataRes.text();
418
- const dataOutPath = prerenderDataOutPath(routePath);
419
- mkdirSync(dataOutPath.substring(0, dataOutPath.lastIndexOf("/")), {
420
- recursive: true,
421
- });
422
- writeFileSync(dataOutPath, dataJson);
423
- console.log(` ✅ ${routePath} → ${outPath} (+ data)`);
424
- } else {
425
- console.log(` ✅ ${routePath} → ${outPath}`);
426
- }
656
+ const dataOutPath = prerenderDataOutPath(routePath);
657
+ mkdirSync(dataOutPath.substring(0, dataOutPath.lastIndexOf("/")), { recursive: true });
658
+ writeFileSync(dataOutPath, dataJson);
659
+ console.log(` ✅ ${routePath} → ${outPath} (+ data)`);
427
660
  } catch (err) {
428
- if (err instanceof DOMException && err.name === "TimeoutError") {
429
- console.error(
430
- ` ❌ Prerender timed out for ${routePath} after ${PRERENDER_TIMEOUT / 1000}s — increase PRERENDER_TIMEOUT to raise the limit`,
431
- );
432
- } else {
433
- console.error(` ❌ Failed to prerender ${routePath}:`, err);
434
- }
661
+ const why = err instanceof ReplyProblem ? err.message : fetchFailure(err, timeout);
662
+ if (err instanceof TimedOut) timedOut++;
663
+ if (err instanceof ConnLost) connLost.push({ route: routePath, line: line(why) });
664
+ else fail(why);
435
665
  }
436
666
  };
437
667
 
438
- // Bounded worker pool: N workers pull from a shared index until targets drain.
439
- let next = 0;
440
- const worker = async () => {
441
- while (next < targets.length) {
442
- const target = targets[next++];
443
- if (target) await prerenderOne(target);
444
- }
445
- };
446
- await Promise.all(
447
- Array.from({ length: Math.min(PRERENDER_CONCURRENCY, targets.length) }, worker),
448
- );
668
+ // Every page starts at once; the request slots above are the only limit, and they're
669
+ // handed out in target order (a page's HTML, then its data).
670
+ await Promise.all(targets.map(prerenderOne));
671
+ // Checked once, after the pages, and only when a page broke or timed out: the exit can
672
+ // land well after the errors it causes, so a dead server gets up to 1s to show it. A
673
+ // server that dies with every page saved lost nothing; the 404 probe says so if it hits it.
674
+ const died = connLost.length + timedOut > 0 && (await exitsWithin(1000));
675
+ if (died) {
676
+ // An example, not the culprit: a crash breaks every page in flight at once.
677
+ const pages = connLost.length
678
+ ? `; ${connLost.length} page(s) not built, e.g. ${connLost[0]!.route}`
679
+ : "";
680
+ failures.push(`❌ The prerender server stopped (${stopReason()})${pages}`);
681
+ } else failures.push(...connLost.map((f) => f.line));
449
682
 
450
683
  // Static hosts (Cloudflare assets) serve index.html with a 200 for unknown
451
684
  // paths unless a 404.html exists. Save the server's own 404 page while it's up.
@@ -454,34 +687,60 @@ export async function prerenderStaticRoutes(
454
687
  // Skipped on Workers: the worker answers unknown paths, and an uploaded file
455
688
  // would only add that soft 404. A public/404.html overwrites it.
456
689
  // One probe, one locale: add per-locale probe paths (e.g. /id/404.html) when asked.
457
- if (target !== "workers")
690
+ // A server that died is already reported, and the probe would only fail too.
691
+ if (target !== "workers" && !died)
458
692
  try {
459
- const res = await fetch(`${base}/__bosia-404-probe`, {
460
- signal: AbortSignal.timeout(PRERENDER_TIMEOUT),
461
- });
462
- if (res.status !== 404) throw new Error(`expected a 404 page, got ${res.status}`);
693
+ // Follows only its own slash form, unlike the pages: a catch-all `always` page 308s
694
+ // the probe to `/__bosia-404-probe/`, and its 404 there is the page a static host
695
+ // should show. Any other redirect (a hook sending it to /login, or off the build
696
+ // server) is reported with where it goes, not fetched.
697
+ const probe = (url: string) =>
698
+ fetch(url, { redirect: "manual", signal: AbortSignal.timeout(timeout) });
699
+ const probeUrl = `${base}/__bosia-404-probe`;
700
+ let res = await probe(probeUrl);
701
+ const location = res.status >= 300 && res.status < 400 && res.headers.get("location");
702
+ if (location && new URL(location, probeUrl).href === `${probeUrl}/`) {
703
+ await dropBody(res);
704
+ res = await probe(`${probeUrl}/`);
705
+ }
463
706
  // No +error.svelte (or it crashed) → plain-text "Not Found", not a page.
464
- if (!res.headers.get("content-type")?.includes("text/html"))
465
- throw new Error(`expected an HTML 404 page, got ${res.headers.get("content-type")}`);
707
+ const problem = prerenderReplyProblem("page", res, 404);
708
+ if (problem) {
709
+ await dropBody(res);
710
+ throw new ReplyProblem(`an unknown URL ${problem}`);
711
+ }
466
712
  mkdirSync(`${OUT_DIR}/static`, { recursive: true });
467
713
  writeFileSync(`${OUT_DIR}/static/404.html`, await res.text());
468
714
  console.log(` ✅ 404 page → ${OUT_DIR}/static/404.html`);
469
715
  } catch (err) {
716
+ // A broken connection (not a timeout, as with pages) may mean the server died.
717
+ const gone = !(err instanceof ReplyProblem) && !isTimeout(err) && (await exitsWithin(1000));
718
+ const reason = gone
719
+ ? `the prerender server stopped (${stopReason()})`
720
+ : err instanceof ReplyProblem
721
+ ? err.message
722
+ : fetchFailure(err, timeout);
470
723
  // Only a deploy that serves 404.html needs it; elsewhere (an app whose
471
724
  // hooks redirect every request) a missing one is fine, so just warn.
472
- const reason = (err as Error).message;
725
+ // Listed with the page failures, so one build shows every problem.
473
726
  if (require404)
474
- throw new Error(
475
- `404.html could not be built (${reason}), but wrangler sets ` +
727
+ failures.push(
728
+ `❌ 404.html could not be built (${reason}), but wrangler sets ` +
476
729
  `not_found_handling "404-page". Make unknown paths answer 404, or add public/404.html.`,
477
730
  );
478
- console.warn(` ⚠️ 404.html skipped: ${reason}`);
731
+ else console.warn(` ⚠️ 404.html skipped: ${reason}`);
479
732
  }
480
733
 
734
+ // build.ts prints this and exits 1; the finally below still stops the server.
735
+ if (failures.length) throw new PrerenderEntriesError(failures.join("\n"));
481
736
  if (targets.length) console.log("✅ Prerendering complete");
482
737
  } finally {
483
738
  for (const sig of signals) process.off(sig, onSignal);
739
+ // SIGTERM drains in-flight requests for up to 10s (server.bun.ts), and an aborted fetch
740
+ // doesn't stop its loader. Nothing here needs that drain: give it 1s, then SIGKILL.
484
741
  child.kill();
742
+ const stopped = await exitsWithin(1000);
743
+ if (!stopped) child.kill("SIGKILL");
485
744
  await child.exited;
486
745
  if (receivedSignal) {
487
746
  // Re-raise so parent exit code is 128+signum (standard Unix convention)
@@ -785,6 +785,25 @@ async function resolve(event: RequestEvent): Promise<Response> {
785
785
  );
786
786
  }
787
787
 
788
+ // Matched once before the hooks; reused for trailing-slash, form-action, and SSR phases.
789
+ const pageMatch = routes.page;
790
+
791
+ // Trailing-slash canonicalization — 308 preserves method (form POSTs included).
792
+ // Before the prerendered lookup, which ignores the slash: `/about/` on a `never`
793
+ // page would otherwise answer 200 from the saved file.
794
+ if (pageMatch) {
795
+ const canonical = canonicalPathname(path, (pageMatch.route as any).trailingSlash ?? "never");
796
+ if (canonical !== null) {
797
+ return new Response(null, {
798
+ status: 308,
799
+ // `path` is app-space — the base came off at the top of handleRequest.
800
+ // This Location goes back to the browser, so it has to be put back on,
801
+ // or a `trailingSlash: "always"` route 308s every request off the mount.
802
+ headers: { Location: withBase(currentBase(), canonical) + url.search + url.hash },
803
+ });
804
+ }
805
+ }
806
+
788
807
  // Prerendered pages — serve static HTML built at build time.
789
808
  // SKIP in dev: prerender runs with NODE_ENV=production, which disables the
790
809
  // inspector plugin and the dev-only error pipeline. Serving its output back
@@ -808,23 +827,6 @@ async function resolve(event: RequestEvent): Promise<Response> {
808
827
  }
809
828
  }
810
829
 
811
- // Matched once before the hooks; reused for trailing-slash, form-action, and SSR phases.
812
- const pageMatch = routes.page;
813
-
814
- // Trailing-slash canonicalization — 308 preserves method (form POSTs included)
815
- if (pageMatch) {
816
- const canonical = canonicalPathname(path, (pageMatch.route as any).trailingSlash ?? "never");
817
- if (canonical !== null) {
818
- return new Response(null, {
819
- status: 308,
820
- // `path` is app-space — the base came off at the top of handleRequest.
821
- // This Location goes back to the browser, so it has to be put back on,
822
- // or a `trailingSlash: "always"` route 308s every request off the mount.
823
- headers: { Location: withBase(currentBase(), canonical) + url.search + url.hash },
824
- });
825
- }
826
- }
827
-
828
830
  // Form actions — POST to page routes with `actions` export
829
831
  if (method === "POST") {
830
832
  if (pageMatch?.route.pageServer) {