bosia 1.2.1 → 1.2.2
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/client/App.svelte +28 -3
- package/src/core/headScan.ts +6 -0
- package/src/core/prerender.ts +89 -10
- package/src/core/renderer.ts +2 -2
package/package.json
CHANGED
package/src/core/build.ts
CHANGED
|
@@ -400,7 +400,7 @@ await bundleRuntimeUserFiles(process.cwd());
|
|
|
400
400
|
const devBuild = process.env.BOSIA_DEV_BUILD === "1";
|
|
401
401
|
|
|
402
402
|
// 9. Prerender static routes
|
|
403
|
-
if (!devBuild) await prerenderStaticRoutes(manifest);
|
|
403
|
+
if (!devBuild) await prerenderStaticRoutes(manifest, target);
|
|
404
404
|
|
|
405
405
|
// 10. Generate static site output (HTML + client assets + public → dist/static/)
|
|
406
406
|
if (!devBuild) generateStaticSite();
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { pickErrorPage } from "../errorMatch.ts";
|
|
10
10
|
import { fireAfterNavigate, type Navigation } from "./navListeners.ts";
|
|
11
11
|
import { drainNavResolvers } from "./navigation.ts";
|
|
12
|
-
import { collapseTitle } from "../headScan.ts";
|
|
12
|
+
import { collapseTitle, errorTitle } from "../headScan.ts";
|
|
13
13
|
import { chainTitles, takeTitleWrite, titleNav } from "./titleMemory.svelte.ts";
|
|
14
14
|
import TitleProbe from "./TitleProbe.svelte";
|
|
15
15
|
|
|
@@ -309,7 +309,10 @@
|
|
|
309
309
|
// can't clobber a newer nav's loader.
|
|
310
310
|
// The skeleton and the error page render in their own {#if} branch, so
|
|
311
311
|
// leaving either remounts the page. Read before both are cleared below.
|
|
312
|
-
|
|
312
|
+
// The skeleton shows only once its chunk loaded (template's condition).
|
|
313
|
+
const skeletonShown = showLoading && !!LoadingComponent;
|
|
314
|
+
const pageShown = !skeletonShown && !appState.errorComponent;
|
|
315
|
+
const errorShown = skeletonShown ? null : appState.errorComponent;
|
|
313
316
|
showLoading = false;
|
|
314
317
|
LoadingComponent = null;
|
|
315
318
|
lastSettledPath = pathname;
|
|
@@ -350,9 +353,22 @@
|
|
|
350
353
|
...match.route.layouts.slice(0, K).map((l: any) => l()),
|
|
351
354
|
]);
|
|
352
355
|
if (cancelled) return;
|
|
356
|
+
// Error page to error page: the layouts both share stay mounted and
|
|
357
|
+
// never write their title again. Same error component under all of
|
|
358
|
+
// them too: nothing remounts, so a <title> it wrote stays — keep it.
|
|
359
|
+
const kept = errorShown
|
|
360
|
+
? sharedDepth(currentLayoutPaths, destLayoutPaths.slice(0, K))
|
|
361
|
+
: 0;
|
|
362
|
+
const prev = appState.errorProps?.error;
|
|
363
|
+
const keepTitle =
|
|
364
|
+
errorShown === errMod.default &&
|
|
365
|
+
kept === K &&
|
|
366
|
+
appState.errorDepth === K &&
|
|
367
|
+
!!prev &&
|
|
368
|
+
document.title !== collapseTitle(errorTitle(prev.status, prev.message));
|
|
353
369
|
layoutComponents = layoutModsForError.map((m: any) => m.default);
|
|
354
370
|
currentLayoutPaths = destLayoutPaths.slice(0, K);
|
|
355
|
-
titleNav.seq++; // the
|
|
371
|
+
titleNav.seq++; // the probes read again as this flushes
|
|
356
372
|
const newLayoutData: Record<string, any>[] = [];
|
|
357
373
|
for (let i = 0; i < K; i++) newLayoutData.push({});
|
|
358
374
|
appState.layoutData = newLayoutData;
|
|
@@ -361,6 +377,15 @@
|
|
|
361
377
|
appState.errorComponent = errMod.default;
|
|
362
378
|
appState.errorProps = { error: { status: errStatus, message: errMessage } };
|
|
363
379
|
appState.errorDepth = K;
|
|
380
|
+
// What a hard load shows (renderer.ts): a layout's title, else the
|
|
381
|
+
// status title. Set before Svelte flushes, so a freshly mounted
|
|
382
|
+
// <title> in +error.svelte or a layout still wins.
|
|
383
|
+
document.querySelectorAll("[data-bosia-meta]").forEach((el) => el.remove());
|
|
384
|
+
if (!keepTitle) {
|
|
385
|
+
document.title =
|
|
386
|
+
(kept > 0 ? chainTitles[kept - 1] : null) ?? errorTitle(errStatus, errMessage);
|
|
387
|
+
navTitle = document.title;
|
|
388
|
+
}
|
|
364
389
|
settleScroll();
|
|
365
390
|
settle({ url, params: match.params });
|
|
366
391
|
} catch {
|
package/src/core/headScan.ts
CHANGED
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
/** The title a page gets when nothing else sets one. */
|
|
6
6
|
export const DEFAULT_TITLE = "Bosia App";
|
|
7
7
|
|
|
8
|
+
/** The title an error page gets when its components set none: `404 — Not Found`.
|
|
9
|
+
* SSR and client navigation both use it, so the two never drift apart. */
|
|
10
|
+
export function errorTitle(status: number, message: string): string {
|
|
11
|
+
return `${status} — ${message}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
8
14
|
/** Whether a chunk of head markup already sets the document title. Matches a
|
|
9
15
|
* <title> with attributes too. Ignores one in a comment, an attribute value, a
|
|
10
16
|
* script or JSON-LD string, <style>, <noscript>, <template>, or an inline <svg>
|
package/src/core/prerender.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { writeFileSync, mkdirSync, cpSync, existsSync } from "fs";
|
|
1
|
+
import { writeFileSync, mkdirSync, cpSync, existsSync, readFileSync } from "fs";
|
|
2
2
|
import { createServer } from "net";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import type { RouteManifest, TrailingSlash } from "./types.ts";
|
|
5
|
+
import type { RuntimeTarget } from "./types/plugin.ts";
|
|
5
6
|
|
|
6
7
|
import { BOSIA_NODE_PATH, OUT_DIR } from "./paths.ts";
|
|
7
8
|
import { currentBase } from "./appBase.ts";
|
|
@@ -28,6 +29,26 @@ export function getEphemeralPort(): Promise<number> {
|
|
|
28
29
|
|
|
29
30
|
const CORE_DIR = import.meta.dir;
|
|
30
31
|
|
|
32
|
+
/**
|
|
33
|
+
* True when the app's wrangler config serves `404.html` for unknown paths
|
|
34
|
+
* (`not_found_handling: "404-page"`) and `public/404.html` doesn't provide one,
|
|
35
|
+
* so a failed 404 probe must fail the build. Reads the text, skipping whole-line
|
|
36
|
+
* `//` and `#` comments; a block comment around the key still counts.
|
|
37
|
+
*/
|
|
38
|
+
export function needsStatic404(cwd = process.cwd()): boolean {
|
|
39
|
+
if (existsSync(join(cwd, "public", "404.html"))) return false;
|
|
40
|
+
for (const name of ["wrangler.jsonc", "wrangler.json", "wrangler.toml"]) {
|
|
41
|
+
const file = join(cwd, name);
|
|
42
|
+
if (!existsSync(file)) continue;
|
|
43
|
+
const text = readFileSync(file, "utf-8")
|
|
44
|
+
.split("\n")
|
|
45
|
+
.filter((line) => !/^\s*(\/\/|#)/.test(line))
|
|
46
|
+
.join("\n");
|
|
47
|
+
return /not_found_handling"?\s*[:=]\s*"404-page"/.test(text);
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
31
52
|
const PRERENDER_TIMEOUT = Number(process.env.PRERENDER_TIMEOUT) || 5_000; // 5s default
|
|
32
53
|
const PRERENDER_CONCURRENCY = Number(process.env.PRERENDER_CONCURRENCY) || 6;
|
|
33
54
|
|
|
@@ -157,23 +178,44 @@ async function detectPrerenderRoutes(manifest: RouteManifest): Promise<Prerender
|
|
|
157
178
|
return results.flat();
|
|
158
179
|
}
|
|
159
180
|
|
|
160
|
-
export async function prerenderStaticRoutes(
|
|
181
|
+
export async function prerenderStaticRoutes(
|
|
182
|
+
manifest: RouteManifest,
|
|
183
|
+
target: RuntimeTarget = "bun",
|
|
184
|
+
): Promise<void> {
|
|
161
185
|
const targets = await detectPrerenderRoutes(manifest);
|
|
162
|
-
|
|
186
|
+
// On Workers the worker answers unknown paths, so 404.html is never required.
|
|
187
|
+
const require404 = target !== "workers" && needsStatic404();
|
|
188
|
+
// No routes to prerender: start the server only for the required 404.html.
|
|
189
|
+
if (targets.length === 0 && !require404) return;
|
|
163
190
|
|
|
164
|
-
console.log(
|
|
191
|
+
console.log(
|
|
192
|
+
targets.length
|
|
193
|
+
? `\n🖨️ Prerendering ${targets.length} route(s)...`
|
|
194
|
+
: "\n🖨️ Building 404.html (wrangler sets 404-page)...",
|
|
195
|
+
);
|
|
165
196
|
|
|
166
197
|
const port = await getEphemeralPort();
|
|
198
|
+
// No BOSIA_REUSE_PORT: the child must fail to bind rather than share a port
|
|
199
|
+
// with another server, whose pages we'd then save as ours. The ready loop
|
|
200
|
+
// stops once the child exits, but a server that grabbed the port first can
|
|
201
|
+
// still answer /_health before the child dies. Have /_health echo a nonce
|
|
202
|
+
// from the child's env if that race ever shows up.
|
|
203
|
+
const { BOSIA_REUSE_PORT: _, ...env } = process.env;
|
|
167
204
|
const child = Bun.spawn(["bun", "run", `${OUT_DIR}/server/index.js`], {
|
|
168
205
|
env: {
|
|
169
|
-
...
|
|
206
|
+
...env,
|
|
170
207
|
NODE_ENV: "production",
|
|
171
208
|
PORT: String(port),
|
|
172
209
|
NODE_PATH: BOSIA_NODE_PATH,
|
|
173
210
|
},
|
|
174
211
|
stdout: "ignore",
|
|
175
|
-
|
|
212
|
+
// Show render errors (a throwing +error.svelte, a crashing loader).
|
|
213
|
+
stderr: "inherit",
|
|
176
214
|
});
|
|
215
|
+
// exitCode stays null when a signal kills the child (OOM, segfault), so
|
|
216
|
+
// watch `exited` instead.
|
|
217
|
+
let exited = false;
|
|
218
|
+
child.exited.then(() => (exited = true));
|
|
177
219
|
|
|
178
220
|
const signals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"];
|
|
179
221
|
let receivedSignal: NodeJS.Signals | null = null;
|
|
@@ -191,7 +233,7 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
|
|
|
191
233
|
const base = `http://localhost:${port}${currentBase()}`;
|
|
192
234
|
let ready = false;
|
|
193
235
|
const deadline = Date.now() + 10_000;
|
|
194
|
-
while (Date.now() < deadline) {
|
|
236
|
+
while (Date.now() < deadline && !exited) {
|
|
195
237
|
try {
|
|
196
238
|
const res = await fetch(`${base}/_health`);
|
|
197
239
|
if (res.ok) {
|
|
@@ -204,8 +246,14 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
|
|
|
204
246
|
await Bun.sleep(50);
|
|
205
247
|
}
|
|
206
248
|
|
|
207
|
-
if (!ready) {
|
|
208
|
-
|
|
249
|
+
if (!ready || exited) {
|
|
250
|
+
const why = child.signalCode ?? `exit code ${child.exitCode ?? "none"}`;
|
|
251
|
+
if (require404)
|
|
252
|
+
throw new Error(
|
|
253
|
+
`Prerender server failed to start (${why}), so 404.html could not be built, but ` +
|
|
254
|
+
`wrangler sets not_found_handling "404-page". Fix the server, or add public/404.html.`,
|
|
255
|
+
);
|
|
256
|
+
console.error(`❌ Prerender server failed to start (${why})`);
|
|
209
257
|
return;
|
|
210
258
|
}
|
|
211
259
|
|
|
@@ -286,7 +334,38 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
|
|
|
286
334
|
Array.from({ length: Math.min(PRERENDER_CONCURRENCY, targets.length) }, worker),
|
|
287
335
|
);
|
|
288
336
|
|
|
289
|
-
|
|
337
|
+
// Static hosts (Cloudflare assets) serve index.html with a 200 for unknown
|
|
338
|
+
// paths unless a 404.html exists. Save the server's own 404 page while it's up.
|
|
339
|
+
// Goes to dist/static, not prerendered/, so the Bun server doesn't route /404
|
|
340
|
+
// to it, though it (and static hosts) still serve /404.html with a 200.
|
|
341
|
+
// Skipped on Workers: the worker answers unknown paths, and an uploaded file
|
|
342
|
+
// would only add that soft 404. A public/404.html overwrites it.
|
|
343
|
+
// One probe, one locale: add per-locale probe paths (e.g. /id/404.html) when asked.
|
|
344
|
+
if (target !== "workers")
|
|
345
|
+
try {
|
|
346
|
+
const res = await fetch(`${base}/__bosia-404-probe`, {
|
|
347
|
+
signal: AbortSignal.timeout(PRERENDER_TIMEOUT),
|
|
348
|
+
});
|
|
349
|
+
if (res.status !== 404) throw new Error(`expected a 404 page, got ${res.status}`);
|
|
350
|
+
// No +error.svelte (or it crashed) → plain-text "Not Found", not a page.
|
|
351
|
+
if (!res.headers.get("content-type")?.includes("text/html"))
|
|
352
|
+
throw new Error(`expected an HTML 404 page, got ${res.headers.get("content-type")}`);
|
|
353
|
+
mkdirSync(`${OUT_DIR}/static`, { recursive: true });
|
|
354
|
+
writeFileSync(`${OUT_DIR}/static/404.html`, await res.text());
|
|
355
|
+
console.log(` ✅ 404 page → ${OUT_DIR}/static/404.html`);
|
|
356
|
+
} catch (err) {
|
|
357
|
+
// Only a deploy that serves 404.html needs it; elsewhere (an app whose
|
|
358
|
+
// hooks redirect every request) a missing one is fine, so just warn.
|
|
359
|
+
const reason = (err as Error).message;
|
|
360
|
+
if (require404)
|
|
361
|
+
throw new Error(
|
|
362
|
+
`404.html could not be built (${reason}), but wrangler sets ` +
|
|
363
|
+
`not_found_handling "404-page". Make unknown paths answer 404, or add public/404.html.`,
|
|
364
|
+
);
|
|
365
|
+
console.warn(` ⚠️ 404.html skipped: ${reason}`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (targets.length) console.log("✅ Prerendering complete");
|
|
290
369
|
} finally {
|
|
291
370
|
for (const sig of signals) process.off(sig, onSignal);
|
|
292
371
|
child.kill();
|
package/src/core/renderer.ts
CHANGED
|
@@ -45,7 +45,7 @@ import { reportDevErrorFromCatch } from "./devErrorReport.ts";
|
|
|
45
45
|
import { dev500Response } from "./dev-500.ts";
|
|
46
46
|
import type { BosiaPlugin, RenderContext } from "./types/plugin.ts";
|
|
47
47
|
import { getAppHtmlSegments } from "./appHtml.ts";
|
|
48
|
-
import { hasTitle } from "./headScan.ts";
|
|
48
|
+
import { errorTitle, hasTitle } from "./headScan.ts";
|
|
49
49
|
import type { AppHtmlSegments } from "./appHtml.ts";
|
|
50
50
|
|
|
51
51
|
// Shared, stateless — one instance instead of a fresh allocation per stream.
|
|
@@ -1217,7 +1217,7 @@ export async function renderErrorPage(
|
|
|
1217
1217
|
// head, so a real title hasTitle misses still comes first), except when
|
|
1218
1218
|
// app.html has its own <title>: that would come first, so the status title
|
|
1219
1219
|
// takes the metadata slot ahead of it instead.
|
|
1220
|
-
const statusTitle =
|
|
1220
|
+
const statusTitle = errorTitle(status, message);
|
|
1221
1221
|
const errMeta = (head: string): Metadata | null =>
|
|
1222
1222
|
appHtmlHasTitle(appHtmlSegments) && !hasTitle(head) ? { title: statusTitle } : null;
|
|
1223
1223
|
|