bosia 0.3.3 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosia",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
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": [
@@ -4,6 +4,7 @@
4
4
  import { clientRoutes } from "bosia:routes";
5
5
  import { consumePrefetch, prefetchCache, dataUrl } from "./prefetch.ts";
6
6
  import { appState } from "./appState.svelte.ts";
7
+ import { pickErrorPage } from "../errorMatch.ts";
7
8
 
8
9
  let {
9
10
  ssrMode = false,
@@ -12,6 +13,9 @@
12
13
  ssrPageData = {},
13
14
  ssrLayoutData = [],
14
15
  ssrFormData = null,
16
+ ssrErrorComponent = null,
17
+ ssrErrorProps = null,
18
+ ssrErrorDepth = null,
15
19
  }: {
16
20
  ssrMode?: boolean;
17
21
  ssrPageComponent?: any;
@@ -19,6 +23,9 @@
19
23
  ssrPageData?: Record<string, any>;
20
24
  ssrLayoutData?: Record<string, any>[];
21
25
  ssrFormData?: any;
26
+ ssrErrorComponent?: any;
27
+ ssrErrorProps?: { error: { status: number; message: string } } | null;
28
+ ssrErrorDepth?: number | null;
22
29
  } = $props();
23
30
 
24
31
  let PageComponent = $state<any>(ssrPageComponent);
@@ -30,6 +37,9 @@
30
37
  const layoutData = $derived(ssrMode ? (ssrLayoutData ?? []) : appState.layoutData);
31
38
  const routeParams = $derived(ssrMode ? (ssrPageData?.params ?? {}) : appState.routeParams);
32
39
  const formData = $derived(ssrMode ? ssrFormData : appState.form);
40
+ const ErrorComponent = $derived(ssrMode ? ssrErrorComponent : appState.errorComponent);
41
+ const errorProps = $derived(ssrMode ? ssrErrorProps : appState.errorProps);
42
+ const errorDepth = $derived(ssrMode ? ssrErrorDepth : appState.errorDepth);
33
43
  let navigating = $state(false);
34
44
  let navDone = $state(false);
35
45
  // Skip bar on the very first effect run (initial hydration — data already present)
@@ -74,7 +84,7 @@
74
84
  match.route.page(),
75
85
  Promise.all(match.route.layouts.map((l: any) => l())),
76
86
  dataFetch,
