bosia 0.3.3 → 0.4.0

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.4.0",
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": [
@@ -32,7 +32,8 @@
32
32
  ],
33
33
  "exports": {
34
34
  ".": "./src/lib/index.ts",
35
- "./client": "./src/lib/client.ts"
35
+ "./client": "./src/lib/client.ts",
36
+ "./plugins/server-timing": "./src/core/plugins/server-timing.ts"
36
37
  },
37
38
  "bin": {
38
39
  "bosia": "src/cli/index.ts"
package/src/core/build.ts CHANGED
@@ -10,6 +10,8 @@ import { prerenderStaticRoutes, generateStaticSite } from "./prerender.ts";
10
10
  import { loadEnv, classifyEnvVars } from "./env.ts";
11
11
  import { generateEnvModules } from "./envCodegen.ts";
12
12
  import { BOSIA_NODE_PATH, resolveBosiaBin } from "./paths.ts";
13
+ import { loadPlugins } from "./config.ts";
14
+ import type { BuildContext } from "./types/plugin.ts";
13
15
 
14
16
  // Resolved from this file's location inside the bosia package
15
17
  const CORE_DIR = import.meta.dir;
@@ -21,7 +23,24 @@ const isProduction = process.env.NODE_ENV === "production";
21
23
  const buildStart = performance.now();
22
24
  console.log("🏗️ Starting Bosia build...\n");
23
25
 
24
- // 0. Load .env files (before cleaning .bosia so loadEnv can set process.env early)
26
+ // 0. Load plugins from bosia.config.ts
27
+ const userPlugins = await loadPlugins(process.cwd());
28
+ if (userPlugins.length > 0) {
29
+ console.log(`🔌 Plugins: ${userPlugins.map((p) => p.name).join(", ")}`);
30
+ }
31
+
32
+ const buildCtx: BuildContext = {
33
+ mode: isProduction ? "production" : "development",
34
+ cwd: process.cwd(),
35
+ };
36
+
37
+ for (const p of userPlugins) {
38
+ if (p.build?.preBuild) {
39
+ await p.build.preBuild(buildCtx);
40
+ }
41
+ }
42
+
43
+ // 0a. Load .env files (before cleaning .bosia so loadEnv can set process.env early)
25
44
  const envMode = isProduction ? "production" : "development";
26
45
  const envVars = loadEnv(envMode);
27
46
  const classifiedEnv = classifyEnvVars(envVars);
@@ -36,6 +55,7 @@ try {
36
55
 
37
56
  // 1. Scan routes
38
57
  const manifest = scanRoutes();
58
+ buildCtx.manifest = manifest;
39
59
  console.log(`📂 Found ${manifest.pages.length} page route(s):`);
40
60
  for (const r of manifest.pages) {
41
61
  console.log(` ${r.pattern} → ${r.page}${r.pageServer ? " (server)" : ""}`);
@@ -47,6 +67,12 @@ if (manifest.apis.length > 0) {
47
67
  }
48
68
  }
49
69
 
70
+ for (const p of userPlugins) {
71
+ if (p.build?.postScan) {
72
+ await p.build.postScan(manifest, buildCtx);
73
+ }
74
+ }
75
+
50
76
  // 2. Generate .bosia/routes.ts (single file replaces all old code generators)
51
77
  generateRoutesFile(manifest);
52
78
 
@@ -82,6 +108,10 @@ const tailwindPromise = tailwindProc.exited;
82
108
  const clientPlugin = makeBosiaPlugin("browser");
83
109
  const serverPlugin = makeBosiaPlugin("bun");
84
110
 
111
+ // Collect Bun build plugins contributed by user plugins, per target.
112
+ const userClientBunPlugins = userPlugins.flatMap((p) => p.build?.bunPlugins?.("browser") ?? []);
113
+ const userServerBunPlugins = userPlugins.flatMap((p) => p.build?.bunPlugins?.("bun") ?? []);
114
+
85
115
  // Build-time defines: inline PUBLIC_STATIC_* and STATIC_* vars
86
116
  const staticDefines: Record<string, string> = {};
87
117
  for (const [key, value] of Object.entries(classifiedEnv.publicStatic)) {
@@ -104,7 +134,7 @@ const clientPromise = Bun.build({
104
134
  "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development"),
105
135
  ...staticDefines,
106
136
  },
107
- plugins: [clientPlugin, SveltePlugin()],
137
+ plugins: [clientPlugin, ...userClientBunPlugins, SveltePlugin()],
108
138
  });
109
139
 
110
140
  const serverPromise = Bun.build({
@@ -115,7 +145,7 @@ const serverPromise = Bun.build({
115
145
  naming: { entry: "index.[ext]", chunk: "[name]-[hash].[ext]" },
116
146
  minify: isProduction,
117
147
  external: ["elysia"],
118
- plugins: [serverPlugin, SveltePlugin()],
148
+ plugins: [serverPlugin, ...userServerBunPlugins, SveltePlugin()],
119
149
  });
120
150
 
121
151
  const [tailwindExitCode, clientResult, serverResult] = await Promise.all([
@@ -174,10 +204,19 @@ writeFileSync("./dist/manifest.json", JSON.stringify(distManifest, null, 2));
174
204
  console.log(`✅ Client bundle: ${jsFiles.join(", ")}`);
175
205
  console.log(`✅ Server entry: dist/server/${serverEntry}`);
176
206
 
207
+ // 8b. Persist route manifest for runtime plugins (backend.after consumers like OpenAPI).
208
+ writeFileSync("./dist/route-manifest.json", JSON.stringify(manifest, null, 2));
209
+
177
210
  // 9. Prerender static routes
178
211
  await prerenderStaticRoutes(manifest);
179
212
 
180
213
  // 10. Generate static site output (HTML + client assets + public → dist/static/)
181
214
  generateStaticSite();
182
215
 
216
+ for (const p of userPlugins) {
217
+ if (p.build?.postBuild) {
218
+ await p.build.postBuild(buildCtx);
219
+ }
220
+ }
221
+
183
222
  console.log(`\n🎉 Build complete in ${Math.round(performance.now() - buildStart)}ms!`);
@@ -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();
@@ -0,0 +1,93 @@
1
+ import { existsSync, mkdirSync } from "fs";
2
+ import { join } from "path";
3
+
4
+ import type { BosiaConfig, BosiaPlugin } from "./types/plugin.ts";
5
+
6
+ let cached: BosiaConfig | null = null;
7
+ let cachedFromPath: string | null = null;
8
+
9
+ const CONFIG_NAMES = ["bosia.config.ts", "bosia.config.js", "bosia.config.mjs"];
10
+
11
+ function findConfigPath(cwd: string): string | null {
12
+ for (const name of CONFIG_NAMES) {
13
+ const p = join(cwd, name);
14
+ if (existsSync(p)) return p;
15
+ }
16
+ return null;
17
+ }
18
+
19
+ /**
20
+ * Resolve and load `bosia.config.ts` (or `.js`/`.mjs`) from cwd. Returns the
21
+ * default export. If no config is present, returns `{ plugins: [] }`.
22
+ *
23
+ * Cached per-cwd. Compiled via `Bun.build({ target: "bun" })` so user code can
24
+ * use TypeScript and bare-specifier imports.
25
+ */
26
+ export async function loadBosiaConfig(cwd: string = process.cwd()): Promise<BosiaConfig> {
27
+ if (cached && cachedFromPath === cwd) return cached;
28
+
29
+ const configPath = findConfigPath(cwd);
30
+ if (!configPath) {
31
+ cached = { plugins: [] };
32
+ cachedFromPath = cwd;
33
+ return cached;
34
+ }
35
+
36
+ const result = await Bun.build({
37
+ entrypoints: [configPath],
38
+ target: "bun",
39
+ format: "esm",
40
+ external: ["bosia", "elysia", "bun", "svelte", "svelte/server"],
41
+ });
42
+
43
+ if (!result.success || !result.outputs[0]) {
44
+ const logs = result.logs.map((l) => String(l)).join("\n");
45
+ throw new Error(`Failed to compile ${configPath}:\n${logs}`);
46
+ }
47
+
48
+ const code = await result.outputs[0].text();
49
+ // Write inside cwd so bare-specifier imports (e.g. `bosia/plugins/...`) resolve via
50
+ // the project's own node_modules. /tmp would have no node_modules to walk into.
51
+ const cacheDir = join(cwd, ".bosia");
52
+ mkdirSync(cacheDir, { recursive: true });
53
+ const tmpFile = join(
54
+ cacheDir,
55
+ `config.${Date.now()}.${Math.random().toString(36).slice(2)}.mjs`,
56
+ );
57
+ await Bun.write(tmpFile, code);
58
+
59
+ let mod: { default?: BosiaConfig };
60
+ try {
61
+ mod = await import(tmpFile);
62
+ } finally {
63
+ try {
64
+ await Bun.file(tmpFile).delete();
65
+ } catch {}
66
+ }
67
+
68
+ const config = mod.default;
69
+ if (!config || typeof config !== "object") {
70
+ throw new Error(
71
+ `${configPath} must export a default object (use \`export default defineConfig({...})\` or \`export default {...} satisfies BosiaConfig\`).`,
72
+ );
73
+ }
74
+
75
+ const plugins = Array.isArray(config.plugins) ? config.plugins : [];
76
+ const normalized: BosiaConfig = { plugins };
77
+
78
+ cached = normalized;
79
+ cachedFromPath = cwd;
80
+ return normalized;
81
+ }
82
+
83
+ /** Test-only — drops the in-memory cache so tests can reload fresh config files. */
84
+ export function resetConfigCache(): void {
85
+ cached = null;
86
+ cachedFromPath = null;
87
+ }
88
+
89
+ /** Convenience: load and return only the plugin list. */
90
+ export async function loadPlugins(cwd?: string): Promise<BosiaPlugin[]> {
91
+ const config = await loadBosiaConfig(cwd);
92
+ return config.plugins ?? [];
93
+ }
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
+ }
package/src/core/html.ts CHANGED
@@ -150,7 +150,7 @@ const SPINNER =
150
150
  `@keyframes __bs__{to{transform:rotate(360deg)}}</style><i></i></div>`;
151
151
 
152
152
  /** Chunk 2: metadata tags + close </head> + open <body> + spinner */
153
- export function buildMetadataChunk(metadata: Metadata | null): string {
153
+ export function buildMetadataChunk(metadata: Metadata | null, headExtras?: string[]): string {
154
154
  let out = "\n";
155
155
  if (metadata) {
156
156
  if (metadata.title) out += ` <title>${escapeHtml(metadata.title)}</title>\n`;
@@ -175,6 +175,11 @@ export function buildMetadataChunk(metadata: Metadata | null): string {
175
175
  } else {
176
176
  out += ` <title>Bosia App</title>\n`;
177
177
  }
178
+ if (headExtras?.length) {
179
+ for (const fragment of headExtras) {
180
+ if (fragment) out += ` ${fragment}\n`;
181
+ }
182
+ }
178
183
  out += `</head>\n<body>\n${SPINNER}`;
179
184
  return out;
180
185
  }
@@ -199,6 +204,7 @@ export function buildHtmlTail(
199
204
  csr: boolean,
200
205
  formData: any = null,
201
206
  ssr = true,
207
+ bodyEndExtras?: string[],
202
208
  ): string {
203
209
  let out = `<script>document.getElementById('__bs__').remove()</script>`;
204
210
  out += `\n<div id="app">${body}</div>`;
@@ -219,6 +225,11 @@ export function buildHtmlTail(
219
225
  } else if (isDev) {
220
226
  out += `\n<script>!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>`;
221
227
  }
228
+ if (bodyEndExtras?.length) {
229
+ for (const fragment of bodyEndExtras) {
230
+ if (fragment) out += `\n${fragment}`;
231
+ }
232
+ }
222
233
  out += `\n</body>\n</html>`;
223
234
  return out;
224
235
  }
@@ -0,0 +1,51 @@
1
+ import type { BosiaPlugin } from "../types/plugin.ts";
2
+
3
+ export interface ServerTimingOptions {
4
+ /** Header name (defaults to standard `Server-Timing`). */
5
+ header?: string;
6
+ /** Metric name (defaults to `handler`). */
7
+ metric?: string;
8
+ }
9
+
10
+ /**
11
+ * Adds a `Server-Timing` response header that reports how long each request
12
+ * spent inside the framework's handler. Measured from `onRequest` to
13
+ * `onAfterHandle` — so for streaming SSR routes this is "time to start
14
+ * streaming," not full end-to-end. Headers must flush before the body, so a
15
+ * true end-to-end value cannot be reported in a response header.
16
+ */
17
+ export function serverTiming(options: ServerTimingOptions = {}): BosiaPlugin {
18
+ const headerName = options.header ?? "Server-Timing";
19
+ const metric = options.metric ?? "handler";
20
+
21
+ return {
22
+ name: "server-timing",
23
+ backend: {
24
+ before(app) {
25
+ const starts = new WeakMap<Request, number>();
26
+ return app
27
+ .onRequest(({ request }) => {
28
+ starts.set(request, performance.now());
29
+ })
30
+ .onAfterHandle(({ request, response, set }) => {
31
+ const start = starts.get(request);
32
+ if (start === undefined) return;
33
+ const dur = (performance.now() - start).toFixed(2);
34
+ const value = `${metric};dur=${dur}`;
35
+
36
+ // Set on Response objects directly when possible (preserves immutability semantics)
37
+ if (response instanceof Response) {
38
+ response.headers.set(headerName, value);
39
+ return;
40
+ }
41
+
42
+ // Otherwise, push into Elysia's outbound header bag.
43
+ const headers = (set.headers ??= {}) as Record<string, string>;
44
+ headers[headerName] = value;
45
+ });
46
+ },
47
+ },
48
+ };
49
+ }
50
+
51
+ export default serverTiming;
@@ -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,
@@ -14,6 +15,32 @@ import {
14
15
  isDev,
15
16
  } from "./html.ts";
16
17
  import type { Metadata } from "./hooks.ts";
18
+ import { loadPlugins } from "./config.ts";
19
+ import type { BosiaPlugin, RenderContext } from "./types/plugin.ts";
20
+
21
+ // Plugins are loaded once per process at module init via top-level await elsewhere
22
+ // (server.ts), but renderer is also reachable from build/prerender contexts where
23
+ // loadPlugins() may not have been called yet. The function is cached, so awaiting
24
+ // per request is cheap (Map hit on second call).
25
+ async function pluginRenderFragments(
26
+ hook: "head" | "bodyEnd",
27
+ ctx: RenderContext,
28
+ ): Promise<string[]> {
29
+ const plugins = await loadPlugins();
30
+ const out: string[] = [];
31
+ for (const p of plugins as BosiaPlugin[]) {
32
+ const fn = p.render?.[hook];
33
+ if (!fn) continue;
34
+ try {
35
+ const fragment = await fn(ctx);
36
+ if (fragment) out.push(fragment);
37
+ } catch (err) {
38
+ if (isDev) console.error(`Plugin "${p.name}" render.${hook} failed:`, err);
39
+ else console.error(`Plugin "${p.name}" render.${hook} failed:`, (err as Error).message);
40
+ }
41
+ }
42
+ return out;
43
+ }
17
44
 
18
45
  // ─── Timeout Helpers ─────────────────────────────────────
19
46
 
@@ -107,6 +134,28 @@ function makeFetch(req: Request, url: URL) {
107
134
  };
108
135
  }
109
136
 
137
+ // ─── Error Context Stamping ──────────────────────────────
138
+ // Annotate an HttpError with the layout depth and origin where it was
139
+ // thrown, plus the partial layoutData accumulated so far. The data
140
+ // endpoint forwards this to the client; the SSR catch sites use it to
141
+ // render the right nested boundary inside the right layout chain.
142
+
143
+ function stampErrorContext(
144
+ err: HttpError,
145
+ depth: number,
146
+ origin: ErrorOrigin,
147
+ partialLayoutData: Record<string, any>[],
148
+ ): void {
149
+ const e = err as HttpError & {
150
+ errorDepth?: number;
151
+ errorOrigin?: ErrorOrigin;
152
+ partialLayoutData?: Record<string, any>[];
153
+ };
154
+ e.errorDepth ??= depth;
155
+ e.errorOrigin ??= origin;
156
+ e.partialLayoutData ??= [...partialLayoutData];
157
+ }
158
+
110
159
  // ─── Route Data Loader ───────────────────────────────────
111
160
  // Runs layout + page server loaders for a given URL.
112
161
  // Used by both SSR and the /__bosia/data JSON endpoint.
@@ -143,10 +192,16 @@ export async function loadRouteData(
143
192
  )) ?? {};
144
193
  }
145
194
  } catch (err) {
146
- if (err instanceof HttpError || err instanceof Redirect) throw err;
195
+ if (err instanceof Redirect) throw err;
196
+ if (err instanceof HttpError) {
197
+ stampErrorContext(err, ls.depth, "layout", layoutData);
198
+ throw err;
199
+ }
147
200
  if (isDev) console.error("Layout server load error:", err);
148
201
  else console.error("Layout server load error:", (err as Error).message ?? err);
149
- throw new HttpError(500, "Internal Server Error");
202
+ const wrapped = new HttpError(500, "Internal Server Error");
203
+ stampErrorContext(wrapped, ls.depth, "layout", layoutData);
204
+ throw wrapped;
150
205
  }
151
206
  }
152
207
 
@@ -181,10 +236,16 @@ export async function loadRouteData(
181
236
  )) ?? {};
