bosia 0.5.6 → 0.5.8

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/README.md CHANGED
@@ -92,8 +92,8 @@ Data returned is passed as the `data` prop to `+page.svelte`:
92
92
 
93
93
  ```svelte
94
94
  <script lang="ts">
95
- let { data } = $props();
96
- // data.post, data.params ...
95
+ let { data, params } = $props();
96
+ // data.post, params.slug ...
97
97
  </script>
98
98
  ```
99
99
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosia",
3
- "version": "0.5.6",
3
+ "version": "0.5.8",
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": [
@@ -51,6 +51,7 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "@clack/prompts": "^1.1.0",
54
+ "@jridgewell/trace-mapping": "^0.3.31",
54
55
  "@tailwindcss/cli": "^4.2.1",
55
56
  "elysia": "^1.4.26",
56
57
  "magic-string": "^0.30.0",
package/src/core/build.ts CHANGED
@@ -5,7 +5,7 @@ import { scanRoutes } from "./scanner.ts";
5
5
  import { generateRoutesFile } from "./routeFile.ts";
6
6
  import { generateRouteTypes, ensureRootDirs } from "./routeTypes.ts";
7
7
  import { makeBosiaPlugin } from "./plugin.ts";
8
- import { makeBosiaSvelteCompiler } from "./svelteCompiler.ts";
8
+ import { makeBosiaSvelteCompiler, svelteMapCache } from "./svelteCompiler.ts";
9
9
  import { prerenderStaticRoutes, generateStaticSite } from "./prerender.ts";
10
10
  import { loadEnv, classifyEnvVars } from "./env.ts";
11
11
  import { generateEnvModules } from "./envCodegen.ts";
