bosia 0.5.6 → 0.5.7

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.7",
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
@@ -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
  });
@@ -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
  }
@@ -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,75 @@
1
+ import { TraceMap, originalPositionFor } 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
+ function loadMap(mapPath: string): TraceMap | null {
9
+ if (cache.has(mapPath)) return cache.get(mapPath)!;
10
+ try {
11
+ if (!existsSync(mapPath)) {
12
+ cache.set(mapPath, null);
13
+ return null;
14
+ }
15
+ const tm = new TraceMap(readFileSync(mapPath, "utf8"));
16
+ cache.set(mapPath, tm);
17
+ return tm;
18
+ } catch {
19
+ cache.set(mapPath, null);
20
+ return null;
21
+ }
22
+ }
23
+
24
+ // URL namespace stays at `/dist/...` but the on-disk location is `OUT_DIR`
25
+ // (e.g. `.bosia/dev` in dev). v0.5.5 decoupled these; the resolver has to
26
+ // rewrite the URL prefix back to the real filesystem prefix.
27
+ function mapPathFor(file: string): string | null {
28
+ let fsPath: string;
29
+ if (/^https?:\/\//.test(file)) {
30
+ let pathname: string;
31
+ try {
32
+ pathname = new URL(file).pathname;
33
+ } catch {
34
+ return null;
35
+ }
36
+ const relFromCwd = pathname.startsWith("/dist/")
37
+ ? OUT_DIR + pathname.slice("/dist".length)
38
+ : "." + pathname;
39
+ fsPath = pathResolve(process.cwd(), relFromCwd);
40
+ } else if (file.startsWith("/")) {
41
+ fsPath = file;
42
+ } else {
43
+ fsPath = pathResolve(process.cwd(), file);
44
+ }
45
+ return fsPath + ".map";
46
+ }
47
+
48
+ export function resolveFrame(
49
+ file: string,
50
+ line: number,
51
+ col: number,
52
+ ): { file: string; line: number; col: number } | null {
53
+ const mp = mapPathFor(file);
54
+ if (!mp) return null;
55
+ const tm = loadMap(mp);
56
+ if (!tm) return null;
57
+ const pos = originalPositionFor(tm, { line, column: col });
58
+ if (!pos.source || pos.line == null) return null;
59
+ const abs = pathResolve(dirname(mp), pos.source);
60
+ const rel = abs.startsWith(process.cwd() + "/") ? abs.slice(process.cwd().length + 1) : abs;
61
+ return { file: rel, line: pos.line, col: pos.column ?? 1 };
62
+ }
63
+
64
+ // Rewrite frames in stack strings: "(url:L:C)", "at url:L:C", "@url:L:C".
65
+ // Lazy match on the file body so URLs with ports (`http://host:9000/...`) keep
66
+ // the port as part of the file rather than being chopped at the first `:`.
67
+ export function resolveStack(stack: string): string {
68
+ return stack.replace(
69
+ /(\(|at\s+|@)((?:https?:\/\/|\/)[^\s)]+?):(\d+):(\d+)(\)?)/g,
70
+ (_m, lead, file, l, c, tail) => {
71
+ const r = resolveFrame(file, Number(l), Number(c));
72
+ return r ? `${lead}${r.file}:${r.line}:${r.col}${tail}` : _m;
73
+ },
74
+ );
75
+ }
@@ -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
@@ -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>