bosia 0.5.11 → 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 +1 -1
- package/src/core/dev-500.ts +127 -0
- package/src/core/dev.ts +127 -21
- package/src/core/plugins/inspector/overlay.ts +13 -0
- package/src/core/renderer.ts +9 -0
- package/src/core/server.ts +62 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bosia",
|
|
3
|
-
"version": "0.5.
|
|
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
|
+
}
|
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
|
|
38
|
-
|
|
39
|
-
const
|
|
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
|
-
//
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
|
@@ -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
|
})();`;
|
package/src/core/renderer.ts
CHANGED
|
@@ -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";
|
|
@@ -952,6 +953,14 @@ export async function renderErrorPage(
|
|
|
952
953
|
if (isDev) reportDevErrorFromCatch(err);
|
|
953
954
|
}
|
|
954
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
|
+
}
|
|
955
964
|
return new Response(message, {
|
|
956
965
|
status,
|
|
957
966
|
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
package/src/core/server.ts
CHANGED
|
@@ -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
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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--;
|