77
- ]).then(([pageMod, layoutMods, result]: [any, any[], any]) => {
87
+ ]).then(async ([pageMod, layoutMods, result]: [any, any[], any]) => {
78
88
  if (cancelled) return;
79
89
  navigating = false;
80
90
  navDone = true;
@@ -86,8 +96,49 @@
86
96
  return;
87
97
  }
88
98
  if (result?.error || (result === null && match.route.hasServerData)) {
89
- // Data fetch failed (e.g. static hosting with no server) full page load
90
- window.location.href = path;
99
+ // New shape: { error: { status, message }, errorDepth, errorOrigin }
100
+ const errInfo = result?.error;
101
+ const errStatus =
102
+ typeof errInfo === "object" && errInfo !== null
103
+ ? (errInfo.status ?? 500)
104
+ : (result?.status ?? 500);
105
+ const errMessage =
106
+ typeof errInfo === "object" && errInfo !== null
107
+ ? (errInfo.message ?? "Internal Server Error")
108
+ : typeof errInfo === "string"
109
+ ? errInfo
110
+ : "Internal Server Error";
111
+ const errDepth: number =
112
+ typeof result?.errorDepth === "number"
113
+ ? result.errorDepth
114
+ : match.route.layouts.length;
115
+ const errOrigin = result?.errorOrigin === "layout" ? "layout" : "page";
116
+ const picked = pickErrorPage(match.route.errorPages ?? [], errDepth, errOrigin);
117
+ if (!picked) {
118
+ // No nested boundary — full reload so server can render global error page
119
+ window.location.href = path;
120
+ return;
121
+ }
122
+ try {
123
+ const K = picked.depth;
124
+ const [errMod, ...layoutModsForError] = await Promise.all([
125
+ picked.loader(),
126
+ ...match.route.layouts.slice(0, K).map((l: any) => l()),
127
+ ]);
128
+ if (cancelled) return;
129
+ layoutComponents = layoutModsForError.map((m: any) => m.default);
130
+ const newLayoutData: Record<string, any>[] = [];
131
+ for (let i = 0; i < K; i++) newLayoutData.push({});
132
+ appState.layoutData = newLayoutData;
133
+ appState.pageData = {};
134
+ appState.routeParams = match.params;
135
+ appState.errorComponent = errMod.default;
136
+ appState.errorProps = { error: { status: errStatus, message: errMessage } };
137
+ appState.errorDepth = K;
138
+ if (router.isPush) window.scrollTo(0, 0);
139
+ } catch {
140
+ window.location.href = path;
141
+ }
91
142
  return;
92
143
  }
93
144
  PageComponent = pageMod.default;
@@ -95,6 +146,10 @@
95
146
  appState.pageData = result?.pageData ?? {};
96
147
  appState.layoutData = result?.layoutData ?? [];
97
148
  appState.routeParams = result?.pageData?.params ?? match.params;
149
+ // Successful navigation — clear any prior error state.
150
+ appState.errorComponent = null;
151
+ appState.errorProps = null;
152
+ appState.errorDepth = null;
98
153
 
99
154
  // Scroll to top on forward navigation (not on popstate/back-forward)
100
155
  if (router.isPush) window.scrollTo(0, 0);
@@ -133,25 +188,34 @@
133
188
  <div class="bosia-bar done"></div>
134
189
  {/if}
135
190
 
136
- {#if layoutComponents.length > 0}
137
- {@render renderLayout(0)}
191
+ {#if ErrorComponent}
192
+ {@const depth = errorDepth ?? 0}
193
+ {#if depth > 0 && layoutComponents.length > 0}
194
+ {@render renderLayout(0, depth)}
195
+ {:else}
196
+ <ErrorComponent {...errorProps ?? {}} />
197
+ {/if}
198
+ {:else if layoutComponents.length > 0}
199
+ {@render renderLayout(0, layoutComponents.length)}
138
200
  {:else if PageComponent}
139
201
  <PageComponent data={{ ...pageData, params: routeParams }} form={formData} />
140
202
  {:else}
141
203
  <p>Loading...</p>
142
204
  {/if}
143
205
 
144
- {#snippet renderLayout(index: number)}
206
+ {#snippet renderLayout(index: number, leafDepth: number)}
145
207
  {@const Layout = layoutComponents[index]}
146
208
  {@const data = layoutData[index] ?? {}}
147
209
 
148
- {#if index < layoutComponents.length - 1}
210
+ {#if index < leafDepth - 1}
149
211
  <Layout {data}>
150
- {@render renderLayout(index + 1)}
212
+ {@render renderLayout(index + 1, leafDepth)}
151
213
  </Layout>
152
214
  {:else}
153
215
  <Layout {data}>
154
- {#if PageComponent}
216
+ {#if ErrorComponent}
217
+ <ErrorComponent {...errorProps ?? {}} />
218
+ {:else if PageComponent}
155
219
  <PageComponent data={{ ...pageData, params: routeParams }} form={formData} />
156
220
  {:else}
157
221
  <p>Loading...</p>
@@ -15,6 +15,12 @@ class AppState {
15
15
  layoutData = $state<Record<string, any>[]>([]);
16
16
  routeParams = $state<Record<string, string>>({});
17
17
  form = $state<any>(null);
18
+ // Nested-error boundary state — set when a client navigation hits an
19
+ // error and a matching +error.svelte is found. Cleared on every
20
+ // successful navigation in App.svelte.
21
+ errorComponent = $state<any>(null);
22
+ errorProps = $state<{ error: { status: number; message: string } } | null>(null);
23
+ errorDepth = $state<number | null>(null);
18
24
  }
19
25
 
20
26
  export const appState = new AppState();
package/src/core/dev.ts CHANGED
@@ -163,7 +163,7 @@ function scheduleBuild() {
163
163
  // Owns the SSE connection so it survives app server restarts.
164
164
  // All other requests are proxied to the app server.
165
165
 
166
- Bun.serve({
166
+ const devServer = Bun.serve({
167
167
  port: DEV_PORT,
168
168
  idleTimeout: 255,
169
169
  async fetch(req) {
@@ -242,7 +242,7 @@ function isGenerated(path: string): boolean {
242
242
  return GENERATED.some((g) => path.startsWith(g));
243
243
  }
244
244
 
245
- watch(join(process.cwd(), "src"), { recursive: true }, (_event, filename) => {
245
+ const srcWatcher = watch(join(process.cwd(), "src"), { recursive: true }, (_event, filename) => {
246
246
  if (!filename) return;
247
247
  const abs = join(process.cwd(), "src", filename);
248
248
  if (isGenerated(abs)) return;
@@ -257,7 +257,7 @@ watch(join(process.cwd(), "src"), { recursive: true }, (_event, filename) => {
257
257
 
258
258
  const ENV_FILES = new Set([".env", ".env.local", ".env.development", ".env.development.local"]);
259
259
 
260
- watch(process.cwd(), { recursive: false }, (_event, filename) => {
260
+ const envWatcher = watch(process.cwd(), { recursive: false }, (_event, filename) => {
261
261
  if (!filename || !ENV_FILES.has(filename)) return;
262
262
  console.log(`[watch] env changed: ${filename}`);
263
263
  reloadEnv();
@@ -274,14 +274,23 @@ console.log("👀 Watching src/ for changes...\n");
274
274
 
275
275
  let shuttingDown = false;
276
276
  async function shutdown() {
277
- if (shuttingDown) process.exit(130);
277
+ if (shuttingDown) return; // re-entry from process-group signals or impatient ^C — drain is already running
278
278
  shuttingDown = true;
279
279
  intentionalKill = true;
280
+
281
+ if (buildTimer) clearTimeout(buildTimer);
282
+ srcWatcher.close();
283
+ envWatcher.close();
284
+ devServer.stop(true); // closes SSE conns → abort listeners clear ping intervals
285
+
280
286
  if (appProcess) {
281
287
  appProcess.kill("SIGTERM");
282
288
  await Promise.race([appProcess.exited, Bun.sleep(2_500)]);
283
289
  }
284
- process.exit(0);
290
+
291
+ // Safety net: if any stray handle still holds the loop, force clean exit.
292
+ // .unref() so the timer itself doesn't keep the loop alive when drain succeeds.
293
+ setTimeout(() => process.exit(0), 1_500).unref();
285
294
  }
286
295
 
287
296
  process.on("SIGINT", shutdown);
@@ -0,0 +1,33 @@
1
+ // ─── Nested Error-Page Matcher ────────────────────────────
2
+ // Picks the deepest +error.svelte boundary that protects the failing
3
+ // code. Shared by SSR (renderer.ts) and CSR (App.svelte) so client
4
+ // and server agree on which boundary catches a thrown error.
5
+ //
6
+ // Catch rules (SvelteKit-compatible):
7
+ // - "page" origin: error in +page / +page.server at depth = layouts.length
8
+ // → caught by deepest entry where `depth ≤ errorDepth`.
9
+ // - "layout" origin: error in +layout.server (or layout render) at depth L
10
+ // → caught by deepest entry where `depth < errorDepth`. An error
11
+ // page in the same dir as the failing layout cannot catch its own
12
+ // layout — it would render *inside* the broken layout.
13
+
14
+ export type ErrorOrigin = "page" | "layout";
15
+
16
+ export interface ErrorPageEntry<L = unknown> {
17
+ loader: L;
18
+ depth: number;
19
+ }
20
+
21
+ export function pickErrorPage<L>(
22
+ errorPages: readonly ErrorPageEntry<L>[],
23
+ errorDepth: number,
24
+ origin: ErrorOrigin,
25
+ ): ErrorPageEntry<L> | null {
26
+ let best: ErrorPageEntry<L> | null = null;
27
+ for (const ep of errorPages) {
28
+ const ok = origin === "page" ? ep.depth <= errorDepth : ep.depth < errorDepth;
29
+ if (!ok) continue;
30
+ if (!best || ep.depth > best.depth) best = ep;
31
+ }
32
+ return best;
33
+ }
@@ -4,6 +4,7 @@ import { findMatch } from "./matcher.ts";
4
4
  import { serverRoutes, errorPage } from "bosia:routes";
5
5
  import type { Cookies } from "./hooks.ts";
6
6
  import { HttpError, Redirect } from "./errors.ts";
7
+ import { pickErrorPage, type ErrorOrigin } from "./errorMatch.ts";
7
8
  import App from "./client/App.svelte";
8
9
  import {
9
10
  buildHtml,
@@ -107,6 +108,28 @@ function makeFetch(req: Request, url: URL) {
107
108
  };
108
109
  }
109
110
 
111
+ // ─── Error Context Stamping ──────────────────────────────
112
+ // Annotate an HttpError with the layout depth and origin where it was
113
+ // thrown, plus the partial layoutData accumulated so far. The data
114
+ // endpoint forwards this to the client; the SSR catch sites use it to
115
+ // render the right nested boundary inside the right layout chain.
116
+
117
+ function stampErrorContext(
118
+ err: HttpError,
119
+ depth: number,
120
+ origin: ErrorOrigin,
121
+ partialLayoutData: Record<string, any>[],
122
+ ): void {
123
+ const e = err as HttpError & {
124
+ errorDepth?: number;
125
+ errorOrigin?: ErrorOrigin;
126
+ partialLayoutData?: Record<string, any>[];
127
+ };
128
+ e.errorDepth ??= depth;
129
+ e.errorOrigin ??= origin;
130
+ e.partialLayoutData ??= [...partialLayoutData];
131
+ }
132
+
110
133
  // ─── Route Data Loader ───────────────────────────────────
111
134
  // Runs layout + page server loaders for a given URL.
112
135
  // Used by both SSR and the /__bosia/data JSON endpoint.
@@ -143,10 +166,16 @@ export async function loadRouteData(
143
166
  )) ?? {};
144
167
  }
145
168
  } catch (err) {
146
- if (err instanceof HttpError || err instanceof Redirect) throw err;
169
+ if (err instanceof Redirect) throw err;
170
+ if (err instanceof HttpError) {
171
+ stampErrorContext(err, ls.depth, "layout", layoutData);
172
+ throw err;
173
+ }
147
174
  if (isDev) console.error("Layout server load error:", err);
148
175
  else console.error("Layout server load error:", (err as Error).message ?? err);
149
- throw new HttpError(500, "Internal Server Error");
176
+ const wrapped = new HttpError(500, "Internal Server Error");
177
+ stampErrorContext(wrapped, ls.depth, "layout", layoutData);
178
+ throw wrapped;
150
179
  }
151
180
  }
152
181
 
@@ -181,10 +210,16 @@ export async function loadRouteData(
181
210
  )) ?? {};
182
211
  }
183
212
  } catch (err) {
184
- if (err instanceof HttpError || err instanceof Redirect) throw err;
213
+ if (err instanceof Redirect) throw err;
214
+ if (err instanceof HttpError) {
215
+ stampErrorContext(err, route.layoutModules.length, "page", layoutData);
216
+ throw err;
217
+ }
185
218
  if (isDev) console.error("Page server load error:", err);
186
219
  else console.error("Page server load error:", (err as Error).message ?? err);
187
- throw new HttpError(500, "Internal Server Error");
220
+ const wrapped = new HttpError(500, "Internal Server Error");
221
+ stampErrorContext(wrapped, route.layoutModules.length, "page", layoutData);
222
+ throw wrapped;
188
223
  }
189
224
  }
190
225
 
@@ -244,7 +279,7 @@ export async function renderSSRStream(
244
279
  return Response.redirect(err.location, err.status);
245
280
  }
246
281
  if (err instanceof HttpError) {
247
- return renderErrorPage(err.status, err.message, url, req);
282
+ return renderErrorPage(err.status, err.message, url, req, route);
248
283
  }
249
284
  if (isDev) console.error("Metadata load error:", err);
250
285
  else console.error("Metadata load error:", (err as Error).message ?? err);
@@ -266,10 +301,26 @@ export async function renderSSRStream(
266
301
  ]);
267
302
  } catch (err) {
268
303
  if (err instanceof Redirect) return Response.redirect(err.location, err.status);
269
- if (err instanceof HttpError) return renderErrorPage(err.status, err.message, url, req);
304
+ if (err instanceof HttpError) {
305
+ const e = err as HttpError & {
306
+ errorDepth?: number;
307
+ errorOrigin?: ErrorOrigin;
308
+ partialLayoutData?: Record<string, any>[];
309
+ };
310
+ return renderErrorPage(
311
+ err.status,
312
+ err.message,
313
+ url,
314
+ req,
315
+ route,
316
+ e.errorDepth,
317
+ e.errorOrigin,
318
+ e.partialLayoutData,
319
+ );
320
+ }
270
321
  if (isDev) console.error("SSR load error:", err);
271
322
  else console.error("SSR load error:", (err as Error).message ?? err);
272
- return renderErrorPage(500, "Internal Server Error", url, req);
323
+ return renderErrorPage(500, "Internal Server Error", url, req, route);
273
324
  }
274
325
 
275
326
  if (!data) return renderErrorPage(404, "Not Found", url, req);
@@ -309,7 +360,17 @@ export async function renderSSRStream(
309
360
  } catch (err) {
310
361
  if (isDev) console.error("SSR render error:", err);
311
362
  else console.error("SSR render error:", (err as Error).message ?? err);
312
- return renderErrorPage(500, "Internal Server Error", url, req);
363
+ // Render-phase errors fall through to deepest boundary like a page error.
364
+ return renderErrorPage(
365
+ 500,
366
+ "Internal Server Error",
367
+ url,
368
+ req,
369
+ route,
370
+ route.layoutModules.length,
371
+ "page",
372
+ data.layoutData,
373
+ );
313
374
  }
314
375
 
315
376
  // Pre-compute all chunks; pull-based stream gives Bun native backpressure.
@@ -411,18 +472,71 @@ export async function renderPageWithFormData(
411
472
  }
412
473
 
413
474
  // ─── Error Page Renderer ──────────────────────────────────
475
+ // 1. If a route is known, try the nearest nested +error.svelte and render
476
+ // it inside the matching prefix of the layout chain.
477
+ // 2. Otherwise fall back to the global root +error.svelte.
478
+ // 3. Otherwise return a plain-text response.
414
479
 
415
480
  export async function renderErrorPage(
416
481
  status: number,
417
482
  message: string,
418
483
  url: URL,
419
484
  req: Request,
485
+ route?: any,
486
+ errorDepth?: number,
487
+ errorOrigin?: ErrorOrigin,
488
+ partialLayoutData?: Record<string, any>[],
420
489
  ): Promise<Response> {
490
+ // 1. Nested boundary
491
+ if (route && errorDepth !== undefined && route.errorPages?.length) {
492
+ const origin = errorOrigin ?? "page";
493
+ const picked = pickErrorPage<() => Promise<any>>(
494
+ route.errorPages as { loader: () => Promise<any>; depth: number }[],
495
+ errorDepth,
496
+ origin,
497
+ );
498
+ if (picked) {
499
+ try {
500
+ const K = picked.depth;
501
+ const [errorMod, layoutMods] = await Promise.all([
502
+ picked.loader(),
503
+ Promise.all(
504
+ route.layoutModules.slice(0, K).map((l: () => Promise<any>) => l()),
505
+ ),
506
+ ]);
507
+ const layoutData: Record<string, any>[] = [];
508
+ for (let i = 0; i < K; i++) layoutData.push(partialLayoutData?.[i] ?? {});
509
+ const { body, head } = render(App, {
510
+ props: {
511
+ ssrMode: true,
512
+ ssrLayoutComponents: layoutMods.map((m: any) => m.default),
513
+ ssrLayoutData: layoutData,
514
+ ssrErrorComponent: errorMod.default,
515
+ ssrErrorProps: { error: { status, message } },
516
+ ssrErrorDepth: K,
517
+ },
518
+ });
519
+ // csr=false: no client hydration on the error page itself.
520
+ const html = buildHtml(body, head, { status, message }, layoutData, false);
521
+ return compress(html, "text/html; charset=utf-8", req, status);
522
+ } catch (err) {
523
+ if (isDev) console.error("Nested error page render failed:", err);
524
+ else
525
+ console.error(
526
+ "Nested error page render failed:",
527
+ (err as Error).message ?? err,
528
+ );
529
+ // fall through to global / text fallback
530
+ }
531
+ }
532
+ }
533
+
534
+ // 2. Global root error page
421
535
  if (errorPage) {
422
536
  try {
423
537
  const mod = await errorPage();
424
538
  // Render the error component directly — NOT through App.svelte.
425
- // App.svelte always remaps ssrPageData to a `data` prop, but +error.svelte
539
+ // App.svelte remaps ssrPageData to a `data` prop, but +error.svelte
426
540
  // expects `error` as a direct prop: `let { error } = $props()`.
427
541
  const { body, head } = render(mod.default, {
428
542
  props: { error: { status, message } },
@@ -37,6 +37,7 @@ export function generateRoutesFile(manifest: RouteManifest): void {
37
37
  lines.push(" pattern: string;");
38
38
  lines.push(" page: () => Promise<any>;");
39
39
  lines.push(" layouts: (() => Promise<any>)[];");
40
+ lines.push(" errorPages: { loader: () => Promise<any>; depth: number }[];");
40
41
  lines.push(" hasServerData: boolean;");
41
42
  lines.push(' trailingSlash: "never" | "always" | "ignore";');
42
43
  lines.push("}> = [");
@@ -44,11 +45,18 @@ export function generateRoutesFile(manifest: RouteManifest): void {
44
45
  const layoutImports = r.layouts
45
46
  .map((l) => `() => import(${JSON.stringify(toImportPath(l))})`)
46
47
  .join(", ");
48
+ const errorPageImports = r.errorPages
49
+ .map(
50
+ (ep) =>
51
+ `{ loader: () => import(${JSON.stringify(toImportPath(ep.path))}), depth: ${ep.depth} }`,
52
+ )
53
+ .join(", ");
47
54
  const hasServerData = !!(r.pageServer || r.layoutServers.length > 0);
48
55
  lines.push(" {");
49
56
  lines.push(` pattern: ${JSON.stringify(r.pattern)},`);
50
57
  lines.push(` page: () => import(${JSON.stringify(toImportPath(r.page))}),`);
51
58
  lines.push(` layouts: [${layoutImports}],`);
59
+ lines.push(` errorPages: [${errorPageImports}],`);
52
60
  lines.push(` hasServerData: ${hasServerData},`);
53
61
  lines.push(` trailingSlash: ${JSON.stringify(r.trailingSlash)},`);
54
62
  lines.push(" },");
@@ -62,6 +70,7 @@ export function generateRoutesFile(manifest: RouteManifest): void {
62
70
  lines.push(" layoutModules: (() => Promise<any>)[];");
63
71
  lines.push(" pageServer: (() => Promise<any>) | null;");
64
72
  lines.push(" layoutServers: { loader: () => Promise<any>; depth: number }[];");
73
+ lines.push(" errorPages: { loader: () => Promise<any>; depth: number }[];");
65
74
  lines.push(' trailingSlash: "never" | "always" | "ignore";');
66
75
  lines.push(' scope: "public" | "private";');
67
76
  lines.push("}> = [");
@@ -75,6 +84,12 @@ export function generateRoutesFile(manifest: RouteManifest): void {
75
84
  `{ loader: () => import(${JSON.stringify(toImportPath(ls.path))}), depth: ${ls.depth} }`,
76
85
  )
77
86
  .join(", ");
87
+ const errorPageImports = r.errorPages
88
+ .map(
89
+ (ep) =>
90
+ `{ loader: () => import(${JSON.stringify(toImportPath(ep.path))}), depth: ${ep.depth} }`,
91
+ )
92
+ .join(", ");
78
93
  lines.push(" {");
79
94
  lines.push(` pattern: ${JSON.stringify(r.pattern)},`);
80
95
  lines.push(` pageModule: () => import(${JSON.stringify(toImportPath(r.page))}),`);
@@ -83,6 +98,7 @@ export function generateRoutesFile(manifest: RouteManifest): void {
83
98
  ` pageServer: ${r.pageServer ? `() => import(${JSON.stringify(toImportPath(r.pageServer))})` : "null"},`,
84
99
  );
85
100
  lines.push(` layoutServers: [${layoutServerImports}],`);
101
+ lines.push(` errorPages: [${errorPageImports}],`);
86
102
  lines.push(` trailingSlash: ${JSON.stringify(r.trailingSlash)},`);
87
103
  lines.push(` scope: ${JSON.stringify(r.scope)},`);
88
104
  lines.push(" },");
@@ -138,6 +154,7 @@ function generateClientRoutesFile(
138
154
  lines.push(" pattern: string;");
139
155
  lines.push(" page: () => Promise<any>;");
140
156
  lines.push(" layouts: (() => Promise<any>)[];");
157
+ lines.push(" errorPages: { loader: () => Promise<any>; depth: number }[];");
141
158
  lines.push(" hasServerData: boolean;");
142
159
  lines.push(' trailingSlash: "never" | "always" | "ignore";');
143
160
  lines.push("}> = [");
@@ -145,11 +162,18 @@ function generateClientRoutesFile(
145
162
  const layoutImports = r.layouts
146
163
  .map((l) => `() => import(${JSON.stringify(toImportPath(l))})`)
147
164
  .join(", ");
165
+ const errorPageImports = r.errorPages
166
+ .map(
167
+ (ep) =>
168
+ `{ loader: () => import(${JSON.stringify(toImportPath(ep.path))}), depth: ${ep.depth} }`,
169
+ )
170
+ .join(", ");
148
171
  const hasServerData = !!(r.pageServer || r.layoutServers.length > 0);
149
172
  lines.push(" {");
150
173
  lines.push(` pattern: ${JSON.stringify(r.pattern)},`);
151
174
  lines.push(` page: () => import(${JSON.stringify(toImportPath(r.page))}),`);
152
175
  lines.push(` layouts: [${layoutImports}],`);
176
+ lines.push(` errorPages: [${errorPageImports}],`);
153
177
  lines.push(` hasServerData: ${hasServerData},`);
154
178
  lines.push(` trailingSlash: ${JSON.stringify(r.trailingSlash)},`);
155
179
  lines.push(" },");
@@ -34,8 +34,11 @@ function paramsForDir(dir: string): string[] {
34
34
  }
35
35
 
36
36
  export function generateRouteTypes(manifest: RouteManifest): void {
37
- // Collect { dir → { pageServer?, layoutServer? } }
38
- const dirs = new Map<string, { pageServer?: string; layoutServer?: string }>();
37
+ // Collect { dir → { pageServer?, layoutServer?, hasErrorPage? } }
38
+ const dirs = new Map<
39
+ string,
40
+ { pageServer?: string; layoutServer?: string; hasErrorPage?: boolean }
41
+ >();
39
42
 
40
43
  for (const route of manifest.pages) {
41
44
  const pageDir = routeDirOf(route.page);
@@ -48,9 +51,17 @@ export function generateRouteTypes(manifest: RouteManifest): void {
48
51
  if (!dirs.has(lsDir)) dirs.set(lsDir, {});
49
52
  dirs.get(lsDir)!.layoutServer = ls.path;
50
53
  }
54
+ for (const ep of route.errorPages ?? []) {
55
+ const epDir = routeDirOf(ep.path);
56
+ if (!dirs.has(epDir)) dirs.set(epDir, {});
57
+ dirs.get(epDir)!.hasErrorPage = true;
58
+ }
51
59
  }
52
60
 
53
- if (manifest.errorPage && !dirs.has(".")) dirs.set(".", {});
61
+ if (manifest.errorPage) {
62
+ if (!dirs.has(".")) dirs.set(".", {});
63
+ dirs.get(".")!.hasErrorPage = true;
64
+ }
54
65
 
55
66
  for (const [dir, info] of dirs) {
56
67
  // Path segments of the route dir (empty array for root ".")
@@ -98,7 +109,7 @@ export function generateRouteTypes(manifest: RouteManifest): void {
98
109
  }
99
110
  lines.push(`export type PageProps = { data: PageData };`);
100
111
 
101
- if (dir === "." && manifest.errorPage) {
112
+ if (info.hasErrorPage) {
102
113
  lines.push(``);
103
114
  lines.push(`export type PageError = { status: number; message: string };`);
104
115
  lines.push(`export type ErrorProps = { error: PageError };`);
@@ -43,6 +43,7 @@ export function scanRoutes(): RouteManifest {
43
43
  urlSegments: string[],
44
44
  layoutChain: string[],
45
45
  layoutServerChain: { path: string; depth: number }[],
46
+ errorPageChain: { path: string; depth: number }[],
46
47
  inheritedTrailingSlash: TrailingSlash,
47
48
  inheritedScope: "public" | "private",
48
49
  ) {
@@ -54,6 +55,7 @@ export function scanRoutes(): RouteManifest {
54
55
  // Accumulate layouts for this level
55
56
  const currentLayouts = [...layoutChain];
56
57
  const currentLayoutServers = [...layoutServerChain];
58
+ const currentErrorPages = [...errorPageChain];
57
59
  let currentTrailingSlash = inheritedTrailingSlash;
58
60
 
59
61
  if (items.some((i) => i.isFile() && i.name === "+layout.svelte")) {
@@ -68,6 +70,14 @@ export function scanRoutes(): RouteManifest {
68
70
  const ts = readTrailingSlash(join(ROUTES_DIR, layoutServerPath));
69
71
  if (ts) currentTrailingSlash = ts;
70
72
  }
73
+ if (items.some((i) => i.isFile() && i.name === "+error.svelte")) {
74
+ // depth = number of layouts wrapping this dir (this dir's layout included).
75
+ // An error page at depth K renders inside layouts[0..K-1].
76
+ currentErrorPages.push({
77
+ path: join(dir, "+error.svelte"),
78
+ depth: currentLayouts.length,
79
+ });
80
+ }
71
81
 
72
82
  // API route (+server.ts)
73
83
  if (items.some((i) => i.isFile() && i.name === "+server.ts")) {
@@ -94,6 +104,7 @@ export function scanRoutes(): RouteManifest {
94
104
  layouts: [...currentLayouts],
95
105
  pageServer: pageServerFile,
96
106
  layoutServers: [...currentLayoutServers],
107
+ errorPages: [...currentErrorPages],
97
108
  trailingSlash: effectiveTs,
98
109
  scope: inheritedScope,
99
110
  });
@@ -116,13 +127,14 @@ export function scanRoutes(): RouteManifest {
116
127
  isGroup ? [...urlSegments] : [...urlSegments, dirName],
117
128
  currentLayouts,
118
129
  currentLayoutServers,
130
+ currentErrorPages,
119
131
  currentTrailingSlash,
120
132
  childScope,
121
133
  );
122
134
  }
123
135
  }
124
136
 
125
- walk("", [], [], [], "never", "public");
137
+ walk("", [], [], [], [], "never", "public");
126
138
 
127
139
  // Warn when a catch-all exists but no exact route covers its prefix.
128
140
  // e.g. "/[...slug]" matches everything EXCEPT "/" (which needs its own +page.svelte).
@@ -210,8 +210,16 @@ async function resolve(event: RequestEvent): Promise<Response> {
210
210
  );
211
211
  }
212
212
  if (err instanceof HttpError) {
213
+ const e = err as HttpError & {
214
+ errorDepth?: number;
215
+ errorOrigin?: "page" | "layout";
216
+ };
213
217
  return compress(
214
- JSON.stringify({ error: err.message, status: err.status }),
218
+ JSON.stringify({
219
+ error: { status: err.status, message: err.message },
220
+ errorDepth: e.errorDepth ?? null,
221
+ errorOrigin: e.errorOrigin ?? null,
222
+ }),
215
223
  "application/json",
216
224
  request,
217
225
  err.status,
package/src/core/types.ts CHANGED
@@ -15,6 +15,13 @@ export interface PageRoute {
15
15
  pageServer: string | null;
16
16
  /** Chain of +layout.server.ts files root → leaf, with their layout depth */
17
17
  layoutServers: { path: string; depth: number }[];
18
+ /**
19
+ * Chain of +error.svelte files root → leaf. `depth` is the layout depth this
20
+ * boundary protects: errors thrown by code at depth ≥ `depth` (page) or
21
+ * depth > `depth` (layout) are caught by this page. Depth 0 = wrapped by no
22
+ * layouts; depth N = wrapped by layouts[0..N-1].
23
+ */
24
+ errorPages: { path: string; depth: number }[];
18
25
  /** Effective trailing-slash mode (page wins over layout chain). Defaults to "never". */
19
26
  trailingSlash: TrailingSlash;
20
27
  /**