bosia 1.2.4 → 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/prerender.ts +338 -79
- package/src/core/server.ts +19 -17
package/package.json
CHANGED
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,44 @@ 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
|
+
*/
|
|
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
|
+
|
|
257
404
|
/**
|
|
258
405
|
* Files two targets would both write. macOS/Windows ignore case, so `About` and `about`
|
|
259
406
|
* (or folders `A/` and `a/`) merge; a file and a folder can't share a path either.
|
|
@@ -264,7 +411,7 @@ export function findPrerenderClash(targets: PrerenderTarget[]): string[] {
|
|
|
264
411
|
const clashes: string[] = [];
|
|
265
412
|
for (const t of targets) {
|
|
266
413
|
// Name the route a slug came from, so the message says which file to fix.
|
|
267
|
-
const who =
|
|
414
|
+
const who = targetLabel(t);
|
|
268
415
|
let clash = "";
|
|
269
416
|
const files =
|
|
270
417
|
t.kind === "api"
|
|
@@ -295,6 +442,24 @@ export async function prerenderStaticRoutes(
|
|
|
295
442
|
manifest: RouteManifest,
|
|
296
443
|
target: RuntimeTarget = "bun",
|
|
297
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!;
|
|
298
463
|
const targets = await detectPrerenderRoutes(manifest);
|
|
299
464
|
// On Workers the worker answers unknown paths, so 404.html is never required.
|
|
300
465
|
const require404 = target !== "workers" && needsStatic404();
|
|
@@ -319,6 +484,16 @@ export async function prerenderStaticRoutes(
|
|
|
319
484
|
...env,
|
|
320
485
|
NODE_ENV: "production",
|
|
321
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",
|
|
322
497
|
NODE_PATH: BOSIA_NODE_PATH,
|
|
323
498
|
},
|
|
324
499
|
stdout: "ignore",
|
|
@@ -329,6 +504,9 @@ export async function prerenderStaticRoutes(
|
|
|
329
504
|
// watch `exited` instead.
|
|
330
505
|
let exited = false;
|
|
331
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}`;
|
|
332
510
|
|
|
333
511
|
const signals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"];
|
|
334
512
|
let receivedSignal: NodeJS.Signals | null = null;
|
|
@@ -348,7 +526,10 @@ export async function prerenderStaticRoutes(
|
|
|
348
526
|
const deadline = Date.now() + 10_000;
|
|
349
527
|
while (Date.now() < deadline && !exited) {
|
|
350
528
|
try {
|
|
351
|
-
|
|
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
|
+
});
|
|
352
533
|
if (res.ok) {
|
|
353
534
|
ready = true;
|
|
354
535
|
break;
|
|
@@ -360,30 +541,82 @@ export async function prerenderStaticRoutes(
|
|
|
360
541
|
}
|
|
361
542
|
|
|
362
543
|
if (!ready || exited) {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
+
);
|
|
371
554
|
}
|
|
372
555
|
|
|
373
556
|
mkdirSync(`${OUT_DIR}/prerendered`, { recursive: true });
|
|
374
557
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
+
};
|
|
380
616
|
try {
|
|
381
617
|
if (kind === "api") {
|
|
382
618
|
// 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();
|
|
619
|
+
const body = await fetchText(`${base}${routePath.replace(/\/$/, "")}`, kind);
|
|
387
620
|
const outPath = prerenderApiOutPath(routePath);
|
|
388
621
|
mkdirSync(outPath.substring(0, outPath.lastIndexOf("/")), { recursive: true });
|
|
389
622
|
writeFileSync(outPath, body);
|
|
@@ -394,10 +627,22 @@ export async function prerenderStaticRoutes(
|
|
|
394
627
|
// Hit the canonical URL so the server doesn't 308 us mid-prerender
|
|
395
628
|
const canonicalRoute = canonicalRouteFor(routePath, ts);
|
|
396
629
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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}`);
|
|
401
646
|
|
|
402
647
|
// Filename strategy:
|
|
403
648
|
// never → about.html (canonical /about, served by static host as /about → about.html)
|
|
@@ -405,47 +650,35 @@ export async function prerenderStaticRoutes(
|
|
|
405
650
|
// ignore → about/index.html (single emit; both URLs resolve via server canonicalize=off)
|
|
406
651
|
// root → index.html
|
|
407
652
|
const outPath = prerenderOutPath(routePath, ts);
|
|
653
|
+
|
|
408
654
|
mkdirSync(outPath.substring(0, outPath.lastIndexOf("/")), { recursive: true });
|
|
409
655
|
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
|
-
}
|
|
656
|
+
const dataOutPath = prerenderDataOutPath(routePath);
|
|
657
|
+
mkdirSync(dataOutPath.substring(0, dataOutPath.lastIndexOf("/")), { recursive: true });
|
|
658
|
+
writeFileSync(dataOutPath, dataJson);
|
|
659
|
+
console.log(` ✅ ${routePath} → ${outPath} (+ data)`);
|
|
427
660
|
} catch (err) {
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
} else {
|
|
433
|
-
console.error(` ❌ Failed to prerender ${routePath}:`, err);
|
|
434
|
-
}
|
|
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);
|
|
435
665
|
}
|
|
436
666
|
};
|
|
437
667
|
|
|
438
|
-
//
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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));
|
|
449
682
|
|
|
450
683
|
// Static hosts (Cloudflare assets) serve index.html with a 200 for unknown
|
|
451
684
|
// paths unless a 404.html exists. Save the server's own 404 page while it's up.
|
|
@@ -454,34 +687,60 @@ export async function prerenderStaticRoutes(
|
|
|
454
687
|
// Skipped on Workers: the worker answers unknown paths, and an uploaded file
|
|
455
688
|
// would only add that soft 404. A public/404.html overwrites it.
|
|
456
689
|
// One probe, one locale: add per-locale probe paths (e.g. /id/404.html) when asked.
|
|
457
|
-
|
|
690
|
+
// A server that died is already reported, and the probe would only fail too.
|
|
691
|
+
if (target !== "workers" && !died)
|
|
458
692
|
try {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
+
}
|
|
463
706
|
// No +error.svelte (or it crashed) → plain-text "Not Found", not a page.
|
|
464
|
-
|
|
465
|
-
|
|
707
|
+
const problem = prerenderReplyProblem("page", res, 404);
|
|
708
|
+
if (problem) {
|
|
709
|
+
await dropBody(res);
|
|
710
|
+
throw new ReplyProblem(`an unknown URL ${problem}`);
|
|
711
|
+
}
|
|
466
712
|
mkdirSync(`${OUT_DIR}/static`, { recursive: true });
|
|
467
713
|
writeFileSync(`${OUT_DIR}/static/404.html`, await res.text());
|
|
468
714
|
console.log(` ✅ 404 page → ${OUT_DIR}/static/404.html`);
|
|
469
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);
|
|
470
723
|
// Only a deploy that serves 404.html needs it; elsewhere (an app whose
|
|
471
724
|
// hooks redirect every request) a missing one is fine, so just warn.
|
|
472
|
-
|
|
725
|
+
// Listed with the page failures, so one build shows every problem.
|
|
473
726
|
if (require404)
|
|
474
|
-
|
|
475
|
-
|
|
727
|
+
failures.push(
|
|
728
|
+
`❌ 404.html could not be built (${reason}), but wrangler sets ` +
|
|
476
729
|
`not_found_handling "404-page". Make unknown paths answer 404, or add public/404.html.`,
|
|
477
730
|
);
|
|
478
|
-
console.warn(` ⚠️ 404.html skipped: ${reason}`);
|
|
731
|
+
else console.warn(` ⚠️ 404.html skipped: ${reason}`);
|
|
479
732
|
}
|
|
480
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"));
|
|
481
736
|
if (targets.length) console.log("✅ Prerendering complete");
|
|
482
737
|
} finally {
|
|
483
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.
|
|
484
741
|
child.kill();
|
|
742
|
+
const stopped = await exitsWithin(1000);
|
|
743
|
+
if (!stopped) child.kill("SIGKILL");
|
|
485
744
|
await child.exited;
|
|
486
745
|
if (receivedSignal) {
|
|
487
746
|
// Re-raise so parent exit code is 128+signum (standard Unix convention)
|
package/src/core/server.ts
CHANGED
|
@@ -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) {
|