bosia 1.2.2 → 1.2.4

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,6 +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";
11
+ import { HEALTH_PATH, isFrameworkPath, isStaticPath } from "./staticPath.ts";
10
12
 
11
13
  /** Acquire an OS-assigned ephemeral port. Tiny TOCTOU race window; acceptable for build-time use. */
12
14
  export function getEphemeralPort(): Promise<number> {
@@ -54,8 +56,10 @@ const PRERENDER_CONCURRENCY = Number(process.env.PRERENDER_CONCURRENCY) || 6;
54
56
 
55
57
  // ─── Prerendering ─────────────────────────────────────────
56
58
 
57
- interface PrerenderTarget {
59
+ export interface PrerenderTarget {
58
60
  path: string;
61
+ /** Route pattern the path came from (`/[slug]`); equals `path` for a static route. */
62
+ route: string;
59
63
  kind: "page" | "api";
60
64
  /** Page targets only; APIs always write a single `.json` file regardless of slash mode. */
61
65
  trailingSlash: TrailingSlash;
@@ -68,23 +72,43 @@ interface PrerenderTarget {
68
72
  * concrete values from an `entries()` record.
69
73
  */
70
74
  export function substituteParams(pattern: string, entry: Record<string, string>): string {
71
- let resolved = pattern;
72
- for (const [key, value] of Object.entries(entry)) {
73
- // `..` and `\` are never legitimate in a route segment — they let a build
74
- // emit prerendered HTML outside the intended output tree. Forward slashes
75
- // are only allowed for catch-all (`[...key]`) segments, which by design
76
- // expand to multiple path parts. Validate accordingly.
77
- const isRest = pattern.includes(`[...${key}]`);
78
- if (/\\|\.\./.test(value) || (!isRest && value.includes("/"))) {
79
- throw new Error(
80
- `Prerender entries(): unsafe value "${value}" for [${key}] — ` +
81
- `path traversal characters are not allowed in dynamic segment values.`,
82
- );
83
- }
84
- resolved = resolved.replace(`[...${key}]`, value);
85
- resolved = resolved.replace(`[${key}]`, value);
86
- }
87
- return resolved;
75
+ // Per segment, so an inserted value is never re-read as a placeholder.
76
+ return pattern
77
+ .split("/")
78
+ .map((seg, i, segs) => {
79
+ const rank = segmentRank(seg);
80
+ if (!rank) return seg;
81
+ const name = segmentName(seg);
82
+ if (entry[name] == null || !Object.hasOwn(entry, name))
83
+ throw new Error(`entries() gave no value for ${seg}`);
84
+ // A catch-all may end in one "/" ("guide/intro/"); the out path drops it.
85
+ const value = rank === 2 ? String(entry[name]).replace(/\/$/, "") : String(entry[name]);
86
+ const bad = (why: string): never => {
87
+ throw new Error(
88
+ `entries() value ${JSON.stringify(String(entry[name]))} for ${seg} is unsafe — ${why}`,
89
+ );
90
+ };
91
+ // Strict on purpose (decided 2026-10-14): ASCII unreserved URL characters only, so the
92
+ // fetched URL, the file written and a static host's lookup all agree, with nothing to
93
+ // percent-encode or decode. `50%-off`, `café`, `what%3F` fail by design: slugify them.
94
+ // No part may end in `.`: the URL parser folds `.`/`..`, and Windows drops trailing
95
+ // dots (`v1.` → `v1`), so the page would land on another route's file. No part may
96
+ // start with `.` either: static hosts hide dotfiles (`.env`).
97
+ const parts = rank === 2 ? value.split("/") : [value];
98
+ if (!parts.every((part) => /^(?!\.)[A-Za-z0-9._~-]*[A-Za-z0-9_~-]$/.test(part)))
99
+ bad(
100
+ `use only ASCII letters, digits, - _ . ~${rank === 2 ? " and /" : ""}, with no empty parts or leading/trailing \`.\``,
101
+ );
102
+ // Windows device names: writing `nul.html` hits the device, not a file.
103
+ if (parts.some((part) => /^(con|prn|aux|nul|com\d|lpt\d)(\.|$)/i.test(part)))
104
+ bad("Windows device names (con, nul, com1, …) are not allowed");
105
+ // Only the URL's last part names a file: `index` is written as `<parent>/index.html`,
106
+ // which serves the parent URL; `post.html` as `post.html.html`, never served for it.
107
+ if (i === segs.length - 1 && /^index$|\.(html|json)$/i.test(parts.at(-1)!))
108
+ bad("the last part can't be `index` or end with .html or .json");
109
+ return value;
110
+ })
111
+ .join("/");
88
112
  }
89
113
 
90
114
  /**
@@ -112,6 +136,11 @@ export function prerenderDataPath(routePath: string): string {
112
136
  return routePath === "/" ? "/index.json" : `${routePath.replace(/\/$/, "")}.json`;
113
137
  }
114
138
 
139
+ /** Where a page's data payload is written. */
140
+ export function prerenderDataOutPath(routePath: string): string {
141
+ return `${OUT_DIR}/prerendered/__bosia/data${prerenderDataPath(routePath)}`;
142
+ }
143
+
115
144
  /**
116
145
  * Output filename for a prerendered API route. Always emits a single `.json`
117
146
  * file at the route's path (no trailing-slash variants — static hosts serve
@@ -121,6 +150,42 @@ export function prerenderApiOutPath(routePath: string): string {
121
150
  return `${OUT_DIR}/prerendered${routePath.replace(/\/$/, "")}.json`;
122
151
  }
123
152
 
153
+ /** A dynamic prerender route whose entries() can't be used. build.ts prints `message` and exits. */
154
+ export class PrerenderEntriesError extends Error {
155
+ override name = "PrerenderEntriesError";
156
+ }
157
+
158
+ /**
159
+ * Throws when a dynamic route's path would save the wrong thing. Pure, so the invariant
160
+ * tests (test/prerender-invariants.test.ts) can sweep every value × slash mode × kind.
161
+ */
162
+ export function checkPrerenderPaths(
163
+ paths: string[],
164
+ kind: "page" | "api",
165
+ ts: TrailingSlash,
166
+ ): void {
167
+ for (const p of paths) {
168
+ // `dist/` and `__bosia/` are the framework's folders in static/: `dist/client` is copied
169
+ // after the pages, so on macOS/Windows a page folder `DIST/` would swallow the bundle
170
+ // (served as `DIST/client/…`, a 404 on case-sensitive hosts). A page `/dist` also fails:
171
+ // `/dist/` is answered as a static file, not 308'd to it.
172
+ // Lowercased for case-insensitive disks; a page's `/` form is tested too, so `/dist` fails.
173
+ const lower = p.toLowerCase();
174
+ if (isFrameworkPath(kind === "page" ? `${lower}/` : lower))
175
+ 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)))
181
+ throw new Error(`entries() gives ${p}, a URL the server answers itself`);
182
+ // generateStaticSite copies prerendered/ over static/, where the 404 probe saves
183
+ // 404.html. A static src/routes/404 page may replace it on purpose; a slug may not.
184
+ if (kind === "page" && ts === "never" && lower === "/404")
185
+ throw new Error("an entries() value would overwrite 404.html, the not-found page");
186
+ }
187
+ }
188
+
124
189
  /** Dynamic route → import module, call entries(), expand to concrete targets. */
125
190
  async function expandDynamicRoute(
126
191
  pattern: string,
@@ -130,23 +195,22 @@ async function expandDynamicRoute(
130
195
  ): Promise<PrerenderTarget[]> {
131
196
  try {
132
197
  const mod = await import(join(process.cwd(), filePath));
133
- if (typeof mod.entries !== "function") {
134
- console.warn(` ⚠️ ${pattern} has prerender=true but no entries() export — skipped`);
135
- return [];
136
- }
198
+ if (typeof mod.entries !== "function")
199
+ throw new Error("prerender = true on a dynamic route needs an entries() export");
137
200
  const entryList: Record<string, string>[] = await mod.entries();
138
- return entryList.map((entry) => ({
139
- path: substituteParams(pattern, entry),
140
- kind,
141
- trailingSlash: ts,
142
- }));
201
+ // A repeated value (a tag on two posts) is written once. Only within this route:
202
+ // the same URL from two routes is a clash, reported by findPrerenderClash.
203
+ const paths = [...new Set(entryList.map((entry) => substituteParams(pattern, entry)))];
204
+ checkPrerenderPaths(paths, kind, ts);
205
+ return paths.map((path) => ({ path, route: pattern, kind, trailingSlash: ts }));
143
206
  } catch (err) {
144
- console.error(` ❌ Failed to resolve entries() for ${pattern}:`, err);
145
- return [];
207
+ // Fail the build: skipping the route would silently drop every page it lists.
208
+ const why = err instanceof Error ? err.message : String(err);
209
+ throw new PrerenderEntriesError(`Prerender failed for ${pattern}: ${why}`, { cause: err });
146
210
  }
147
211
  }
148
212
 
149
- async function detectPrerenderRoutes(manifest: RouteManifest): Promise<PrerenderTarget[]> {
213
+ export async function detectPrerenderRoutes(manifest: RouteManifest): Promise<PrerenderTarget[]> {
150
214
  const pageTasks = manifest.pages.map(async (route): Promise<PrerenderTarget[]> => {
151
215
  if (!route.pageServer) return [];
152
216
  const filePath = join("src", "routes", route.pageServer);
@@ -158,24 +222,73 @@ async function detectPrerenderRoutes(manifest: RouteManifest): Promise<Prerender
158
222
  return [];
159
223
  }
160
224
  const ts = route.trailingSlash;
161
- if (route.pattern.includes("[")) return expandDynamicRoute(route.pattern, filePath, "page", ts);
162
- return [{ path: route.pattern, kind: "page", trailingSlash: ts }];
225
+ if (isDynamic(route.pattern)) return expandDynamicRoute(route.pattern, filePath, "page", ts);
226
+ return [{ path: route.pattern, route: route.pattern, kind: "page", trailingSlash: ts }];
163
227
  });
164
228
 
165
229
  const apiTasks = manifest.apis.map(async (route): Promise<PrerenderTarget[]> => {
166
230
  const filePath = join("src", "routes", route.server);
167
231
  const content = await Bun.file(filePath).text();
168
232
  if (!wantsPrerender(content)) return [];
169
- if (route.pattern.includes("["))
233
+ if (isDynamic(route.pattern))
170
234
  return expandDynamicRoute(route.pattern, filePath, "api", "never");
171
- return [{ path: route.pattern, kind: "api", trailingSlash: "never" }];
235
+ return [{ path: route.pattern, route: route.pattern, kind: "api", trailingSlash: "never" }];
172
236
  });
173
237
 
174
- // Unbounded Promise.all over all routes; each task holds only small text reads
238
+ // Unbounded Promise.allSettled over all routes; each task holds only small text reads
175
239
  // and route metadata, so RAM stays trivial. Chunk it if route counts ever hit
176
240
  // tens of thousands (open file descriptors would be the first limit).
177
- const results = await Promise.all([...pageTasks, ...apiTasks]);
178
- return results.flat();
241
+ const results = await Promise.allSettled([...pageTasks, ...apiTasks]);
242
+ // List every broken entries() route in one error, not just the first.
243
+ const failed = results.flatMap((r) => (r.status === "rejected" ? [r.reason] : []));
244
+ const other = failed.find((e) => !(e instanceof PrerenderEntriesError));
245
+ if (other) throw other;
246
+ // One `❌` per route; a multi-line message (a ZodError) stays under its own mark.
247
+ if (failed.length)
248
+ throw new PrerenderEntriesError(failed.map((e) => `❌ ${e.message}`).join("\n"), {
249
+ cause: failed,
250
+ });
251
+ 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"));
254
+ return targets;
255
+ }
256
+
257
+ /**
258
+ * Files two targets would both write. macOS/Windows ignore case, so `About` and `about`
259
+ * (or folders `A/` and `a/`) merge; a file and a folder can't share a path either.
260
+ * Every folder level counts, and so do data payloads.
261
+ */
262
+ export function findPrerenderClash(targets: PrerenderTarget[]): string[] {
263
+ const owners = new Map<string, { path: string; who: string; file: boolean }>();
264
+ const clashes: string[] = [];
265
+ for (const t of targets) {
266
+ // 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})`;
268
+ let clash = "";
269
+ const files =
270
+ t.kind === "api"
271
+ ? [prerenderApiOutPath(t.path)]
272
+ : [prerenderOutPath(t.path, t.trailingSlash), prerenderDataOutPath(t.path)];
273
+ // One ❌ per URL, but every file is still recorded so a later target can't slip past it.
274
+ for (const out of files) {
275
+ const segs = out.split("/");
276
+ for (let i = 1; i <= segs.length; i++) {
277
+ const path = segs.slice(0, i).join("/");
278
+ const file = i === segs.length;
279
+ const key = path.toLowerCase();
280
+ const prev = owners.get(key);
281
+ if (!prev) owners.set(key, { path, who, file });
282
+ // Only two folders with the exact same name may share a path.
283
+ else if (!clash && (prev.path !== path || file || prev.file)) {
284
+ const what = file && prev.file ? "file" : "path";
285
+ clash = `❌ Prerender: ${prev.who} and ${who} write the same ${what} (${path})`;
286
+ }
287
+ }
288
+ }
289
+ if (clash) clashes.push(clash);
290
+ }
291
+ return clashes;
179
292
  }
180
293
 
181
294
  export async function prerenderStaticRoutes(
@@ -302,7 +415,7 @@ export async function prerenderStaticRoutes(
302
415
  });
303
416
  if (dataRes.ok) {
304
417
  const dataJson = await dataRes.text();
305
- const dataOutPath = `${OUT_DIR}/prerendered/__bosia/data${dataPath}`;
418
+ const dataOutPath = prerenderDataOutPath(routePath);
306
419
  mkdirSync(dataOutPath.substring(0, dataOutPath.lastIndexOf("/")), {
307
420
  recursive: true,
308
421
  });
@@ -2,6 +2,7 @@ import { writeFileSync, mkdirSync } from "fs";
2
2
  import type { RouteManifest } from "./types.ts";
3
3
  import { currentBase } from "./appBase.ts";
4
4
  import { toPosix } from "./paths.ts";
5
+ import { segmentRank } from "./matcher.ts";
5
6
 
6
7
  /**
7
8
  * The pattern the *client* router matches against. It sees real browser URLs, so
@@ -28,23 +29,26 @@ function clientPattern(pattern: string): string {
28
29
  // serverRoutes — used by SSR renderer (+ pageServer + layoutServers)
29
30
  // apiRoutes — used by API handler
30
31
 
31
- function routePriority(pattern: string): number {
32
- if (!pattern.includes("[")) return 0; // exact
33
- if (!pattern.includes("[...")) return 1; // dynamic
34
- return 2; // catch-all
35
- }
36
-
37
- function sortRoutes<T extends { pattern: string }>(routes: T[]): T[] {
38
- return [...routes].sort((a, b) => {
39
- const pa = routePriority(a.pattern);
40
- const pb = routePriority(b.pattern);
41
- if (pa !== pb) return pa - pb;
42
- // same tier: more segments first, then alphabetical
43
- const sa = a.pattern.split("/").length;
44
- const sb = b.pattern.split("/").length;
45
- if (sa !== sb) return sb - sa;
46
- return a.pattern.localeCompare(b.pattern);
47
- });
32
+ /** Most specific first: compared segment by segment, the first differing kind
33
+ * decides (so `/label/[id]` beats `/[a]/[b]`, `/blog/[...r]` beats `/[a]/about`).
34
+ * Ties fall back to more segments first, then alphabetical. findMatch takes the
35
+ * first match, so this order is the routing precedence. */
36
+ export function sortRoutes<T extends { pattern: string }>(routes: T[]): T[] {
37
+ return routes
38
+ .map((route) => ({ route, ranks: route.pattern.split("/").filter(Boolean).map(segmentRank) }))
39
+ .sort((a, b) => {
40
+ const ra = a.ranks;
41
+ const rb = b.ranks;
42
+ for (let i = 0; i < Math.min(ra.length, rb.length); i++) {
43
+ if (ra[i] !== rb[i]) return ra[i]! - rb[i]!;
44
+ }
45
+ if (ra.length !== rb.length) return rb.length - ra.length;
46
+ // plain code-unit compare: same order on every machine, unlike localeCompare
47
+ const pa = a.route.pattern;
48
+ const pb = b.route.pattern;
49
+ return pa < pb ? -1 : pa > pb ? 1 : 0;
50
+ })
51
+ .map((x) => x.route);
48
52
  }
49
53
 
50
54
  /** `defaultTitle` is what a page with no title of its own is called: app.html's
@@ -1,7 +1,9 @@
1
1
  import { writeFileSync, readFileSync, existsSync, mkdirSync } from "fs";
2
2
  import { join } from "path";
3
3
  import type { RouteManifest } from "./types.ts";
4
+ import { segmentName, segmentRank } from "./matcher.ts";
4
5
  import { toPosix } from "./paths.ts";
6
+ import { PARAM_NAME } from "./scanner.ts";
5
7
 
6
8
  // ─── Route Types Generator ────────────────────────────────
7
9
  // Generates .bosia/types/src/routes/**/$types.d.ts for each
@@ -23,13 +25,7 @@ function paramsForDir(dir: string): string[] {
23
25
  .filter((s) => !/^\(.*\)$/.test(s));
24
26
  const out: string[] = [];
25
27
  for (const seg of segs) {
26
- const catchAll = seg.match(/^\[\.\.\.(\w+)\]$/);
27
- if (catchAll) {
28
- out.push(catchAll[1]!);
29
- continue;
30
- }
31
- const dyn = seg.match(/^\[(\w+)\]$/);
32
- if (dyn) out.push(dyn[1]!);
28
+ if (segmentRank(seg)) out.push(segmentName(seg));
33
29
  }
34
30
  return out;
35
31
  }
@@ -75,8 +71,11 @@ export function generateRouteTypes(manifest: RouteManifest): void {
75
71
  const srcBase = `${up}src/routes/${segments.length ? segments.join("/") + "/" : ""}`;
76
72
 
77
73
  const params = paramsForDir(dir);
74
+ // Quoted fallback: a cached manifest (BOSIA_SKIP_ROUTE_SCAN) may predate the name check.
78
75
  const paramsType =
79
- params.length === 0 ? `{}` : `{ ${params.map((p) => `${p}: string`).join("; ")} }`;
76
+ params.length === 0
77
+ ? `{}`
78
+ : `{ ${params.map((p) => `${PARAM_NAME.test(p) ? p : JSON.stringify(p)}: string`).join("; ")} }`;
80
79
 
81
80
  const lines: string[] = ["// AUTO-GENERATED by bosia — do not edit\n"];
82
81
  lines.push(`import type { LoadEvent, MetadataEvent, RequestEvent, Metadata } from 'bosia';`);
@@ -101,7 +100,9 @@ export function generateRouteTypes(manifest: RouteManifest): void {
101
100
 
102
101
  if (info.pageServer) {
103
102
  lines.push(``);
104
- lines.push(`import type { load as _pageLoad } from '${srcBase}+page.server.ts';`);
103
+ lines.push(
104
+ `import type { load as _pageLoad } from ${JSON.stringify(srcBase + "+page.server.ts")};`,
105
+ );
105
106
  lines.push(`export type PageServerLoad = (event: _LoadEvent) => any;`);
106
107
  lines.push(
107
108
  `export type PageMetadataLoad = (event: _MetadataEvent) => Metadata | Promise<Metadata>;`,
@@ -131,7 +132,9 @@ export function generateRouteTypes(manifest: RouteManifest): void {
131
132
  // ActionData — union of all action return types, unwrapping ActionFailure
132
133
  if (info.pageServer) {
133
134
  lines.push(``);
134
- lines.push(`import type { actions as _actions } from '${srcBase}+page.server.ts';`);
135
+ lines.push(
136
+ `import type { actions as _actions } from ${JSON.stringify(srcBase + "+page.server.ts")};`,
137
+ );
135
138
  lines.push(
136
139
  `type _ActionReturn<T> = T extends (...args: any[]) => infer R ? Awaited<R> : never;`,
137
140
  );
@@ -144,7 +147,9 @@ export function generateRouteTypes(manifest: RouteManifest): void {
144
147
  }
145
148
 
146
149
  if (info.layoutServer) {
147
- lines.push(`\nimport type { load as _layoutLoad } from '${srcBase}+layout.server.ts';`);
150
+ lines.push(
151
+ `\nimport type { load as _layoutLoad } from ${JSON.stringify(srcBase + "+layout.server.ts")};`,
152
+ );
148
153
  lines.push(`export type LayoutServerLoad = (event: _LoadEvent) => any;`);
149
154
  lines.push(`export type LayoutData = Awaited<ReturnType<typeof _layoutLoad>>;`);
150
155
  lines.push(`export type LayoutProps = { data: LayoutData; params: Params; children: any };`);
@@ -1,6 +1,7 @@
1
1
  import { readdirSync, existsSync, readFileSync } from "fs";
2
2
  import { join } from "path";
3
3
  import type { PageRoute, ApiRoute, RouteManifest, TrailingSlash } from "./types.ts";
4
+ import { segmentName, segmentRank } from "./matcher.ts";
4
5
 
5
6
  // ─── Route Scanner ───────────────────────────────────────
6
7
  // Walks src/routes/ and produces a RouteManifest.
@@ -192,19 +193,22 @@ export function scanRoutes(): RouteManifest {
192
193
 
193
194
  walk("", [], [], [], [], "never", null);
194
195
 
195
- // Warn when a catch-all exists but no exact route covers its prefix.
196
- // e.g. "/[...slug]" matches everything EXCEPT "/" (which needs its own +page.svelte).
197
- const exactPatterns = new Set(
198
- pages.filter((p) => !p.pattern.includes("[")).map((p) => p.pattern),
199
- );
196
+ // Invalid segments first, so they get their own message, not a conflict or warning.
197
+ checkParams(pages, (p) => p.page);
198
+ checkParams(apis, (a) => a.server);
199
+
200
+ // Warn when a trailing catch-all exists but no page covers its prefix.
201
+ // e.g. "/[...slug]" matches everything EXCEPT "/" (which needs its own +page.svelte),
202
+ // "/[lang]/[...rest]" needs "/[lang]" for "/en".
203
+ const pageKeys = new Set(pages.map((p) => routeKey(p.pattern)));
200
204
  for (const p of pages) {
201
- const m = p.pattern.match(/^(.*?)\/\[\.\.\.(\w+)\]$/);
202
- if (m) {
203
- const exactEquivalent = m[1] || "/";
204
- if (!exactPatterns.has(exactEquivalent)) {
205
+ const cut = p.pattern.lastIndexOf("/");
206
+ if (segmentRank(p.pattern.slice(cut + 1)) === 2) {
207
+ const exactEquivalent = p.pattern.slice(0, cut) || "/";
208
+ if (!pageKeys.has(routeKey(exactEquivalent))) {
205
209
  console.warn(
206
- `⚠️ No exact route for "${exactEquivalent}" — the catch-all "${p.pattern}" will NOT match it.\n` +
207
- ` Add a +page.svelte at the "${exactEquivalent}" level to serve that URL.`,
210
+ `⚠️ The catch-all "${p.pattern}" doesn't match "${exactEquivalent}" itself.\n` +
211
+ ` Add a +page.svelte at "${exactEquivalent}" unless another route already serves it.`,
208
212
  );
209
213
  }
210
214
  }
@@ -228,7 +232,7 @@ export function scanRoutes(): RouteManifest {
228
232
  function preventConflicts<T extends { pattern: string }>(routes: T[], fileOf: (r: T) => string) {
229
233
  const seen = new Map<string, T>();
230
234
  for (const r of routes) {
231
- const key = r.pattern.replace(/\[\.\.\.\w+\]/g, "[...]").replace(/\[\w+\]/g, "[]");
235
+ const key = routeKey(r.pattern);
232
236
  const first = seen.get(key);
233
237
  if (!first) {
234
238
  seen.set(key, r);
@@ -244,7 +248,62 @@ function preventConflicts<T extends { pattern: string }>(routes: T[], fileOf: (r
244
248
  }
245
249
  }
246
250
 
247
- /** Two route files resolve to the same URL. Callers print `message` and exit. */
251
+ const KINDS = ["", "[]", "[...]"];
252
+
253
+ /** The URL shape of a pattern, param names dropped: `/[lang]/[...rest]` → `/[]/[...]`. */
254
+ function routeKey(pattern: string): string {
255
+ return pattern.replace(/[^/]+/g, (seg) => {
256
+ const rank = segmentRank(seg);
257
+ return rank ? KINDS[rank]! : seg;
258
+ });
259
+ }
260
+
261
+ /**
262
+ * Fail on params the matcher can't serve: brackets that don't wrap a whole
263
+ * segment, a `[...rest]` that isn't the last segment, `__proto__`, or one name
264
+ * bound twice. Mid-path catch-alls (`docs/[...path]/edit`) are left out on
265
+ * purpose: each needs an unanchored `(.+)` (several backtrack for seconds on a
266
+ * long URL) and span-aware ranking.
267
+ * Add both together if that route shape is ever needed.
268
+ */
269
+ function checkParams<T extends { pattern: string }>(routes: T[], fileOf: (r: T) => string) {
270
+ for (const r of routes) {
271
+ const segs = r.pattern.split("/").filter(Boolean);
272
+ const names = new Set<string>();
273
+ const fail = (problem: string) => {
274
+ throw new RouteConflictError(
275
+ `The "${r.pattern}" route is invalid: ${problem}.\n src/routes/${fileOf(r)}`,
276
+ );
277
+ };
278
+ for (const [i, seg] of segs.entries()) {
279
+ const rank = segmentRank(seg);
280
+ if (!rank) {
281
+ // `[...]`, `[.x]` would match only their literal text.
282
+ if (/^\[[^[\]/]*\]$/.test(seg)) fail(BAD_NAME(seg));
283
+ // `[id].json`, `post-[slug]`, `[[lang]]`.
284
+ if (/[[\]]/.test(seg)) fail(`${seg} must be a whole segment like [slug] or [...rest]`);
285
+ continue;
286
+ }
287
+ const name = segmentName(seg);
288
+ if (rank === 2 && i !== segs.length - 1) fail(`${seg} must be the last part of the route`);
289
+ if (!PARAM_NAME.test(name)) fail(BAD_NAME(seg));
290
+ // params is a plain object: assigning "__proto__" sets the prototype, not a key.
291
+ if (name === "__proto__") fail(`"__proto__" can't be a param name`);
292
+ if (names.has(name)) fail(`the param "${name}" is used twice`);
293
+ names.add(name);
294
+ }
295
+ }
296
+ }
297
+
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])`;
304
+
305
+ /** A route tree the build refuses (two files at one URL, or an invalid param).
306
+ * Callers print `message` and exit. */
248
307
  export class RouteConflictError extends Error {
249
308
  override name = "RouteConflictError";
250
309
  }
@@ -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
  }
@@ -1144,7 +1144,7 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
1144
1144
  }
1145
1145
 
1146
1146
  // Reject new non-health requests during shutdown
1147
- if (shuttingDown && url.pathname !== "/_health") {
1147
+ if (shuttingDown && url.pathname !== HEALTH_PATH) {
1148
1148
  return new Response("Service Unavailable", {
1149
1149
  status: 503,
1150
1150
  headers: { "Retry-After": "5" },
@@ -1154,7 +1154,7 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
1154
1154
  // Shed load above MAX_INFLIGHT. Checked before any work so the 503 is
1155
1155
  // cheap. /_health stays available so the orchestrator can still tell the
1156
1156
  // process is alive (and decide whether to restart or scale out).
1157
- if (inFlight >= MAX_INFLIGHT && url.pathname !== "/_health") {
1157
+ if (inFlight >= MAX_INFLIGHT && url.pathname !== HEALTH_PATH) {
1158
1158
  return new Response("Service Unavailable", {
1159
1159
  status: 503,
1160
1160
  headers: { "Retry-After": "1" },
@@ -1215,7 +1215,7 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
1215
1215
  const path = event.url.pathname;
1216
1216
  let response: Response | null = null;
1217
1217
  let staticTried = false;
1218
- if (!dataReq && staticManifest && (path.startsWith("/dist/") || path.startsWith("/__bosia/"))) {
1218
+ if (!dataReq && staticManifest && isFrameworkPath(path)) {
1219
1219
  response = await serveStaticFile(path, request);
1220
1220
  staticTried = true;
1221
1221
  }
@@ -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
+ }
@@ -29,7 +29,7 @@ This page lives at routes/(public)/blog/[slug]/+page.svelte but is served at /bl
29
29
 
30
30
  The matched value is available as params.slug inside +page.server.ts load() and inside the page component via the params prop.
31
31
 
32
- The route matcher uses 3-pass priority: exact matches first, then dynamic segments, then catch-all routes.`,
32
+ The route matcher compares routes part by part: a fixed name beats a param, which beats a catch-all.`,
33
33
  },
34
34
  };
35
35