@@ -142,6 +142,7 @@ const clientPromise = Bun.build({
142
142
  splitting: true,
143
143
  naming: { chunk: "[name]-[hash].[ext]" },
144
144
  minify: isProduction,
145
+ sourcemap: isProduction ? "none" : "linked",
145
146
  define: {
146
147
  "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development"),
147
148
  ...staticDefines,
@@ -156,6 +157,7 @@ const serverPromise = Bun.build({
156
157
  splitting: true,
157
158
  naming: { entry: "index.[ext]", chunk: "[name]-[hash].[ext]" },
158
159
  minify: isProduction,
160
+ sourcemap: isProduction ? "none" : "linked",
159
161
  external: ["elysia"],
160
162
  plugins: [serverPlugin, ...userServerBunPlugins, makeBosiaSvelteCompiler("bun")],
161
163
  });
@@ -185,6 +187,18 @@ if (!serverResult.success) {
185
187
  process.exit(1);
186
188
  }
187
189
 
190
+ // Persist the per-file Svelte compile maps so the inspector's runtime stack
191
+ // resolver can chain bundle-map → svelte-map to land on original source. We
192
+ // cannot chain at bundle time because Bun's bundle maps reference intermediate
193
+ // JS positions that are mostly absent from Svelte's sparse compile map —
194
+ // post-build remapping nukes mappings. Instead we keep both maps separate and
195
+ // do a two-stage lookup with `bias` interpolation in the resolver.
196
+ if (!isProduction && svelteMapCache.size > 0) {
197
+ const entries: Record<string, unknown> = {};
198
+ for (const [k, v] of svelteMapCache) entries[k] = v;
199
+ writeFileSync(`${OUT_DIR}/svelte-maps.json`, JSON.stringify(entries));
200
+ }
201
+
188
202
  // 6. Collect output files for dist/manifest.json
189
203
  const jsFiles: string[] = [];
190
204
  const cssFiles: string[] = [];
@@ -31,12 +31,19 @@
31
31
 
32
32
  let PageComponent = $state<any>(ssrPageComponent);
33
33
  let layoutComponents = $state<any[]>(ssrLayoutComponents ?? []);
34
+ // Network protocol still ships `params` merged into pageData (see renderer.ts).
35
+ // Strip it at the component boundary so consumers receive `params` only via
36
+ // the dedicated prop, matching SvelteKit's surface.
37
+ const stripParams = (d: Record<string, any>) => {
38
+ const { params: _p, ...rest } = d;
39
+ return rest;
40
+ };
34
41
  // In SSR mode, render directly from props (server module singletons must
35
42
  // not hold per-request state). On the client, read/write through `appState`
36
43
  // so `use:enhance` and other helpers can update the same cells.
37
- const pageData = $derived(ssrMode ? (ssrPageData ?? {}) : appState.pageData);
44
+ const pageData = $derived(ssrMode ? stripParams(ssrPageData ?? {}) : appState.pageData);
38
45
  const layoutData = $derived(ssrMode ? (ssrLayoutData ?? []) : appState.layoutData);
39
- const routeParams = $derived(ssrMode ? (ssrPageData?.params ?? {}) : appState.routeParams);
46
+ const params = $derived(ssrMode ? (ssrPageData?.params ?? {}) : appState.routeParams);
40
47
  const formData = $derived(ssrMode ? ssrFormData : appState.form);
41
48
  const ErrorComponent = $derived(ssrMode ? ssrErrorComponent : appState.errorComponent);
42
49
  const errorProps = $derived(ssrMode ? ssrErrorProps : appState.errorProps);
@@ -257,9 +264,7 @@
257
264
  mergedPageData = {};
258
265
  }
259
266
 
260
- // Always overlay current match.params — cached pageData carries the
261
- // stale params from when the loader ran, so trust the live match.
262
- appState.pageData = { ...mergedPageData, params: match.params };
267
+ appState.pageData = mergedPageData;
263
268
  appState.layoutData = mergedLayoutData;
264
269
  appState.routeParams = match.params;
265
270
  // Successful navigation — clear any prior error state.
@@ -314,7 +319,7 @@
314
319
  {:else if layoutComponents.length > 0}
315
320
  {@render renderLayout(0, layoutComponents.length)}
316
321
  {:else if PageComponent}
317
- <PageComponent data={{ ...pageData, params: routeParams }} form={formData} />
322
+ <PageComponent data={pageData} {params} form={formData} />
318
323
  {:else}
319
324
  <p>Loading...</p>
320
325
  {/if}
@@ -324,15 +329,15 @@
324
329
  {@const data = layoutData[index] ?? {}}
325
330
 
326
331
  {#if index < leafDepth - 1}
327
- <Layout {data}>
332
+ <Layout {data} {params}>
328
333
  {@render renderLayout(index + 1, leafDepth)}
329
334
  </Layout>
330
335
  {:else}
331
- <Layout {data}>
336
+ <Layout {data} {params}>
332
337
  {#if ErrorComponent}
333
338
  <ErrorComponent {...errorProps ?? {}} />
334
339
  {:else if PageComponent}
335
- <PageComponent data={{ ...pageData, params: routeParams }} form={formData} />
340
+ <PageComponent data={pageData} {params} form={formData} />
336
341
  {:else}
337
342
  <p>Loading...</p>
338
343
  {/if}
@@ -67,7 +67,10 @@ async function main() {
67
67
 
68
68
  // Seed shared client state so `use:enhance` and other helpers
69
69
  // start from the same values App.svelte renders during hydration.
70
- appState.pageData = ssrPageData;
70
+ // Network protocol still ships `params` merged into pageData; strip it
71
+ // so the in-memory pageData mirrors what +page.svelte receives.
72
+ const { params: _p, ...pageDataNoParams } = ssrPageData ?? {};
73
+ appState.pageData = pageDataNoParams;
71
74
  appState.layoutData = ssrLayoutData;
72
75
  appState.routeParams = ssrPageData?.params ?? match?.params ?? {};
73
76
  appState.form = ssrFormData;
@@ -0,0 +1,30 @@
1
+ // Dev-only bridge: framework catch sites (renderer, server) push errors into
2
+ // the inspector plugin's overlay via a globalThis-set function. The inspector
3
+ // is loaded from disk via the user's bosia.config.ts and lives in a separate
4
+ // module graph from the bundled server, so a direct import would not share
5
+ // state. The global is only set when the inspector plugin is installed; this
6
+ // is a safe no-op otherwise.
7
+
8
+ interface DevReportInput {
9
+ source?: "elysia" | "uncaught" | "rejection";
10
+ message: string;
11
+ stack?: string;
12
+ }
13
+
14
+ declare global {
15
+ // eslint-disable-next-line no-var
16
+ var __BOSIA_REPORT_ERROR__: ((e: DevReportInput) => void) | undefined;
17
+ }
18
+
19
+ export function reportDevErrorFromCatch(err: unknown): void {
20
+ const fn = globalThis.__BOSIA_REPORT_ERROR__;
21
+ if (typeof fn !== "function") return;
22
+ const e = err as Error | undefined;
23
+ try {
24
+ fn({
25
+ source: "elysia",
26
+ message: e?.message ?? String(err),
27
+ stack: e?.stack,
28
+ });
29
+ } catch {}
30
+ }
package/src/core/html.ts CHANGED
@@ -97,6 +97,7 @@ export function buildHtml(
97
97
  nonce?: string,
98
98
  pageDeps: any = null,
99
99
  layoutDeps: any[] | null = null,
100
+ bodyEndExtras?: string[],
100
101
  ): string {
101
102
  const cssLinks = (distManifest.css ?? [])
102
103
  .map((f: string) => `<link rel="stylesheet" href="/dist/client/${f}">`)
@@ -135,6 +136,8 @@ export function buildHtml(
135
136
  ? `\n <script${n}>!function r(){var e=new EventSource("/__bosia/sse");e.addEventListener("reload",()=>location.reload());e.onopen=()=>r._ok||(r._ok=1);e.onerror=()=>{e.close();setTimeout(r,2000)}}()</script>`
136
137
  : "";
137
138
 
139
+ const bodyEnd = bodyEndExtras?.length ? "\n " + bodyEndExtras.join("\n ") : "";
140
+
138
141
  return `<!DOCTYPE html>
139
142
  <html lang="${safeLang(lang)}">
140
143
  <head>
@@ -148,7 +151,7 @@ export function buildHtml(
148
151
  <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>
149
152
  </head>
150
153
  <body>
151
- <div id="app">${body}</div>${scripts}
154
+ <div id="app">${body}</div>${scripts}${bodyEnd}
152
155
  </body>
153
156
  </html>`;
154
157
  }
@@ -2,6 +2,7 @@ import { parse, compile } from "svelte/compiler";
2
2
  import MagicString from "magic-string";
3
3
  import { basename, relative } from "node:path";
4
4
  import type { BunPlugin } from "bun";
5
+ import { svelteMapCache } from "../../svelteCompiler.ts";
5
6
 
6
7
  const VIRTUAL_NS = "bosia-inspector-css";
7
8
 
@@ -87,6 +88,21 @@ export interface InspectorBunPluginOptions {
87
88
  dev: boolean;
88
89
  }
89
90
 
91
+ // Svelte 5 dev compile emits named `function get()` / `function set($$value)`
92
+ // expressions inside `$.bind_*` calls (for nicer `$inspect` stack traces). Bun's
93
+ // bundler destructures `import * as $ from "svelte/internal/client"` into named
94
+ // imports, so `$.get(search)` becomes plain `get(search)` — which collides with
95
+ // the wrapping function name and recurses into itself → RangeError. Prod compile
96
+ // uses anonymous arrow functions and is unaffected.
97
+ //
98
+ // Rename to `$$g` / `$$s` (3 chars — length-preserving so cached svelte source
99
+ // map columns stay accurate). These names aren't present in svelte/internal/client.
100
+ function fixBindShadow(code: string): string {
101
+ return code
102
+ .replace(/\bfunction get\(\)/g, () => "function $$g()")
103
+ .replace(/\bfunction set\(\$\$value\)/g, () => "function $$s($$value)");
104
+ }
105
+
90
106
  const fnv = (s: string): string => {
91
107
  let h = 2166136261;
92
108
  for (let i = 0; i < s.length; i++) {
@@ -120,7 +136,29 @@ export function createInspectorBunPlugin(opts: InspectorBunPluginOptions): BunPl
120
136
  cssHash: ({ css }) => `svelte-${fnv(css)}`,
121
137
  });
122
138
 
123
- let js = result.js.code;
139
+ // Store Svelte's compile-step map. Bun's bundler doesn't chain
140
+ // onLoad sourcemaps, so the final bundle map resolves stack frames
141
+ // to the post-svelte-compile JS intermediate (`$.next()` /
142
+ // `$.append()` calls) using the .svelte filename — line numbers
143
+ // land past EOF. The inspector's runtime resolver chases bundle
144
+ // map → this svelte map (with bias-interpolated lookup) to refine
145
+ // to original source. `injectLocs` only shifts column offsets
146
+ // inside HTML open tags, never script-block line numbers, so we
147
+ // accept the small column drift to skip a second map chain.
148
+ //
149
+ // Only cache for the client target — server (Bun) and client builds
150
+ // share `svelteMapCache` keyed by abs path, but their compile output
151
+ // line numbers differ. The resolver translates browser-side stack
152
+ // frames (delivered via SSE), which run client code.
153
+ if (dev && generate === "client" && result.js.map) {
154
+ const m =
155
+ typeof result.js.map === "string"
156
+ ? JSON.parse(result.js.map)
157
+ : result.js.map;
158
+ svelteMapCache.set(args.path, m);
159
+ }
160
+
161
+ let js = dev ? fixBindShadow(result.js.code) : result.js.code;
124
162
  // Skip empty CSS — multiple +page.svelte routes with no <style> would
125
163
  // otherwise each emit an empty CSS chunk, all hashing to the same
126
164
  // content → Bun fails with "Multiple files share the same output path".
@@ -1,6 +1,9 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import type { Elysia } from "elysia";
2
4
  import { createInspectorBunPlugin } from "./bun-plugin.ts";
3
5
  import { getOverlayScript } from "./overlay.ts";
6
+ import { resolveFrame, resolveStack } from "./sourcemap.ts";
4
7
  import type { BosiaPlugin } from "../../types/plugin.ts";
5
8
 
6
9
  export interface InspectorOptions {
@@ -10,13 +13,147 @@ export interface InspectorOptions {
10
13
  aiEndpoint?: string;
11
14
  /** Endpoint path the overlay POSTs to. Defaults to `/__bosia/locate`. */
12
15
  endpoint?: string;
16
+ /** Capture client + server runtime errors and surface them in the overlay. Default `true`. */
17
+ errorsEnabled?: boolean;
13
18
  }
14
19
 
20
+ interface ServerError {
21
+ id: string;
22
+ ts: number;
23
+ source: "elysia" | "uncaught" | "rejection";
24
+ message: string;
25
+ stack?: string;
26
+ file?: string;
27
+ line?: number;
28
+ col?: number;
29
+ }
30
+
31
+ const RUNTIME_ERROR_PREFIX = "[runtime error]";
32
+
15
33
  function buildEditorArgs(editor: string, file: string, line: number, col: number): string[] {
16
34
  if (editor === "zed") return [`${file}:${line}:${col}`];
17
35
  return ["-g", `${file}:${line}:${col}`];
18
36
  }
19
37
 
38
+ // Parse the top frame out of a stack trace string. Best-effort.
39
+ function parseTopFrame(
40
+ stack: string | undefined,
41
+ ): { file: string; line: number; col: number } | null {
42
+ if (!stack) return null;
43
+ const m =
44
+ /\((https?:\/\/[^)]+|\/[^)]+):(\d+):(\d+)\)/.exec(stack) ||
45
+ /at\s+(\S+):(\d+):(\d+)/.exec(stack) ||
46
+ /@(\S+):(\d+):(\d+)/.exec(stack);
47
+ return m ? { file: m[1], line: Number(m[2]), col: Number(m[3]) } : null;
48
+ }
49
+
50
+ // Module-scoped state for the error feature. Initialised lazily inside the
51
+ // plugin so importing this module doesn't attach process-wide listeners.
52
+ let processListenersInstalled = false;
53
+ const sseClients = new Set<ReadableStreamDefaultController<Uint8Array>>();
54
+ const dedupMap = new Map<string, { lastTs: number }>();
55
+ const DEDUP_WINDOW_MS = 500;
56
+ // Small bounded replay buffer — errors that fire during a failing render
57
+ // happen *before* the 500 page's overlay can connect to SSE. On connect we
58
+ // flush recent errors so the badge isn't empty.
59
+ const REPLAY_LIMIT = 50;
60
+ const REPLAY_TTL_MS = 30_000;
61
+ const replayBuffer: ServerError[] = [];
62
+
63
+ function encode(line: string): Uint8Array {
64
+ return new TextEncoder().encode(line);
65
+ }
66
+
67
+ function broadcastError(err: ServerError) {
68
+ const payload = encode(`event: bosia-error\ndata: ${JSON.stringify(err)}\n\n`);
69
+ for (const ctrl of sseClients) {
70
+ try {
71
+ ctrl.enqueue(payload);
72
+ } catch {
73
+ sseClients.delete(ctrl);
74
+ }
75
+ }
76
+ }
77
+
78
+ function pushServerError(input: Omit<ServerError, "id" | "ts">) {
79
+ const firstFrame = input.stack ? (input.stack.split("\n").find((l) => l.trim()) ?? "") : "";
80
+ const key = `${input.source}:${input.message}:${firstFrame}`;
81
+ const now = Date.now();
82
+ const prev = dedupMap.get(key);
83
+ if (prev && now - prev.lastTs < DEDUP_WINDOW_MS) {
84
+ prev.lastTs = now;
85
+ return;
86
+ }
87
+ dedupMap.set(key, { lastTs: now });
88
+
89
+ const top = parseTopFrame(input.stack);
90
+ const err: ServerError = {
91
+ id: randomUUID(),
92
+ ts: now,
93
+ source: input.source,
94
+ message: input.message,
95
+ stack: input.stack,
96
+ file: input.file ?? top?.file,
97
+ line: input.line ?? top?.line,
98
+ col: input.col ?? top?.col,
99
+ };
100
+ replayBuffer.push(err);
101
+ if (replayBuffer.length > REPLAY_LIMIT) replayBuffer.shift();
102
+ broadcastError(err);
103
+ }
104
+
105
+ function flushReplay(ctrl: ReadableStreamDefaultController<Uint8Array>) {
106
+ const cutoff = Date.now() - REPLAY_TTL_MS;
107
+ for (const err of replayBuffer) {
108
+ if (err.ts < cutoff) continue;
109
+ try {
110
+ ctrl.enqueue(encode(`event: bosia-error\ndata: ${JSON.stringify(err)}\n\n`));
111
+ } catch {
112
+ return;
113
+ }
114
+ }
115
+ }
116
+
117
+ function installProcessListeners() {
118
+ if (processListenersInstalled) return;
119
+ processListenersInstalled = true;
120
+
121
+ process.on("uncaughtException", (err: Error) => {
122
+ pushServerError({
123
+ source: "uncaught",
124
+ message: err?.message ?? String(err),
125
+ stack: err?.stack,
126
+ });
127
+ // Rethrow so the dev runner's existing crash-recovery still triggers.
128
+ throw err;
129
+ });
130
+
131
+ process.on("unhandledRejection", (reason: unknown) => {
132
+ const e = reason as Error | undefined;
133
+ pushServerError({
134
+ source: "rejection",
135
+ message: e?.message ?? String(reason),
136
+ stack: e?.stack,
137
+ });
138
+ });
139
+ }
140
+
141
+ // Expose the push function via globalThis so framework catch sites — which
142
+ // live in a separately-bundled module graph from this disk-loaded plugin —
143
+ // can notify the inspector without an import dependency. Renderer catches
144
+ // (page load, layout load, SSR load) swallow errors and return a rendered
145
+ // error page, so they never reach Elysia `.onError()` on their own. The
146
+ // global is typed in core/devErrorReport.ts.
147
+ function installGlobalReporter() {
148
+ globalThis.__BOSIA_REPORT_ERROR__ = (e) => {
149
+ pushServerError({
150
+ source: e.source ?? "elysia",
151
+ message: e.message,
152
+ stack: e.stack,
153
+ });
154
+ };
155
+ }
156
+
20
157
  /**
21
158
  * Inspector plugin — alt+click an element in the running dev page to jump
22
159
  * to its source in your editor, or open a comment form that hands off to an
@@ -27,6 +164,7 @@ export function inspector(options: InspectorOptions = {}): BosiaPlugin | false {
27
164
  const editor = options.editor ?? "code";
28
165
  const endpoint = options.endpoint ?? "/__bosia/locate";
29
166
  const aiEndpoint = options.aiEndpoint;
167
+ const errorsEnabled = options.errorsEnabled !== false;
30
168
 
31
169
  return {
32
170
  name: "inspector",
@@ -39,7 +177,10 @@ export function inspector(options: InspectorOptions = {}): BosiaPlugin | false {
39
177
 
40
178
  backend: {
41
179
  before(app) {
42
- return app.post(endpoint, async ({ body }: { body: unknown }) => {
180
+ // Cast to the base Elysia type chaining .post/.get/.onError narrows
181
+ // the generic so the plugin return type drifts unless we widen back.
182
+ let chained: Elysia = app as unknown as Elysia;
183
+ chained = chained.post(endpoint, async ({ body }: { body: unknown }) => {
43
184
  const data = (body ?? {}) as {
44
185
  file?: string;
45
186
  line?: number;
@@ -61,6 +202,22 @@ export function inspector(options: InspectorOptions = {}): BosiaPlugin | false {
61
202
 
62
203
  const comment = typeof data.comment === "string" ? data.comment.trim() : "";
63
204
  if (comment && aiEndpoint) {
205
+ // Runtime-error comments arrive with compiled stack frames. Resolve
206
+ // them lazily here so the AI receives source paths it can actually
207
+ // edit. Plain alt-click comments skip resolution.
208
+ let finalFile = file;
209
+ let finalLine = line;
210
+ let finalCol = col;
211
+ let finalComment = comment;
212
+ if (comment.startsWith(RUNTIME_ERROR_PREFIX)) {
213
+ const resolved = resolveFrame(file, line, col);
214
+ if (resolved) {
215
+ finalFile = resolved.file;
216
+ finalLine = resolved.line;
217
+ finalCol = resolved.col;
218
+ }
219
+ finalComment = resolveStack(comment);
220
+ }
64
221
  try {
65
222
  let origin: string;
66
223
  try {
@@ -74,7 +231,12 @@ export function inspector(options: InspectorOptions = {}): BosiaPlugin | false {
74
231
  "content-type": "application/json",
75
232
  origin,
76
233
  },
77
- body: JSON.stringify({ file, line, col, comment }),
234
+ body: JSON.stringify({
235
+ file: finalFile,
236
+ line: finalLine,
237
+ col: finalCol,
238
+ comment: finalComment,
239
+ }),
78
240
  });
79
241
  return { ok: true, mode: "ai" as const };
80
242
  } catch (err) {
@@ -110,11 +272,75 @@ export function inspector(options: InspectorOptions = {}): BosiaPlugin | false {
110
272
  }
111
273
  return { ok: true, mode: "editor" as const };
112
274
  });
275
+
276
+ if (errorsEnabled) {
277
+ installProcessListeners();
278
+ installGlobalReporter();
279
+
280
+ // Elysia onError — runs because server.ts registers plugin
281
+ // backend.before() ahead of the base .onError() responder.
282
+ chained = chained.onError(({ error }) => {
283
+ const e = error as Error;
284
+ pushServerError({
285
+ source: "elysia",
286
+ message: e?.message ?? String(error),
287
+ stack: e?.stack,
288
+ });
289
+ // Fall through to the base handler.
290
+ return undefined;
291
+ }) as unknown as Elysia;
292
+
293
+ // Live SSE stream. New clients also get a flush of the bounded
294
+ // replay buffer so errors that fired during a failing render
295
+ // (before the 500 page's overlay could subscribe) are visible.
296
+ chained = chained.get(
297
+ "/__bosia/errors",
298
+ ({ request }: { request: Request }) => {
299
+ const stream = new ReadableStream<Uint8Array>({
300
+ start(ctrl) {
301
+ sseClients.add(ctrl);
302
+ try {
303
+ ctrl.enqueue(encode(":ok\n\n"));
304
+ } catch {
305
+ sseClients.delete(ctrl);
306
+ return;
307
+ }
308
+ flushReplay(ctrl);
309
+ const ping = setInterval(() => {
310
+ try {
311
+ ctrl.enqueue(encode(":ping\n\n"));
312
+ } catch {
313
+ clearInterval(ping);
314
+ sseClients.delete(ctrl);
315
+ }
316
+ }, 25_000);
317
+
318
+ request.signal.addEventListener("abort", () => {
319
+ clearInterval(ping);
320
+ sseClients.delete(ctrl);
321
+ try {
322
+ ctrl.close();
323
+ } catch {}
324
+ });
325
+ },
326
+ });
327
+ return new Response(stream, {
328
+ headers: {
329
+ "Content-Type": "text/event-stream; charset=utf-8",
330
+ "Cache-Control": "no-cache",
331
+ Connection: "keep-alive",
332
+ },
333
+ });
334
+ },
335
+ ) as unknown as Elysia;
336
+ }
337
+
338
+ return chained;
113
339
  },
114
340
  },
115
341
 
116
342
  render: {
117
- bodyEnd: () => getOverlayScript({ aiEndpoint, endpoint }),
343
+ bodyEnd: () => getOverlayScript({ aiEndpoint, endpoint, errorsEnabled }),
118
344
  },
119
345
  };
120
346
  }
@@ -3,6 +3,7 @@ import { safeJsonStringify } from "../../html.ts";
3
3
  export interface OverlayConfig {
4
4
  aiEndpoint?: string;
5
5
  endpoint: string;
6
+ errorsEnabled?: boolean;
6
7
  }
7
8
 
8
9
  export function getOverlayScript(config: OverlayConfig): string {
@@ -16,6 +17,7 @@ const OVERLAY_IIFE = `(function(){
16
17
  var CFG=window.__BOSIA_INSPECTOR__||{};
17
18
  var EP=CFG.endpoint||"/__bosia/locate";
18
19
  var AI=CFG.aiEndpoint||null;
20
+ var ERR_ENABLED=CFG.errorsEnabled!==false;
19
21
  var altDown=false,outline=null,tip=null,form=null;
20
22
 
21
23
  function ensureOutline(){
@@ -50,11 +52,11 @@ function toast(msg,err){
50
52
  setTimeout(function(){t.style.opacity="0";setTimeout(function(){t.remove()},200)},2200);
51
53
  }
52
54
 
53
- function send(payload,onOk){
55
+ function send(payload,onOk,onErr){
54
56
  fetch(EP,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(payload)})
55
57
  .then(function(r){return r.json().catch(function(){return{}})})
56
- .then(function(j){if(j&&j.ok){onOk&&onOk(j)}else{toast("Inspector: request failed",true)}})
57
- .catch(function(){toast("Inspector: network error",true)});
58
+ .then(function(j){if(j&&j.ok){onOk&&onOk(j)}else{toast("Inspector: request failed",true);onErr&&onErr()}})
59
+ .catch(function(){toast("Inspector: network error",true);onErr&&onErr()});
58
60
  }
59
61
 
60
62
  function closeForm(){if(form){form.remove();form=null}}
@@ -113,4 +115,157 @@ window.addEventListener("click",function(e){
113
115
  if(AI)openForm(loc,el);
114
116
  else send(loc,function(){toast("opened "+loc.file+":"+loc.line)});
115
117
  },true);
118
+
119
+ // ─── Error capture + badge UI ─────────────────────────────────
120
+ if(ERR_ENABLED){
121
+ var errors=[];
122
+ var lastInteraction=null;
123
+ var badge=null,panel=null,seenIds={};
124
+
125
+ function uid(){return "c-"+Math.random().toString(36).slice(2)+Date.now().toString(36)}
126
+ function escapeHtml(s){return String(s==null?"":s).replace(/[&<>"']/g,function(c){return{"&":"&amp;","<":"&lt;",">":"&gt;","\\"":"&quot;","'":"&#39;"}[c]})}
127
+
128
+ function parseTopFrame(stack){
129
+ if(!stack)return null;
130
+ var m=/\\((https?:\\/\\/[^)]+|\\/[^)]+):(\\d+):(\\d+)\\)/.exec(stack)
131
+ ||/at\\s+(\\S+):(\\d+):(\\d+)/.exec(stack)
132
+ ||/@(\\S+):(\\d+):(\\d+)/.exec(stack);
133
+ return m?{file:m[1],line:+m[2],col:+m[3]}:null;
134
+ }
135
+
136
+ function trackInteraction(e){
137
+ var t=e.target;
138
+ if(!t||!t.closest)return;
139
+ var el=t.closest("[data-bosia-loc]");
140
+ if(el)lastInteraction=el.getAttribute("data-bosia-loc");
141
+ }
142
+ window.addEventListener("mousedown",trackInteraction,true);
143
+ window.addEventListener("keydown",trackInteraction,true);
144
+
145
+ function ensureBadge(){
146
+ if(badge)return;
147
+ badge=document.createElement("div");
148
+ badge.style.cssText="position:fixed;bottom:16px;right:16px;background:#dc2626;color:#fff;font:600 12px/1 ui-sans-serif,system-ui,sans-serif;padding:8px 12px;border-radius:999px;box-shadow:0 4px 12px rgba(0,0,0,.2);cursor:pointer;z-index:2147483646;display:none;user-select:none";
149
+ badge.addEventListener("click",togglePanel);
150
+ document.body.appendChild(badge);
151
+ }
152
+ function renderBadge(){
153
+ ensureBadge();
154
+ if(!errors.length){badge.style.display="none";if(panel){panel.remove();panel=null}return}
155
+ badge.style.display="block";
156
+ badge.textContent="● "+errors.length+" error"+(errors.length===1?"":"s");
157
+ if(panel)renderPanel();
158
+ }
159
+ function togglePanel(){
160
+ if(panel){panel.remove();panel=null;return}
161
+ renderPanel();
162
+ }
163
+ function renderPanel(){
164
+ if(panel)panel.remove();
165
+ panel=document.createElement("div");
166
+ panel.style.cssText="position:fixed;bottom:56px;right:16px;width:420px;max-height:60vh;overflow:auto;background:#fff;color:#111;border:1px solid #e4e4e7;border-radius:8px;box-shadow:0 12px 32px rgba(0,0,0,.18);font:13px ui-sans-serif,system-ui,sans-serif;z-index:2147483647";
167
+ var head='<div style="padding:8px 12px;border-bottom:1px solid #e4e4e7;display:flex;justify-content:space-between;align-items:center"><strong>Runtime errors</strong><button data-clear style="border:0;background:transparent;color:#71717a;font:12px ui-sans-serif,system-ui,sans-serif;cursor:pointer">Clear all</button></div>';
168
+ var rows="";
169
+ for(var i=0;i<errors.length;i++){
170
+ var er=errors[i];
171
+ var locStr=er.file?(er.file+(er.line?":"+er.line:"")):er.source;
172
+ rows+='<div data-row="'+er.id+'" style="padding:10px 12px;border-bottom:1px solid #f4f4f5">'
173
+ +'<div style="font:11px ui-monospace,monospace;color:#71717a;margin-bottom:4px">['+escapeHtml(er.source)+'] '+escapeHtml(locStr)+'</div>'
174
+ +'<div style="margin-bottom:6px;font-weight:500;word-break:break-word">'+escapeHtml(er.message)+'</div>'
175
+ +(er.stack?'<details><summary style="cursor:pointer;color:#71717a;font-size:11px">Stack</summary><pre style="margin:6px 0 0;padding:8px;background:#f4f4f5;border-radius:4px;font:11px/1.4 ui-monospace,monospace;white-space:pre-wrap;overflow:auto;max-height:200px">'+escapeHtml(er.stack)+'</pre></details>':"")
176
+ +'<div style="margin-top:8px;display:flex;gap:6px;justify-content:flex-end">'
177
+ +(AI?'<button data-send="'+er.id+'" style="padding:4px 10px;border:0;background:#111;color:#fff;border-radius:4px;cursor:pointer;font-size:12px">Send to AI</button>':"")
178
+ +'<button data-dismiss="'+er.id+'" style="padding:4px 10px;border:1px solid #e4e4e7;background:#fff;border-radius:4px;cursor:pointer;font-size:12px">Dismiss</button>'
179
+ +'</div>'
180
+ +'</div>';
181
+ }
182
+ panel.innerHTML=head+(rows||'<div style="padding:16px;color:#71717a">No errors</div>');
183
+ document.body.appendChild(panel);
184
+ panel.querySelector("[data-clear]").addEventListener("click",function(){errors=[];seenIds={};renderBadge()});
185
+ var sendBtns=panel.querySelectorAll("[data-send]");
186
+ for(var s=0;s<sendBtns.length;s++)sendBtns[s].addEventListener("click",function(ev){
187
+ var id=ev.currentTarget.getAttribute("data-send");
188
+ sendError(id);
189
+ });
190
+ var dis=panel.querySelectorAll("[data-dismiss]");
191
+ for(var d=0;d<dis.length;d++)dis[d].addEventListener("click",function(ev){
192
+ var id=ev.currentTarget.getAttribute("data-dismiss");
193
+ removeError(id);
194
+ });
195
+ }
196
+
197
+ function removeError(id){
198
+ for(var i=0;i<errors.length;i++)if(errors[i].id===id){errors.splice(i,1);break}
199
+ delete seenIds[id];
200
+ renderBadge();
201
+ }
202
+
203
+ function sendError(id){
204
+ var er=null;
205
+ for(var i=0;i<errors.length;i++)if(errors[i].id===id){er=errors[i];break}
206
+ if(!er)return;
207
+ var top=parseTopFrame(er.stack);
208
+ var file=er.file||(top&&top.file)||"runtime";
209
+ var line=er.line||(top&&top.line)||1;
210
+ var col=er.col||(top&&top.col)||1;
211
+ var comment="[runtime error] "+er.message+"\\n\\n"+(er.stack||"(no stack)");
212
+ if(er.lastInteraction)comment+="\\n\\nLast user interaction: "+er.lastInteraction;
213
+ send({file:file,line:line,col:col,comment:comment},function(){
214
+ toast("sent to AI");
215
+ removeError(id);
216
+ });
217
+ }
218
+
219
+ function pushError(e){
220
+ var id=e.id||uid();
221
+ if(seenIds[id])return;
222
+ seenIds[id]=true;
223
+ var entry={
224
+ id:id,
225
+ ts:e.ts||Date.now(),
226
+ source:e.source||"unknown",
227
+ message:e.message||"(no message)",
228
+ stack:e.stack,
229
+ file:e.file,line:e.line,col:e.col,
230
+ lastInteraction:lastInteraction
231
+ };
232
+ errors.push(entry);
233
+ renderBadge();
234
+ }
235
+
236
+ window.addEventListener("error",function(ev){
237
+ // ev.filename/lineno/colno point at the *dispatch site* — for delegated
238
+ // events that's Svelte's internal events.js, not the user's handler. Prefer
239
+ // the top frame of ev.error.stack so the resolved source path lands in
240
+ // user code; fall back to event coords only when no stack is available.
241
+ var stack=ev.error&&ev.error.stack;
242
+ var top=parseTopFrame(stack);
243
+ pushError({
244
+ source:"window",
245
+ message:ev.message||"Unhandled error",
246
+ stack:stack,
247
+ file:(top&&top.file)||ev.filename,
248
+ line:(top&&top.line)||ev.lineno,
249
+ col:(top&&top.col)||ev.colno
250
+ });
251
+ });
252
+ window.addEventListener("unhandledrejection",function(ev){
253
+ var reason=ev.reason;
254
+ var stack=reason&&reason.stack;
255
+ var loc=parseTopFrame(stack);
256
+ pushError({
257
+ source:"rejection",
258
+ message:String(reason&&reason.message||reason||"Unhandled rejection"),
259
+ stack:stack,
260
+ file:loc&&loc.file,line:loc&&loc.line,col:loc&&loc.col
261
+ });
262
+ });
263
+
264
+ try{
265
+ var es=new EventSource("/__bosia/errors");
266
+ es.addEventListener("bosia-error",function(ev){
267
+ try{pushError(JSON.parse(ev.data))}catch(_){}
268
+ });
269
+ }catch(_){/* EventSource not available — ignore */}
270
+ }
116
271
  })();`;
@@ -0,0 +1,135 @@
1
+ import { TraceMap, originalPositionFor, GREATEST_LOWER_BOUND } from "@jridgewell/trace-mapping";
2
+ import { readFileSync, existsSync } from "node:fs";
3
+ import { dirname, resolve as pathResolve } from "node:path";
4
+ import { OUT_DIR } from "../../paths.ts";
5
+
6
+ const cache = new Map<string, TraceMap | null>();
7
+
8
+ // Per-`.svelte` (absolute path) compile maps written by the build step. The
9
+ // bundle map only resolves a stack frame to the post-svelte-compile JS
10
+ // position labeled with the .svelte filename; a second lookup against this
11
+ // map (with `bias: GREATEST_LOWER_BOUND` to interpolate sparse mappings)
12
+ // translates that intermediate position to original source line/col.
13
+ let svelteMaps: Map<string, TraceMap> | null = null;
14
+ let svelteMapsLoaded = false;
15
+
16
+ function loadSvelteMaps(): Map<string, TraceMap> | null {
17
+ if (svelteMapsLoaded) return svelteMaps;
18
+ svelteMapsLoaded = true;
19
+ try {
20
+ const p = pathResolve(process.cwd(), OUT_DIR, "svelte-maps.json");
21
+ if (!existsSync(p)) return null;
22
+ const raw = JSON.parse(readFileSync(p, "utf8")) as Record<string, unknown>;
23
+ svelteMaps = new Map();
24
+ for (const [absPath, m] of Object.entries(raw)) {
25
+ try {
26
+ svelteMaps.set(absPath, new TraceMap(m as never));
27
+ } catch {}
28
+ }
29
+ return svelteMaps;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ function loadMap(mapPath: string): TraceMap | null {
36
+ if (cache.has(mapPath)) return cache.get(mapPath)!;
37
+ try {
38
+ if (!existsSync(mapPath)) {
39
+ cache.set(mapPath, null);
40
+ return null;
41
+ }
42
+ const tm = new TraceMap(readFileSync(mapPath, "utf8"));
43
+ cache.set(mapPath, tm);
44
+ return tm;
45
+ } catch {
46
+ cache.set(mapPath, null);
47
+ return null;
48
+ }
49
+ }
50
+
51
+ // URL namespace stays at `/dist/...` but the on-disk location is `OUT_DIR`
52
+ // (e.g. `.bosia/dev` in dev). v0.5.5 decoupled these; the resolver has to
53
+ // rewrite the URL prefix back to the real filesystem prefix.
54
+ function mapPathFor(file: string): string | null {
55
+ let fsPath: string;
56
+ if (/^https?:\/\//.test(file)) {
57
+ let pathname: string;
58
+ try {
59
+ pathname = new URL(file).pathname;
60
+ } catch {
61
+ return null;
62
+ }
63
+ const relFromCwd = pathname.startsWith("/dist/")
64
+ ? OUT_DIR + pathname.slice("/dist".length)
65
+ : "." + pathname;
66
+ fsPath = pathResolve(process.cwd(), relFromCwd);
67
+ } else if (file.startsWith("/")) {
68
+ fsPath = file;
69
+ } else {
70
+ fsPath = pathResolve(process.cwd(), file);
71
+ }
72
+ return fsPath + ".map";
73
+ }
74
+
75
+ export function resolveFrame(
76
+ file: string,
77
+ line: number,
78
+ col: number,
79
+ ): { file: string; line: number; col: number } | null {
80
+ const mp = mapPathFor(file);
81
+ if (!mp) return null;
82
+ const tm = loadMap(mp);
83
+ if (!tm) return null;
84
+ const pos = originalPositionFor(tm, { line, column: col });
85
+ if (!pos.source || pos.line == null) return null;
86
+ const abs = pathResolve(dirname(mp), pos.source);
87
+
88
+ // Bundle map points at the post-svelte-compile JS position labeled with the
89
+ // .svelte filename. Refine by chasing through the cached svelte compile map
90
+ // to the real source position. Svelte's map is sparse — a given line may
91
+ // only carry mappings starting at some column. Try the exact column first,
92
+ // then fall back to the rightmost mapping on the same line, so we never lose
93
+ // a frame just because the bundle's reported column lands in a gap.
94
+ if (abs.endsWith(".svelte") || abs.endsWith(".svelte.ts") || abs.endsWith(".svelte.js")) {
95
+ const maps = loadSvelteMaps();
96
+ const svelteMap = maps?.get(abs);
97
+ if (svelteMap) {
98
+ let refined = originalPositionFor(svelteMap, {
99
+ line: pos.line,
100
+ column: pos.column ?? 0,
101
+ bias: GREATEST_LOWER_BOUND,
102
+ });
103
+ if (!refined.source) {
104
+ refined = originalPositionFor(svelteMap, {
105
+ line: pos.line,
106
+ column: Number.MAX_SAFE_INTEGER,
107
+ bias: GREATEST_LOWER_BOUND,
108
+ });
109
+ }
110
+ if (refined.source && refined.line != null) {
111
+ const refinedAbs = pathResolve(dirname(abs), refined.source);
112
+ const rel = refinedAbs.startsWith(process.cwd() + "/")
113
+ ? refinedAbs.slice(process.cwd().length + 1)
114
+ : refinedAbs;
115
+ return { file: rel, line: refined.line, col: refined.column ?? 1 };
116
+ }
117
+ }
118
+ }
119
+
120
+ const rel = abs.startsWith(process.cwd() + "/") ? abs.slice(process.cwd().length + 1) : abs;
121
+ return { file: rel, line: pos.line, col: pos.column ?? 1 };
122
+ }
123
+
124
+ // Rewrite frames in stack strings: "(url:L:C)", "at url:L:C", "@url:L:C".
125
+ // Lazy match on the file body so URLs with ports (`http://host:9000/...`) keep
126
+ // the port as part of the file rather than being chopped at the first `:`.
127
+ export function resolveStack(stack: string): string {
128
+ return stack.replace(
129
+ /(\(|at\s+|@)((?:https?:\/\/|\/)[^\s)]+?):(\d+):(\d+)(\)?)/g,
130
+ (_m, lead, file, l, c, tail) => {
131
+ const r = resolveFrame(file, Number(l), Number(c));
132
+ return r ? `${lead}${r.file}:${r.line}:${r.col}${tail}` : _m;
133
+ },
134
+ );
135
+ }
@@ -18,6 +18,7 @@ import {
18
18
  } from "./html.ts";
