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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosia",
3
- "version": "1.2.1",
3
+ "version": "1.2.2",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bun": ">=1.4"
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
- const pageShown = !showLoading && !appState.errorComponent;
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 error branch remounts the layouts
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 {
@@ -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>
@@ -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(manifest: RouteManifest): Promise<void> {
181
+ export async function prerenderStaticRoutes(
182
+ manifest: RouteManifest,
183
+ target: RuntimeTarget = "bun",
184
+ ): Promise<void> {
161
185
  const targets = await detectPrerenderRoutes(manifest);
162
- if (targets.length === 0) return;
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(`\n🖨️ Prerendering ${targets.length} route(s)...`);
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
- ...process.env,
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
- stderr: "ignore",
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
- console.error("❌ Prerender server failed to start");
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
- console.log("✅ Prerendering complete");
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();
@@ -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 = `${status} — ${message}`;
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