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 +3 -2
- package/src/core/build.ts +42 -3
- package/src/core/client/App.svelte +73 -9
- package/src/core/client/appState.svelte.ts +6 -0
- package/src/core/config.ts +93 -0
- package/src/core/dev.ts +14 -5
- package/src/core/errorMatch.ts +33 -0
- package/src/core/html.ts +12 -1
- package/src/core/plugins/server-timing.ts +51 -0
- package/src/core/renderer.ts +174 -13
- 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 +86 -12
- package/src/core/types/plugin.ts +81 -0
- package/src/core/types.ts +7 -0
- package/src/lib/index.ts +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bosia",
|
|
3
|
-
"version": "0.
|
|
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
|
|
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
|
-
//
|
|
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();
|
|
@@ -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
|
|
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/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;
|
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,
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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(
|
|
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
|
|
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 } },
|
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
|
@@ -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({
|
|
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
|
-
|
|
594
|
-
|
|
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";
|