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 +1 -1
- package/src/core/build.ts +1 -1
- package/src/core/html.ts +0 -27
- package/src/core/prerender.ts +123 -30
- package/src/core/routeTypes.ts +3 -1
- package/src/core/scanner.ts +10 -14
- package/src/core/server.ts +6 -6
- package/src/core/staticPath.ts +35 -0
package/package.json
CHANGED
package/src/core/build.ts
CHANGED
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
|
-
}
|
package/src/core/prerender.ts
CHANGED
|
@@ -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(
|
|
87
|
+
throw new Error(
|
|
88
|
+
`entries() value ${JSON.stringify(String(entry[name]))} for ${seg} is unsafe — ${why}`,
|
|
89
|
+
);
|
|
85
90
|
};
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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.
|
|
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.
|
|
198
|
-
|
|
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 =
|
|
418
|
+
const dataOutPath = prerenderDataOutPath(routePath);
|
|
326
419
|
mkdirSync(dataOutPath.substring(0, dataOutPath.lastIndexOf("/")), {
|
|
327
420
|
recursive: true,
|
|
328
421
|
});
|
package/src/core/routeTypes.ts
CHANGED
|
@@ -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) => `${
|
|
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';`);
|
package/src/core/scanner.ts
CHANGED
|
@@ -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]
|
|
282
|
-
if (/^\[
|
|
283
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
307
|
-
|
|
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. */
|
package/src/core/server.ts
CHANGED
|
@@ -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 ===
|
|
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
|
|
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 !==
|
|
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 !==
|
|
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
|
|
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
|
+
}
|