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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosia",
3
- "version": "0.3.4",
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 .env files (before cleaning .bosia so loadEnv can set process.env early)
26
+ // 0. Load plugins from bosia.config.ts
27
+ const userPlugins = await loadPlugins(process.cwd());
28
+ if (userPlugins.length > 0) {
29
+ console.log(`🔌 Plugins: ${userPlugins.map((p) => p.name).join(", ")}`);
30
+ }
31
+
32
+ const buildCtx: BuildContext = {
33
+ mode: isProduction ? "production" : "development",
34
+ cwd: process.cwd(),
35
+ };
36
+
37
+ for (const p of userPlugins) {
38
+ if (p.build?.preBuild) {
39
+ await p.build.preBuild(buildCtx);
40
+ }
41
+ }
42
+
43
+ // 0a. Load .env files (before cleaning .bosia so loadEnv can set process.env early)
25
44
  const envMode = isProduction ? "production" : "development";
26
45
  const envVars = loadEnv(envMode);
27
46
  const classifiedEnv = classifyEnvVars(envVars);
@@ -36,6 +55,7 @@ try {
36
55
 
37
56
  // 1. Scan routes
38
57
  const manifest = scanRoutes();
58
+ buildCtx.manifest = manifest;
39
59
  console.log(`📂 Found ${manifest.pages.length} page route(s):`);
40
60
  for (const r of manifest.pages) {
41
61
  console.log(` ${r.pattern} → ${r.page}${r.pageServer ? " (server)" : ""}`);
@@ -47,6 +67,12 @@ if (manifest.apis.length > 0) {
47
67
  }
48
68
  }
49
69
 
70
+ for (const p of userPlugins) {
71
+ if (p.build?.postScan) {
72
+ await p.build.postScan(manifest, buildCtx);
73
+ }
74
+ }
75
+
50
76
  // 2. Generate .bosia/routes.ts (single file replaces all old code generators)
51
77
  generateRoutesFile(manifest);
52
78
 
@@ -82,6 +108,10 @@ const tailwindPromise = tailwindProc.exited;
82
108
  const clientPlugin = makeBosiaPlugin("browser");
83
109
  const serverPlugin = makeBosiaPlugin("bun");
84
110
 
111
+ // Collect Bun build plugins contributed by user plugins, per target.
112
+ const userClientBunPlugins = userPlugins.flatMap((p) => p.build?.bunPlugins?.("browser") ?? []);
113
+ const userServerBunPlugins = userPlugins.flatMap((p) => p.build?.bunPlugins?.("bun") ?? []);
114
+
85
115
  // Build-time defines: inline PUBLIC_STATIC_* and STATIC_* vars
86
116
  const staticDefines: Record<string, string> = {};
87
117
  for (const [key, value] of Object.entries(classifiedEnv.publicStatic)) {
@@ -104,7 +134,7 @@ const clientPromise = Bun.build({
104
134
  "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development"),
105
135
  ...staticDefines,
106
136
  },
107
- plugins: [clientPlugin, SveltePlugin()],
137
+ plugins: [clientPlugin, ...userClientBunPlugins, SveltePlugin()],
108
138
  });
109
139
 
110
140
  const serverPromise = Bun.build({
@@ -115,7 +145,7 @@ const serverPromise = Bun.build({
115
145
  naming: { entry: "index.[ext]", chunk: "[name]-[hash].[ext]" },
116
146
  minify: isProduction,
117
147
  external: ["elysia"],
118
- plugins: [serverPlugin, SveltePlugin()],
148
+ plugins: [serverPlugin, ...userServerBunPlugins, SveltePlugin()],
119
149
  });
120
150
 
121
151
  const [tailwindExitCode, clientResult, serverResult] = await Promise.all([
@@ -174,10 +204,19 @@ writeFileSync("./dist/manifest.json", JSON.stringify(distManifest, null, 2));
174
204
  console.log(`✅ Client bundle: ${jsFiles.join(", ")}`);
175
205
  console.log(`✅ Server entry: dist/server/${serverEntry}`);
176
206
 
207
+ // 8b. Persist route manifest for runtime plugins (backend.after consumers like OpenAPI).
208
+ writeFileSync("./dist/route-manifest.json", JSON.stringify(manifest, null, 2));
209
+
177
210
  // 9. Prerender static routes
178
211
  await prerenderStaticRoutes(manifest);
179
212
 
180
213
  // 10. Generate static site output (HTML + client assets + public → dist/static/)
181
214
  generateStaticSite();
182
215
 
216
+ for (const p of userPlugins) {
217
+ if (p.build?.postBuild) {
218
+ await p.build.postBuild(buildCtx);
219
+ }
220
+ }
221
+
183
222
  console.log(`\n🎉 Build complete in ${Math.round(performance.now() - buildStart)}ms!`);
@@ -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, "&quot;");
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;
@@ -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(buildHtmlTail(body, head, data.pageData, data.layoutData, data.csr)),
415
+ enc.encode(buildMetadataChunk(metadata, headExtras)),
416
+ enc.encode(
417
+ buildHtmlTail(
418
+ body,
419
+ head,
420
+ data.pageData,
421
+ data.layoutData,
422
+ data.csr,
423
+ null,
424
+ true,
425
+ bodyEndExtras,
426
+ ),
427
+ ),
381
428
  ];
382
429
 
383
430
  let i = 0;
@@ -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
- const app = new Elysia({ serve: { maxRequestBodySize: BODY_SIZE_LIMIT } })
602
- .onError(({ error }) => {
637
+ // Elysia's chained generics drift when plugins add routes track the app as a
638
+ // loose `Elysia` so plugin-extended types stay assignable.
639
+ let app: Elysia = new Elysia({ serve: { maxRequestBodySize: BODY_SIZE_LIMIT } }).onError(
640
+ ({ error }) => {
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";