182
237
  }
183
238
  } catch (err) {
184
- if (err instanceof HttpError || err instanceof Redirect) throw err;
239
+ if (err instanceof Redirect) throw err;
240
+ if (err instanceof HttpError) {
241
+ stampErrorContext(err, route.layoutModules.length, "page", layoutData);
242
+ throw err;
243
+ }
185
244
  if (isDev) console.error("Page server load error:", err);
186
245
  else console.error("Page server load error:", (err as Error).message ?? err);
187
- throw new HttpError(500, "Internal Server Error");
246
+ const wrapped = new HttpError(500, "Internal Server Error");
247
+ stampErrorContext(wrapped, route.layoutModules.length, "page", layoutData);
248
+ throw wrapped;
188
249
  }
189
250
  }
190
251
 
@@ -244,7 +305,7 @@ export async function renderSSRStream(
244
305
  return Response.redirect(err.location, err.status);
245
306
  }
246
307
  if (err instanceof HttpError) {
247
- return renderErrorPage(err.status, err.message, url, req);
308
+ return renderErrorPage(err.status, err.message, url, req, route);
248
309
  }
249
310
  if (isDev) console.error("Metadata load error:", err);
250
311
  else console.error("Metadata load error:", (err as Error).message ?? err);
@@ -266,15 +327,41 @@ export async function renderSSRStream(
266
327
  ]);
