bosia 1.2.3 → 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.3",
3
+ "version": "1.2.5",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bun": ">=1.4"
package/src/core/build.ts CHANGED
@@ -405,7 +405,7 @@ if (!devBuild) {
405
405
  await prerenderStaticRoutes(manifest, target);
406
406
  } catch (err) {
407
407
  if (!(err instanceof PrerenderEntriesError)) throw err;
408
- console.error(`❌ ${err.message}`);
408
+ console.error(err.message);
409
409
  process.exit(1);
410
410
  }
411
411
  }
package/src/core/html.ts CHANGED
@@ -700,30 +700,3 @@ export function compressBytes(
700
700
  }
701
701
  return new Response(bytes, { status, headers });
702
702
  }
703
-
704
- // ─── Static File Detection ────────────────────────────────
705
-
706
- const STATIC_EXTS = new Set([
707
- ".ico",
708
- ".png",
709
- ".jpg",
710
- ".jpeg",
711
- ".gif",
712
- ".webp",
713
- ".svg",
714
- ".css",
715
- ".js",
716
- ".woff",
717
- ".woff2",
718
- ".ttf",
719
- ".xml",
720
- ".txt",
721
- ".json",
722
- ".webmanifest",
723
- ]);
724
-
725
- export function isStaticPath(path: string): boolean {
726
- if (path.startsWith("/dist/") || path.startsWith("/__bosia/")) return true;
727
- const dot = path.lastIndexOf(".");
728
- return dot !== -1 && STATIC_EXTS.has(path.slice(dot));
729
- }
@@ -7,7 +7,9 @@ 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";
12
+ import { HEALTH_PATH, isFrameworkPath, isStaticPath } from "./staticPath.ts";
11
13
 
12
14
  /** Acquire an OS-assigned ephemeral port. Tiny TOCTOU race window; acceptable for build-time use. */
13
15
  export function getEphemeralPort(): Promise<number> {
@@ -50,13 +52,26 @@ export function needsStatic404(cwd = process.cwd()): boolean {
50
52
  return false;
51
53
  }
52
54
 
53
- const PRERENDER_TIMEOUT = Number(process.env.PRERENDER_TIMEOUT) || 5_000; // 5s default
54
- 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);
55
68
 
56
69
  // ─── Prerendering ─────────────────────────────────────────
57
70
 
