bosia 0.3.4 → 0.4.1
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 +5 -2
- package/src/core/build.ts +42 -3
- package/src/core/config.ts +94 -0
- package/src/core/html.ts +12 -1
- package/src/core/plugins/inspector/bun-plugin.ts +146 -0
- package/src/core/plugins/inspector/index.ts +122 -0
- package/src/core/plugins/inspector/overlay.ts +116 -0
- package/src/core/plugins/server-timing.ts +51 -0
- package/src/core/renderer.ts +51 -4
- package/src/core/server.ts +77 -11
- package/src/core/types/plugin.ts +81 -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.1",
|
|
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,9 @@
|
|
|
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",
|
|
37
|
+
"./plugins/inspector": "./src/core/plugins/inspector/index.ts"
|
|
36
38
|
},
|
|
37
39
|
"bin": {
|
|
38
40
|
"bosia": "src/cli/index.ts"
|
|
@@ -51,6 +53,7 @@
|
|
|
51
53
|
"@tailwindcss/cli": "^4.2.1",
|
|
52
54
|
"bun-plugin-svelte": "^0.0.6",
|
|
53
55
|
"elysia": "^1.4.26",
|
|
56
|
+
"magic-string": "^0.30.0",
|
|
54
57
|
"svelte": "^5.53.6",
|
|
55
58
|
"tailwind-merge": "^3.5.0",
|
|
56
59
|
"tailwindcss": "^4.2.1"
|
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!`);
|
|
@@ -0,0 +1,94 @@
|
|
|
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 rawPlugins = Array.isArray(config.plugins) ? config.plugins : [];
|
|
76
|
+
const plugins = rawPlugins.filter((p): p is BosiaPlugin => Boolean(p));
|
|
77
|
+
const normalized: BosiaConfig = { plugins };
|
|
78
|
+
|
|
79
|
+
cached = normalized;
|
|
80
|
+
cachedFromPath = cwd;
|
|
81
|
+
return normalized;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Test-only — drops the in-memory cache so tests can reload fresh config files. */
|
|
85
|
+
export function resetConfigCache(): void {
|
|
86
|
+
cached = null;
|
|
87
|
+
cachedFromPath = null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Convenience: load and return only the plugin list. */
|
|
91
|
+
export async function loadPlugins(cwd?: string): Promise<BosiaPlugin[]> {
|
|
92
|
+
const config = await loadBosiaConfig(cwd);
|
|
93
|
+
return (config.plugins ?? []).filter((p): p is BosiaPlugin => Boolean(p));
|
|
94
|
+
}
|
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,146 @@
|
|
|
1
|
+
import { parse, compile } from "svelte/compiler";
|
|
2
|
+
import MagicString from "magic-string";
|
|
3
|
+
import { basename, relative } from "node:path";
|
|
4
|
+
import type { BunPlugin } from "bun";
|
|
5
|
+
|
|
6
|
+
const VIRTUAL_NS = "bosia-inspector-css";
|
|
7
|
+
|
|
8
|
+
type AnyNode = {
|
|
9
|
+
type?: string;
|
|
10
|
+
name?: string;
|
|
11
|
+
start?: number;
|
|
12
|
+
end?: number;
|
|
13
|
+
[k: string]: unknown;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Child-bearing keys across Svelte 5 modern AST nodes. Order doesn't matter.
|
|
17
|
+
const CHILD_KEYS = [
|
|
18
|
+
"nodes", // Fragment
|
|
19
|
+
"fragment", // RegularElement, KeyBlock, SvelteElement, SvelteComponent
|
|
20
|
+
"consequent", // IfBlock (Fragment)
|
|
21
|
+
"alternate", // IfBlock (Fragment | null)
|
|
22
|
+
"body", // EachBlock, SnippetBlock (Fragment)
|
|
23
|
+
"fallback", // EachBlock (Fragment | null)
|
|
24
|
+
"pending", // AwaitBlock (Fragment | null)
|
|
25
|
+
"then", // AwaitBlock (Fragment | null)
|
|
26
|
+
"catch", // AwaitBlock (Fragment | null)
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
function walk(node: unknown, visit: (n: AnyNode) => void) {
|
|
30
|
+
if (!node) return;
|
|
31
|
+
if (Array.isArray(node)) {
|
|
32
|
+
for (const c of node) walk(c, visit);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (typeof node !== "object") return;
|
|
36
|
+
const n = node as AnyNode;
|
|
37
|
+
if (typeof n.type === "string") visit(n);
|
|
38
|
+
for (const key of CHILD_KEYS) {
|
|
39
|
+
const child = n[key];
|
|
40
|
+
if (child) walk(child, visit);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function lineColFromOffset(source: string, offset: number): { line: number; col: number } {
|
|
45
|
+
let line = 1;
|
|
46
|
+
let col = 1;
|
|
47
|
+
for (let i = 0; i < offset && i < source.length; i++) {
|
|
48
|
+
if (source[i] === "\n") {
|
|
49
|
+
line++;
|
|
50
|
+
col = 1;
|
|
51
|
+
} else {
|
|
52
|
+
col++;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { line, col };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function injectLocs(source: string, relPath: string): string {
|
|
59
|
+
let ast: { fragment?: AnyNode };
|
|
60
|
+
try {
|
|
61
|
+
ast = parse(source, { modern: true }) as unknown as { fragment?: AnyNode };
|
|
62
|
+
} catch {
|
|
63
|
+
return source;
|
|
64
|
+
}
|
|
65
|
+
if (!ast.fragment) return source;
|
|
66
|
+
|
|
67
|
+
const ms = new MagicString(source);
|
|
68
|
+
walk(ast.fragment, (node) => {
|
|
69
|
+
if (node.type !== "RegularElement") return;
|
|
70
|
+
const name = node.name ?? "";
|
|
71
|
+
if (!name) return;
|
|
72
|
+
if (name === "script" || name === "style") return;
|
|
73
|
+
if (/^[A-Z]/.test(name)) return;
|
|
74
|
+
if (name.includes(":")) return;
|
|
75
|
+
if (typeof node.start !== "number") return;
|
|
76
|
+
const insertAt = node.start + 1 + name.length;
|
|
77
|
+
const { line, col } = lineColFromOffset(source, node.start);
|
|
78
|
+
const safe = relPath.replace(/"/g, """);
|
|
79
|
+
ms.appendLeft(insertAt, ` data-bosia-loc="${safe}:${line}:${col}"`);
|
|
80
|
+
});
|
|
81
|
+
return ms.toString();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface InspectorBunPluginOptions {
|
|
85
|
+
cwd: string;
|
|
86
|
+
target: "browser" | "bun";
|
|
87
|
+
dev: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const fnv = (s: string): string => {
|
|
91
|
+
let h = 2166136261;
|
|
92
|
+
for (let i = 0; i < s.length; i++) {
|
|
93
|
+
h ^= s.charCodeAt(i);
|
|
94
|
+
h = Math.imul(h, 16777619);
|
|
95
|
+
}
|
|
96
|
+
return (h >>> 0).toString(36);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export function createInspectorBunPlugin(opts: InspectorBunPluginOptions): BunPlugin {
|
|
100
|
+
const { cwd, target, dev } = opts;
|
|
101
|
+
const generate: "client" | "server" = target === "browser" ? "client" : "server";
|
|
102
|
+
const virtualCss = new Map<string, string>();
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
name: "bosia-inspector",
|
|
106
|
+
setup(build) {
|
|
107
|
+
build.onLoad({ filter: /\.svelte$/ }, async (args) => {
|
|
108
|
+
const source = await Bun.file(args.path).text();
|
|
109
|
+
const rel = relative(cwd, args.path);
|
|
110
|
+
const transformed = injectLocs(source, rel);
|
|
111
|
+
|
|
112
|
+
const result = compile(transformed, {
|
|
113
|
+
filename: args.path,
|
|
114
|
+
generate,
|
|
115
|
+
dev,
|
|
116
|
+
hmr: dev,
|
|
117
|
+
css: "external",
|
|
118
|
+
preserveWhitespace: dev,
|
|
119
|
+
preserveComments: dev,
|
|
120
|
+
cssHash: ({ css }) => `svelte-${fnv(css)}`,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
let js = result.js.code;
|
|
124
|
+
if (result.css?.code && generate !== "server") {
|
|
125
|
+
const uid = `${basename(args.path)}-${fnv(args.path)}-style.css`;
|
|
126
|
+
const virtualName = `${VIRTUAL_NS}:${uid}`;
|
|
127
|
+
virtualCss.set(virtualName, result.css.code);
|
|
128
|
+
js += `\nimport ${JSON.stringify(virtualName)};`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { contents: js, loader: "ts" };
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
build.onResolve({ filter: new RegExp(`^${VIRTUAL_NS}:`) }, (args) => ({
|
|
135
|
+
path: args.path,
|
|
136
|
+
namespace: VIRTUAL_NS,
|
|
137
|
+
}));
|
|
138
|
+
|
|
139
|
+
build.onLoad({ filter: /.*/, namespace: VIRTUAL_NS }, (args) => {
|
|
140
|
+
const css = virtualCss.get(args.path) ?? "";
|
|
141
|
+
virtualCss.delete(args.path);
|
|
142
|
+
return { contents: css, loader: "css" };
|
|
143
|
+
});
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createInspectorBunPlugin } from "./bun-plugin.ts";
|
|
3
|
+
import { getOverlayScript } from "./overlay.ts";
|
|
4
|
+
import type { BosiaPlugin } from "../../types/plugin.ts";
|
|
5
|
+
|
|
6
|
+
export interface InspectorOptions {
|
|
7
|
+
/** Editor CLI command. Defaults to `code`. */
|
|
8
|
+
editor?: "code" | "cursor" | "zed" | (string & {});
|
|
9
|
+
/** When set, alt+click opens a comment form whose contents POST here. */
|
|
10
|
+
aiEndpoint?: string;
|
|
11
|
+
/** Endpoint path the overlay POSTs to. Defaults to `/__bosia/locate`. */
|
|
12
|
+
endpoint?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function buildEditorArgs(editor: string, file: string, line: number, col: number): string[] {
|
|
16
|
+
if (editor === "zed") return [`${file}:${line}:${col}`];
|
|
17
|
+
return ["-g", `${file}:${line}:${col}`];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Inspector plugin — alt+click an element in the running dev page to jump
|
|
22
|
+
* to its source in your editor, or open a comment form that hands off to an
|
|
23
|
+
* AI agent. Dev-only: production builds inject nothing and mount no endpoint.
|
|
24
|
+
*/
|
|
25
|
+
export function inspector(options: InspectorOptions = {}): BosiaPlugin | false {
|
|
26
|
+
if (process.env.NODE_ENV === "production") return false;
|
|
27
|
+
const editor = options.editor ?? "code";
|
|
28
|
+
const endpoint = options.endpoint ?? "/__bosia/locate";
|
|
29
|
+
const aiEndpoint = options.aiEndpoint;
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
name: "inspector",
|
|
33
|
+
|
|
34
|
+
build: {
|
|
35
|
+
bunPlugins: (target) => [
|
|
36
|
+
createInspectorBunPlugin({ cwd: process.cwd(), target, dev: true }),
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
backend: {
|
|
41
|
+
before(app) {
|
|
42
|
+
return app.post(endpoint, async ({ body }: { body: unknown }) => {
|
|
43
|
+
const data = (body ?? {}) as {
|
|
44
|
+
file?: string;
|
|
45
|
+
line?: number;
|
|
46
|
+
col?: number;
|
|
47
|
+
comment?: string;
|
|
48
|
+
};
|
|
49
|
+
const file = typeof data.file === "string" ? data.file : null;
|
|
50
|
+
const line = Number.isFinite(data.line) ? Number(data.line) : null;
|
|
51
|
+
const col = Number.isFinite(data.col) ? Number(data.col) : 1;
|
|
52
|
+
if (!file || line === null) {
|
|
53
|
+
return new Response(
|
|
54
|
+
JSON.stringify({ ok: false, error: "missing file/line" }),
|
|
55
|
+
{
|
|
56
|
+
status: 400,
|
|
57
|
+
headers: { "content-type": "application/json" },
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const comment = typeof data.comment === "string" ? data.comment.trim() : "";
|
|
63
|
+
if (comment && aiEndpoint) {
|
|
64
|
+
try {
|
|
65
|
+
let origin: string;
|
|
66
|
+
try {
|
|
67
|
+
origin = new URL(aiEndpoint).origin;
|
|
68
|
+
} catch {
|
|
69
|
+
origin = "http://localhost";
|
|
70
|
+
}
|
|
71
|
+
await fetch(aiEndpoint, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: {
|
|
74
|
+
"content-type": "application/json",
|
|
75
|
+
origin,
|
|
76
|
+
},
|
|
77
|
+
body: JSON.stringify({ file, line, col, comment }),
|
|
78
|
+
});
|
|
79
|
+
return { ok: true, mode: "ai" as const };
|
|
80
|
+
} catch (err) {
|
|
81
|
+
console.error("[inspector] aiEndpoint POST failed:", err);
|
|
82
|
+
return new Response(
|
|
83
|
+
JSON.stringify({ ok: false, error: "ai endpoint failed" }),
|
|
84
|
+
{
|
|
85
|
+
status: 502,
|
|
86
|
+
headers: { "content-type": "application/json" },
|
|
87
|
+
},
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const proc = spawn(editor, buildEditorArgs(editor, file, line, col), {
|
|
94
|
+
detached: true,
|
|
95
|
+
stdio: "ignore",
|
|
96
|
+
});
|
|
97
|
+
proc.unref();
|
|
98
|
+
proc.on("error", (err) => {
|
|
99
|
+
console.error(`[inspector] failed to launch "${editor}":`, err);
|
|
100
|
+
});
|
|
101
|
+
} catch (err) {
|
|
102
|
+
console.error(`[inspector] failed to launch "${editor}":`, err);
|
|
103
|
+
return new Response(
|
|
104
|
+
JSON.stringify({ ok: false, error: "editor launch failed" }),
|
|
105
|
+
{
|
|
106
|
+
status: 500,
|
|
107
|
+
headers: { "content-type": "application/json" },
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
return { ok: true, mode: "editor" as const };
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
render: {
|
|
117
|
+
bodyEnd: () => getOverlayScript({ aiEndpoint, endpoint }),
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export default inspector;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { safeJsonStringify } from "../../html.ts";
|
|
2
|
+
|
|
3
|
+
export interface OverlayConfig {
|
|
4
|
+
aiEndpoint?: string;
|
|
5
|
+
endpoint: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function getOverlayScript(config: OverlayConfig): string {
|
|
9
|
+
const cfg = safeJsonStringify(config);
|
|
10
|
+
return (
|
|
11
|
+
`<script>window.__BOSIA_INSPECTOR__=${cfg};</script>\n` + `<script>${OVERLAY_IIFE}</script>`
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const OVERLAY_IIFE = `(function(){
|
|
16
|
+
var CFG=window.__BOSIA_INSPECTOR__||{};
|
|
17
|
+
var EP=CFG.endpoint||"/__bosia/locate";
|
|
18
|
+
var AI=CFG.aiEndpoint||null;
|
|
19
|
+
var altDown=false,outline=null,tip=null,form=null;
|
|
20
|
+
|
|
21
|
+
function ensureOutline(){
|
|
22
|
+
if(outline)return;
|
|
23
|
+
outline=document.createElement("div");
|
|
24
|
+
outline.style.cssText="position:fixed;pointer-events:none;border:2px solid #f73b27;background:rgba(247,59,39,.08);z-index:2147483646;border-radius:2px;transition:all .05s linear;display:none";
|
|
25
|
+
document.body.appendChild(outline);
|
|
26
|
+
tip=document.createElement("div");
|
|
27
|
+
tip.style.cssText="position:fixed;pointer-events:none;background:#111;color:#fff;font:11px/1.4 ui-monospace,monospace;padding:3px 6px;border-radius:3px;z-index:2147483647;display:none;white-space:nowrap";
|
|
28
|
+
document.body.appendChild(tip);
|
|
29
|
+
}
|
|
30
|
+
function hideOutline(){if(outline)outline.style.display="none";if(tip)tip.style.display="none"}
|
|
31
|
+
function showOutline(el,loc){
|
|
32
|
+
ensureOutline();
|
|
33
|
+
var r=el.getBoundingClientRect();
|
|
34
|
+
outline.style.display="block";
|
|
35
|
+
outline.style.left=r.left+"px";outline.style.top=r.top+"px";
|
|
36
|
+
outline.style.width=r.width+"px";outline.style.height=r.height+"px";
|
|
37
|
+
tip.style.display="block";tip.textContent=loc;
|
|
38
|
+
var ty=r.top-22;if(ty<0)ty=r.bottom+4;
|
|
39
|
+
tip.style.left=r.left+"px";tip.style.top=ty+"px";
|
|
40
|
+
}
|
|
41
|
+
function parseLoc(s){var m=/^(.+):(\\d+):(\\d+)$/.exec(s);if(!m)return null;return{file:m[1],line:+m[2],col:+m[3]}}
|
|
42
|
+
function findTarget(e){var n=e.target;while(n&&n.nodeType===1){if(n.hasAttribute&&n.hasAttribute("data-bosia-loc"))return n;n=n.parentNode}return null}
|
|
43
|
+
|
|
44
|
+
function toast(msg,err){
|
|
45
|
+
var t=document.createElement("div");
|
|
46
|
+
t.textContent=msg;
|
|
47
|
+
t.style.cssText="position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:"+(err?"#dc2626":"#111")+";color:#fff;padding:8px 14px;border-radius:6px;font:13px ui-sans-serif,system-ui,sans-serif;z-index:2147483647;box-shadow:0 4px 12px rgba(0,0,0,.2);opacity:0;transition:opacity .15s";
|
|
48
|
+
document.body.appendChild(t);
|
|
49
|
+
requestAnimationFrame(function(){t.style.opacity="1"});
|
|
50
|
+
setTimeout(function(){t.style.opacity="0";setTimeout(function(){t.remove()},200)},2200);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function send(payload,onOk){
|
|
54
|
+
fetch(EP,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(payload)})
|
|
55
|
+
.then(function(r){return r.json().catch(function(){return{}})})
|
|
56
|
+
.then(function(j){if(j&&j.ok){onOk&&onOk(j)}else{toast("Inspector: request failed",true)}})
|
|
57
|
+
.catch(function(){toast("Inspector: network error",true)});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function closeForm(){if(form){form.remove();form=null}}
|
|
61
|
+
function openForm(loc,el){
|
|
62
|
+
closeForm();
|
|
63
|
+
var r=el.getBoundingClientRect();
|
|
64
|
+
form=document.createElement("div");
|
|
65
|
+
form.style.cssText="position:fixed;left:"+r.left+"px;top:"+(r.bottom+6)+"px;background:#fff;color:#111;border:1px solid #d4d4d8;border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.18);padding:10px;width:340px;z-index:2147483647;font:13px ui-sans-serif,system-ui,sans-serif";
|
|
66
|
+
form.innerHTML='<div style="font-size:11px;color:#71717a;margin-bottom:6px;font-family:ui-monospace,monospace">'+loc.file+":"+loc.line+'</div>'+
|
|
67
|
+
'<textarea placeholder="Describe a fix (Enter to send, Esc to cancel, empty = open in editor)" style="width:100%;min-height:64px;border:1px solid #e4e4e7;border-radius:4px;padding:6px;font:13px ui-sans-serif,system-ui,sans-serif;resize:vertical;box-sizing:border-box;outline:none"></textarea>'+
|
|
68
|
+
'<div style="margin-top:8px;display:flex;gap:6px;justify-content:flex-end">'+
|
|
69
|
+
'<button data-cancel style="padding:4px 10px;border:1px solid #e4e4e7;background:#fff;border-radius:4px;cursor:pointer;font-size:12px">Cancel</button>'+
|
|
70
|
+
'<button data-send style="padding:4px 10px;border:0;background:#111;color:#fff;border-radius:4px;cursor:pointer;font-size:12px">Send</button>'+
|
|
71
|
+
'</div>';
|
|
72
|
+
document.body.appendChild(form);
|
|
73
|
+
var ta=form.querySelector("textarea");
|
|
74
|
+
ta.focus();
|
|
75
|
+
function submit(){
|
|
76
|
+
var comment=ta.value.trim();
|
|
77
|
+
var payload={file:loc.file,line:loc.line,col:loc.col};
|
|
78
|
+
if(comment)payload.comment=comment;
|
|
79
|
+
send(payload,function(j){toast(j.mode==="ai"?"sent to AI":"opened "+loc.file+":"+loc.line)});
|
|
80
|
+
closeForm();
|
|
81
|
+
}
|
|
82
|
+
form.querySelector("[data-send]").addEventListener("click",submit);
|
|
83
|
+
form.querySelector("[data-cancel]").addEventListener("click",closeForm);
|
|
84
|
+
ta.addEventListener("keydown",function(e){
|
|
85
|
+
if(e.key==="Escape"){e.preventDefault();closeForm()}
|
|
86
|
+
else if(e.key==="Enter"&&!e.shiftKey){e.preventDefault();submit()}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
window.addEventListener("keydown",function(e){
|
|
91
|
+
if(e.key==="Alt"||e.altKey)altDown=true;
|
|
92
|
+
if(e.key==="Escape")closeForm();
|
|
93
|
+
},true);
|
|
94
|
+
window.addEventListener("keyup",function(e){if(e.key==="Alt"){altDown=false;hideOutline()}},true);
|
|
95
|
+
window.addEventListener("blur",function(){altDown=false;hideOutline()});
|
|
96
|
+
|
|
97
|
+
window.addEventListener("mousemove",function(e){
|
|
98
|
+
if(!altDown||form){hideOutline();return}
|
|
99
|
+
var el=findTarget(e);
|
|
100
|
+
if(!el){hideOutline();return}
|
|
101
|
+
showOutline(el,el.getAttribute("data-bosia-loc"));
|
|
102
|
+
},true);
|
|
103
|
+
|
|
104
|
+
window.addEventListener("click",function(e){
|
|
105
|
+
if(!altDown)return;
|
|
106
|
+
if(form&&form.contains(e.target))return;
|
|
107
|
+
var el=findTarget(e);
|
|
108
|
+
if(!el)return;
|
|
109
|
+
e.preventDefault();e.stopPropagation();
|
|
110
|
+
var loc=parseLoc(el.getAttribute("data-bosia-loc"));
|
|
111
|
+
if(!loc)return;
|
|
112
|
+
hideOutline();
|
|
113
|
+
if(AI)openForm(loc,el);
|
|
114
|
+
else send(loc,function(){toast("opened "+loc.file+":"+loc.line)});
|
|
115
|
+
},true);
|
|
116
|
+
})();`;
|
|
@@ -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
|
@@ -15,6 +15,32 @@ import {
|
|
|
15
15
|
isDev,
|
|
16
16
|
} from "./html.ts";
|
|
17
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
|
+
}
|
|
18
44
|
|
|
19
45
|
// ─── Timeout Helpers ─────────────────────────────────────
|
|
20
46
|
|
|
@@ -326,6 +352,16 @@ export async function renderSSRStream(
|
|
|
326
352
|
if (!data) return renderErrorPage(404, "Not Found", url, req);
|
|
327
353
|
|
|
328
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
|
+
]);
|
|
329
365
|
|
|
330
366
|
// ssr=false → no render() needed; ship shell + hydration as a single response.
|
|
331
367
|
// ssr=false && csr=false is meaningless (nothing renders) — force csr=true.
|
|
@@ -337,8 +373,8 @@ export async function renderSSRStream(
|
|
|
337
373
|
}
|
|
338
374
|
const html =
|
|
339
375
|
buildHtmlShellOpen(metadata?.lang) +
|
|
340
|
-
buildMetadataChunk(metadata) +
|
|
341
|
-
buildHtmlTail("", "", data.pageData, data.layoutData, true, null, false);
|
|
376
|
+
buildMetadataChunk(metadata, headExtras) +
|
|
377
|
+
buildHtmlTail("", "", data.pageData, data.layoutData, true, null, false, bodyEndExtras);
|
|
342
378
|
return new Response(html, {
|
|
343
379
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
344
380
|
});
|
|
@@ -376,8 +412,19 @@ export async function renderSSRStream(
|
|
|
376
412
|
// Pre-compute all chunks; pull-based stream gives Bun native backpressure.
|
|
377
413
|
const chunks: Uint8Array[] = [
|
|
378
414
|
enc.encode(buildHtmlShellOpen(metadata?.lang)),
|
|
379
|
-
enc.encode(buildMetadataChunk(metadata)),
|
|
380
|
-
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
|
+
),
|
|
381
428
|
];
|
|
382
429
|
|
|
383
430
|
let i = 0;
|
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);
|
|
@@ -594,16 +596,68 @@ let shuttingDown = false;
|
|
|
594
596
|
let inFlight = 0;
|
|
595
597
|
let drainResolve: (() => void) | null = null;
|
|
596
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
|
+
|
|
597
633
|
// ─── Elysia App ───────────────────────────────────────────
|
|
598
634
|
|
|
599
635
|
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : isDev ? 9001 : 9000;
|
|
600
636
|
|
|
601
|
-
|
|
602
|
-
|
|
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 }) => {
|
|
603
641
|
if (isDev) console.error("Uncaught server error:", error);
|
|
604
642
|
else console.error("Uncaught server error:", (error as Error)?.message ?? error);
|
|
605
643
|
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
|
606
|
-
}
|
|
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
|
|
607
661
|
// Static files are served by resolve() with path traversal protection and security headers
|
|
608
662
|
// API routes must intercept all HTTP methods before the GET catch-all
|
|
609
663
|
.onBeforeHandle(async ({ request }) => {
|
|
@@ -612,31 +666,43 @@ const app = new Elysia({ serve: { maxRequestBodySize: BODY_SIZE_LIMIT } })
|
|
|
612
666
|
return handleRequest(request, url);
|
|
613
667
|
})
|
|
614
668
|
// SSR pages
|
|
615
|
-
.get("*", ({ request }) => {
|
|
669
|
+
.get("*", ({ request }: { request: Request }) => {
|
|
616
670
|
const url = new URL(request.url);
|
|
617
671
|
return handleRequest(request, url);
|
|
618
672
|
})
|
|
619
673
|
// Non-GET catch-alls so onBeforeHandle fires for API routes on other methods
|
|
620
|
-
.post("*", ({ request }) => {
|
|
674
|
+
.post("*", ({ request }: { request: Request }) => {
|
|
621
675
|
const url = new URL(request.url);
|
|
622
676
|
return handleRequest(request, url);
|
|
623
677
|
})
|
|
624
|
-
.put("*", ({ request }) => {
|
|
678
|
+
.put("*", ({ request }: { request: Request }) => {
|
|
625
679
|
const url = new URL(request.url);
|
|
626
680
|
return handleRequest(request, url);
|
|
627
681
|
})
|
|
628
|
-
.patch("*", ({ request }) => {
|
|
682
|
+
.patch("*", ({ request }: { request: Request }) => {
|
|
629
683
|
const url = new URL(request.url);
|
|
630
684
|
return handleRequest(request, url);
|
|
631
685
|
})
|
|
632
|
-
.delete("*", ({ request }) => {
|
|
686
|
+
.delete("*", ({ request }: { request: Request }) => {
|
|
633
687
|
const url = new URL(request.url);
|
|
634
688
|
return handleRequest(request, url);
|
|
635
689
|
})
|
|
636
|
-
.options("*", ({ request }) => {
|
|
690
|
+
.options("*", ({ request }: { request: Request }) => {
|
|
637
691
|
const url = new URL(request.url);
|
|
638
692
|
return handleRequest(request, url);
|
|
639
|
-
});
|
|
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
|
+
}
|
|
640
706
|
|
|
641
707
|
app.listen(PORT, () => {
|
|
642
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 | false | null | undefined)[];
|
|
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/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";
|