267
328
  } catch (err) {
268
329
  if (err instanceof Redirect) return Response.redirect(err.location, err.status);
269
- if (err instanceof HttpError) return renderErrorPage(err.status, err.message, url, req);
330
+ if (err instanceof HttpError) {
331
+ const e = err as HttpError & {
332
+ errorDepth?: number;
333
+ errorOrigin?: ErrorOrigin;
334
+ partialLayoutData?: Record<string, any>[];
335
+ };
336
+ return renderErrorPage(
337
+ err.status,
338
+ err.message,
339
+ url,
340
+ req,
341
+ route,
342
+ e.errorDepth,
343
+ e.errorOrigin,
344
+ e.partialLayoutData,
345
+ );
346
+ }
270
347
  if (isDev) console.error("SSR load error:", err);
271
348
  else console.error("SSR load error:", (err as Error).message ?? err);
272
- return renderErrorPage(500, "Internal Server Error", url, req);
349
+ return renderErrorPage(500, "Internal Server Error", url, req, route);
273
350
  }
274
351
 
275
352
  if (!data) return renderErrorPage(404, "Not Found", url, req);
276
353
 
277
354
  const enc = new TextEncoder();
355
+ const renderCtx: RenderContext = {
356
+ request: req,
357
+ url,
358
+ route: { pattern: route.pattern },
359
+ metadata,
360
+ };
361
+ const [headExtras, bodyEndExtras] = await Promise.all([
362
+ pluginRenderFragments("head", renderCtx),
363
+ pluginRenderFragments("bodyEnd", renderCtx),
364
+ ]);
278
365
 
