bosia 1.2.4 → 1.2.6

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.
@@ -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,181 @@ 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
+ * Checked ignoring case too: on a macOS/Windows host `blog/New.html` answers `/blog/new`.
384
+ */
385
+ export function findMisownedTargets(
386
+ manifest: Pick<RouteManifest, "pages" | "apis">,
387
+ targets: PrerenderTarget[],
388
+ ): Map<PrerenderTarget, string> {
389
+ // Copies: findMatch caches a compiled regex on each route object it sees.
390
+ const pages = sortRoutes(manifest.pages.map((r) => ({ pattern: r.pattern })));
391
+ const apis = sortRoutes(manifest.apis.map((r) => ({ pattern: r.pattern })));
392
+ // The server's own matcher decides who serves the URL exactly.
393
+ const ownerOf = (t: PrerenderTarget) => {
394
+ const api = findMatch(apis, t.path)?.route.pattern;
395
+ if (t.kind === "api") return api;
396
+ // A page loses to any API at its URL, even a +server.ts in its own folder.
397
+ return api ? `${api} (+server.ts)` : findMatch(pages, t.path)?.route.pattern;
398
+ };
399
+ // Split once, in match order, for the case pass: every API first, then pages. An API
400
+ // target meets pages too: a live `/API/ALL` page is hidden by a saved `api/all` file.
401
+ const shape = (pattern: string, api: boolean): RouteShape => {
402
+ const parts = pattern.split("/").filter(Boolean);
403
+ const ranks = parts.map(segmentRank);
404
+ const lower = parts.map((p) => p.toLowerCase());
405
+ return { pattern, api, parts, lower, ranks, rest: ranks.at(-1) === 2 };
406
+ };
407
+ const order = [
408
+ ...apis.map((r) => shape(r.pattern, true)),
409
+ ...pages.map((r) => shape(r.pattern, false)),
410
+ ];
411
+ const out = new Map<PrerenderTarget, string>();
412
+ for (const t of targets) {
413
+ const owner = ownerOf(t);
414
+ if (owner && owner !== t.route) {
415
+ out.set(t, `❌ Prerender: ${targetLabel(t)} is served by ${owner}, not this route`);
416
+ continue;
417
+ }
418
+ // No route answers the URL (a hand-built table): nothing to measure a case clash by.
419
+ if (!owner) continue;
420
+ const problem = caseProblem(t, order);
421
+ if (problem) out.set(t, problem);
422
+ }
423
+ return out;
424
+ }
425
+
426
+ interface RouteShape {
427
+ pattern: string;
428
+ api: boolean;
429
+ parts: string[];
430
+ lower: string[];
431
+ ranks: (0 | 1 | 2)[];
432
+ /** Ends in `[...rest]`, so it takes paths longer than its parts. */
433
+ rest: boolean;
434
+ }
435
+
436
+ /**
437
+ * This route serves the URL exactly (findMatch said so). A route matching only ignoring
438
+ * case (a static part spelled `NEW` for `new`) then clashes if it wins some spelling: it
439
+ * ranks above this route, or the spelling differs at one of this route's static parts so
440
+ * this route can't answer it. On a host that ignores case the saved file answers that
441
+ * spelling. Overlaps through params alone (`/[...rest]` catching `/BLOG/new`) are allowed,
442
+ * or every catch-all next to a prerendered page would fail. A route ranked higher still
443
+ * that takes the very same spellings isn't looked for; the check may then name a route
444
+ * that never answers.
445
+ */
446
+ function caseProblem(t: PrerenderTarget, order: RouteShape[]): string | null {
447
+ const path = t.path.split("/").filter(Boolean);
448
+ const pathLower = path.map((p) => p.toLowerCase());
449
+ let own: PartMatch[] | null = null;
450
+ let hit: { r: RouteShape; kinds: PartMatch[]; above: boolean } | null = null;
451
+ for (const r of order) {
452
+ // Cheap filter first: most routes can't match on part count alone.
453
+ if (r.rest ? path.length < r.parts.length : path.length !== r.parts.length) continue;
454
+ const kinds = matchPartsFolded(r.parts, r.lower, r.ranks, path, pathLower);
455
+ if (!kinds) continue;
456
+ if (r.pattern === t.route && r.api === (t.kind === "api")) {
457
+ own = kinds;
458
+ if (hit) break; // a route above already wins a spelling
459
+ } else if (!kinds.includes("case")) continue;
460
+ else if (!own) hit ??= { r, kinds, above: true };
461
+ else if (escapesOwn(kinds, own, path)) {
462
+ hit = { r, kinds, above: false };
463
+ break;
464
+ }
465
+ }
466
+ // findMatch said this route serves the URL, so the walk must find it too; if not, the two
467
+ // matchers have drifted apart and every case clash would pass unseen.
468
+ if (!own)
469
+ throw new Error(
470
+ `Bosia internal: the prerender case check can't match ${t.path} to ${t.route}; please report it`,
471
+ );
472
+ if (!hit) return null;
473
+ const { r, kinds, above } = hit;
474
+ // The spelling it wins: its own static parts. Ranked below, every param under this
475
+ // route's static part changes case too, which also steers clear of routes in between
476
+ // (`/BLOG/NEW`, not `/blog/NEW` that `/blog/[slug]` takes).
477
+ const spelling = kinds.map((k, j) => {
478
+ if (k !== "param") return r.parts[j]!;
479
+ return (!above && own[j] === "same" && otherCase(path[j]!)) || path[j]!;
480
+ });
481
+ const name = r.api && t.kind === "page" ? `${r.pattern} (+server.ts)` : r.pattern;
482
+ return `❌ Prerender: ${targetLabel(t)} would also answer /${spelling.join("/")} on hosts that ignore case (macOS/Windows), which ${name} serves`;
483
+ }
484
+
485
+ /** How one part of a URL meets a route part: `same` static, `case` static spelled in
486
+ * another case, `param`. A match with no `case` is an exact match. */
487
+ type PartMatch = "same" | "case" | "param";
488
+
489
+ /**
490
+ * Match a route's parts against a URL's parts ignoring case, by compilePattern's rules: only
491
+ * a last `[...rest]` takes several parts, any other bracket part is one param. `null` when
492
+ * they don't match. `lower`/`pathLower` are the same parts in `toLowerCase`, made once by the
493
+ * caller. ASCII case only in effect: Unicode forms and special folds (`ς`/`σ`) aren't merged.
494
+ * Keep in step with matcher.ts compilePattern.
495
+ */
496
+ function matchPartsFolded(
497
+ parts: string[],
498
+ lower: string[],
499
+ ranks: (0 | 1 | 2)[],
500
+ path: string[],
501
+ pathLower: string[],
502
+ ): PartMatch[] | null {
503
+ const kinds: PartMatch[] = [];
504
+ for (const [i, seg] of parts.entries()) {
505
+ if (ranks[i] === 2 && i === parts.length - 1) {
506
+ if (path.length <= i) return null;
507
+ for (let j = i; j < path.length; j++) kinds.push("param");
508
+ return kinds;
509
+ }
510
+ const part = path[i];
511
+ if (part === undefined) return null;
512
+ if (ranks[i]) kinds.push("param");
513
+ else if (part === seg) kinds.push("same");
514
+ else if (pathLower[i] === lower[i]) kinds.push("case");
515
+ else return null;
516
+ }
517
+ return kinds.length === path.length ? kinds : null;
518
+ }
519
+
520
+ /** Some part where this route is static has a spelling the other route takes and this one doesn't. */
521
+ function escapesOwn(kinds: PartMatch[], own: PartMatch[], path: string[]): boolean {
522
+ return kinds.some(
523
+ (k, j) => own[j] === "same" && (k === "case" || (k === "param" && otherCase(path[j]!))),
524
+ );
525
+ }
526
+
527
+ /**
528
+ * `s` with every letter in the other case, keeping only swaps that lowercase back to the same
529
+ * letter (`ß` → `SS` and `İ` → `i̇` change length, so they stay). `null` if nothing swaps.
530
+ */
531
+ function otherCase(s: string): string | null {
532
+ const swapped = [...s]
533
+ .map((c) => {
534
+ const o = c === c.toUpperCase() ? c.toLowerCase() : c.toUpperCase();
535
+ return o.length === c.length && o.toLowerCase() === c.toLowerCase() ? o : c;
536
+ })
537
+ .join("");
538
+ return swapped === s ? null : swapped;
539
+ }
540
+
257
541
  /**
258
542
  * Files two targets would both write. macOS/Windows ignore case, so `About` and `about`
259
543
  * (or folders `A/` and `a/`) merge; a file and a folder can't share a path either.
@@ -264,7 +548,7 @@ export function findPrerenderClash(targets: PrerenderTarget[]): string[] {
264
548
  const clashes: string[] = [];
265
549
  for (const t of targets) {
266
550
  // 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})`;
551
+ const who = targetLabel(t);
268
552
  let clash = "";
269
553
  const files =
270
554
  t.kind === "api"
@@ -295,6 +579,24 @@ export async function prerenderStaticRoutes(
295
579
  manifest: RouteManifest,
296
580
  target: RuntimeTarget = "bun",
297
581
  ): Promise<void> {
582
+ // A bad value would leave every page waiting for a request slot, or make every fetch throw.
583
+ const bad = (
584
+ [
585
+ ["PRERENDER_TIMEOUT", PRERENDER_TIMEOUT],
586
+ ["PRERENDER_CONCURRENCY", PRERENDER_CONCURRENCY],
587
+ ] as const
588
+ ).filter(([, value]) => value === null);
589
+ if (bad.length)
590
+ throw new PrerenderEntriesError(
591
+ bad
592
+ .map(
593
+ ([name]) =>
594
+ `❌ ${name} must be a whole number from 1 to ${SETTING_MAX} (got "${process.env[name]}")`,
595
+ )
596
+ .join("\n"),
597
+ );
598
+ const timeout = PRERENDER_TIMEOUT!;
599
+ const concurrency = PRERENDER_CONCURRENCY!;
298
600
  const targets = await detectPrerenderRoutes(manifest);
299
601
  // On Workers the worker answers unknown paths, so 404.html is never required.
300
602
  const require404 = target !== "workers" && needsStatic404();
@@ -319,6 +621,16 @@ export async function prerenderStaticRoutes(
319
621
  ...env,
320
622
  NODE_ENV: "production",
321
623
  PORT: String(port),
624
+ // Live-traffic limits from .env, set rather than removed (Bun would load them back).
625
+ // MAX_INFLIGHT would 503 the build's own requests (a page and its data,
626
+ // PRERENDER_CONCURRENCY at once); IDLE_TIMEOUT (0 = off) and LOAD_TIMEOUT would cut a
627
+ // loader that PRERENDER_TIMEOUT allows, and METADATA_TIMEOUT would save the page without
628
+ // its metadata (a timeout there is swallowed). All off, so PRERENDER_TIMEOUT alone rules
629
+ // the build: an equal server limit would race it, and its 500 would hide the hint.
630
+ MAX_INFLIGHT: "infinity",
631
+ IDLE_TIMEOUT: "0",
632
+ LOAD_TIMEOUT: "Infinity",
633
+ METADATA_TIMEOUT: "Infinity",
322
634
  NODE_PATH: BOSIA_NODE_PATH,
323
635
  },
324
636
  stdout: "ignore",
@@ -329,6 +641,9 @@ export async function prerenderStaticRoutes(
329
641
  // watch `exited` instead.
330
642
  let exited = false;
331
643
  child.exited.then(() => (exited = true));
644
+ const exitsWithin = (ms: number) =>
645
+ Promise.race([child.exited.then(() => true), Bun.sleep(ms).then(() => false)]);
646
+ const stopReason = () => child.signalCode ?? `exit code ${child.exitCode}`;
332
647
 
333
648
  const signals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"];
334
649
  let receivedSignal: NodeJS.Signals | null = null;
@@ -348,7 +663,10 @@ export async function prerenderStaticRoutes(
348
663
  const deadline = Date.now() + 10_000;
349
664
  while (Date.now() < deadline && !exited) {
350
665
  try {
351
- const res = await fetch(`${base}/_health`);
666
+ // Answered after the hooks, so a stuck hook would hang here past the deadline.
667
+ const res = await fetch(`${base}${HEALTH_PATH}`, {
668
+ signal: AbortSignal.timeout(Math.max(1, deadline - Date.now())),
669
+ });
352
670
  if (res.ok) {
353
671
  ready = true;
354
672
  break;
@@ -360,30 +678,82 @@ export async function prerenderStaticRoutes(
360
678
  }
361
679
 
362
680
  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;
681
+ // Alive but never healthy (slow hooks init, a bad BASE_PATH) is not a crash.
682
+ const why = exited ? stopReason() : "not ready within 10s";
683
+ // Returning here would ship a build with every prerendered page missing.
684
+ throw new PrerenderEntriesError(
685
+ `❌ Prerender server failed to start (${why})` +
686
+ (require404
687
+ ? `, so 404.html could not be built, but wrangler sets not_found_handling ` +
688
+ `"404-page". Fix the server, or add public/404.html.`
689
+ : ""),
690
+ );
371
691
  }
372
692
 
373
693
  mkdirSync(`${OUT_DIR}/prerendered`, { recursive: true });
374
694
 
375
- const prerenderOne = async ({
376
- path: routePath,
377
- kind,
378
- trailingSlash: ts,
379
- }: PrerenderTarget): Promise<void> => {
695
+ // Every target that can't be saved; the build fails with all of them once the pool drains.
696
+ const failures: string[] = [];
697
+ // Pages whose connection broke (a reset, a refused connect), with their ❌ line. If the
698
+ // server turns out to have died (a loader that exits or runs out of memory), one line
699
+ // names the exit instead. A timeout isn't one: a dead server resets its connections, it
700
+ // doesn't leave them hanging, so a timeout keeps its own line and hint.
701
+ const connLost: { route: string; line: string }[] = [];
702
+ class ConnLost extends ReplyProblem {}
703
+ // A timeout can mean a loader stuck the server, which may die next: counted, so the
704
+ // check after the pages waits for that too.
705
+ class TimedOut extends ReplyProblem {}
706
+ let timedOut = 0;
707
+ // PRERENDER_CONCURRENCY counts requests, not pages: a page's HTML and data each run its
708
+ // loaders, so N slots keep N loader runs at once (a cap users set for rate-limited APIs).
709
+ let free = concurrency;
710
+ const waiting: (() => void)[] = [];
711
+ const acquire = async () => {
712
+ if (free > 0) free--;
713
+ else await new Promise<void>((resolve) => waiting.push(resolve));
714
+ };
715
+ const release = () => {
716
+ const nextInLine = waiting.shift();
717
+ if (nextInLine) nextInLine();
718
+ else free++;
719
+ };
720
+
721
+ const prerenderOne = async (t: PrerenderTarget): Promise<void> => {
722
+ const { path: routePath, kind, trailingSlash: ts } = t;
723
+ // `manual`: a redirect is reported, not followed into another page that gets saved.
724
+ // A new timeout per fetch: a shared one would give the page and its data (two
725
+ // loader runs) one PRERENDER_TIMEOUT between them.
726
+ // `stop` ends a page's data fetch once the page itself has failed.
727
+ const stop = new AbortController();
728
+ const line = (why: string) => `❌ Prerender: ${targetLabel(t)} ${why}`;
729
+ const fail = (why: string) => failures.push(line(why));
730
+ // Fetches `url` and reads its body. Any failure (a bad reply, a timeout, a reset) is
731
+ // thrown as a ReplyProblem carrying `label`, so a data fetch's says "data".
732
+ // The timeout starts once a slot is free, so time spent queued isn't counted.
733
+ const fetchText = async (url: string, as: "page" | "api", label = "") => {
734
+ await acquire();
735
+ try {
736
+ stop.signal.throwIfAborted();
737
+ const res = await fetch(url, {
738
+ redirect: "manual",
739
+ signal: AbortSignal.any([AbortSignal.timeout(timeout), stop.signal]),
740
+ });
741
+ const problem = prerenderReplyProblem(as, res);
742
+ if (!problem) return await res.text();
743
+ await dropBody(res);
744
+ throw new ReplyProblem(label + problem);
745
+ } catch (err) {
746
+ if (err instanceof ReplyProblem) throw err;
747
+ const why = label + fetchFailure(err, timeout);
748
+ throw isTimeout(err) ? new TimedOut(why) : new ConnLost(why);
749
+ } finally {
750
+ release();
751
+ }
752
+ };
380
753
  try {
381
754
  if (kind === "api") {
382
755
  // 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();
756
+ const body = await fetchText(`${base}${routePath.replace(/\/$/, "")}`, kind);
387
757
  const outPath = prerenderApiOutPath(routePath);
388
758
  mkdirSync(outPath.substring(0, outPath.lastIndexOf("/")), { recursive: true });
389
759
  writeFileSync(outPath, body);
@@ -394,10 +764,22 @@ export async function prerenderStaticRoutes(
394
764
  // Hit the canonical URL so the server doesn't 308 us mid-prerender
395
765
  const canonicalRoute = canonicalRouteFor(routePath, ts);
396
766
 
397
- const res = await fetch(`${base}${canonicalRoute}`, {
398
- signal: AbortSignal.timeout(PRERENDER_TIMEOUT),
399
- });
400
- const html = await res.text();
767
+ // The data payload (filename matches dataUrl() — strips trailing slash). Client
768
+ // navigation on a static host fetches it, so a page saved without it is broken:
769
+ // both are checked before either is written. Fetched side by side, each with its
770
+ // own timeout. One ❌ per page, the page's own problem first: a failed page stops
771
+ // its data fetch, and a failed data fetch still waits for the page.
772
+ const dataPath = prerenderDataPath(routePath);
773
+ const htmlP = fetchText(`${base}${canonicalRoute}`, kind);
774
+ htmlP.catch(() => stop.abort());
775
+ const dataP = fetchText(`${base}/__bosia/data${dataPath}`, "api", "data ");
776
+ const [htmlGot, dataGot] = await Promise.allSettled([htmlP, dataP]);
777
+ if (htmlGot.status === "rejected") throw htmlGot.reason;
778
+ if (dataGot.status === "rejected") throw dataGot.reason;
779
+ const html = htmlGot.value;
780
+ const dataJson = dataGot.value;
781
+ const payloadProblem = prerenderDataProblem(dataJson);
782
+ if (payloadProblem) return void fail(`data ${payloadProblem}`);
401
783
 
402
784
  // Filename strategy:
403
785
  // never → about.html (canonical /about, served by static host as /about → about.html)
@@ -405,47 +787,35 @@ export async function prerenderStaticRoutes(
405
787
  // ignore → about/index.html (single emit; both URLs resolve via server canonicalize=off)
406
788
  // root → index.html
407
789
  const outPath = prerenderOutPath(routePath, ts);
790
+
408
791
  mkdirSync(outPath.substring(0, outPath.lastIndexOf("/")), { recursive: true });
409
792
  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
- }
793
+ const dataOutPath = prerenderDataOutPath(routePath);
794
+ mkdirSync(dataOutPath.substring(0, dataOutPath.lastIndexOf("/")), { recursive: true });
795
+ writeFileSync(dataOutPath, dataJson);
796
+ console.log(` ✅ ${routePath} → ${outPath} (+ data)`);
427
797
  } 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
- }
798
+ const why = err instanceof ReplyProblem ? err.message : fetchFailure(err, timeout);
799
+ if (err instanceof TimedOut) timedOut++;
800
+ if (err instanceof ConnLost) connLost.push({ route: routePath, line: line(why) });
801
+ else fail(why);
435
802
  }
436
803
  };
437
804
 
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
- );
805
+ // Every page starts at once; the request slots above are the only limit, and they're
806
+ // handed out in target order (a page's HTML, then its data).
807
+ await Promise.all(targets.map(prerenderOne));
808
+ // Checked once, after the pages, and only when a page broke or timed out: the exit can
809
+ // land well after the errors it causes, so a dead server gets up to 1s to show it. A
810
+ // server that dies with every page saved lost nothing; the 404 probe says so if it hits it.
811
+ const died = connLost.length + timedOut > 0 && (await exitsWithin(1000));
812
+ if (died) {
813
+ // An example, not the culprit: a crash breaks every page in flight at once.
814
+ const pages = connLost.length
815
+ ? `; ${connLost.length} page(s) not built, e.g. ${connLost[0]!.route}`
816
+ : "";
817
+ failures.push(`❌ The prerender server stopped (${stopReason()})${pages}`);
818
+ } else failures.push(...connLost.map((f) => f.line));
449
819
 
450
820
  // Static hosts (Cloudflare assets) serve index.html with a 200 for unknown
451
821
  // paths unless a 404.html exists. Save the server's own 404 page while it's up.
@@ -454,34 +824,60 @@ export async function prerenderStaticRoutes(
454
824
  // Skipped on Workers: the worker answers unknown paths, and an uploaded file
455
825
  // would only add that soft 404. A public/404.html overwrites it.
456
826
  // One probe, one locale: add per-locale probe paths (e.g. /id/404.html) when asked.
457
- if (target !== "workers")
827
+ // A server that died is already reported, and the probe would only fail too.
828
+ if (target !== "workers" && !died)
458
829
  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}`);
830
+ // Follows only its own slash form, unlike the pages: a catch-all `always` page 308s
831
+ // the probe to `/__bosia-404-probe/`, and its 404 there is the page a static host
832
+ // should show. Any other redirect (a hook sending it to /login, or off the build
833
+ // server) is reported with where it goes, not fetched.
834
+ const probe = (url: string) =>
835
+ fetch(url, { redirect: "manual", signal: AbortSignal.timeout(timeout) });
836
+ const probeUrl = `${base}/__bosia-404-probe`;
837
+ let res = await probe(probeUrl);
838
+ const location = res.status >= 300 && res.status < 400 && res.headers.get("location");
839
+ if (location && new URL(location, probeUrl).href === `${probeUrl}/`) {
840
+ await dropBody(res);
841
+ res = await probe(`${probeUrl}/`);
842
+ }
463
843
  // 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")}`);
844
+ const problem = prerenderReplyProblem("page", res, 404);
845
+ if (problem) {
846
+ await dropBody(res);
847
+ throw new ReplyProblem(`an unknown URL ${problem}`);
848
+ }
466
849
  mkdirSync(`${OUT_DIR}/static`, { recursive: true });
467
850
  writeFileSync(`${OUT_DIR}/static/404.html`, await res.text());
468
851
  console.log(` ✅ 404 page → ${OUT_DIR}/static/404.html`);
469
852
  } catch (err) {
853
+ // A broken connection (not a timeout, as with pages) may mean the server died.
854
+ const gone = !(err instanceof ReplyProblem) && !isTimeout(err) && (await exitsWithin(1000));
855
+ const reason = gone
856
+ ? `the prerender server stopped (${stopReason()})`
857
+ : err instanceof ReplyProblem
858
+ ? err.message
859
+ : fetchFailure(err, timeout);
470
860
  // Only a deploy that serves 404.html needs it; elsewhere (an app whose
471
861
  // hooks redirect every request) a missing one is fine, so just warn.
472
- const reason = (err as Error).message;
862
+ // Listed with the page failures, so one build shows every problem.
473
863
  if (require404)
474
- throw new Error(
475
- `404.html could not be built (${reason}), but wrangler sets ` +
864
+ failures.push(
865
+ `❌ 404.html could not be built (${reason}), but wrangler sets ` +
476
866
  `not_found_handling "404-page". Make unknown paths answer 404, or add public/404.html.`,
477
867
  );
478
- console.warn(` ⚠️ 404.html skipped: ${reason}`);
868
+ else console.warn(` ⚠️ 404.html skipped: ${reason}`);
479
869
  }
480
870
 
871
+ // build.ts prints this and exits 1; the finally below still stops the server.
872
+ if (failures.length) throw new PrerenderEntriesError(failures.join("\n"));
481
873
  if (targets.length) console.log("✅ Prerendering complete");
482
874
  } finally {
483
875
  for (const sig of signals) process.off(sig, onSignal);
876
+ // SIGTERM drains in-flight requests for up to 10s (server.bun.ts), and an aborted fetch
877
+ // doesn't stop its loader. Nothing here needs that drain: give it 1s, then SIGKILL.
484
878
  child.kill();
879
+ const stopped = await exitsWithin(1000);
880
+ if (!stopped) child.kill("SIGKILL");
485
881
  await child.exited;
486
882
  if (receivedSignal) {
487
883
  // Re-raise so parent exit code is 128+signum (standard Unix convention)