bosia 1.2.4 → 1.2.6
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/README.md +5 -6
- package/package.json +2 -1
- package/src/core/backend.ts +56 -2
- package/src/core/cache.ts +378 -30
- package/src/core/client/App.svelte +17 -9
- package/src/core/client/prefetch.ts +57 -24
- package/src/core/cors.ts +17 -1
- package/src/core/dedup.ts +51 -7
- package/src/core/env.ts +3 -0
- package/src/core/hooks.ts +1 -1
- package/src/core/html.ts +13 -1
- package/src/core/plugins/access-log.ts +128 -0
- package/src/core/prerender.ts +475 -79
- package/src/core/renderer.ts +69 -28
- package/src/core/server.ts +95 -53
- package/src/core/server.workers.ts +1 -1
- package/src/core/types/plugin.ts +2 -1
- package/templates/default/.env.example +20 -5
- package/templates/default/tsconfig.json +1 -1
- package/templates/demo/.env.example +20 -5
- package/templates/demo/src/hooks.server.ts +3 -12
- package/templates/demo/tsconfig.json +1 -1
- package/templates/shop/src/app.d.ts +0 -1
- package/templates/shop/src/hooks.server.ts +3 -11
- package/templates/shop/src/routes/+layout.server.ts +0 -1
- package/templates/shop/tsconfig.json +1 -1
- package/templates/store/src/app.d.ts +0 -1
- package/templates/store/src/hooks.server.ts +3 -11
- package/templates/store/src/routes/+layout.server.ts +0 -1
- package/templates/store/tsconfig.json +1 -1
package/src/core/prerender.ts
CHANGED
|
@@ -7,7 +7,8 @@ import type { RuntimeTarget } from "./types/plugin.ts";
|
|
|
7
7
|
import { BOSIA_NODE_PATH, OUT_DIR } from "./paths.ts";
|
|
8
8
|
import { currentBase } from "./appBase.ts";
|
|
9
9
|
import { prerenderSkipReason, wantsPrerender } from "./scanner.ts";
|
|
10
|
-
import { isDynamic, segmentName, segmentRank } from "./matcher.ts";
|
|
10
|
+
import { findMatch, isDynamic, segmentName, segmentRank } from "./matcher.ts";
|
|
11
|
+
import { sortRoutes } from "./routeFile.ts";
|
|
11
12
|
import { HEALTH_PATH, isFrameworkPath, isStaticPath } from "./staticPath.ts";
|
|
12
13
|
|
|
13
14
|
/** Acquire an OS-assigned ephemeral port. Tiny TOCTOU race window; acceptable for build-time use. */
|
|
@@ -51,8 +52,19 @@ export function needsStatic404(cwd = process.cwd()): boolean {
|
|
|
51
52
|
return false;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
|
|
55
|
-
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);
|
|
56
68
|
|
|
57
69
|
// ─── Prerendering ─────────────────────────────────────────
|
|
58
70
|
|
|
@@ -150,6 +162,105 @@ export function prerenderApiOutPath(routePath: string): string {
|
|
|
150
162
|
return `${OUT_DIR}/prerendered${routePath.replace(/\/$/, "")}.json`;
|
|
151
163
|
}
|
|
152
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
|
+
|
|
153
264
|
/** A dynamic prerender route whose entries() can't be used. build.ts prints `message` and exits. */
|
|
154
265
|
export class PrerenderEntriesError extends Error {
|
|
155
266
|
override name = "PrerenderEntriesError";
|
|
@@ -173,11 +284,14 @@ export function checkPrerenderPaths(
|
|
|
173
284
|
const lower = p.toLowerCase();
|
|
174
285
|
if (isFrameworkPath(kind === "page" ? `${lower}/` : lower))
|
|
175
286
|
throw new Error(`entries() gives ${p}; dist and __bosia are the framework's folders`);
|
|
176
|
-
// The server answers `/_health
|
|
177
|
-
// so
|
|
178
|
-
|
|
179
|
-
//
|
|
180
|
-
|
|
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))
|
|
181
295
|
throw new Error(`entries() gives ${p}, a URL the server answers itself`);
|
|
182
296
|
// generateStaticSite copies prerendered/ over static/, where the 404 probe saves
|
|
183
297
|
// 404.html. A static src/routes/404 page may replace it on purpose; a slug may not.
|
|
@@ -249,11 +363,181 @@ export async function detectPrerenderRoutes(manifest: RouteManifest): Promise<Pr
|
|
|
249
363
|
cause: failed,
|
|
250
364
|
});
|
|
251
365
|
const targets = results.flatMap((r) => (r.status === "fulfilled" ? r.value : []));
|
|
252
|
-
const
|
|
253
|
-
|
|
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"));
|
|
254
371
|
return targets;
|
|
255
372
|
}
|
|
256
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
|
+
* Checked ignoring case too: on a macOS/Windows host `blog/New.html` answers `/blog/new`.
|
|
384
|
+
*/
|
|
385
|
+
export function findMisownedTargets(
|
|
386
|
+
manifest: Pick<RouteManifest, "pages" | "apis">,
|
|
387
|
+
targets: PrerenderTarget[],
|
|
388
|
+
): Map<PrerenderTarget, string> {
|
|
389
|
+
// Copies: findMatch caches a compiled regex on each route object it sees.
|
|
390
|
+
const pages = sortRoutes(manifest.pages.map((r) => ({ pattern: r.pattern })));
|
|
391
|
+
const apis = sortRoutes(manifest.apis.map((r) => ({ pattern: r.pattern })));
|
|
392
|
+
// The server's own matcher decides who serves the URL exactly.
|
|
393
|
+
const ownerOf = (t: PrerenderTarget) => {
|
|
394
|
+
const api = findMatch(apis, t.path)?.route.pattern;
|
|
395
|
+
if (t.kind === "api") return api;
|
|
396
|
+
// A page loses to any API at its URL, even a +server.ts in its own folder.
|
|
397
|
+
return api ? `${api} (+server.ts)` : findMatch(pages, t.path)?.route.pattern;
|
|
398
|
+
};
|
|
399
|
+
// Split once, in match order, for the case pass: every API first, then pages. An API
|
|
400
|
+
// target meets pages too: a live `/API/ALL` page is hidden by a saved `api/all` file.
|
|
401
|
+
const shape = (pattern: string, api: boolean): RouteShape => {
|
|
402
|
+
const parts = pattern.split("/").filter(Boolean);
|
|
403
|
+
const ranks = parts.map(segmentRank);
|
|
404
|
+
const lower = parts.map((p) => p.toLowerCase());
|
|
405
|
+
return { pattern, api, parts, lower, ranks, rest: ranks.at(-1) === 2 };
|
|
406
|
+
};
|
|
407
|
+
const order = [
|
|
408
|
+
...apis.map((r) => shape(r.pattern, true)),
|
|
409
|
+
...pages.map((r) => shape(r.pattern, false)),
|
|
410
|
+
];
|
|
411
|
+
const out = new Map<PrerenderTarget, string>();
|
|
412
|
+
for (const t of targets) {
|
|
413
|
+
const owner = ownerOf(t);
|
|
414
|
+
if (owner && owner !== t.route) {
|
|
415
|
+
out.set(t, `❌ Prerender: ${targetLabel(t)} is served by ${owner}, not this route`);
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
// No route answers the URL (a hand-built table): nothing to measure a case clash by.
|
|
419
|
+
if (!owner) continue;
|
|
420
|
+
const problem = caseProblem(t, order);
|
|
421
|
+
if (problem) out.set(t, problem);
|
|
422
|
+
}
|
|
423
|
+
return out;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
interface RouteShape {
|
|
427
|
+
pattern: string;
|
|
428
|
+
api: boolean;
|
|
429
|
+
parts: string[];
|
|
430
|
+
lower: string[];
|
|
431
|
+
ranks: (0 | 1 | 2)[];
|
|
432
|
+
/** Ends in `[...rest]`, so it takes paths longer than its parts. */
|
|
433
|
+
rest: boolean;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* This route serves the URL exactly (findMatch said so). A route matching only ignoring
|
|
438
|
+
* case (a static part spelled `NEW` for `new`) then clashes if it wins some spelling: it
|
|
439
|
+
* ranks above this route, or the spelling differs at one of this route's static parts so
|
|
440
|
+
* this route can't answer it. On a host that ignores case the saved file answers that
|
|
441
|
+
* spelling. Overlaps through params alone (`/[...rest]` catching `/BLOG/new`) are allowed,
|
|
442
|
+
* or every catch-all next to a prerendered page would fail. A route ranked higher still
|
|
443
|
+
* that takes the very same spellings isn't looked for; the check may then name a route
|
|
444
|
+
* that never answers.
|
|
445
|
+
*/
|
|
446
|
+
function caseProblem(t: PrerenderTarget, order: RouteShape[]): string | null {
|
|
447
|
+
const path = t.path.split("/").filter(Boolean);
|
|
448
|
+
const pathLower = path.map((p) => p.toLowerCase());
|
|
449
|
+
let own: PartMatch[] | null = null;
|
|
450
|
+
let hit: { r: RouteShape; kinds: PartMatch[]; above: boolean } | null = null;
|
|
451
|
+
for (const r of order) {
|
|
452
|
+
// Cheap filter first: most routes can't match on part count alone.
|
|
453
|
+
if (r.rest ? path.length < r.parts.length : path.length !== r.parts.length) continue;
|
|
454
|
+
const kinds = matchPartsFolded(r.parts, r.lower, r.ranks, path, pathLower);
|
|
455
|
+
if (!kinds) continue;
|
|
456
|
+
if (r.pattern === t.route && r.api === (t.kind === "api")) {
|
|
457
|
+
own = kinds;
|
|
458
|
+
if (hit) break; // a route above already wins a spelling
|
|
459
|
+
} else if (!kinds.includes("case")) continue;
|
|
460
|
+
else if (!own) hit ??= { r, kinds, above: true };
|
|
461
|
+
else if (escapesOwn(kinds, own, path)) {
|
|
462
|
+
hit = { r, kinds, above: false };
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
// findMatch said this route serves the URL, so the walk must find it too; if not, the two
|
|
467
|
+
// matchers have drifted apart and every case clash would pass unseen.
|
|
468
|
+
if (!own)
|
|
469
|
+
throw new Error(
|
|
470
|
+
`Bosia internal: the prerender case check can't match ${t.path} to ${t.route}; please report it`,
|
|
471
|
+
);
|
|
472
|
+
if (!hit) return null;
|
|
473
|
+
const { r, kinds, above } = hit;
|
|
474
|
+
// The spelling it wins: its own static parts. Ranked below, every param under this
|
|
475
|
+
// route's static part changes case too, which also steers clear of routes in between
|
|
476
|
+
// (`/BLOG/NEW`, not `/blog/NEW` that `/blog/[slug]` takes).
|
|
477
|
+
const spelling = kinds.map((k, j) => {
|
|
478
|
+
if (k !== "param") return r.parts[j]!;
|
|
479
|
+
return (!above && own[j] === "same" && otherCase(path[j]!)) || path[j]!;
|
|
480
|
+
});
|
|
481
|
+
const name = r.api && t.kind === "page" ? `${r.pattern} (+server.ts)` : r.pattern;
|
|
482
|
+
return `❌ Prerender: ${targetLabel(t)} would also answer /${spelling.join("/")} on hosts that ignore case (macOS/Windows), which ${name} serves`;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** How one part of a URL meets a route part: `same` static, `case` static spelled in
|
|
486
|
+
* another case, `param`. A match with no `case` is an exact match. */
|
|
487
|
+
type PartMatch = "same" | "case" | "param";
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Match a route's parts against a URL's parts ignoring case, by compilePattern's rules: only
|
|
491
|
+
* a last `[...rest]` takes several parts, any other bracket part is one param. `null` when
|
|
492
|
+
* they don't match. `lower`/`pathLower` are the same parts in `toLowerCase`, made once by the
|
|
493
|
+
* caller. ASCII case only in effect: Unicode forms and special folds (`ς`/`σ`) aren't merged.
|
|
494
|
+
* Keep in step with matcher.ts compilePattern.
|
|
495
|
+
*/
|
|
496
|
+
function matchPartsFolded(
|
|
497
|
+
parts: string[],
|
|
498
|
+
lower: string[],
|
|
499
|
+
ranks: (0 | 1 | 2)[],
|
|
500
|
+
path: string[],
|
|
501
|
+
pathLower: string[],
|
|
502
|
+
): PartMatch[] | null {
|
|
503
|
+
const kinds: PartMatch[] = [];
|
|
504
|
+
for (const [i, seg] of parts.entries()) {
|
|
505
|
+
if (ranks[i] === 2 && i === parts.length - 1) {
|
|
506
|
+
if (path.length <= i) return null;
|
|
507
|
+
for (let j = i; j < path.length; j++) kinds.push("param");
|
|
508
|
+
return kinds;
|
|
509
|
+
}
|
|
510
|
+
const part = path[i];
|
|
511
|
+
if (part === undefined) return null;
|
|
512
|
+
if (ranks[i]) kinds.push("param");
|
|
513
|
+
else if (part === seg) kinds.push("same");
|
|
514
|
+
else if (pathLower[i] === lower[i]) kinds.push("case");
|
|
515
|
+
else return null;
|
|
516
|
+
}
|
|
517
|
+
return kinds.length === path.length ? kinds : null;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Some part where this route is static has a spelling the other route takes and this one doesn't. */
|
|
521
|
+
function escapesOwn(kinds: PartMatch[], own: PartMatch[], path: string[]): boolean {
|
|
522
|
+
return kinds.some(
|
|
523
|
+
(k, j) => own[j] === "same" && (k === "case" || (k === "param" && otherCase(path[j]!))),
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* `s` with every letter in the other case, keeping only swaps that lowercase back to the same
|
|
529
|
+
* letter (`ß` → `SS` and `İ` → `i̇` change length, so they stay). `null` if nothing swaps.
|
|
530
|
+
*/
|
|
531
|
+
function otherCase(s: string): string | null {
|
|
532
|
+
const swapped = [...s]
|
|
533
|
+
.map((c) => {
|
|
534
|
+
const o = c === c.toUpperCase() ? c.toLowerCase() : c.toUpperCase();
|
|
535
|
+
return o.length === c.length && o.toLowerCase() === c.toLowerCase() ? o : c;
|
|
536
|
+
})
|
|
537
|
+
.join("");
|
|
538
|
+
return swapped === s ? null : swapped;
|
|
539
|
+
}
|
|
540
|
+
|
|
257
541
|
/**
|
|
258
542
|
* Files two targets would both write. macOS/Windows ignore case, so `About` and `about`
|
|
259
543
|
* (or folders `A/` and `a/`) merge; a file and a folder can't share a path either.
|
|
@@ -264,7 +548,7 @@ export function findPrerenderClash(targets: PrerenderTarget[]): string[] {
|
|
|
264
548
|
const clashes: string[] = [];
|
|
265
549
|
for (const t of targets) {
|
|
266
550
|
// Name the route a slug came from, so the message says which file to fix.
|
|
267
|
-
const who =
|
|
551
|
+
const who = targetLabel(t);
|
|
268
552
|
let clash = "";
|
|
269
553
|
const files =
|
|
270
554
|
t.kind === "api"
|
|
@@ -295,6 +579,24 @@ export async function prerenderStaticRoutes(
|
|
|
295
579
|
manifest: RouteManifest,
|
|
296
580
|
target: RuntimeTarget = "bun",
|
|
297
581
|
): Promise<void> {
|
|
582
|
+
// A bad value would leave every page waiting for a request slot, or make every fetch throw.
|
|
583
|
+
const bad = (
|
|
584
|
+
[
|
|
585
|
+
["PRERENDER_TIMEOUT", PRERENDER_TIMEOUT],
|
|
586
|
+
["PRERENDER_CONCURRENCY", PRERENDER_CONCURRENCY],
|
|
587
|
+
] as const
|
|
588
|
+
).filter(([, value]) => value === null);
|
|
589
|
+
if (bad.length)
|
|
590
|
+
throw new PrerenderEntriesError(
|
|
591
|
+
bad
|
|
592
|
+
.map(
|
|
593
|
+
([name]) =>
|
|
594
|
+
`❌ ${name} must be a whole number from 1 to ${SETTING_MAX} (got "${process.env[name]}")`,
|
|
595
|
+
)
|
|
596
|
+
.join("\n"),
|
|
597
|
+
);
|
|
598
|
+
const timeout = PRERENDER_TIMEOUT!;
|
|
599
|
+
const concurrency = PRERENDER_CONCURRENCY!;
|
|
298
600
|
const targets = await detectPrerenderRoutes(manifest);
|
|
299
601
|
// On Workers the worker answers unknown paths, so 404.html is never required.
|
|
300
602
|
const require404 = target !== "workers" && needsStatic404();
|
|
@@ -319,6 +621,16 @@ export async function prerenderStaticRoutes(
|
|
|
319
621
|
...env,
|
|
320
622
|
NODE_ENV: "production",
|
|
321
623
|
PORT: String(port),
|
|
624
|
+
// Live-traffic limits from .env, set rather than removed (Bun would load them back).
|
|
625
|
+
// MAX_INFLIGHT would 503 the build's own requests (a page and its data,
|
|
626
|
+
// PRERENDER_CONCURRENCY at once); IDLE_TIMEOUT (0 = off) and LOAD_TIMEOUT would cut a
|
|
627
|
+
// loader that PRERENDER_TIMEOUT allows, and METADATA_TIMEOUT would save the page without
|
|
628
|
+
// its metadata (a timeout there is swallowed). All off, so PRERENDER_TIMEOUT alone rules
|
|
629
|
+
// the build: an equal server limit would race it, and its 500 would hide the hint.
|
|
630
|
+
MAX_INFLIGHT: "infinity",
|
|
631
|
+
IDLE_TIMEOUT: "0",
|
|
632
|
+
LOAD_TIMEOUT: "Infinity",
|
|
633
|
+
METADATA_TIMEOUT: "Infinity",
|
|
322
634
|
NODE_PATH: BOSIA_NODE_PATH,
|
|
323
635
|
},
|
|
324
636
|
stdout: "ignore",
|
|
@@ -329,6 +641,9 @@ export async function prerenderStaticRoutes(
|
|
|
329
641
|
// watch `exited` instead.
|
|
330
642
|
let exited = false;
|
|
331
643
|
child.exited.then(() => (exited = true));
|
|
644
|
+
const exitsWithin = (ms: number) =>
|
|
645
|
+
Promise.race([child.exited.then(() => true), Bun.sleep(ms).then(() => false)]);
|
|
646
|
+
const stopReason = () => child.signalCode ?? `exit code ${child.exitCode}`;
|
|
332
647
|
|
|
333
648
|
const signals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"];
|
|
334
649
|
let receivedSignal: NodeJS.Signals | null = null;
|
|
@@ -348,7 +663,10 @@ export async function prerenderStaticRoutes(
|
|
|
348
663
|
const deadline = Date.now() + 10_000;
|
|
349
664
|
while (Date.now() < deadline && !exited) {
|
|
350
665
|
try {
|
|
351
|
-
|
|
666
|
+
// Answered after the hooks, so a stuck hook would hang here past the deadline.
|
|
667
|
+
const res = await fetch(`${base}${HEALTH_PATH}`, {
|
|
668
|
+
signal: AbortSignal.timeout(Math.max(1, deadline - Date.now())),
|
|
669
|
+
});
|
|
352
670
|
if (res.ok) {
|
|
353
671
|
ready = true;
|
|
354
672
|
break;
|
|
@@ -360,30 +678,82 @@ export async function prerenderStaticRoutes(
|
|
|
360
678
|
}
|
|
361
679
|
|
|
362
680
|
if (!ready || exited) {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
681
|
+
// Alive but never healthy (slow hooks init, a bad BASE_PATH) is not a crash.
|
|
682
|
+
const why = exited ? stopReason() : "not ready within 10s";
|
|
683
|
+
// Returning here would ship a build with every prerendered page missing.
|
|
684
|
+
throw new PrerenderEntriesError(
|
|
685
|
+
`❌ Prerender server failed to start (${why})` +
|
|
686
|
+
(require404
|
|
687
|
+
? `, so 404.html could not be built, but wrangler sets not_found_handling ` +
|
|
688
|
+
`"404-page". Fix the server, or add public/404.html.`
|
|
689
|
+
: ""),
|
|
690
|
+
);
|
|
371
691
|
}
|
|
372
692
|
|
|
373
693
|
mkdirSync(`${OUT_DIR}/prerendered`, { recursive: true });
|
|
374
694
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
695
|
+
// Every target that can't be saved; the build fails with all of them once the pool drains.
|
|
696
|
+
const failures: string[] = [];
|
|
697
|
+
// Pages whose connection broke (a reset, a refused connect), with their ❌ line. If the
|
|
698
|
+
// server turns out to have died (a loader that exits or runs out of memory), one line
|
|
699
|
+
// names the exit instead. A timeout isn't one: a dead server resets its connections, it
|
|
700
|
+
// doesn't leave them hanging, so a timeout keeps its own line and hint.
|
|
701
|
+
const connLost: { route: string; line: string }[] = [];
|
|
702
|
+
class ConnLost extends ReplyProblem {}
|
|
703
|
+
// A timeout can mean a loader stuck the server, which may die next: counted, so the
|
|
704
|
+
// check after the pages waits for that too.
|
|
705
|
+
class TimedOut extends ReplyProblem {}
|
|
706
|
+
let timedOut = 0;
|
|
707
|
+
// PRERENDER_CONCURRENCY counts requests, not pages: a page's HTML and data each run its
|
|
708
|
+
// loaders, so N slots keep N loader runs at once (a cap users set for rate-limited APIs).
|
|
709
|
+
let free = concurrency;
|
|
710
|
+
const waiting: (() => void)[] = [];
|
|
711
|
+
const acquire = async () => {
|
|
712
|
+
if (free > 0) free--;
|
|
713
|
+
else await new Promise<void>((resolve) => waiting.push(resolve));
|
|
714
|
+
};
|
|
715
|
+
const release = () => {
|
|
716
|
+
const nextInLine = waiting.shift();
|
|
717
|
+
if (nextInLine) nextInLine();
|
|
718
|
+
else free++;
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
const prerenderOne = async (t: PrerenderTarget): Promise<void> => {
|
|
722
|
+
const { path: routePath, kind, trailingSlash: ts } = t;
|
|
723
|
+
// `manual`: a redirect is reported, not followed into another page that gets saved.
|
|
724
|
+
// A new timeout per fetch: a shared one would give the page and its data (two
|
|
725
|
+
// loader runs) one PRERENDER_TIMEOUT between them.
|
|
726
|
+
// `stop` ends a page's data fetch once the page itself has failed.
|
|
727
|
+
const stop = new AbortController();
|
|
728
|
+
const line = (why: string) => `❌ Prerender: ${targetLabel(t)} ${why}`;
|
|
729
|
+
const fail = (why: string) => failures.push(line(why));
|
|
730
|
+
// Fetches `url` and reads its body. Any failure (a bad reply, a timeout, a reset) is
|
|
731
|
+
// thrown as a ReplyProblem carrying `label`, so a data fetch's says "data".
|
|
732
|
+
// The timeout starts once a slot is free, so time spent queued isn't counted.
|
|
733
|
+
const fetchText = async (url: string, as: "page" | "api", label = "") => {
|
|
734
|
+
await acquire();
|
|
735
|
+
try {
|
|
736
|
+
stop.signal.throwIfAborted();
|
|
737
|
+
const res = await fetch(url, {
|
|
738
|
+
redirect: "manual",
|
|
739
|
+
signal: AbortSignal.any([AbortSignal.timeout(timeout), stop.signal]),
|
|
740
|
+
});
|
|
741
|
+
const problem = prerenderReplyProblem(as, res);
|
|
742
|
+
if (!problem) return await res.text();
|
|
743
|
+
await dropBody(res);
|
|
744
|
+
throw new ReplyProblem(label + problem);
|
|
745
|
+
} catch (err) {
|
|
746
|
+
if (err instanceof ReplyProblem) throw err;
|
|
747
|
+
const why = label + fetchFailure(err, timeout);
|
|
748
|
+
throw isTimeout(err) ? new TimedOut(why) : new ConnLost(why);
|
|
749
|
+
} finally {
|
|
750
|
+
release();
|
|
751
|
+
}
|
|
752
|
+
};
|
|
380
753
|
try {
|
|
381
754
|
if (kind === "api") {
|
|
382
755
|
// APIs: fetch the bare route URL, write body to `<path>.json`.
|
|
383
|
-
const
|
|
384
|
-
signal: AbortSignal.timeout(PRERENDER_TIMEOUT),
|
|
385
|
-
});
|
|
386
|
-
const body = await res.text();
|
|
756
|
+
const body = await fetchText(`${base}${routePath.replace(/\/$/, "")}`, kind);
|
|
387
757
|
const outPath = prerenderApiOutPath(routePath);
|
|
388
758
|
mkdirSync(outPath.substring(0, outPath.lastIndexOf("/")), { recursive: true });
|
|
389
759
|
writeFileSync(outPath, body);
|
|
@@ -394,10 +764,22 @@ export async function prerenderStaticRoutes(
|
|
|
394
764
|
// Hit the canonical URL so the server doesn't 308 us mid-prerender
|
|
395
765
|
const canonicalRoute = canonicalRouteFor(routePath, ts);
|
|
396
766
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
767
|
+
// The data payload (filename matches dataUrl() — strips trailing slash). Client
|
|
768
|
+
// navigation on a static host fetches it, so a page saved without it is broken:
|
|
769
|
+
// both are checked before either is written. Fetched side by side, each with its
|
|
770
|
+
// own timeout. One ❌ per page, the page's own problem first: a failed page stops
|
|
771
|
+
// its data fetch, and a failed data fetch still waits for the page.
|
|
772
|
+
const dataPath = prerenderDataPath(routePath);
|
|
773
|
+
const htmlP = fetchText(`${base}${canonicalRoute}`, kind);
|
|
774
|
+
htmlP.catch(() => stop.abort());
|
|
775
|
+
const dataP = fetchText(`${base}/__bosia/data${dataPath}`, "api", "data ");
|
|
776
|
+
const [htmlGot, dataGot] = await Promise.allSettled([htmlP, dataP]);
|
|
777
|
+
if (htmlGot.status === "rejected") throw htmlGot.reason;
|
|
778
|
+
if (dataGot.status === "rejected") throw dataGot.reason;
|
|
779
|
+
const html = htmlGot.value;
|
|
780
|
+
const dataJson = dataGot.value;
|
|
781
|
+
const payloadProblem = prerenderDataProblem(dataJson);
|
|
782
|
+
if (payloadProblem) return void fail(`data ${payloadProblem}`);
|
|
401
783
|
|
|
402
784
|
// Filename strategy:
|
|
403
785
|
// never → about.html (canonical /about, served by static host as /about → about.html)
|
|
@@ -405,47 +787,35 @@ export async function prerenderStaticRoutes(
|
|
|
405
787
|
// ignore → about/index.html (single emit; both URLs resolve via server canonicalize=off)
|
|
406
788
|
// root → index.html
|
|
407
789
|
const outPath = prerenderOutPath(routePath, ts);
|
|
790
|
+
|
|
408
791
|
mkdirSync(outPath.substring(0, outPath.lastIndexOf("/")), { recursive: true });
|
|
409
792
|
writeFileSync(outPath, html);
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
signal: AbortSignal.timeout(PRERENDER_TIMEOUT),
|
|
415
|
-
});
|
|
416
|
-
if (dataRes.ok) {
|
|
417
|
-
const dataJson = await dataRes.text();
|
|
418
|
-
const dataOutPath = prerenderDataOutPath(routePath);
|
|
419
|
-
mkdirSync(dataOutPath.substring(0, dataOutPath.lastIndexOf("/")), {
|
|
420
|
-
recursive: true,
|
|
421
|
-
});
|
|
422
|
-
writeFileSync(dataOutPath, dataJson);
|
|
423
|
-
console.log(` ✅ ${routePath} → ${outPath} (+ data)`);
|
|
424
|
-
} else {
|
|
425
|
-
console.log(` ✅ ${routePath} → ${outPath}`);
|
|
426
|
-
}
|
|
793
|
+
const dataOutPath = prerenderDataOutPath(routePath);
|
|
794
|
+
mkdirSync(dataOutPath.substring(0, dataOutPath.lastIndexOf("/")), { recursive: true });
|
|
795
|
+
writeFileSync(dataOutPath, dataJson);
|
|
796
|
+
console.log(` ✅ ${routePath} → ${outPath} (+ data)`);
|
|
427
797
|
} catch (err) {
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
} else {
|
|
433
|
-
console.error(` ❌ Failed to prerender ${routePath}:`, err);
|
|
434
|
-
}
|
|
798
|
+
const why = err instanceof ReplyProblem ? err.message : fetchFailure(err, timeout);
|
|
799
|
+
if (err instanceof TimedOut) timedOut++;
|
|
800
|
+
if (err instanceof ConnLost) connLost.push({ route: routePath, line: line(why) });
|
|
801
|
+
else fail(why);
|
|
435
802
|
}
|
|
436
803
|
};
|
|
437
804
|
|
|
438
|
-
//
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
805
|
+
// Every page starts at once; the request slots above are the only limit, and they're
|
|
806
|
+
// handed out in target order (a page's HTML, then its data).
|
|
807
|
+
await Promise.all(targets.map(prerenderOne));
|
|
808
|
+
// Checked once, after the pages, and only when a page broke or timed out: the exit can
|
|
809
|
+
// land well after the errors it causes, so a dead server gets up to 1s to show it. A
|
|
810
|
+
// server that dies with every page saved lost nothing; the 404 probe says so if it hits it.
|
|
811
|
+
const died = connLost.length + timedOut > 0 && (await exitsWithin(1000));
|
|
812
|
+
if (died) {
|
|
813
|
+
// An example, not the culprit: a crash breaks every page in flight at once.
|
|
814
|
+
const pages = connLost.length
|
|
815
|
+
? `; ${connLost.length} page(s) not built, e.g. ${connLost[0]!.route}`
|
|
816
|
+
: "";
|
|
817
|
+
failures.push(`❌ The prerender server stopped (${stopReason()})${pages}`);
|
|
818
|
+
} else failures.push(...connLost.map((f) => f.line));
|
|
449
819
|
|
|
450
820
|
// Static hosts (Cloudflare assets) serve index.html with a 200 for unknown
|
|
451
821
|
// paths unless a 404.html exists. Save the server's own 404 page while it's up.
|
|
@@ -454,34 +824,60 @@ export async function prerenderStaticRoutes(
|
|
|
454
824
|
// Skipped on Workers: the worker answers unknown paths, and an uploaded file
|
|
455
825
|
// would only add that soft 404. A public/404.html overwrites it.
|
|
456
826
|
// One probe, one locale: add per-locale probe paths (e.g. /id/404.html) when asked.
|
|
457
|
-
|
|
827
|
+
// A server that died is already reported, and the probe would only fail too.
|
|
828
|
+
if (target !== "workers" && !died)
|
|
458
829
|
try {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
830
|
+
// Follows only its own slash form, unlike the pages: a catch-all `always` page 308s
|
|
831
|
+
// the probe to `/__bosia-404-probe/`, and its 404 there is the page a static host
|
|
832
|
+
// should show. Any other redirect (a hook sending it to /login, or off the build
|
|
833
|
+
// server) is reported with where it goes, not fetched.
|
|
834
|
+
const probe = (url: string) =>
|
|
835
|
+
fetch(url, { redirect: "manual", signal: AbortSignal.timeout(timeout) });
|
|
836
|
+
const probeUrl = `${base}/__bosia-404-probe`;
|
|
837
|
+
let res = await probe(probeUrl);
|
|
838
|
+
const location = res.status >= 300 && res.status < 400 && res.headers.get("location");
|
|
839
|
+
if (location && new URL(location, probeUrl).href === `${probeUrl}/`) {
|
|
840
|
+
await dropBody(res);
|
|
841
|
+
res = await probe(`${probeUrl}/`);
|
|
842
|
+
}
|
|
463
843
|
// No +error.svelte (or it crashed) → plain-text "Not Found", not a page.
|
|
464
|
-
|
|
465
|
-
|
|
844
|
+
const problem = prerenderReplyProblem("page", res, 404);
|
|
845
|
+
if (problem) {
|
|
846
|
+
await dropBody(res);
|
|
847
|
+
throw new ReplyProblem(`an unknown URL ${problem}`);
|
|
848
|
+
}
|
|
466
849
|
mkdirSync(`${OUT_DIR}/static`, { recursive: true });
|
|
467
850
|
writeFileSync(`${OUT_DIR}/static/404.html`, await res.text());
|
|
468
851
|
console.log(` ✅ 404 page → ${OUT_DIR}/static/404.html`);
|
|
469
852
|
} catch (err) {
|
|
853
|
+
// A broken connection (not a timeout, as with pages) may mean the server died.
|
|
854
|
+
const gone = !(err instanceof ReplyProblem) && !isTimeout(err) && (await exitsWithin(1000));
|
|
855
|
+
const reason = gone
|
|
856
|
+
? `the prerender server stopped (${stopReason()})`
|
|
857
|
+
: err instanceof ReplyProblem
|
|
858
|
+
? err.message
|
|
859
|
+
: fetchFailure(err, timeout);
|
|
470
860
|
// Only a deploy that serves 404.html needs it; elsewhere (an app whose
|
|
471
861
|
// hooks redirect every request) a missing one is fine, so just warn.
|
|
472
|
-
|
|
862
|
+
// Listed with the page failures, so one build shows every problem.
|
|
473
863
|
if (require404)
|
|
474
|
-
|
|
475
|
-
|
|
864
|
+
failures.push(
|
|
865
|
+
`❌ 404.html could not be built (${reason}), but wrangler sets ` +
|
|
476
866
|
`not_found_handling "404-page". Make unknown paths answer 404, or add public/404.html.`,
|
|
477
867
|
);
|
|
478
|
-
console.warn(` ⚠️ 404.html skipped: ${reason}`);
|
|
868
|
+
else console.warn(` ⚠️ 404.html skipped: ${reason}`);
|
|
479
869
|
}
|
|
480
870
|
|
|
871
|
+
// build.ts prints this and exits 1; the finally below still stops the server.
|
|
872
|
+
if (failures.length) throw new PrerenderEntriesError(failures.join("\n"));
|
|
481
873
|
if (targets.length) console.log("✅ Prerendering complete");
|
|
482
874
|
} finally {
|
|
483
875
|
for (const sig of signals) process.off(sig, onSignal);
|
|
876
|
+
// SIGTERM drains in-flight requests for up to 10s (server.bun.ts), and an aborted fetch
|
|
877
|
+
// doesn't stop its loader. Nothing here needs that drain: give it 1s, then SIGKILL.
|
|
484
878
|
child.kill();
|
|
879
|
+
const stopped = await exitsWithin(1000);
|
|
880
|
+
if (!stopped) child.kill("SIGKILL");
|
|
485
881
|
await child.exited;
|
|
486
882
|
if (receivedSignal) {
|
|
487
883
|
// Re-raise so parent exit code is 128+signum (standard Unix convention)
|