19
19
  import type { Metadata } from "./hooks.ts";
20
20
  import { loadPlugins } from "./config.ts";
21
+ import { reportDevErrorFromCatch } from "./devErrorReport.ts";
21
22
  import type { BosiaPlugin, RenderContext } from "./types/plugin.ts";
22
23
 
23
24
  // Plugins are loaded once per process at module init via top-level await elsewhere
@@ -362,6 +363,7 @@ export async function loadRouteData(
362
363
  }
363
364
  if (isDev) console.error("Layout server load error:", err);
364
365
  else console.error("Layout server load error:", (err as Error).message ?? err);
366
+ if (isDev) reportDevErrorFromCatch(err);
365
367
  const wrapped = new HttpError(500, "Internal Server Error");
366
368
  stampErrorContext(
367
369
  wrapped,
@@ -424,6 +426,7 @@ export async function loadRouteData(
424
426
  }
425
427
  if (isDev) console.error("Page server load error:", err);
426
428
  else console.error("Page server load error:", (err as Error).message ?? err);
429
+ if (isDev) reportDevErrorFromCatch(err);
427
430
  const wrapped = new HttpError(500, "Internal Server Error");
428
431
  stampErrorContext(
429
432
  wrapped,
@@ -552,6 +555,7 @@ export async function renderSSRStream(
552
555
  }
553
556
  if (isDev) console.error("SSR load error:", err);
554
557
  else console.error("SSR load error:", (err as Error).message ?? err);
558
+ if (isDev) reportDevErrorFromCatch(err);
555
559
  return renderErrorPage(
556
560
  500,
557
561
  "Internal Server Error",
@@ -828,6 +832,18 @@ export async function renderErrorPage(
828
832
  // Strip the nonce from emitted scripts when CSP is off — the attribute
829
833
  // is dead bytes without a matching policy header.
830
834
  if (!CSP_ENABLED) nonce = undefined;
835
+
836
+ // Inspector overlay and other plugin bodyEnd fragments must be injected
837
+ // on error pages too — otherwise SSE never connects and runtime errors
838
+ // from the failing render are invisible in the UI.
839
+ const renderCtx: RenderContext = {
840
+ request: req,
841
+ url,
842
+ route: route ? { pattern: route.pattern } : { pattern: "" },
843
+ metadata: null,
844
+ };
845
+ const bodyEndExtras = await pluginRenderFragments("bodyEnd", renderCtx);
846
+
831
847
  // 1. Nested boundary
832
848
  if (route && errorDepth !== undefined && route.errorPages?.length) {
833
849
  const origin = errorOrigin ?? "page";
@@ -868,6 +884,9 @@ export async function renderErrorPage(
868
884
  undefined,
869
885
  true,
870
886
  nonce,
887
+ null,
888
+ null,
889
+ bodyEndExtras,
871
890
  );
872
891
  return compress(html, "text/html; charset=utf-8", req, status);
873
892
  } catch (err) {
@@ -902,6 +921,9 @@ export async function renderErrorPage(
902
921
  undefined,
903
922
  true,
904
923
  nonce,
924
+ null,
925
+ null,
926
+ bodyEndExtras,
905
927
  );
906
928
  return compress(html, "text/html; charset=utf-8", req, status);
907
929
  } catch (err) {
@@ -96,18 +96,16 @@ export function generateRouteTypes(manifest: RouteManifest): void {
96
96
  `export type PageMetadataLoad = (event: _MetadataEvent) => Metadata | Promise<Metadata>;`,
97
97
  );
98
98
  lines.push(`export type Action = (event: _RequestEvent) => any;`);
99
- lines.push(
100
- `export type PageData = Awaited<ReturnType<typeof _pageLoad>> & { params: Params };`,
101
- );
99
+ lines.push(`export type PageData = Awaited<ReturnType<typeof _pageLoad>>;`);
102
100
  } else {
103
101
  lines.push(``);
104
102
  lines.push(
105
103
  `export type PageMetadataLoad = (event: _MetadataEvent) => Metadata | Promise<Metadata>;`,
106
104
  );
107
105
  lines.push(`export type Action = (event: _RequestEvent) => any;`);
108
- lines.push(`export type PageData = { params: Params };`);
106
+ lines.push(`export type PageData = {};`);
109
107
  }
110
- lines.push(`export type PageProps = { data: PageData };`);
108
+ lines.push(`export type PageProps = { data: PageData; params: Params };`);
111
109
 
112
110
  if (info.hasErrorPage) {
113
111
  lines.push(``);
@@ -135,10 +133,10 @@ export function generateRouteTypes(manifest: RouteManifest): void {
135
133
  if (info.layoutServer) {
136
134
  lines.push(`\nimport type { load as _layoutLoad } from '${srcBase}+layout.server.ts';`);
137
135
  lines.push(`export type LayoutServerLoad = (event: _LoadEvent) => any;`);
136
+ lines.push(`export type LayoutData = Awaited<ReturnType<typeof _layoutLoad>>;`);
138
137
  lines.push(
139
- `export type LayoutData = Awaited<ReturnType<typeof _layoutLoad>> & { params: Params };`,
138
+ `export type LayoutProps = { data: LayoutData; params: Params; children: any };`,
140
139
  );
141
- lines.push(`export type LayoutProps = { data: LayoutData; children: any };`);
142
140
  }
143
141
 
144
142
  const outDir = join(process.cwd(), ".bosia", "types", "src", "routes", ...segments);
@@ -24,6 +24,7 @@ import { buildCspHeader, CSP_DIRECTIVES_TEMPLATE, CSP_ENABLED, generateNonce } f
24
24
  import { isDev, compress, isStaticPath } from "./html.ts";
25
25
  import { OUT_DIR } from "./paths.ts";
26
26
  import { dedup, dedupKey } from "./dedup.ts";
27
+ import { reportDevErrorFromCatch } from "./devErrorReport.ts";
27
28
  import {
28
29
  loadRouteData,
29
30
  loadMetadata,
@@ -280,6 +281,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
280
281
  }
281
282
  if (isDev) console.error("Data endpoint error:", err);
282
283
  else console.error("Data endpoint error:", (err as Error).message ?? err);
284
+ if (isDev) reportDevErrorFromCatch(err);
283
285
  return Response.json({ error: "Internal Server Error" }, { status: 500 });
284
286
  }
285
287
  }
@@ -362,6 +364,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
362
364
  } catch (err) {
363
365
  if (isDev) console.error("API route error:", err);
364
366
  else console.error("API route error:", (err as Error).message ?? err);
367
+ if (isDev) reportDevErrorFromCatch(err);
365
368
  return Response.json({ error: "Internal Server Error" }, { status: 500 });
366
369
  }
367
370
  }
@@ -535,6 +538,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
535
538
  }
536
539
  if (isDev) console.error("Form action error:", err);
537
540
  else console.error("Form action error:", (err as Error).message ?? err);
541
+ if (isDev) reportDevErrorFromCatch(err);
538
542
  if (isEnhanced) {
539
543
  return Response.json(
540
544
  { type: "error", status: 500, message: "Internal Server Error" },
@@ -639,6 +643,7 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
639
643
  } catch (err) {
640
644
  if (isDev) console.error("Unhandled request error:", err);
641
645
  else console.error("Unhandled request error:", (err as Error).message ?? err);
646
+ if (isDev) reportDevErrorFromCatch(err);
642
647
  return Response.json({ error: "Internal Server Error" }, { status: 500 });
643
648
  } finally {
644
649
  inFlight--;
@@ -758,14 +763,15 @@ const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : isDev ? 9001 :
758
763
  // loose `Elysia` so plugin-extended types stay assignable.
759
764
  let app: Elysia = new Elysia({
760
765
  serve: { maxRequestBodySize: BODY_SIZE_LIMIT, idleTimeout: IDLE_TIMEOUT },
761
- }).onError(({ error }) => {
762
- if (isDev) console.error("Uncaught server error:", error);
763
- else console.error("Uncaught server error:", (error as Error)?.message ?? error);
764
- return Response.json({ error: "Internal Server Error" }, { status: 500 });
765
766
  }) as unknown as Elysia;
766
767
 
767
768
  // Plugins.backend.before — runs before framework middleware/routes.
768
769
  // Plugin-registered routes here BYPASS the framework (CSRF, hooks, etc.).
770
+ // Plugins register their own `.onError()` handlers here. Elysia fires onError
771
+ // handlers in registration order; plugin handlers run first and (when they
772
+ // return undefined) fall through to the base 500 responder chained after this
773
+ // loop. Registering the base responder before the loop would short-circuit
774
+ // every plugin handler.
769
775
  for (const plugin of plugins) {
770
776
  if (plugin.backend?.before) {
771
777
  try {
@@ -777,6 +783,12 @@ for (const plugin of plugins) {
777
783
  }
778
784
  }
779
785
 
786
+ app = app.onError(({ error }) => {
787
+ if (isDev) console.error("Uncaught server error:", error);
788
+ else console.error("Uncaught server error:", (error as Error)?.message ?? error);
789
+ return Response.json({ error: "Internal Server Error" }, { status: 500 });
790
+ }) as unknown as Elysia;
791
+
780
792
  app = app
781
793
  // Static files are served by resolve() with path traversal protection and security headers
782
794
  // SSR pages
@@ -3,6 +3,29 @@ import type { BunPlugin } from "bun";
3
3
 
4
4
  const svelteHash = (s: string) => Bun.hash(s, 5381).toString(36);
5
5
 
6
+ // Bun's bundler does not chain sourcemaps from `onLoad` results, so the final
7
+ // bundle map points at the compiled svelte output (e.g. `$.next()`) using the
8
+ // .svelte filename — runtime stacks resolve to nonsense line numbers past EOF.
9
+ // We capture each per-file svelte compile map here, keyed by absolute source
10
+ // path; `remapBundleSourcemaps()` reads these after `Bun.build` and rewrites
11
+ // the output `.map` files to chain back to original source positions.
12
+ export const svelteMapCache = new Map<string, unknown>();
13
+
14
+ // Svelte 5 dev compile emits named `function get()` / `function set($$value)`
15
+ // expressions inside `$.bind_*` calls (for nicer `$inspect` stack traces). Bun's
16
+ // bundler destructures `import * as $ from "svelte/internal/client"` into named
17
+ // imports, so `$.get(search)` becomes plain `get(search)` — which collides with
18
+ // the wrapping function name and recurses into itself → RangeError. Prod compile
19
+ // uses anonymous arrow functions and is unaffected.
20
+ //
21
+ // Rename to `$$g` / `$$s` (3 chars — length-preserving so cached svelte source
22
+ // map columns stay accurate). These names aren't present in svelte/internal/client.
23
+ function fixBindShadow(code: string): string {
24
+ return code
25
+ .replace(/\bfunction get\(\)/g, () => "function $$g()")
26
+ .replace(/\bfunction set\(\$\$value\)/g, () => "function $$s($$value)");
27
+ }
28
+
6
29
  export function makeBosiaSvelteCompiler(target: "browser" | "bun"): BunPlugin {
7
30
  const generate = target === "browser" ? "client" : "server";
8
31
  const dev = process.env.NODE_ENV !== "production";
@@ -25,7 +48,19 @@ export function makeBosiaSvelteCompiler(target: "browser" | "bun"): BunPlugin {
25
48
  cssHash: ({ css }) => `svelte-${svelteHash(css)}`,
26
49
  filename: args.path,
27
50
  });
28
- return { contents: result.js.code, loader: "ts" };
51
+ // Only the client target's map is useful to the inspector's runtime
52
+ // resolver — browser-side stack frames are what we need to translate.
53
+ // Server (Bun) compile output has different line numbers and would
54
+ // clobber the client entry under the same cache key.
55
+ if (dev && target === "browser" && result.js.map) {
56
+ const m =
57
+ typeof result.js.map === "string"
58
+ ? JSON.parse(result.js.map)
59
+ : result.js.map;
60
+ svelteMapCache.set(args.path, m);
61
+ }
62
+ const contents = dev ? fixBindShadow(result.js.code) : result.js.code;
63
+ return { contents, loader: "ts" };
29
64
  });
30
65
 
31
66
  build.onLoad({ filter: /\.svelte\.[tj]s$/ }, async (args) => {
@@ -38,6 +73,13 @@ export function makeBosiaSvelteCompiler(target: "browser" | "bun"): BunPlugin {
38
73
  dev,
39
74
  filename: args.path,
40
75
  });
76
+ if (dev && target === "browser" && result.js.map) {
77
+ const m =
78
+ typeof result.js.map === "string"
79
+ ? JSON.parse(result.js.map)
80
+ : result.js.map;
81
+ svelteMapCache.set(args.path, m);
82
+ }
41
83
  return { contents: result.js.code, loader: "js" };
42
84
  });
43
85
  },
@@ -19,7 +19,8 @@ src/
19
19
  ├── lib/ # Shared utilities — import via $lib
20
20
  └── routes/ # Pages and API endpoints
21
21
  ├── +layout.svelte # Root layout
22
- └── +page.svelte # Home page (/)
22
+ └── (public)/ # Public route group
23
+ └── +page.svelte # Home page (/)
23
24
  public/ # Static assets
24
25
  ```
25
26
 
@@ -29,16 +30,19 @@ Add pages by creating `+page.svelte` files under `src/routes/`:
29
30
 
30
31
  ```
31
32
  src/routes/
32
- ├── +page.svelte # /
33
- ├── about/
34
- │ └── +page.svelte # /about
35
- └── blog/
36
- ├── +page.svelte # /blog
37
- └── [slug]/
38
- ├── +page.server.ts # server loader
39
- └── +page.svelte # /blog/:slug
33
+ └── (public)/ # public route group — no URL segment
34
+ ├── +page.svelte # /
35
+ ├── about/
36
+ └── +page.svelte # /about
37
+ └── blog/
38
+ ├── +page.svelte # /blog
39
+ └── [slug]/
40
+ ├── +page.server.ts # server loader
41
+ └── +page.svelte # /blog/:slug
40
42
  ```
41
43
 
44
+ Bosia's convention: put public-facing pages under `(public)/`, and authenticated areas under their own group (e.g. `(app)/`, `(admin)/`). The parentheses make the segment a layout group — it groups routes without appearing in the URL.
45
+
42
46
  ### Server Loaders
43
47
 
44
48
  Fetch data on the server before rendering:
@@ -25,7 +25,8 @@
25
25
  </div>
26
26
 
27
27
  <p class="text-muted-foreground mt-6 text-sm">
28
- Edit <code class="bg-muted rounded px-1 py-0.5 font-mono text-xs">src/routes/+page.svelte</code> to
29
- get started
28
+ Edit <code class="bg-muted rounded px-1 py-0.5 font-mono text-xs"
29
+ >src/routes/(public)/+page.svelte</code
30
+ > to get started
30
31
  </p>
31
32
  </main>
@@ -27,7 +27,7 @@ This page lives at routes/(public)/blog/[slug]/+page.svelte but is served at /bl
27
27
  tags: ["routing", "dynamic"],
28
28
  content: `A directory named [slug] creates a dynamic route segment that matches any URL value.
29
29
 
30
- The matched value is available as params.slug inside +page.server.ts load() and inside the page component via data.params.slug.
30
+ The matched value is available as params.slug inside +page.server.ts load() and inside the page component via the params prop.
31
31
 
32
32
  The route matcher uses 3-pass priority: exact matches first, then dynamic segments, then catch-all routes.`,
33
33
  },
@@ -1,10 +1,10 @@
1
1
  <script lang="ts">
2
- import type { PageData } from "./$types";
2
+ import type { PageProps } from "./$types";
3
3
 
4
- let { data }: { data: PageData } = $props();
4
+ let { data, params }: PageProps = $props();
5
5
 
6
6
  const post = $derived(data.post);
7
- const slug = $derived(data.slug ?? data.params.slug ?? "");
7
+ const slug = $derived(data.slug ?? params.slug ?? "");
8
8
  </script>
9
9
 
10
10
  <svelte:head>