58
- interface PrerenderTarget {
71
+ export interface PrerenderTarget {
59
72
  path: string;
73
+ /** Route pattern the path came from (`/[slug]`); equals `path` for a static route. */
74
+ route: string;
60
75
  kind: "page" | "api";
61
76
  /** Page targets only; APIs always write a single `.json` file regardless of slash mode. */
62
77
  trailingSlash: TrailingSlash;
@@ -72,7 +87,7 @@ export function substituteParams(pattern: string, entry: Record<string, string>)
72
87
  // Per segment, so an inserted value is never re-read as a placeholder.
73
88
  return pattern
74
89
  .split("/")
75
- .map((seg) => {
90
+ .map((seg, i, segs) => {
76
91
  const rank = segmentRank(seg);
77
92
  if (!rank) return seg;
78
93
  const name = segmentName(seg);
@@ -81,23 +96,28 @@ export function substituteParams(pattern: string, entry: Record<string, string>)
81
96
  // A catch-all may end in one "/" ("guide/intro/"); the out path drops it.
82
97
  const value = rank === 2 ? String(entry[name]).replace(/\/$/, "") : String(entry[name]);
83
98
  const bad = (why: string): never => {
84
- throw new Error(`entries() value "${entry[name]}" for ${seg} is unsafe — ${why}`);
99
+ throw new Error(
100
+ `entries() value ${JSON.stringify(String(entry[name]))} for ${seg} is unsafe — ${why}`,
101
+ );
85
102
  };
86
- // Check what the server will see: `%2e%2e` and `%2F` decode to `..` and `/`.
87
- let decoded = value;
88
- try {
89
- decoded = decodeURIComponent(value);
90
- } catch {
91
- bad("invalid percent-encoding");
92
- }
93
- // `..` and `\` let a build write outside the output tree.
94
- if (/\\|\.\./.test(decoded)) bad("path traversal characters are not allowed");
95
- // The fetched URL would differ from the file written.
96
- if (/[?#]/.test(decoded)) bad("`?` and `#` are not allowed");
97
- if (rank === 1 && decoded.includes("/")) bad("`/` is only allowed in a [...rest] param");
98
- // An empty or "." part would land on the parent route's file.
99
- if (decoded.split("/").some((part) => part === "" || part === "."))
100
- bad("empty or `.` parts are not allowed");
103
+ // Strict on purpose (decided 2026-10-14): ASCII unreserved URL characters only, so the
104
+ // fetched URL, the file written and a static host's lookup all agree, with nothing to
105
+ // percent-encode or decode. `50%-off`, `café`, `what%3F` fail by design: slugify them.
106
+ // No part may end in `.`: the URL parser folds `.`/`..`, and Windows drops trailing
107
+ // dots (`v1.` → `v1`), so the page would land on another route's file. No part may
108
+ // start with `.` either: static hosts hide dotfiles (`.env`).
109
+ const parts = rank === 2 ? value.split("/") : [value];
110
+ if (!parts.every((part) => /^(?!\.)[A-Za-z0-9._~-]*[A-Za-z0-9_~-]$/.test(part)))
111
+ bad(
112
+ `use only ASCII letters, digits, - _ . ~${rank === 2 ? " and /" : ""}, with no empty parts or leading/trailing \`.\``,
113
+ );
114
+ // Windows device names: writing `nul.html` hits the device, not a file.
115
+ if (parts.some((part) => /^(con|prn|aux|nul|com\d|lpt\d)(\.|$)/i.test(part)))
116
+ bad("Windows device names (con, nul, com1, …) are not allowed");
117
+ // Only the URL's last part names a file: `index` is written as `<parent>/index.html`,
118
+ // which serves the parent URL; `post.html` as `post.html.html`, never served for it.
119
+ if (i === segs.length - 1 && /^index$|\.(html|json)$/i.test(parts.at(-1)!))
120
+ bad("the last part can't be `index` or end with .html or .json");
101
121
  return value;
102
122
  })
103
123
  .join("/");
@@ -128,6 +148,11 @@ export function prerenderDataPath(routePath: string): string {
128
148
  return routePath === "/" ? "/index.json" : `${routePath.replace(/\/$/, "")}.json`;
129
149
  }
130
150
 
151
+ /** Where a page's data payload is written. */
152
+ export function prerenderDataOutPath(routePath: string): string {
153
+ return `${OUT_DIR}/prerendered/__bosia/data${prerenderDataPath(routePath)}`;
154
+ }
155
+
131
156
  /**
132
157
  * Output filename for a prerendered API route. Always emits a single `.json`
133
158
  * file at the route's path (no trailing-slash variants — static hosts serve
@@ -137,11 +162,144 @@ export function prerenderApiOutPath(routePath: string): string {
137
162
  return `${OUT_DIR}/prerendered${routePath.replace(/\/$/, "")}.json`;
138
163
  }
139
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
+
140
264
  /** A dynamic prerender route whose entries() can't be used. build.ts prints `message` and exits. */
141
265
  export class PrerenderEntriesError extends Error {
142
266
  override name = "PrerenderEntriesError";
143
267
  }
144
268
 
269
+ /**
270
+ * Throws when a dynamic route's path would save the wrong thing. Pure, so the invariant
271
+ * tests (test/prerender-invariants.test.ts) can sweep every value × slash mode × kind.
272
+ */
273
+ export function checkPrerenderPaths(
274
+ paths: string[],
275
+ kind: "page" | "api",
276
+ ts: TrailingSlash,
277
+ ): void {
278
+ for (const p of paths) {
279
+ // `dist/` and `__bosia/` are the framework's folders in static/: `dist/client` is copied
280
+ // after the pages, so on macOS/Windows a page folder `DIST/` would swallow the bundle
281
+ // (served as `DIST/client/…`, a 404 on case-sensitive hosts). A page `/dist` also fails:
282
+ // `/dist/` is answered as a static file, not 308'd to it.
283
+ // Lowercased for case-insensitive disks; a page's `/` form is tested too, so `/dist` fails.
284
+ const lower = p.toLowerCase();
285
+ if (isFrameworkPath(kind === "page" ? `${lower}/` : lower))
286
+ throw new Error(`entries() gives ${p}; dist and __bosia are the framework's folders`);
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))
295
+ throw new Error(`entries() gives ${p}, a URL the server answers itself`);
296
+ // generateStaticSite copies prerendered/ over static/, where the 404 probe saves
297
+ // 404.html. A static src/routes/404 page may replace it on purpose; a slug may not.
298
+ if (kind === "page" && ts === "never" && lower === "/404")
299
+ throw new Error("an entries() value would overwrite 404.html, the not-found page");
300
+ }
301
+ }
302
+
145
303
  /** Dynamic route → import module, call entries(), expand to concrete targets. */
146
304
  async function expandDynamicRoute(
147
305
  pattern: string,
@@ -154,11 +312,11 @@ async function expandDynamicRoute(
154
312
  if (typeof mod.entries !== "function")
155
313
  throw new Error("prerender = true on a dynamic route needs an entries() export");
156
314
  const entryList: Record<string, string>[] = await mod.entries();
157
- return entryList.map((entry) => ({
158
- path: substituteParams(pattern, entry),
159
- kind,
160
- trailingSlash: ts,
161
- }));
315
+ // A repeated value (a tag on two posts) is written once. Only within this route:
316
+ // the same URL from two routes is a clash, reported by findPrerenderClash.
317
+ const paths = [...new Set(entryList.map((entry) => substituteParams(pattern, entry)))];
318
+ checkPrerenderPaths(paths, kind, ts);
319
+ return paths.map((path) => ({ path, route: pattern, kind, trailingSlash: ts }));
162
320
  } catch (err) {
163
321
  // Fail the build: skipping the route would silently drop every page it lists.
164
322
  const why = err instanceof Error ? err.message : String(err);
@@ -166,7 +324,7 @@ async function expandDynamicRoute(
166
324
  }
167
325
  }
168
326
 
169
- async function detectPrerenderRoutes(manifest: RouteManifest): Promise<PrerenderTarget[]> {
327
+ export async function detectPrerenderRoutes(manifest: RouteManifest): Promise<PrerenderTarget[]> {
170
328
  const pageTasks = manifest.pages.map(async (route): Promise<PrerenderTarget[]> => {
171
329
  if (!route.pageServer) return [];
172
330
  const filePath = join("src", "routes", route.pageServer);
@@ -179,7 +337,7 @@ async function detectPrerenderRoutes(manifest: RouteManifest): Promise<Prerender
179
337
  }
180
338
  const ts = route.trailingSlash;
181
339
  if (isDynamic(route.pattern)) return expandDynamicRoute(route.pattern, filePath, "page", ts);
182
- return [{ path: route.pattern, kind: "page", trailingSlash: ts }];
340
+ return [{ path: route.pattern, route: route.pattern, kind: "page", trailingSlash: ts }];
183
341
  });
184
342
 
185
343
  const apiTasks = manifest.apis.map(async (route): Promise<PrerenderTarget[]> => {
@@ -188,20 +346,120 @@ async function detectPrerenderRoutes(manifest: RouteManifest): Promise<Prerender
188
346
  if (!wantsPrerender(content)) return [];
189
347
  if (isDynamic(route.pattern))
190
348
  return expandDynamicRoute(route.pattern, filePath, "api", "never");
191
- return [{ path: route.pattern, kind: "api", trailingSlash: "never" }];
349
+ return [{ path: route.pattern, route: route.pattern, kind: "api", trailingSlash: "never" }];
192
350
  });
193
351
 
194
- // Unbounded Promise.all over all routes; each task holds only small text reads
352
+ // Unbounded Promise.allSettled over all routes; each task holds only small text reads
195
353
  // and route metadata, so RAM stays trivial. Chunk it if route counts ever hit
196
354
  // tens of thousands (open file descriptors would be the first limit).
197
- const results = await Promise.all([...pageTasks, ...apiTasks]);
198
- return results.flat();
355
+ const results = await Promise.allSettled([...pageTasks, ...apiTasks]);
356
+ // List every broken entries() route in one error, not just the first.
357
+ const failed = results.flatMap((r) => (r.status === "rejected" ? [r.reason] : []));
358
+ const other = failed.find((e) => !(e instanceof PrerenderEntriesError));
359
+ if (other) throw other;
360
+ // One `❌` per route; a multi-line message (a ZodError) stays under its own mark.
361
+ if (failed.length)
362
+ throw new PrerenderEntriesError(failed.map((e) => `❌ ${e.message}`).join("\n"), {
363
+ cause: failed,
364
+ });
365
+ const targets = results.flatMap((r) => (r.status === "fulfilled" ? r.value : []));
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"));
371
+ return targets;
372
+ }
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
+
404
+ /**
405
+ * Files two targets would both write. macOS/Windows ignore case, so `About` and `about`
406
+ * (or folders `A/` and `a/`) merge; a file and a folder can't share a path either.
407
+ * Every folder level counts, and so do data payloads.
408
+ */
409
+ export function findPrerenderClash(targets: PrerenderTarget[]): string[] {
410
+ const owners = new Map<string, { path: string; who: string; file: boolean }>();
411
+ const clashes: string[] = [];
412
+ for (const t of targets) {
413
+ // Name the route a slug came from, so the message says which file to fix.
414
+ const who = targetLabel(t);
415
+ let clash = "";
416
+ const files =
417
+ t.kind === "api"
418
+ ? [prerenderApiOutPath(t.path)]
419
+ : [prerenderOutPath(t.path, t.trailingSlash), prerenderDataOutPath(t.path)];
420
+ // One ❌ per URL, but every file is still recorded so a later target can't slip past it.
421
+ for (const out of files) {
422
+ const segs = out.split("/");
423
+ for (let i = 1; i <= segs.length; i++) {
424
+ const path = segs.slice(0, i).join("/");
425
+ const file = i === segs.length;
426
+ const key = path.toLowerCase();
427
+ const prev = owners.get(key);
428
+ if (!prev) owners.set(key, { path, who, file });
429
+ // Only two folders with the exact same name may share a path.
430
+ else if (!clash && (prev.path !== path || file || prev.file)) {
431
+ const what = file && prev.file ? "file" : "path";
432
+ clash = `❌ Prerender: ${prev.who} and ${who} write the same ${what} (${path})`;
433
+ }
434
+ }
435
+ }
436
+ if (clash) clashes.push(clash);
437
+ }
438
+ return clashes;
199
439
  }
200
440
 
201
441
  export async function prerenderStaticRoutes(
202
442
  manifest: RouteManifest,
203
443
  target: RuntimeTarget = "bun",
204
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!;
205
463
  const targets = await detectPrerenderRoutes(manifest);
206
464
  // On Workers the worker answers unknown paths, so 404.html is never required.
207
465
  const require404 = target !== "workers" && needsStatic404();
@@ -226,6 +484,16 @@ export async function prerenderStaticRoutes(
226
484
  ...env,
227
485
  NODE_ENV: "production",
228
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",
229
497
  NODE_PATH: BOSIA_NODE_PATH,
230
498
  },
231
499
  stdout: "ignore",
@@ -236,6 +504,9 @@ export async function prerenderStaticRoutes(
236
504
  // watch `exited` instead.
237
505
  let exited = false;
238
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}`;
239
510
 
240
511
  const signals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"];
241
512
  let receivedSignal: NodeJS.Signals | null = null;
@@ -255,7 +526,10 @@ export async function prerenderStaticRoutes(
255
526
  const deadline = Date.now() + 10_000;
256
527
  while (Date.now() < deadline && !exited) {
257
528
  try {
258
- 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
+ });
259
533
  if (res.ok) {
260
534
  ready = true;
261
535
  break;
@@ -267,30 +541,82 @@ export async function prerenderStaticRoutes(
267
541
  }
268
542
 
269
543
  if (!ready || exited) {
270
- const why = child.signalCode ?? `exit code ${child.exitCode ?? "none"}`;
271
- if (require404)
272
- throw new Error(
273
- `Prerender server failed to start (${why}), so 404.html could not be built, but ` +
274
- `wrangler sets not_found_handling "404-page". Fix the server, or add public/404.html.`,
275
- );
276
- console.error(`❌ Prerender server failed to start (${why})`);
277
- 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
+ );
278
554
  }
279
555
 
280
556
  mkdirSync(`${OUT_DIR}/prerendered`, { recursive: true });
281
557
 
282
- const prerenderOne = async ({
283
- path: routePath,
284
- kind,
285
- trailingSlash: ts,
286
- }: 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
+ };
287
616
  try {
288
617
  if (kind === "api") {
289
618
  // APIs: fetch the bare route URL, write body to `<path>.json`.
290
- const res = await fetch(`${base}${routePath.replace(/\/$/, "")}`, {
291
- signal: AbortSignal.timeout(PRERENDER_TIMEOUT),
292
- });
293
- const body = await res.text();
619
+ const body = await fetchText(`${base}${routePath.replace(/\/$/, "")}`, kind);
294
620
  const outPath = prerenderApiOutPath(routePath);
295
621
  mkdirSync(outPath.substring(0, outPath.lastIndexOf("/")), { recursive: true });
296
622
  writeFileSync(outPath, body);
@@ -301,10 +627,22 @@ export async function prerenderStaticRoutes(
301
627
  // Hit the canonical URL so the server doesn't 308 us mid-prerender
302
628
  const canonicalRoute = canonicalRouteFor(routePath, ts);
303
629
 
304
- const res = await fetch(`${base}${canonicalRoute}`, {
305
- signal: AbortSignal.timeout(PRERENDER_TIMEOUT),
306
- });
307
- 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}`);
308
646
 
309
647
  // Filename strategy:
310
648
  // never → about.html (canonical /about, served by static host as /about → about.html)
@@ -312,47 +650,35 @@ export async function prerenderStaticRoutes(
312
650
  // ignore → about/index.html (single emit; both URLs resolve via server canonicalize=off)
313
651
  // root → index.html
314
652
  const outPath = prerenderOutPath(routePath, ts);
653
+
315
654
  mkdirSync(outPath.substring(0, outPath.lastIndexOf("/")), { recursive: true });
316
655
  writeFileSync(outPath, html);
317
-
318
- // Also prerender the data payload (filename matches dataUrl() — strips trailing slash)
319
- const dataPath = prerenderDataPath(routePath);
320
- const dataRes = await fetch(`${base}/__bosia/data${dataPath}`, {
321
- signal: AbortSignal.timeout(PRERENDER_TIMEOUT),
322
- });
323
- if (dataRes.ok) {
324
- const dataJson = await dataRes.text();
325
- const dataOutPath = `${OUT_DIR}/prerendered/__bosia/data${dataPath}`;
326
- mkdirSync(dataOutPath.substring(0, dataOutPath.lastIndexOf("/")), {
327
- recursive: true,
328
- });
329
- writeFileSync(dataOutPath, dataJson);
330
- console.log(` ✅ ${routePath} → ${outPath} (+ data)`);
331
- } else {
332
- console.log(` ✅ ${routePath} → ${outPath}`);
333
- }
656
+ const dataOutPath = prerenderDataOutPath(routePath);
657
+ mkdirSync(dataOutPath.substring(0, dataOutPath.lastIndexOf("/")), { recursive: true });
658
+ writeFileSync(dataOutPath, dataJson);
659
+ console.log(` ✅ ${routePath} → ${outPath} (+ data)`);
334
660
  } catch (err) {
335
- if (err instanceof DOMException && err.name === "TimeoutError") {
336
- console.error(
337
- ` ❌ Prerender timed out for ${routePath} after ${PRERENDER_TIMEOUT / 1000}s — increase PRERENDER_TIMEOUT to raise the limit`,
338
- );
339
- } else {
340
- console.error(` ❌ Failed to prerender ${routePath}:`, err);
341
- }
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);
342
665
  }
343
666
  };
344
667
 
345
- // Bounded worker pool: N workers pull from a shared index until targets drain.
346
- let next = 0;
347
- const worker = async () => {
348
- while (next < targets.length) {
349
- const target = targets[next++];
350
- if (target) await prerenderOne(target);
351
- }
352
- };
353
- await Promise.all(
354
- Array.from({ length: Math.min(PRERENDER_CONCURRENCY, targets.length) }, worker),
355
- );
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));
356
682
 
357
683
  // Static hosts (Cloudflare assets) serve index.html with a 200 for unknown
358
684
  // paths unless a 404.html exists. Save the server's own 404 page while it's up.
@@ -361,34 +687,60 @@ export async function prerenderStaticRoutes(
361
687
  // Skipped on Workers: the worker answers unknown paths, and an uploaded file
362
688
  // would only add that soft 404. A public/404.html overwrites it.
363
689
  // One probe, one locale: add per-locale probe paths (e.g. /id/404.html) when asked.
364
- if (target !== "workers")
690
+ // A server that died is already reported, and the probe would only fail too.
691
+ if (target !== "workers" && !died)
365
692
  try {
366
- const res = await fetch(`${base}/__bosia-404-probe`, {
367
- signal: AbortSignal.timeout(PRERENDER_TIMEOUT),
368
- });
369
- 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
+ }
370
706
  // No +error.svelte (or it crashed) → plain-text "Not Found", not a page.
371
- if (!res.headers.get("content-type")?.includes("text/html"))
372
- 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
+ }
373
712
  mkdirSync(`${OUT_DIR}/static`, { recursive: true });
374
713
  writeFileSync(`${OUT_DIR}/static/404.html`, await res.text());
375
714
  console.log(` ✅ 404 page → ${OUT_DIR}/static/404.html`);
376
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);
377
723
  // Only a deploy that serves 404.html needs it; elsewhere (an app whose
