bosia 0.5.10 → 0.5.12

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": "0.5.10",
3
+ "version": "0.5.12",
4
4
  "type": "module",
5
5
  "description": "A fast, batteries-included fullstack framework — SSR · Svelte 5 Runes · Bun · ElysiaJS. File-based routing inspired by SvelteKit. No Node.js, no Vite, no adapters.",
6
6
  "keywords": [
@@ -0,0 +1,127 @@
1
+ // Dev-only 500 response with an embedded SSE reload client.
2
+ //
3
+ // Without this, a bare text/JSON 500 leaves the browser stuck on the error
4
+ // page even after the next successful build — the SSE reload event has no
5
+ // listener attached, so the user must manually reload to see the recovered
6
+ // route. Returning HTML with the same SSE subscriber that hydrate.ts uses
7
+ // closes the loop: fix the source → next build → SSE "reload" → location.reload().
8
+
9
+ import { escapeHtml, safeJsonForScript } from "./html.ts";
10
+ import { loadPlugins } from "./config.ts";
11
+ import type { BosiaPlugin, RenderContext } from "./types/plugin.ts";
12
+
13
+ // Mirrors renderer.ts:pluginRenderFragments but lives here so server.ts can
14
+ // reach it without importing renderer (which would pull in the whole SSR graph
15
+ // just for an error response).
16
+ async function bodyEndFragments(ctx: RenderContext): Promise<string[]> {
17
+ const plugins = await loadPlugins();
18
+ const out: string[] = [];
19
+ for (const p of plugins as BosiaPlugin[]) {
20
+ const fn = p.render?.bodyEnd;
21
+ if (!fn) continue;
22
+ try {
23
+ const fragment = await fn(ctx);
24
+ if (fragment) out.push(fragment);
25
+ } catch {
26
+ // A broken plugin render must not mask the original error we're
27
+ // reporting; swallow and continue with the remaining fragments.
28
+ }
29
+ }
30
+ return out;
31
+ }
32
+
33
+ // Wraps dev500Response with the inspector overlay (and any other plugin
34
+ // bodyEnd fragments) pre-attached. Use from catch sites that don't already
35
+ // have a RenderContext on hand.
36
+ export async function dev500WithPlugins(
37
+ opts: Omit<Dev500Options, "bodyEndExtras"> & { url: URL },
38
+ ): Promise<Response> {
39
+ const ctx: RenderContext = {
40
+ request: opts.request,
41
+ url: opts.url,
42
+ route: { pattern: "" },
43
+ metadata: null,
44
+ };
45
+ const bodyEndExtras = await bodyEndFragments(ctx);
46
+ return dev500Response({ ...opts, bodyEndExtras });
47
+ }
48
+
49
+ export interface Dev500Options {
50
+ request: Request;
51
+ status?: number;
52
+ message: string;
53
+ detail?: string;
54
+ // Plugin bodyEnd fragments (e.g. the inspector overlay). The renderer
55
+ // already computes these for the normal error path; pass them through so
56
+ // the red-badge runtime error reporter stays attached on the fallback page.
57
+ bodyEndExtras?: string[];
58
+ }
59
+
60
+ export function dev500Response({
61
+ request,
62
+ status = 500,
63
+ message,
64
+ detail,
65
+ bodyEndExtras,
66
+ }: Dev500Options): Response {
67
+ // JSON clients (fetch loaders, API consumers) still get JSON — only browser
68
+ // navigations land on the HTML auto-reload page.
69
+ const accept = request.headers.get("accept") ?? "";
70
+ if (!accept.includes("text/html")) {
71
+ return Response.json({ error: message, ...(detail ? { detail } : {}) }, { status });
72
+ }
73
+
74
+ const html = `<!DOCTYPE html>
75
+ <html lang="en">
76
+ <head>
77
+ <meta charset="UTF-8">
78
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
79
+ <title>${status} — ${escapeHtml(message)}</title>
80
+ <style>
81
+ html,body{margin:0;padding:0;height:100%;background:#0a0a0a;color:#e5e5e5;font:14px/1.5 ui-sans-serif,system-ui,-apple-system,sans-serif}
82
+ .wrap{min-height:100%;display:flex;align-items:center;justify-content:center;padding:24px;box-sizing:border-box}
83
+ .card{max-width:720px;width:100%}
84
+ .dot{display:inline-block;width:10px;height:10px;background:#dc2626;border-radius:50%;margin-right:8px;vertical-align:middle;animation:p 1.4s ease-in-out infinite}
85
+ @keyframes p{0%,100%{opacity:1}50%{opacity:.3}}
86
+ h1{font-size:18px;font-weight:600;margin:0 0 12px}
87
+ p{margin:0 0 8px;color:#a3a3a3;font-size:13px}
88
+ pre{margin:12px 0 0;padding:12px;background:#1a1a1a;border:1px solid #262626;border-radius:4px;color:#fafafa;font-family:ui-monospace,monospace;font-size:12px;white-space:pre-wrap;word-break:break-word;max-height:50vh;overflow:auto}
89
+ .hint{font-size:12px;color:#737373;margin-top:12px}
90
+ </style>
91
+ </head>
92
+ <body>
93
+ <div class="wrap">
94
+ <div class="card">
95
+ <h1><span class="dot"></span>${status} ${escapeHtml(message)}</h1>
96
+ <p>The server threw while rendering this route. Fix the source and this page will reload automatically when the next build succeeds.</p>
97
+ ${detail ? `<pre>${escapeHtml(detail)}</pre>` : ""}
98
+ <p class="hint">Auto-reload listens on <code>/__bosia/sse</code>. Reconnects every 2s if the dev server is restarting.</p>
99
+ </div>
100
+ </div>
101
+ <script>
102
+ !function r(){
103
+ try{
104
+ var e=new EventSource("/__bosia/sse");
105
+ e.addEventListener("reload",function(){location.reload()});
106
+ e.onerror=function(){e.close();setTimeout(r,2000)};
107
+ }catch(_){setTimeout(r,2000)}
108
+ }();
109
+ </script>
110
+ <script type="application/json" id="__bosia-dev-errors__">${safeJsonForScript([
111
+ {
112
+ id: `dev500-${Date.now()}`,
113
+ ts: Date.now(),
114
+ source: "server",
115
+ message,
116
+ stack: detail,
117
+ },
118
+ ])}</script>
119
+ ${bodyEndExtras?.join("\n") ?? ""}
120
+ </body>
121
+ </html>`;
122
+
123
+ return new Response(html, {
124
+ status,
125
+ headers: { "Content-Type": "text/html; charset=utf-8" },
126
+ });
127
+ }
@@ -0,0 +1,78 @@
1
+ import { safeJsonForScript } from "./html.ts";
2
+ import { getOverlayScript } from "./plugins/inspector/overlay.ts";
3
+
4
+ export interface DevServerError {
5
+ id: string;
6
+ ts: number;
7
+ source: string;
8
+ message: string;
9
+ stack?: string;
10
+ file?: string;
11
+ line?: number;
12
+ col?: number;
13
+ }
14
+
15
+ /**
16
+ * Minimal HTML shown when the dev proxy can't reach the app server (initial
17
+ * build failure, crash loop, port conflict). Mounts the same inspector overlay
18
+ * so the red error badge appears identical to the in-app experience, pre-seeds
19
+ * the buffered errors that fired before the page loaded, and subscribes to the
20
+ * dev SSE channel so it auto-reloads when the next build succeeds.
21
+ */
22
+ export function renderDevErrorPage(buffered: DevServerError[]): string {
23
+ const overlay = getOverlayScript({ endpoint: "/__bosia/locate", errorsEnabled: true });
24
+ const seed = safeJsonForScript(buffered);
25
+
26
+ return `<!DOCTYPE html>
27
+ <html lang="en">
28
+ <head>
29
+ <meta charset="UTF-8">
30
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
31
+ <title>Dev server error — Bosia</title>
32
+ <style>
33
+ html,body{margin:0;padding:0;height:100%;background:#0a0a0a;color:#e5e5e5;font:14px/1.5 ui-sans-serif,system-ui,-apple-system,sans-serif}
34
+ .wrap{min-height:100%;display:flex;align-items:center;justify-content:center;padding:24px;box-sizing:border-box}
35
+ .card{max-width:540px;text-align:center}
36
+ .dot{display:inline-block;width:10px;height:10px;background:#dc2626;border-radius:50%;margin-right:8px;vertical-align:middle;animation:p 1.4s ease-in-out infinite}
37
+ @keyframes p{0%,100%{opacity:1}50%{opacity:.3}}
38
+ h1{font-size:18px;font-weight:600;margin:0 0 8px}
39
+ p{margin:0;color:#a3a3a3;font-size:13px}
40
+ code{font-family:ui-monospace,monospace;color:#fafafa;background:#1f1f1f;padding:1px 6px;border-radius:3px}
41
+ </style>
42
+ </head>
43
+ <body>
44
+ <div class="wrap">
45
+ <div class="card">
46
+ <h1><span class="dot"></span>Dev server error</h1>
47
+ <p>See the red badge in the bottom-right for details. This page will reload automatically when the next build succeeds.</p>
48
+ </div>
49
+ </div>
50
+ <script type="application/json" id="__bosia-dev-errors__">${seed}</script>
51
+ ${overlay}
52
+ <script>
53
+ (function(){
54
+ function seed(){
55
+ var node=document.getElementById("__bosia-dev-errors__");
56
+ if(!node||!window.__BOSIA_PUSH_ERROR__)return false;
57
+ try{
58
+ var list=JSON.parse(node.textContent||"[]");
59
+ for(var i=0;i<list.length;i++)window.__BOSIA_PUSH_ERROR__(list[i]);
60
+ }catch(_){}
61
+ return true;
62
+ }
63
+ if(!seed()){
64
+ var tries=0;
65
+ var iv=setInterval(function(){tries++;if(seed()||tries>20)clearInterval(iv)},50);
66
+ }
67
+ !function r(){
68
+ try{
69
+ var e=new EventSource("/__bosia/sse");
70
+ e.addEventListener("reload",function(){location.reload()});
71
+ e.onerror=function(){e.close();setTimeout(r,2000)};
72
+ }catch(_){setTimeout(r,2000)}
73
+ }();
74
+ })();
75
+ </script>
76
+ </body>
77
+ </html>`;
78
+ }
package/src/core/dev.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { spawn, type Subprocess } from "bun";
2
- import { watch } from "fs";
2
+ import { readdirSync, statSync, watch, type Dirent } from "fs";
3
3
  import { join } from "path";
4
4
  import { loadEnv, resetDeclaredKeys } from "./env.ts";
5
5
  import { BOSIA_NODE_PATH } from "./paths.ts";
@@ -34,9 +34,11 @@ let appProcess: Subprocess | null = null;
34
34
  let sseClients = new Set<ReadableStreamDefaultController>();
35
35
  let intentionalKill = false;
36
36
  let crashCount = 0;
37
- let lastCrashTime = 0;
38
- const MAX_RAPID_CRASHES = 3;
39
- const RAPID_CRASH_WINDOW = 5_000; // 5 seconds
37
+ let restartTimer: ReturnType<typeof setTimeout> | null = null;
38
+ let healthyTimer: ReturnType<typeof setTimeout> | null = null;
39
+ const HEALTHY_AFTER_MS = 5_000;
40
+ // Exponential backoff between successive crash restarts (capped at last value).
41
+ const BACKOFF_SCHEDULE_MS = [500, 1_000, 2_000, 4_000, 5_000];
40
42
 
41
43
  // ─── SSE Broadcast ────────────────────────────────────────
42
44
 
@@ -89,6 +91,11 @@ async function startAppServer() {
89
91
  serverEntry = manifest.serverEntry ?? "index.js";
90
92
  } catch {}
91
93
 
94
+ if (healthyTimer) {
95
+ clearTimeout(healthyTimer);
96
+ healthyTimer = null;
97
+ }
98
+
92
99
  appProcess = spawn(["bun", "run", `${DEV_OUT_DIR}/server/${serverEntry}`], {
93
100
  stdout: "inherit",
94
101
  stderr: "inherit",
@@ -109,30 +116,42 @@ async function startAppServer() {
109
116
  },
110
117
  });
111
118
 
112
- // Monitor for unexpected crashes
119
+ // Once the child has stayed alive past HEALTHY_AFTER_MS, treat it as a successful
120
+ // boot and zero the backoff counter so the next crash starts at 500ms again.
113
121
  const proc = appProcess;
122
+ healthyTimer = setTimeout(() => {
123
+ if (proc === appProcess && !intentionalKill) crashCount = 0;
124
+ }, HEALTHY_AFTER_MS);
125
+ healthyTimer.unref?.();
126
+
127
+ // Monitor for unexpected crashes. Never give up — backoff and keep trying.
128
+ // A real source-level crash bug will surface as repeated restart logs; the
129
+ // moment the user (or AI) fixes it, the next file event triggers a fresh
130
+ // rebuild and the loop unwinds naturally.
114
131
  proc.exited.then((code) => {
115
132
  if (proc !== appProcess || intentionalKill) return;
116
133
  if (code === 0) return; // clean exit
117
134
 
118
- const now = Date.now();
119
- if (now - lastCrashTime < RAPID_CRASH_WINDOW) {
120
- crashCount++;
121
- } else {
122
- crashCount = 1;
123
- }
124
- lastCrashTime = now;
125
-
126
- if (crashCount >= MAX_RAPID_CRASHES) {
127
- console.error(
128
- `\n💥 App crashed ${crashCount} times in ${RAPID_CRASH_WINDOW / 1000}s — waiting for file change to restart\n`,
129
- );
130
- crashCount = 0;
131
- return;
135
+ if (healthyTimer) {
136
+ clearTimeout(healthyTimer);
137
+ healthyTimer = null;
132
138
  }
133
139
 
134
- console.warn(`\n⚠️ App crashed (exit code ${code}). Restarting...\n`);
135
- startAppServer();
140
+ const delay =
141
+ BACKOFF_SCHEDULE_MS[Math.min(crashCount, BACKOFF_SCHEDULE_MS.length - 1)] ??
142
+ BACKOFF_SCHEDULE_MS[BACKOFF_SCHEDULE_MS.length - 1]!;
143
+ crashCount++;
144
+
145
+ console.warn(
146
+ `\n⚠️ App crashed (exit code ${code}). Restart attempt #${crashCount} in ${delay}ms...\n`,
147
+ );
148
+
149
+ if (restartTimer) clearTimeout(restartTimer);
150
+ restartTimer = setTimeout(() => {
151
+ restartTimer = null;
152
+ startAppServer();
153
+ }, delay);
154
+ restartTimer.unref?.();
136
155
  });
137
156
  }
138
157
 
@@ -276,14 +295,98 @@ function isGenerated(path: string): boolean {
276
295
  return GENERATED.some((g) => path.startsWith(g));
277
296
  }
278
297
 
298
+ // ─── mtime Poll Safety Net ────────────────────────────────
299
+ // On macOS, fs.watch misses events from atomic writes (temp file + rename)
300
+ // and frequently delivers `filename === null` for renames. AI agents that edit
301
+ // via rename therefore slip past the fast path. Walk src/ every 5s and call
302
+ // scheduleBuild() on any mtime delta or add/delete. The 300ms build debounce
303
+ // in scheduleBuild() coalesces this with the fs.watch path when both fire.
304
+ //
305
+ // IMPORTANT: fs.watch must update mtimes[file] when it fires — otherwise the
306
+ // next poll sweep sees the new mtime against a stale seed and fires a duplicate
307
+ // scheduleBuild() for an edit that was already handled.
308
+
309
+ const SRC_DIR = join(process.cwd(), "src");
310
+ const MTIME_POLL_MS = 5_000;
311
+ const mtimes = new Map<string, number>();
312
+
279
313
  const srcWatcher = watch(join(process.cwd(), "src"), { recursive: true }, (_event, filename) => {
280
314
  if (!filename) return;
281
315
  const abs = join(process.cwd(), "src", filename);
282
316
  if (isGenerated(abs)) return;
283
317
  console.log(`[watch] changed: ${filename}`);
318
+ try {
319
+ mtimes.set(abs, statSync(abs).mtimeMs);
320
+ } catch {
321
+ mtimes.delete(abs);
322
+ }
284
323
  scheduleBuild();
285
324
  });
286
325
 
326
+ function walkSrc(out: Map<string, number>): void {
327
+ const stack: string[] = [SRC_DIR];
328
+ while (stack.length > 0) {
329
+ const dir = stack.pop() as string;
330
+ if (isGenerated(dir)) continue;
331
+ let entries: Dirent[];
332
+ try {
333
+ entries = readdirSync(dir, { withFileTypes: true }) as Dirent[];
334
+ } catch {
335
+ continue;
336
+ }
337
+ for (const ent of entries) {
338
+ const name = String(ent.name);
339
+ const abs = join(dir, name);
340
+ if (isGenerated(abs)) continue;
341
+ if (ent.isDirectory()) {
342
+ stack.push(abs);
343
+ continue;
344
+ }
345
+ if (!ent.isFile()) continue;
346
+ try {
347
+ out.set(abs, statSync(abs).mtimeMs);
348
+ } catch {
349
+ // file vanished between readdir and stat — ignore
350
+ }
351
+ }
352
+ }
353
+ }
354
+
355
+ // Seed the map without firing — first sweep just records existing mtimes.
356
+ walkSrc(mtimes);
357
+
358
+ const mtimePoll = setInterval(() => {
359
+ const fresh = new Map<string, number>();
360
+ walkSrc(fresh);
361
+
362
+ let changed: string | null = null;
363
+
364
+ for (const [path, ts] of fresh) {
365
+ const prev = mtimes.get(path);
366
+ if (prev === undefined || prev !== ts) {
367
+ changed = path;
368
+ break;
369
+ }
370
+ }
371
+ if (!changed) {
372
+ for (const path of mtimes.keys()) {
373
+ if (!fresh.has(path)) {
374
+ changed = path;
375
+ break;
376
+ }
377
+ }
378
+ }
379
+
380
+ if (changed) {
381
+ const rel = changed.startsWith(SRC_DIR) ? changed.slice(SRC_DIR.length + 1) : changed;
382
+ console.log(`[poll] changed: ${rel}`);
383
+ mtimes.clear();
384
+ for (const [p, t] of fresh) mtimes.set(p, t);
385
+ scheduleBuild();
386
+ }
387
+ }, MTIME_POLL_MS);
388
+ mtimePoll.unref?.();
389
+
287
390
  // ─── .env Watcher ─────────────────────────────────────────
288
391
  // Reset to shell-env snapshot and re-run loadEnv so removed/renamed
289
392
  // keys don't linger across hot-reloads. The respawn at startAppServer
@@ -313,6 +416,9 @@ async function shutdown() {
313
416
  intentionalKill = true;
314
417
 
315
418
  if (buildTimer) clearTimeout(buildTimer);
419
+ if (restartTimer) clearTimeout(restartTimer);
420
+ if (healthyTimer) clearTimeout(healthyTimer);
421
+ clearInterval(mtimePoll);
316
422
  srcWatcher.close();
317
423
  envWatcher.close();
318
424
  devServer.stop(true); // closes SSE conns → abort listeners clear ping intervals
@@ -6,7 +6,7 @@
6
6
  // is a safe no-op otherwise.
7
7
 
8
8
  interface DevReportInput {
9
- source?: "elysia" | "uncaught" | "rejection";
9
+ source?: "server" | "uncaught" | "rejection";
10
10
  message: string;
11
11
  stack?: string;
12
12
  }
@@ -22,7 +22,7 @@ export function reportDevErrorFromCatch(err: unknown): void {
22
22
  const e = err as Error | undefined;
23
23
  try {
24
24
  fn({
25
- source: "elysia",
25
+ source: "server",
26
26
  message: e?.message ?? String(err),
27
27
  stack: e?.stack,
28
28
  });
package/src/core/html.ts CHANGED
@@ -160,7 +160,7 @@ export function buildHtml(
160
160
  ` <script${n}>try{var t=localStorage.getItem('theme');if(t==='dark'||(!t&&window.matchMedia('(prefers-color-scheme: dark)').matches))document.documentElement.classList.add('dark');else document.documentElement.classList.remove('dark')}catch(_){}</script>\n` +
161
161
  ` ${fallbackTitle}${head}` +
162
162
  headCloseInterpolated +
163
- `\n${SPINNER}` +
163
+ (body ? "" : `\n${SPINNER}`) +
164
164
  `\n <div id="app">${body}</div>${scripts}${bodyEnd}` +
165
165
  tailInterpolated
166
166
  );
@@ -267,5 +267,18 @@ if(ERR_ENABLED){
267
267
  try{pushError(JSON.parse(ev.data))}catch(_){}
268
268
  });
269
269
  }catch(_){/* EventSource not available — ignore */}
270
+
271
+ // Allow inline seed scripts on dev error pages (prerender output, the dev-500
272
+ // fallback, etc.) to surface errors that fired in a process the live SSE can't
273
+ // reach. Without this the badge stays empty whenever the error happened in a
274
+ // different process than the one serving /__bosia/errors.
275
+ window.__BOSIA_PUSH_ERROR__=pushError;
276
+ try{
277
+ var n=document.getElementById("__bosia-dev-errors__");
278
+ if(n){
279
+ var list=JSON.parse(n.textContent||"[]");
280
+ for(var i=0;i<list.length;i++)pushError(list[i]);
281
+ }
282
+ }catch(_){}
270
283
  }
271
284
  })();`;
@@ -19,6 +19,7 @@ import {
19
19
  import type { Metadata } from "./hooks.ts";
20
20
  import { loadPlugins } from "./config.ts";
21
21
  import { reportDevErrorFromCatch } from "./devErrorReport.ts";
22
+ import { dev500Response } from "./dev-500.ts";
22
23
  import type { BosiaPlugin, RenderContext } from "./types/plugin.ts";
23
24
  import { getAppHtmlSegments } from "./appHtml.ts";
24
25
  import type { AppHtmlSegments } from "./appHtml.ts";
@@ -42,6 +43,7 @@ async function pluginRenderFragments(
42
43
  } catch (err) {
43
44
  if (isDev) console.error(`Plugin "${p.name}" render.${hook} failed:`, err);
44
45
  else console.error(`Plugin "${p.name}" render.${hook} failed:`, (err as Error).message);
46
+ if (isDev) reportDevErrorFromCatch(err);
45
47
  }
46
48
  }
47
49
  return out;
@@ -481,6 +483,7 @@ export async function loadMetadata(
481
483
  } catch (err) {
482
484
  if (isDev) console.error("Metadata load error:", err);
483
485
  else console.error("Metadata load error:", (err as Error).message ?? err);
486
+ if (isDev) reportDevErrorFromCatch(err);
484
487
  }
485
488
  return null;
486
489
  }
@@ -524,6 +527,7 @@ export async function renderSSRStream(
524
527
  }
525
528
  if (isDev) console.error("Metadata load error:", err);
526
529
  else console.error("Metadata load error:", (err as Error).message ?? err);
530
+ if (isDev) reportDevErrorFromCatch(err);
527
531
  // Continue with null metadata — don't break the page for a metadata failure
528
532
  }
529
533
 
@@ -651,6 +655,7 @@ export async function renderSSRStream(
651
655
  } catch (err) {
652
656
  if (isDev) console.error("SSR render error:", err);
653
657
  else console.error("SSR render error:", (err as Error).message ?? err);
658
+ if (isDev) reportDevErrorFromCatch(err);
654
659
  // Render-phase errors fall through to deepest boundary like a page error.
655
660
  return renderErrorPage(
656
661
  500,
@@ -910,6 +915,7 @@ export async function renderErrorPage(
910
915
  "Nested error page render failed:",
911
916
  (err as Error).message ?? err,
912
917
  );
918
+ if (isDev) reportDevErrorFromCatch(err);
913
919
  // fall through to global / text fallback
914
920
  }
915
921
  }
@@ -944,8 +950,17 @@ export async function renderErrorPage(
944
950
  } catch (err) {
945
951
  if (isDev) console.error("Error page render failed:", err);
946
952
  else console.error("Error page render failed:", (err as Error).message ?? err);
953
+ if (isDev) reportDevErrorFromCatch(err);
947
954
  }
948
955
  }
956
+ // Dev: render an HTML 500 page that subscribes to /__bosia/sse so the browser
957
+ // auto-reloads the moment the next build succeeds. Without this the user stares
958
+ // at "Internal Server Error" forever even after fixing the source. Pass the
959
+ // already-computed bodyEndExtras so the inspector overlay (red error badge,
960
+ // pre-seeded buffered errors) stays attached even on this bare-fallback path.
961
+ if (isDev) {
962
+ return dev500Response({ request: req, status, message, bodyEndExtras });
963
+ }
949
964
  return new Response(message, {
950
965
  status,
951
966
  headers: { "Content-Type": "text/plain; charset=utf-8" },
@@ -22,6 +22,7 @@ import { applyCorsVary, getCorsHeaders, handlePreflight } from "./cors.ts";
22
22
  import type { CorsConfig } from "./cors.ts";
23
23
  import { buildCspHeader, CSP_DIRECTIVES_TEMPLATE, CSP_ENABLED, generateNonce } from "./csp.ts";
24
24
  import { isDev, compress, isStaticPath } from "./html.ts";
25
+ import { dev500WithPlugins } from "./dev-500.ts";
25
26
  import { OUT_DIR } from "./paths.ts";
26
27
  import { dedup, dedupKey } from "./dedup.ts";
27
28
  import { reportDevErrorFromCatch } from "./devErrorReport.ts";
@@ -282,6 +283,15 @@ async function resolve(event: RequestEvent): Promise<Response> {
282
283
  if (isDev) console.error("Data endpoint error:", err);
283
284
  else console.error("Data endpoint error:", (err as Error).message ?? err);
284
285
  if (isDev) reportDevErrorFromCatch(err);
286
+ if (isDev) {
287
+ const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
288
+ return dev500WithPlugins({
289
+ request,
290
+ url,
291
+ message: "Internal Server Error",
292
+ detail,
293
+ });
294
+ }
285
295
  return Response.json({ error: "Internal Server Error" }, { status: 500 });
286
296
  }
287
297
  }
@@ -324,21 +334,30 @@ async function resolve(event: RequestEvent): Promise<Response> {
324
334
  return new Response("Not Found", { status: 404 });
325
335
  }
326
336
 
327
- // Prerendered pages — serve static HTML built at build time
328
- // Try both `<path>/index.html` (always/ignore mode) and `<path>.html` (never mode)
329
- const prerenderCandidates =
330
- path === "/" ? ["index.html"] : [`${path}/index.html`, `${path.replace(/\/$/, "")}.html`];
331
- for (const candidate of prerenderCandidates) {
332
- const prerenderPath = safePath(`${OUT_DIR}/prerendered`, candidate);
333
- if (!prerenderPath) continue;
334
- const prerenderFile = Bun.file(prerenderPath);
335
- if (await prerenderFile.exists()) {
336
- return new Response(prerenderFile, {
337
- headers: {
338
- "Content-Type": "text/html; charset=utf-8",
339
- "Cache-Control": "public, max-age=3600",
340
- },
341
- });
337
+ // Prerendered pages — serve static HTML built at build time.
338
+ // SKIP in dev: prerender runs with NODE_ENV=production, which disables the
339
+ // inspector plugin and the dev-only error pipeline. Serving its output back
340
+ // in dev would mask errors (the badge stays empty, the SSE reload script
341
+ // isn't injected, and the page can't auto-recover when the source is fixed).
342
+ // Live SSR every request in dev so /about behaves like every other route.
343
+ if (!isDev) {
344
+ // Try both `<path>/index.html` (always/ignore mode) and `<path>.html` (never mode)
345
+ const prerenderCandidates =
346
+ path === "/"
347
+ ? ["index.html"]
348
+ : [`${path}/index.html`, `${path.replace(/\/$/, "")}.html`];
349
+ for (const candidate of prerenderCandidates) {
350
+ const prerenderPath = safePath(`${OUT_DIR}/prerendered`, candidate);
351
+ if (!prerenderPath) continue;
352
+ const prerenderFile = Bun.file(prerenderPath);
353
+ if (await prerenderFile.exists()) {
354
+ return new Response(prerenderFile, {
355
+ headers: {
356
+ "Content-Type": "text/html; charset=utf-8",
357
+ "Cache-Control": "public, max-age=3600",
358
+ },
359
+ });
360
+ }
342
361
  }
343
362
  }
344
363
 
@@ -365,6 +384,15 @@ async function resolve(event: RequestEvent): Promise<Response> {
365
384
  if (isDev) console.error("API route error:", err);
366
385
  else console.error("API route error:", (err as Error).message ?? err);
367
386
  if (isDev) reportDevErrorFromCatch(err);
387
+ if (isDev) {
388
+ const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
389
+ return dev500WithPlugins({
390
+ request,
391
+ url,
392
+ message: "Internal Server Error",
393
+ detail,
394
+ });
395
+ }
368
396
  return Response.json({ error: "Internal Server Error" }, { status: 500 });
369
397
  }
370
398
  }
@@ -545,6 +573,15 @@ async function resolve(event: RequestEvent): Promise<Response> {
545
573
  { status: 500 },
546
574
  );
547
575
  }
576
+ if (isDev) {
577
+ const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
578
+ return dev500WithPlugins({
579
+ request,
580
+ url,
581
+ message: "Internal Server Error",
582
+ detail,
583
+ });
584
+ }
548
585
  return Response.json({ error: "Internal Server Error" }, { status: 500 });
549
586
  }
550
587
  }
@@ -644,6 +681,16 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
644
681
  if (isDev) console.error("Unhandled request error:", err);
645
682
  else console.error("Unhandled request error:", (err as Error).message ?? err);
646
683
  if (isDev) reportDevErrorFromCatch(err);
684
+ if (isDev) {
685
+ const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
686
+ return dev500WithPlugins({
687
+ request,
688
+ url,
689
+ status: 500,
690
+ message: "Internal Server Error",
691
+ detail,
692
+ });
693
+ }
647
694
  return Response.json({ error: "Internal Server Error" }, { status: 500 });
648
695
  } finally {
649
696
  inFlight--;