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 +1 -1
- package/src/core/build.ts +1 -1
- package/src/core/html.ts +0 -27
- package/src/core/prerender.ts +452 -100
- package/src/core/routeTypes.ts +3 -1
- package/src/core/scanner.ts +10 -14
- package/src/core/server.ts +25 -23
- 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
|
@@ -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
|
-
|
|
54
|
-
const
|
|
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(
|
|
99
|
+
throw new Error(
|
|
100
|
+
`entries() value ${JSON.stringify(String(entry[name]))} for ${seg} is unsafe — ${why}`,
|
|
101
|
+
);
|
|
85
102
|
};
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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.
|
|
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.
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
//
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
690
|
+
// A server that died is already reported, and the probe would only fail too.
|
|
691
|
+
if (target !== "workers" && !died)
|
|
365
692
|
try {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
372
|
-
|
|
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
|
-
|
|
725
|
+
// Listed with the page failures, so one build shows every problem.
|
|
380
726
|
if (require404)
|
|
381
|
-
|
|
382
|
-
|
|
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)
|
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
|
}
|
|
@@ -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 !==
|
|
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 !==
|
|
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
|
|
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
|
+
}
|