bosia 1.2.3 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosia",
3
- "version": "1.2.3",
3
+ "version": "1.2.4",
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
- }
@@ -8,6 +8,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
10
  import { isDynamic, segmentName, segmentRank } from "./matcher.ts";
11
+ import { HEALTH_PATH, isFrameworkPath, isStaticPath } from "./staticPath.ts";
11
12
 
12
13
  /** Acquire an OS-assigned ephemeral port. Tiny TOCTOU race window; acceptable for build-time use. */
13
14
  export function getEphemeralPort(): Promise<number> {
@@ -55,8 +56,10 @@ const PRERENDER_CONCURRENCY = Number(process.env.PRERENDER_CONCURRENCY) || 6;
55
56
 
56
57
  // ─── Prerendering ─────────────────────────────────────────
57
58
 
58
- interface PrerenderTarget {
59
+ export interface PrerenderTarget {
59
60
  path: string;
61
+ /** Route pattern the path came from (`/[slug]`); equals `path` for a static route. */
62
+ route: string;
60
63
  kind: "page" | "api";
61
64
  /** Page targets only; APIs always write a single `.json` file regardless of slash mode. */
62
65
  trailingSlash: TrailingSlash;
@@ -72,7 +75,7 @@ export function substituteParams(pattern: string, entry: Record<string, string>)
72
75
  // Per segment, so an inserted value is never re-read as a placeholder.
73
76
  return pattern
74
77
  .split("/")
75
- .map((seg) => {
78
+ .map((seg, i, segs) => {
76
79
  const rank = segmentRank(seg);
77
80
  if (!rank) return seg;
78
81
  const name = segmentName(seg);
@@ -81,23 +84,28 @@ export function substituteParams(pattern: string, entry: Record<string, string>)
81
84
  // A catch-all may end in one "/" ("guide/intro/"); the out path drops it.
82
85
  const value = rank === 2 ? String(entry[name]).replace(/\/$/, "") : String(entry[name]);
83
86
  const bad = (why: string): never => {
84
- throw new Error(`entries() value "${entry[name]}" for ${seg} is unsafe — ${why}`);
87
+ throw new Error(
88
+ `entries() value ${JSON.stringify(String(entry[name]))} for ${seg} is unsafe — ${why}`,
89
+ );
85
90
  };
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");
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");
101
109
  return value;
102
110
  })
103
111
  .join("/");
@@ -128,6 +136,11 @@ export function prerenderDataPath(routePath: string): string {
128
136
  return routePath === "/" ? "/index.json" : `${routePath.replace(/\/$/, "")}.json`;
129
137
  }
130
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
+
131
144
  /**
132
145
  * Output filename for a prerendered API route. Always emits a single `.json`
133
146
  * file at the route's path (no trailing-slash variants — static hosts serve
@@ -142,6 +155,37 @@ export class PrerenderEntriesError extends Error {
142
155
  override name = "PrerenderEntriesError";
143
156
  }
144
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
+
145
189
  /** Dynamic route → import module, call entries(), expand to concrete targets. */
146
190
  async function expandDynamicRoute(
147
191
  pattern: string,
@@ -154,11 +198,11 @@ async function expandDynamicRoute(
154
198
  if (typeof mod.entries !== "function")
155
199
  throw new Error("prerender = true on a dynamic route needs an entries() export");
156
200
  const entryList: Record<string, string>[] = await mod.entries();
157
- return entryList.map((entry) => ({
158
- path: substituteParams(pattern, entry),
159
- kind,
160
- trailingSlash: ts,
161
- }));
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 }));
162
206
  } catch (err) {
163
207
  // Fail the build: skipping the route would silently drop every page it lists.
164
208
  const why = err instanceof Error ? err.message : String(err);
@@ -166,7 +210,7 @@ async function expandDynamicRoute(
166
210
  }
167
211
  }
168
212
 
169
- async function detectPrerenderRoutes(manifest: RouteManifest): Promise<PrerenderTarget[]> {
213
+ export async function detectPrerenderRoutes(manifest: RouteManifest): Promise<PrerenderTarget[]> {
170
214
  const pageTasks = manifest.pages.map(async (route): Promise<PrerenderTarget[]> => {
171
215
  if (!route.pageServer) return [];
172
216
  const filePath = join("src", "routes", route.pageServer);
@@ -179,7 +223,7 @@ async function detectPrerenderRoutes(manifest: RouteManifest): Promise<Prerender
179
223
  }
180
224
  const ts = route.trailingSlash;
181
225
  if (isDynamic(route.pattern)) return expandDynamicRoute(route.pattern, filePath, "page", ts);
182
- return [{ path: route.pattern, kind: "page", trailingSlash: ts }];
226
+ return [{ path: route.pattern, route: route.pattern, kind: "page", trailingSlash: ts }];
183
227
  });
184
228
 
185
229
  const apiTasks = manifest.apis.map(async (route): Promise<PrerenderTarget[]> => {
@@ -188,14 +232,63 @@ async function detectPrerenderRoutes(manifest: RouteManifest): Promise<Prerender
188
232
  if (!wantsPrerender(content)) return [];
189
233
  if (isDynamic(route.pattern))
190
234
  return expandDynamicRoute(route.pattern, filePath, "api", "never");
191
- return [{ path: route.pattern, kind: "api", trailingSlash: "never" }];
235
+ return [{ path: route.pattern, route: route.pattern, kind: "api", trailingSlash: "never" }];
192
236
  });
193
237
 
194
- // 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
195
239
  // and route metadata, so RAM stays trivial. Chunk it if route counts ever hit
196
240
  // tens of thousands (open file descriptors would be the first limit).
197
- const results = await Promise.all([...pageTasks, ...apiTasks]);
198
- 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;
199
292
  }
200
293
 
201
294
  export async function prerenderStaticRoutes(
@@ -322,7 +415,7 @@ export async function prerenderStaticRoutes(
322
415
  });
323
416
  if (dataRes.ok) {
324
417
  const dataJson = await dataRes.text();
325
- const dataOutPath = `${OUT_DIR}/prerendered/__bosia/data${dataPath}`;
418
+ const dataOutPath = prerenderDataOutPath(routePath);
326
419
  mkdirSync(dataOutPath.substring(0, dataOutPath.lastIndexOf("/")), {
327
420
  recursive: true,
328
421
  });
@@ -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
  }
@@ -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
+ }