279
366
  // ssr=false → no render() needed; ship shell + hydration as a single response.
280
367
  // ssr=false && csr=false is meaningless (nothing renders) — force csr=true.
@@ -286,8 +373,8 @@ export async function renderSSRStream(
286
373
  }
287
374
  const html =
288
375
  buildHtmlShellOpen(metadata?.lang) +
289
- buildMetadataChunk(metadata) +
290
- buildHtmlTail("", "", data.pageData, data.layoutData, true, null, false);
376
+ buildMetadataChunk(metadata, headExtras) +
377
+ buildHtmlTail("", "", data.pageData, data.layoutData, true, null, false, bodyEndExtras);
291
378
  return new Response(html, {
292
379
  headers: { "Content-Type": "text/html; charset=utf-8" },
293
380
  });
@@ -309,14 +396,35 @@ export async function renderSSRStream(
309
396
  } catch (err) {
310
397
  if (isDev) console.error("SSR render error:", err);
311
398
  else console.error("SSR render error:", (err as Error).message ?? err);
312
- return renderErrorPage(500, "Internal Server Error", url, req);
399
+ // Render-phase errors fall through to deepest boundary like a page error.
400
+ return renderErrorPage(
401
+ 500,
402
+ "Internal Server Error",
403
+ url,
404
+ req,
405
+ route,
406
+ route.layoutModules.length,
407
+ "page",
408
+ data.layoutData,
409
+ );
313
410
  }