378
724
  // hooks redirect every request) a missing one is fine, so just warn.
379
- const reason = (err as Error).message;
725
+ // Listed with the page failures, so one build shows every problem.
380
726
  if (require404)
381
- throw new Error(
382
- `404.html could not be built (${reason}), but wrangler sets ` +
727
+ failures.push(
728
+ `❌ 404.html could not be built (${reason}), but wrangler sets ` +
383
729
  `not_found_handling "404-page". Make unknown paths answer 404, or add public/404.html.`,
384
730
  );
385
- console.warn(` ⚠️ 404.html skipped: ${reason}`);
731
+ else console.warn(` ⚠️ 404.html skipped: ${reason}`);
386
732
  }
387
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"));
388
736
  if (targets.length) console.log("✅ Prerendering complete");
389
737
  } finally {
390
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.
391
741
  child.kill();
742
+ const stopped = await exitsWithin(1000);
743
+ if (!stopped) child.kill("SIGKILL");
392
744
  await child.exited;
393
745
  if (receivedSignal) {
394
746
  // Re-raise so parent exit code is 128+signum (standard Unix convention)
@@ -3,6 +3,7 @@ import { join } from "path";
3
3
  import type { RouteManifest } from "./types.ts";
4
4
  import { segmentName, segmentRank } from "./matcher.ts";
5
5
  import { toPosix } from "./paths.ts";
6
+ import { PARAM_NAME } from "./scanner.ts";
6
7
 
7
8
  // ─── Route Types Generator ────────────────────────────────
8
9
  // Generates .bosia/types/src/routes/**/$types.d.ts for each
@@ -70,10 +71,11 @@ export function generateRouteTypes(manifest: RouteManifest): void {
70
71
  const srcBase = `${up}src/routes/${segments.length ? segments.join("/") + "/" : ""}`;
71
72
 
72
73
  const params = paramsForDir(dir);
74
+ // Quoted fallback: a cached manifest (BOSIA_SKIP_ROUTE_SCAN) may predate the name check.
73
75
  const paramsType =
74
76
  params.length === 0
75
77
  ? `{}`
76
- : `{ ${params.map((p) => `${/^[A-Za-z_$][\w$]*$/.test(p) ? p : JSON.stringify(p)}: string`).join("; ")} }`;
78
+ : `{ ${params.map((p) => `${PARAM_NAME.test(p) ? p : JSON.stringify(p)}: string`).join("; ")} }`;
77
79
 
78
80
  const lines: string[] = ["// AUTO-GENERATED by bosia — do not edit\n"];
79
81
  lines.push(`import type { LoadEvent, MetadataEvent, RequestEvent, Metadata } from 'bosia';`);
@@ -278,23 +278,15 @@ function checkParams<T extends { pattern: string }>(routes: T[], fileOf: (r: T)
278
278
  for (const [i, seg] of segs.entries()) {
279
279
  const rank = segmentRank(seg);
280
280
  if (!rank) {
281
- // `[...]`, `[.x]`, `[id].json`, `post-[slug]`, `[[lang]]` would match only their literal text.
282
- if (/^\[(\.\.\.)?[^[\]/]*\]$/.test(seg))
283
- fail(`${seg} needs a param name that doesn't start with a dot, like [slug] or [...rest]`);
281
+ // `[...]`, `[.x]` would match only their literal text.
282
+ if (/^\[[^[\]/]*\]$/.test(seg)) fail(BAD_NAME(seg));
283
+ // `[id].json`, `post-[slug]`, `[[lang]]`.
284
284
  if (/[[\]]/.test(seg)) fail(`${seg} must be a whole segment like [slug] or [...rest]`);
285
285
  continue;
286
286
  }
287
287
  const name = segmentName(seg);
288
288
  if (rank === 2 && i !== segs.length - 1) fail(`${seg} must be the last part of the route`);
289
- const dots = rank === 2 ? "..." : "";
290
- if (name.includes("="))
291
- fail(`${seg}: param matchers aren't supported, use [${dots}${name.split("=")[0]}]`);
292
- if (name.endsWith("?"))
293
- fail(`${seg}: optional params aren't supported, use [${dots}${name.slice(0, -1)}]`);
294
- if (/^…|[.…]$/.test(name))
295
- fail(`${seg}: did you mean [...${name.replace(/^…|[.…]+$/g, "")}]?`);
296
- if (!PARAM_NAME.test(name))
297
- fail(`${seg}: a param name uses letters, digits, _, $, - and inner dots only`);
289
+ if (!PARAM_NAME.test(name)) fail(BAD_NAME(seg));
298
290
  // params is a plain object: assigning "__proto__" sets the prototype, not a key.
299
291
  if (name === "__proto__") fail(`"__proto__" can't be a param name`);
300
292
  if (names.has(name)) fail(`the param "${name}" is used twice`);
@@ -303,8 +295,12 @@ function checkParams<T extends { pattern: string }>(routes: T[], fileOf: (r: T)
303
295
  }
304
296
  }
305
297
 
306
- // `[slüg]`, `[$id]`, `[my-path]`, `[file.name]`, `[1st]`; no spaces, quotes, `…` or edge dots.
307
- const PARAM_NAME = /^[\p{L}\p{N}_$-]+(\.[\p{L}\p{N}_$-]+)*$/u;
298
+ // Strict on purpose (decided 2026-10-14): an ASCII TypeScript identifier, so `params.name`
299
+ // always works: `[slug]`, `[x123]`, `[user_id]`, `[_id]`, `[$id]`. `[post-id]`, `[file.name]`,
300
+ // `[slüg]` fail by design, with one message (no per-typo hints).
301
+ export const PARAM_NAME = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
302
+ const BAD_NAME = (seg: string) =>
303
+ `${seg}: a param name uses ASCII letters, digits, _ and $, and can't start with a digit (like [slug], [user_id] or [...rest])`;
308
304
 
309
305
  /** A route tree the build refuses (two files at one URL, or an invalid param).
310
306
  * Callers print `message` and exit. */
@@ -15,6 +15,7 @@ import { NO_FRAME_GUARD_HEADER, type Handle, type RequestEvent } from "./hooks.t
15
15
  import { HttpError, Redirect, ActionFailure, isHttpError, isRedirect } from "./errors.ts";
16
16
  import { CookieJar } from "./cookies.ts";
17
17
  import { safePath } from "./safePath.ts";
18
+ import { HEALTH_PATH, isFrameworkPath, isStaticPath } from "./staticPath.ts";
18
19
  import { checkCsrf } from "./csrf.ts";
19
20
  import type { CsrfConfig } from "./csrf.ts";
20
21
  import { applyCorsVary, getCorsHeaders, handlePreflight } from "./cors.ts";
@@ -25,7 +26,6 @@ import {
25
26
  compress,
26
27
  compressBytes,
27
28
  encodeForRequest,
28
- isStaticPath,
29
29
  distManifest,
30
30
  PRECOMPRESSED,
31
31
  type Encoded,
@@ -372,7 +372,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
372
372
  const method = request.method.toUpperCase();
373
373
 
374
374
  // Health check endpoint — for load balancers and orchestrators
375
- if (path === "/_health") {
375
+ if (path === HEALTH_PATH) {
376
376
  if (shuttingDown) {
377
377
  return Response.json({ status: "shutting_down" }, { status: 503 });
378
378
  }
@@ -593,7 +593,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
593
593
  // by a user `+server.ts`, so serve them straight from the manifest before the
594
594
  // API scan. A miss falls through to the normal path (which 404s). Keeps the
595
595
  // api-before-static ordering intact for every user-facing path.
596
- if (staticManifest && (path.startsWith("/dist/") || path.startsWith("/__bosia/"))) {
596
+ if (staticManifest && isFrameworkPath(path)) {
597
597
  const hit = lookupStatic(staticManifest, path);
598
598
  if (hit) return serveStatic(hit, request);
599
599
  }
@@ -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) {
@@ -1144,7 +1146,7 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
1144
1146
  }
1145
1147
 
1146
1148
  // Reject new non-health requests during shutdown
1147
- if (shuttingDown && url.pathname !== "/_health") {
1149
+ if (shuttingDown && url.pathname !== HEALTH_PATH) {
1148
1150
  return new Response("Service Unavailable", {
1149
1151
  status: 503,
1150
1152
  headers: { "Retry-After": "5" },
@@ -1154,7 +1156,7 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
1154
1156
  // Shed load above MAX_INFLIGHT. Checked before any work so the 503 is
1155
1157
  // cheap. /_health stays available so the orchestrator can still tell the
1156
1158
  // process is alive (and decide whether to restart or scale out).
1157
- if (inFlight >= MAX_INFLIGHT && url.pathname !== "/_health") {
1159
+ if (inFlight >= MAX_INFLIGHT && url.pathname !== HEALTH_PATH) {
1158
1160
  return new Response("Service Unavailable", {
1159
1161
  status: 503,
1160
1162
  headers: { "Retry-After": "1" },
@@ -1215,7 +1217,7 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
1215
1217
  const path = event.url.pathname;
1216
1218
  let response: Response | null = null;
1217
1219
  let staticTried = false;
1218
- if (!dataReq && staticManifest && (path.startsWith("/dist/") || path.startsWith("/__bosia/"))) {
1220
+ if (!dataReq && staticManifest && isFrameworkPath(path)) {
1219
1221
  response = await serveStaticFile(path, request);
1220
1222
  staticTried = true;
1221
1223
  }
@@ -0,0 +1,35 @@
1
+ // URLs the server answers as static files, never as pages. No imports and no top-level
2
+ // work: the build loads this before loadEnv(), so it must not touch env or the base path.
3
+
4
+ const STATIC_EXTS = new Set([
5
+ ".ico",
6
+ ".png",
7
+ ".jpg",
8
+ ".jpeg",
9
+ ".gif",
10
+ ".webp",
11
+ ".svg",
12
+ ".css",
13
+ ".js",
14
+ ".woff",
15
+ ".woff2",
16
+ ".ttf",
17
+ ".xml",
18
+ ".txt",
19
+ ".json",
20
+ ".webmanifest",
21
+ ]);
22
+
23
+ // The server answers these itself; prerender rejects entries() values that reach them.
24
+ export const HEALTH_PATH = "/_health";
25
+
26
+ // The framework's own folders: built assets and data/runtime endpoints.
27
+ export function isFrameworkPath(path: string): boolean {
28
+ return path.startsWith("/dist/") || path.startsWith("/__bosia/");
29
+ }
30
+
31
+ export function isStaticPath(path: string): boolean {
32
+ if (isFrameworkPath(path)) return true;
33
+ const dot = path.lastIndexOf(".");
34
+ return dot !== -1 && STATIC_EXTS.has(path.slice(dot));
35
+ }