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 +1 -1
- package/src/core/client/App.svelte +73 -9
- package/src/core/client/appState.svelte.ts +6 -0
- package/src/core/dev.ts +14 -5
- package/src/core/errorMatch.ts +33 -0
- package/src/core/renderer.ts +123 -9
- package/src/core/routeFile.ts +24 -0
- package/src/core/routeTypes.ts +15 -4
- package/src/core/scanner.ts +13 -1
- package/src/core/server.ts +9 -1
- package/src/core/types.ts +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bosia",
|
|
3
|
-
"version": "0.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
|
-
//
|
|
90
|
-
|
|
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
|
|
137
|
-
{@
|
|
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 <
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/core/renderer.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
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 } },
|
package/src/core/routeFile.ts
CHANGED
|
@@ -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(" },");
|
package/src/core/routeTypes.ts
CHANGED
|
@@ -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<
|
|
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
|
|
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 (
|
|
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 };`);
|
package/src/core/scanner.ts
CHANGED
|
@@ -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).
|
package/src/core/server.ts
CHANGED
|
@@ -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({
|
|
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
|
/**
|