314
411
 
315
412
  // Pre-compute all chunks; pull-based stream gives Bun native backpressure.
316
413
  const chunks: Uint8Array[] = [
317
414
  enc.encode(buildHtmlShellOpen(metadata?.lang)),
318
- enc.encode(buildMetadataChunk(metadata)),
319
- enc.encode(buildHtmlTail(body, head, data.pageData, data.layoutData, data.csr)),
415
+ enc.encode(buildMetadataChunk(metadata, headExtras)),
416
+ enc.encode(
417
+ buildHtmlTail(
418
+ body,
419
+ head,
420
+ data.pageData,
421
+ data.layoutData,
422
+ data.csr,
423
+ null,
424
+ true,
425
+ bodyEndExtras,
426
+ ),
427
+ ),
320
428
  ];
321
429
 
322
430
  let i = 0;
@@ -411,18 +519,71 @@ export async function renderPageWithFormData(
411
519
  }
412
520
 
413
521
  // ─── Error Page Renderer ──────────────────────────────────
522
+ // 1. If a route is known, try the nearest nested +error.svelte and render
523
+ // it inside the matching prefix of the layout chain.
524
+ // 2. Otherwise fall back to the global root +error.svelte.
525
+ // 3. Otherwise return a plain-text response.
414
526
 
415
527
  export async function renderErrorPage(
416
528
  status: number,
417
529
  message: string,
418
530
  url: URL,
419
531
  req: Request,
532
+ route?: any,
533
+ errorDepth?: number,
534
+ errorOrigin?: ErrorOrigin,
535
+ partialLayoutData?: Record<string, any>[],
420
536
  ): Promise<Response> {
537
+ // 1. Nested boundary
538
+ if (route && errorDepth !== undefined && route.errorPages?.length) {
539
+ const origin = errorOrigin ?? "page";
540
+ const picked = pickErrorPage<() => Promise<any>>(
541
+ route.errorPages as { loader: () => Promise<any>; depth: number }[],
542
+ errorDepth,
543
+ origin,
544
+ );
545
+ if (picked) {
546
+ try {
547
+ const K = picked.depth;
548
+ const [errorMod, layoutMods] = await Promise.all([
549
+ picked.loader(),
550
+ Promise.all(
551
+ route.layoutModules.slice(0, K).map((l: () => Promise<any>) => l()),
552
+ ),
553
+ ]);
554
+ const layoutData: Record<string, any>[] = [];
555
+ for (let i = 0; i < K; i++) layoutData.push(partialLayoutData?.[i] ?? {});
556
+ const { body, head } = render(App, {
557
+ props: {
558
+ ssrMode: true,
559
+ ssrLayoutComponents: layoutMods.map((m: any) => m.default),
560
+ ssrLayoutData: layoutData,
561
+ ssrErrorComponent: errorMod.default,
562
+ ssrErrorProps: { error: { status, message } },
563
+ ssrErrorDepth: K,
564
+ },
565
+ });
566
+ // csr=false: no client hydration on the error page itself.
567
+ const html = buildHtml(body, head, { status, message }, layoutData, false);
568
+ return compress(html, "text/html; charset=utf-8", req, status);
569
+ } catch (err) {
570
+ if (isDev) console.error("Nested error page render failed:", err);
571
+ else
572
+ console.error(
573
+ "Nested error page render failed:",
574
+ (err as Error).message ?? err,
575
+ );
576
+ // fall through to global / text fallback
577
+ }
578
+ }
579
+ }
580
+
581
+ // 2. Global root error page
421
582
  if (errorPage) {
422
583
  try {
423
584
  const mod = await errorPage();
424
585
  // Render the error component directly — NOT through App.svelte.
425
- // App.svelte always remaps ssrPageData to a `data` prop, but +error.svelte
586
+ // App.svelte remaps ssrPageData to a `data` prop, but +error.svelte
426
587
  // expects `error` as a direct prop: `let { error } = $props()`.
427
588
  const { body, head } = render(mod.default, {
428
589
  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).
@@ -1,10 +1,12 @@
1
1
  import { Elysia } from "elysia";
2
2
 
3
- import { existsSync } from "fs";
3
+ import { existsSync, readFileSync } from "fs";
4
4
  import { join, resolve as resolvePath } from "path";
5
5
 
6
6
  import { findMatch, compileRoutes, canonicalPathname } from "./matcher.ts";
7
7
  import { apiRoutes, serverRoutes } from "bosia:routes";
8
+ import { loadPlugins } from "./config.ts";
9
+ import type { RouteManifest } from "./types.ts";
8
10
 
9
11
  // Pre-compile route patterns into RegExp at startup (shared by renderer.ts via module reference)
10
12
  compileRoutes(apiRoutes);
@@ -210,8 +212,16 @@ async function resolve(event: RequestEvent): Promise<Response> {
210
212
  );
211
213
  }
212
214
  if (err instanceof HttpError) {
215
+ const e = err as HttpError & {
216
+ errorDepth?: number;
217
+ errorOrigin?: "page" | "layout";
218
+ };
213
219
  return compress(
214
- JSON.stringify({ error: err.message, status: err.status }),
220
+ JSON.stringify({
221
+ error: { status: err.status, message: err.message },
222
+ errorDepth: e.errorDepth ?? null,
223
+ errorOrigin: e.errorOrigin ?? null,
224
+ }),
215
225
  "application/json",
216
226
  request,
217
227
  err.status,
@@ -586,16 +596,68 @@ let shuttingDown = false;
586
596
  let inFlight = 0;
587
597
  let drainResolve: (() => void) | null = null;
588
598
 
599
+ // ─── Plugin Loading ───────────────────────────────────────
600
+
601
+ const plugins = await loadPlugins(process.cwd());
602
+ if (plugins.length > 0) {
603
+ console.log(`🔌 Loaded ${plugins.length} plugin(s): ${plugins.map((p) => p.name).join(", ")}`);
604
+ }
605
+
606
+ // Read the build-time route manifest so plugins.backend.after can introspect routes.
607
+ function loadBuiltManifest(): RouteManifest {
608
+ const path = "./dist/route-manifest.json";
609
+ if (existsSync(path)) {
610
+ try {
611
+ return JSON.parse(readFileSync(path, "utf-8"));
612
+ } catch {}
613
+ }
614
+ // Fallback: synthesize from runtime arrays (no file paths, just patterns).
615
+ return {
616
+ pages: serverRoutes.map((r: any) => ({
617
+ pattern: r.pattern,
618
+ page: "",
619
+ layouts: [],
620
+ pageServer: r.pageServer ? "" : null,
621
+ layoutServers: [],
622
+ errorPages: [],
623
+ trailingSlash: r.trailingSlash,
624
+ scope: r.scope,
625
+ })),
626
+ apis: apiRoutes.map((r: any) => ({ pattern: r.pattern, server: "" })),
627
+ errorPage: null,
628
+ };
629
+ }
630
+
631
+ const routeManifest = loadBuiltManifest();
632
+
589
633
  // ─── Elysia App ───────────────────────────────────────────
590
634
 
591
635
  const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : isDev ? 9001 : 9000;
592
636
 
593
- const app = new Elysia({ serve: { maxRequestBodySize: BODY_SIZE_LIMIT } })
594
- .onError(({ error }) => {
637
+ // Elysia's chained generics drift when plugins add routes track the app as a
638
+ // loose `Elysia` so plugin-extended types stay assignable.
639
+ let app: Elysia = new Elysia({ serve: { maxRequestBodySize: BODY_SIZE_LIMIT } }).onError(
640
+ ({ error }) => {
595
641
  if (isDev) console.error("Uncaught server error:", error);
596
642
  else console.error("Uncaught server error:", (error as Error)?.message ?? error);
597
643
  return Response.json({ error: "Internal Server Error" }, { status: 500 });
598
- })
644
+ },
645
+ ) as unknown as Elysia;
646
+
647
+ // Plugins.backend.before — runs before framework middleware/routes.
648
+ // Plugin-registered routes here BYPASS the framework (CSRF, hooks, etc.).
649
+ for (const plugin of plugins) {
650
+ if (plugin.backend?.before) {
651
+ try {
652
+ app = (await plugin.backend.before(app)) ?? app;
653
+ } catch (err) {
654
+ console.error(`❌ Plugin "${plugin.name}" backend.before failed:`, err);
655
+ throw err;
656
+ }
657
+ }
658
+ }
659
+
660
+ app = app
599
661
  // Static files are served by resolve() with path traversal protection and security headers
600
662
  // API routes must intercept all HTTP methods before the GET catch-all
601
663
  .onBeforeHandle(async ({ request }) => {
@@ -604,31 +666,43 @@ const app = new Elysia({ serve: { maxRequestBodySize: BODY_SIZE_LIMIT } })
604
666
  return handleRequest(request, url);
605
667
  })
606
668
  // SSR pages
607
- .get("*", ({ request }) => {
669
+ .get("*", ({ request }: { request: Request }) => {
608
670
  const url = new URL(request.url);
609
671
  return handleRequest(request, url);
610
672
  })
611
673
  // Non-GET catch-alls so onBeforeHandle fires for API routes on other methods
612
- .post("*", ({ request }) => {
674
+ .post("*", ({ request }: { request: Request }) => {
613
675
  const url = new URL(request.url);
614
676
  return handleRequest(request, url);
615
677
  })
616
- .put("*", ({ request }) => {
678
+ .put("*", ({ request }: { request: Request }) => {
617
679
  const url = new URL(request.url);
618
680
  return handleRequest(request, url);
619
681
  })
620
- .patch("*", ({ request }) => {
682
+ .patch("*", ({ request }: { request: Request }) => {
621
683
  const url = new URL(request.url);
622
684
  return handleRequest(request, url);
623
685
  })
624
- .delete("*", ({ request }) => {
686
+ .delete("*", ({ request }: { request: Request }) => {
625
687
  const url = new URL(request.url);
626
688
  return handleRequest(request, url);
627
689
  })
628
- .options("*", ({ request }) => {
690
+ .options("*", ({ request }: { request: Request }) => {
629
691
  const url = new URL(request.url);
630
692
  return handleRequest(request, url);
631
- });
693
+ }) as unknown as Elysia;
694
+
695
+ // Plugins.backend.after — runs after framework routes; receives the route manifest.
696
+ for (const plugin of plugins) {
697
+ if (plugin.backend?.after) {
698
+ try {
699
+ app = (await plugin.backend.after(app, { manifest: routeManifest })) ?? app;
700
+ } catch (err) {
701
+ console.error(`❌ Plugin "${plugin.name}" backend.after failed:`, err);
702
+ throw err;
703
+ }
704
+ }
705
+ }
632
706
 
633
707
  app.listen(PORT, () => {
634
708
  // In dev mode the proxy owns the user-facing port — don't print the internal port
@@ -0,0 +1,81 @@
1
+ // ─── Bosia Plugin Types ──────────────────────────────────
2
+ // Public surface for first-party and third-party plugins.
3
+
4
+ import type { Elysia } from "elysia";
5
+ import type { BunPlugin } from "bun";
6
+ import type { RouteManifest } from "../types.ts";
7
+ import type { Metadata } from "../hooks.ts";
8
+
9
+ type MaybePromise<T> = T | Promise<T>;
10
+
11
+ export type BuildTarget = "browser" | "bun";
12
+
13
+ export interface BuildContext {
14
+ mode: "production" | "development";
15
+ cwd: string;
16
+ manifest?: RouteManifest;
17
+ }
18
+
19
+ export interface DevContext {
20
+ cwd: string;
21
+ }
22
+
23
+ export interface RenderContext {
24
+ request: Request;
25
+ url: URL;
26
+ route: { pattern: string } | null;
27
+ metadata: Metadata | null;
28
+ }
29
+
30
+ export interface BosiaPlugin {
31
+ name: string;
32
+
33
+ /**
34
+ * Mount points around the framework's HTTP backend. Currently typed as
35
+ * `Elysia` (the underlying backend), but the namespace is intentionally
36
+ * abstract so the API survives a future backend swap.
37
+ */
38
+ backend?: {
39
+ /** Runs before framework middleware/routes — can register routes that bypass Bosia. */
40
+ before?: (app: Elysia) => MaybePromise<Elysia>;
41
+ /** Runs after framework routes — receives the route manifest for introspection. */
42
+ after?: (app: Elysia, ctx: { manifest: RouteManifest }) => MaybePromise<Elysia>;
43
+ };
44
+
45
+ /** Build pipeline hooks. */
46
+ build?: {
47
+ preBuild?: (ctx: BuildContext) => MaybePromise<void>;
48
+ postScan?: (manifest: RouteManifest, ctx: BuildContext) => MaybePromise<void>;
49
+ bunPlugins?: (target: BuildTarget) => BunPlugin[];
50
+ postBuild?: (ctx: BuildContext) => MaybePromise<void>;
51
+ };
52
+
53
+ /** Dev pipeline hooks (wired in v0.5.0+). */
54
+ dev?: {
55
+ onStart?: (ctx: DevContext) => MaybePromise<void>;
56
+ onFileChange?: (path: string) => MaybePromise<void>;
57
+ };
58
+
59
+ /** SSR render hooks — emitted strings are injected into the streaming HTML. */
60
+ render?: {
61
+ /** Injected before `</head>`, after framework metadata. */
62
+ head?: (ctx: RenderContext) => MaybePromise<string>;
63
+ /** Injected before `</body>`, after hydration script. */
64
+ bodyEnd?: (ctx: RenderContext) => MaybePromise<string>;
65
+ };
66
+
67
+ /** Client lifecycle (wired in v0.5.0+). */
68
+ client?: {
69
+ onHydrate?: () => void;
70
+ onNavigate?: (to: URL, from: URL) => void;
71
+ };
72
+ }
73
+
74
+ export interface BosiaConfig {
75
+ plugins?: BosiaPlugin[];
76
+ }
77
+
78
+ /** Identity helper for type inference in `bosia.config.ts`. */
79
+ export function defineConfig(config: BosiaConfig): BosiaConfig {
80
+ return config;
81
+ }
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
  /**
package/src/lib/index.ts CHANGED
@@ -19,3 +19,12 @@ export type {
19
19
  } from "../core/hooks.ts";
20
20
  export type { CsrfConfig } from "../core/csrf.ts";
21
21
  export type { CorsConfig } from "../core/cors.ts";
22
+ export { defineConfig } from "../core/types/plugin.ts";
23
+ export type {
24
+ BosiaPlugin,
25
+ BosiaConfig,
26
+ BuildContext,
27
+ DevContext,
28
+ RenderContext,
29
+ BuildTarget,
30
+ } from "../core/types/plugin.ts";