bosia 0.9.9 → 1.0.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/README.md +4 -4
- package/package.json +5 -4
- package/src/core/backend.ts +238 -0
- package/src/core/build.ts +47 -11
- package/src/core/config.ts +1 -1
- package/src/core/dev.ts +3 -3
- package/src/core/html.ts +10 -4
- package/src/core/plugins/inspector/index.ts +6 -10
- package/src/core/plugins/server-timing.ts +1 -1
- package/src/core/server.ts +32 -54
- package/src/core/server.workers.ts +1 -2
- package/src/core/types/plugin.ts +5 -6
- package/src/lib/server.ts +1 -0
- package/templates/default/README.md +1 -1
- package/templates/default/package.json +3 -0
- package/templates/default/src/routes/(public)/+page.svelte +1 -1
- package/templates/demo/package.json +3 -0
- package/templates/demo/src/routes/(public)/+page.svelte +2 -2
- package/templates/demo/src/routes/(public)/about/+page.svelte +2 -2
- package/templates/shop/package.json +3 -0
- package/templates/store/package.json +3 -0
package/README.md
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
> Full documentation: [bosia.dev](https://bosia.dev)
|
|
4
4
|
|
|
5
|
-
A fast, batteries-included fullstack framework — SSR · Svelte 5 Runes · Bun
|
|
5
|
+
A fast, batteries-included fullstack framework — SSR · Svelte 5 Runes · Bun.
|
|
6
6
|
|
|
7
7
|
**Production-ready out of the box** — built-in security (CSRF, XSS escaping, secure cookies, security headers), performance (response cache, gzip, static asset caching, prerendering), and reliability (graceful shutdown drain, request backpressure, crash backoff).
|
|
8
8
|
|
|
9
|
-
File-based routing inspired by SvelteKit, built on top of the Bun runtime
|
|
9
|
+
File-based routing inspired by SvelteKit, built on top of the Bun runtime with its own tiny HTTP layer. No Node.js, no Vite, no adapters.
|
|
10
10
|
|
|
11
11
|
## Features
|
|
12
12
|
|
|
@@ -52,8 +52,8 @@ bun run start
|
|
|
52
52
|
|
|
53
53
|
| Layer | Technology |
|
|
54
54
|
| ----------- | ------------------------------------------ |
|
|
55
|
-
| Runtime | [Bun](https://bun.sh)
|
|
56
|
-
| HTTP Server |
|
|
55
|
+
| Runtime | [Bun](https://bun.sh) ≥ 1.4 |
|
|
56
|
+
| HTTP Server | Built-in (`Bun.serve`) |
|
|
57
57
|
| UI | [Svelte 5](https://svelte.dev) (Runes) |
|
|
58
58
|
| CSS | [Tailwind CSS v4](https://tailwindcss.com) |
|
|
59
59
|
| Bundler | Bun.build |
|
package/package.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bosia",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"
|
|
5
|
+
"engines": {
|
|
6
|
+
"bun": ">=1.4"
|
|
7
|
+
},
|
|
8
|
+
"description": "A fast, batteries-included fullstack framework — SSR · Svelte 5 Runes · Bun. File-based routing. No Node.js, no Vite. Runs on Bun or Cloudflare Workers.",
|
|
6
9
|
"keywords": [
|
|
7
10
|
"bun",
|
|
8
11
|
"svelte",
|
|
9
12
|
"ssr",
|
|
10
|
-
"elysia",
|
|
11
13
|
"fullstack",
|
|
12
14
|
"framework"
|
|
13
15
|
],
|
|
@@ -55,7 +57,6 @@
|
|
|
55
57
|
"@clack/prompts": "^1.1.0",
|
|
56
58
|
"@jridgewell/trace-mapping": "^0.3.31",
|
|
57
59
|
"@tailwindcss/cli": "^4.2.1",
|
|
58
|
-
"elysia": "^1.4.26",
|
|
59
60
|
"magic-string": "^0.30.0",
|
|
60
61
|
"svelte": "^5.56.3",
|
|
61
62
|
"tailwind-merge": "^3.5.0",
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// ─── Bosia Backend App ────────────────────────────────────
|
|
2
|
+
// A tiny Elysia-shaped HTTP app: chainable routes + onRequest / onAfterHandle /
|
|
3
|
+
// onError hooks, one `fetch(request)` entry for Bun.serve and Workers alike.
|
|
4
|
+
// Only the subset the framework and plugins use — not a general router.
|
|
5
|
+
|
|
6
|
+
/** Outbound response bag — merged into whatever the handler returns. */
|
|
7
|
+
export interface ResponseSet {
|
|
8
|
+
status?: number;
|
|
9
|
+
headers: Record<string, string>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface HandlerContext {
|
|
13
|
+
request: Request;
|
|
14
|
+
/**
|
|
15
|
+
* Parsed by content-type: JSON → object, form → object, else text. Only for
|
|
16
|
+
* exact-path routes — the framework's "*" catch-all reads the body itself.
|
|
17
|
+
*/
|
|
18
|
+
body: unknown;
|
|
19
|
+
query: Record<string, string>;
|
|
20
|
+
set: ResponseSet;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type Handler = (ctx: HandlerContext) => unknown;
|
|
24
|
+
|
|
25
|
+
export type RequestHook = (ctx: { request: Request; set: ResponseSet }) => unknown;
|
|
26
|
+
|
|
27
|
+
export type AfterHandleHook = (ctx: {
|
|
28
|
+
request: Request;
|
|
29
|
+
response: unknown;
|
|
30
|
+
set: ResponseSet;
|
|
31
|
+
}) => unknown;
|
|
32
|
+
|
|
33
|
+
export type ErrorHook = (ctx: { request: Request; error: unknown; set: ResponseSet }) => unknown;
|
|
34
|
+
|
|
35
|
+
export interface ServeOptions {
|
|
36
|
+
maxRequestBodySize?: number;
|
|
37
|
+
idleTimeout?: number;
|
|
38
|
+
reusePort?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS" | "*";
|
|
42
|
+
|
|
43
|
+
interface Route {
|
|
44
|
+
method: Method;
|
|
45
|
+
path: string;
|
|
46
|
+
handler: Handler;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const BODYLESS = new Set(["GET", "HEAD"]);
|
|
50
|
+
|
|
51
|
+
export class BosiaApp {
|
|
52
|
+
private routes: Route[] = [];
|
|
53
|
+
private requestHooks: RequestHook[] = [];
|
|
54
|
+
private afterHooks: AfterHandleHook[] = [];
|
|
55
|
+
private errorHooks: ErrorHook[] = [];
|
|
56
|
+
private server: ReturnType<typeof Bun.serve> | null = null;
|
|
57
|
+
|
|
58
|
+
constructor(private config: { serve?: ServeOptions } = {}) {}
|
|
59
|
+
|
|
60
|
+
get(path: string, handler: Handler): this {
|
|
61
|
+
return this.route("GET", path, handler);
|
|
62
|
+
}
|
|
63
|
+
post(path: string, handler: Handler): this {
|
|
64
|
+
return this.route("POST", path, handler);
|
|
65
|
+
}
|
|
66
|
+
put(path: string, handler: Handler): this {
|
|
67
|
+
return this.route("PUT", path, handler);
|
|
68
|
+
}
|
|
69
|
+
patch(path: string, handler: Handler): this {
|
|
70
|
+
return this.route("PATCH", path, handler);
|
|
71
|
+
}
|
|
72
|
+
delete(path: string, handler: Handler): this {
|
|
73
|
+
return this.route("DELETE", path, handler);
|
|
74
|
+
}
|
|
75
|
+
options(path: string, handler: Handler): this {
|
|
76
|
+
return this.route("OPTIONS", path, handler);
|
|
77
|
+
}
|
|
78
|
+
/** Any method. */
|
|
79
|
+
all(path: string, handler: Handler): this {
|
|
80
|
+
return this.route("*", path, handler);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Runs first on every request. Returning a value short-circuits routing. */
|
|
84
|
+
onRequest(hook: RequestHook): this {
|
|
85
|
+
this.requestHooks.push(hook);
|
|
86
|
+
return this;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Runs after the handler. Returning a value replaces the response. */
|
|
90
|
+
onAfterHandle(hook: AfterHandleHook): this {
|
|
91
|
+
this.afterHooks.push(hook);
|
|
92
|
+
return this;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Runs in registration order; the first hook to return a value answers. */
|
|
96
|
+
onError(hook: ErrorHook): this {
|
|
97
|
+
this.errorHooks.push(hook);
|
|
98
|
+
return this;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Paths match exactly, or "*" matches everything. Enough for plugin endpoints
|
|
102
|
+
// like /__bosia/locate; add `:param` matching here when a plugin needs it.
|
|
103
|
+
private route(method: Method, path: string, handler: Handler): this {
|
|
104
|
+
this.routes.push({ method, path, handler });
|
|
105
|
+
return this;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private match(method: string, pathname: string): Route | null {
|
|
109
|
+
let wildcard: Route | null = null;
|
|
110
|
+
for (const r of this.routes) {
|
|
111
|
+
if (r.method !== "*" && r.method !== method) continue;
|
|
112
|
+
// Exact paths beat "*" regardless of registration order, like Elysia.
|
|
113
|
+
if (r.path === pathname) return r;
|
|
114
|
+
if (r.path === "*" && !wildcard) wildcard = r;
|
|
115
|
+
}
|
|
116
|
+
return wildcard;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private allowed(pathname: string): string[] {
|
|
120
|
+
const methods = new Set<string>();
|
|
121
|
+
for (const r of this.routes) {
|
|
122
|
+
if (r.path !== pathname && r.path !== "*") continue;
|
|
123
|
+
if (r.method === "*") return [];
|
|
124
|
+
methods.add(r.method);
|
|
125
|
+
}
|
|
126
|
+
if (methods.has("GET")) methods.add("HEAD");
|
|
127
|
+
return [...methods];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
fetch = async (request: Request): Promise<Response> => {
|
|
131
|
+
const set: ResponseSet = { headers: {} };
|
|
132
|
+
const isHead = request.method === "HEAD";
|
|
133
|
+
const method = isHead ? "GET" : request.method.toUpperCase();
|
|
134
|
+
try {
|
|
135
|
+
const res = await this.dispatch(request, method, set);
|
|
136
|
+
return isHead ? new Response(null, res) : res;
|
|
137
|
+
} catch (error) {
|
|
138
|
+
for (const hook of this.errorHooks) {
|
|
139
|
+
const out = await hook({ request, error, set });
|
|
140
|
+
if (out !== undefined) return toResponse(out, set);
|
|
141
|
+
}
|
|
142
|
+
return new Response("Internal Server Error", { status: 500 });
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
private async dispatch(request: Request, method: string, set: ResponseSet): Promise<Response> {
|
|
147
|
+
for (const hook of this.requestHooks) {
|
|
148
|
+
const out = await hook({ request, set });
|
|
149
|
+
if (out !== undefined) return toResponse(out, set);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const url = new URL(request.url);
|
|
153
|
+
const route = this.match(method, url.pathname);
|
|
154
|
+
let response: unknown;
|
|
155
|
+
if (route) {
|
|
156
|
+
response = await route.handler({
|
|
157
|
+
request,
|
|
158
|
+
body: BODYLESS.has(method) || route.path === "*" ? undefined : await parseBody(request),
|
|
159
|
+
query: Object.fromEntries(url.searchParams),
|
|
160
|
+
set,
|
|
161
|
+
});
|
|
162
|
+
} else {
|
|
163
|
+
const allow = this.allowed(url.pathname);
|
|
164
|
+
set.status = 405;
|
|
165
|
+
if (allow.length > 0) set.headers["allow"] = allow.join(", ");
|
|
166
|
+
response = "Method Not Allowed";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const hook of this.afterHooks) {
|
|
170
|
+
const out = await hook({ request, response, set });
|
|
171
|
+
if (out !== undefined) response = out;
|
|
172
|
+
}
|
|
173
|
+
return toResponse(response, set);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Bind on Bun. Throws synchronously on EADDRINUSE, like Bun.serve. */
|
|
177
|
+
listen(port: number, callback?: () => void): this {
|
|
178
|
+
const serve = this.config.serve ?? {};
|
|
179
|
+
this.server = Bun.serve({
|
|
180
|
+
port,
|
|
181
|
+
fetch: this.fetch,
|
|
182
|
+
maxRequestBodySize: serve.maxRequestBodySize,
|
|
183
|
+
idleTimeout: serve.idleTimeout,
|
|
184
|
+
reusePort: serve.reusePort ?? false,
|
|
185
|
+
});
|
|
186
|
+
callback?.();
|
|
187
|
+
return this;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async stop(closeActiveConnections = false): Promise<void> {
|
|
191
|
+
await this.server?.stop(closeActiveConnections);
|
|
192
|
+
this.server = null;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function parseBody(request: Request): Promise<unknown> {
|
|
197
|
+
const type = (request.headers.get("content-type") ?? "").toLowerCase();
|
|
198
|
+
try {
|
|
199
|
+
if (type.includes("application/json")) {
|
|
200
|
+
const text = await request.clone().text();
|
|
201
|
+
return text ? JSON.parse(text) : undefined;
|
|
202
|
+
}
|
|
203
|
+
if (
|
|
204
|
+
type.includes("multipart/form-data") ||
|
|
205
|
+
type.includes("application/x-www-form-urlencoded")
|
|
206
|
+
) {
|
|
207
|
+
return Object.fromEntries(await request.clone().formData());
|
|
208
|
+
}
|
|
209
|
+
const text = await request.clone().text();
|
|
210
|
+
return text || undefined;
|
|
211
|
+
} catch {
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Handler return value → Response, with `set` status/headers merged in. */
|
|
217
|
+
function toResponse(value: unknown, set: ResponseSet): Response {
|
|
218
|
+
const extra = Object.entries(set.headers);
|
|
219
|
+
if (value instanceof Response) {
|
|
220
|
+
if (extra.length === 0 && set.status === undefined) return value;
|
|
221
|
+
// Rebuilt rather than mutated: a fetch() Response has immutable headers.
|
|
222
|
+
const headers = new Headers(value.headers);
|
|
223
|
+
for (const [k, v] of extra) headers.set(k, v);
|
|
224
|
+
return new Response(value.body, {
|
|
225
|
+
status: set.status ?? value.status,
|
|
226
|
+
statusText: set.status === undefined ? value.statusText : undefined,
|
|
227
|
+
headers,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
const status = set.status ?? 200;
|
|
231
|
+
if (value === undefined || value === null) return new Response(null, { status, headers: extra });
|
|
232
|
+
if (typeof value === "string") {
|
|
233
|
+
const headers = new Headers(extra);
|
|
234
|
+
if (!headers.has("content-type")) headers.set("content-type", "text/plain;charset=utf-8");
|
|
235
|
+
return new Response(value, { status, headers });
|
|
236
|
+
}
|
|
237
|
+
return Response.json(value, { status, headers: extra });
|
|
238
|
+
}
|
package/src/core/build.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
writeFileSync,
|
|
3
|
+
readFileSync,
|
|
4
|
+
rmSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
existsSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
unlinkSync,
|
|
9
|
+
rmdirSync,
|
|
10
|
+
} from "fs";
|
|
11
|
+
import { basename, join, relative, resolve } from "path";
|
|
3
12
|
import type { RouteManifest } from "./types.ts";
|
|
4
13
|
|
|
5
14
|
import { scanRoutes, RouteConflictError } from "./scanner.ts";
|
|
@@ -28,7 +37,7 @@ import { workersGuardReport } from "./workersGuard.ts";
|
|
|
28
37
|
const CORE_DIR = import.meta.dir;
|
|
29
38
|
|
|
30
39
|
// Runtime externals: never bundled into dist/hooks.server.js or dist/bosia.config.js
|
|
31
|
-
const BOSIA_RUNTIME_EXTERNALS = ["bosia", "
|
|
40
|
+
const BOSIA_RUNTIME_EXTERNALS = ["bosia", "bun", "svelte", "svelte/server"];
|
|
32
41
|
|
|
33
42
|
// ─── Entry Point ─────────────────────────────────────────
|
|
34
43
|
|
|
@@ -92,9 +101,39 @@ if (process.env.BOSIA_SKIP_ROUTE_SCAN === "1") {
|
|
|
92
101
|
// would clobber a concurrently-running `bosia dev` whose compiled server lives
|
|
93
102
|
// at .bosia/dev/ — the codegen files (routes*.ts, env.*.ts, types/) are the
|
|
94
103
|
// only things this build needs to clear to avoid stale entries on route renames.
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
104
|
+
// Windows won't delete a file another process holds open (a running `bosia
|
|
105
|
+
// start`, an editor, antivirus scanning fresh files); warn instead of silently
|
|
106
|
+
// building over stale output. rmSync is only a first try: on Windows, Bun 1.4.2
|
|
107
|
+
// threw ENOENT for an existing "./dist" (and with `force` hid it, deleting
|
|
108
|
+
// nothing). So resolve the path, check what's left, and finish entry by entry —
|
|
109
|
+
// a real failure then names the exact file.
|
|
110
|
+
function clearOutput(path: string): void {
|
|
111
|
+
const abs = resolve(path);
|
|
112
|
+
if (!existsSync(abs)) return;
|
|
113
|
+
try {
|
|
114
|
+
rmSync(abs, { recursive: true, maxRetries: 5, retryDelay: 100 });
|
|
115
|
+
} catch {}
|
|
116
|
+
if (!existsSync(abs)) return;
|
|
117
|
+
try {
|
|
118
|
+
removeTree(abs);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
const e = err as NodeJS.ErrnoException;
|
|
121
|
+
console.warn(
|
|
122
|
+
`⚠️ Could not delete ${e.path ?? path} (${e.code ?? String(err)}) — stop whatever is using it and rebuild.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function removeTree(path: string): void {
|
|
127
|
+
for (const entry of readdirSync(path, { withFileTypes: true })) {
|
|
128
|
+
const child = join(path, entry.name);
|
|
129
|
+
if (entry.isDirectory()) removeTree(child);
|
|
130
|
+
else unlinkSync(child);
|
|
131
|
+
}
|
|
132
|
+
rmdirSync(path);
|
|
133
|
+
}
|
|
134
|
+
clearOutput(OUT_DIR);
|
|
135
|
+
// A Bun build must not ship a leftover Workers bundle from an earlier target.
|
|
136
|
+
if (target !== "workers" && existsSync(`${OUT_DIR}/worker`)) clearOutput(`${OUT_DIR}/worker`);
|
|
98
137
|
for (const p of [
|
|
99
138
|
".bosia/routes.ts",
|
|
100
139
|
".bosia/routes.client.ts",
|
|
@@ -104,9 +143,7 @@ for (const p of [
|
|
|
104
143
|
".bosia/runtime.workers.ts",
|
|
105
144
|
".bosia/types",
|
|
106
145
|
]) {
|
|
107
|
-
|
|
108
|
-
rmSync(p, { recursive: true, force: true });
|
|
109
|
-
} catch {}
|
|
146
|
+
clearOutput(p);
|
|
110
147
|
}
|
|
111
148
|
|
|
112
149
|
// 1. Scan routes (or reuse the cached manifest — see 0b-pre)
|
|
@@ -233,7 +270,6 @@ const serverPromise = Bun.build({
|
|
|
233
270
|
naming: { entry: "index.[ext]", chunk: "[name]-[hash].[ext]" },
|
|
234
271
|
minify: isProduction,
|
|
235
272
|
sourcemap: isProduction ? "none" : "linked",
|
|
236
|
-
external: ["elysia"],
|
|
237
273
|
plugins: [serverPlugin, ...userServerBunPlugins, makeBosiaSvelteCompiler("bun")],
|
|
238
274
|
});
|
|
239
275
|
|
|
@@ -288,7 +324,7 @@ for (const output of clientResult.outputs) {
|
|
|
288
324
|
const rel = toPosix(relative(`${OUT_DIR}/client`, output.path)); // URL path, not fs path
|
|
289
325
|
if (output.path.endsWith(".js")) jsFiles.push(rel);
|
|
290
326
|
if (output.path.endsWith(".css")) cssFiles.push(rel);
|
|
291
|
-
if (
|
|
327
|
+
if (output.kind === "entry-point" && output.path.endsWith(".js")) {
|
|
292
328
|
clientEntry = rel;
|
|
293
329
|
}
|
|
294
330
|
}
|
package/src/core/config.ts
CHANGED
|
@@ -55,7 +55,7 @@ export async function loadBosiaConfig(cwd: string = process.cwd()): Promise<Bosi
|
|
|
55
55
|
entrypoints: [configPath],
|
|
56
56
|
target: "bun",
|
|
57
57
|
format: "esm",
|
|
58
|
-
external: ["bosia", "
|
|
58
|
+
external: ["bosia", "bun", "svelte", "svelte/server"],
|
|
59
59
|
});
|
|
60
60
|
|
|
61
61
|
if (!result.success || !result.outputs[0]) {
|
package/src/core/dev.ts
CHANGED
|
@@ -210,7 +210,7 @@ async function startAppServer() {
|
|
|
210
210
|
NODE_ENV: "development",
|
|
211
211
|
// Force app server to APP_PORT — prevents PORT from .env conflicting with the dev proxy
|
|
212
212
|
PORT: String(APP_PORT),
|
|
213
|
-
// Allow externalized deps
|
|
213
|
+
// Allow externalized deps to resolve from bosia's node_modules
|
|
214
214
|
NODE_PATH: BOSIA_NODE_PATH,
|
|
215
215
|
// Point the server child at dev's output dir so its OUT_DIR reads match what build wrote.
|
|
216
216
|
BOSIA_OUT_DIR: DEV_OUT_DIR,
|
|
@@ -423,8 +423,8 @@ const devServer = Bun.serve({
|
|
|
423
423
|
// the eBPF cgroup filter only permits packets to/from 127.0.0.0/8 + ::1.
|
|
424
424
|
// "localhost" resolves to both ::1 and 127.0.0.1; bun's fetch() picks IPv6
|
|
425
425
|
// first, but the inner Bun.serve() listens IPv4-only, so the v6 connect
|
|
426
|
-
// fails fast and (under bun 1.3) doesn't fall back to v4. Pinning to
|
|
427
|
-
// avoids the failure path entirely.
|
|
426
|
+
// fails fast and (seen under bun 1.3) doesn't fall back to v4. Pinning to
|
|
427
|
+
// v4 avoids the failure path entirely, whatever bun's resolver does.
|
|
428
428
|
target.hostname = "127.0.0.1";
|
|
429
429
|
target.port = String(APP_PORT);
|
|
430
430
|
|
package/src/core/html.ts
CHANGED
|
@@ -38,6 +38,12 @@ const TW_CSS = `${B}/bosia-tw.css`;
|
|
|
38
38
|
const FAVICON = `${B}/favicon.svg`;
|
|
39
39
|
const SSE = `${B}/__bosia/sse`;
|
|
40
40
|
|
|
41
|
+
// The client entry, emitted verbatim — never with a `?v=` buster. Split chunks
|
|
42
|
+
// import it by bare relative URL (Bun 1.4 keeps shared code in the entry), so a
|
|
43
|
+
// query here makes the browser load it twice: two Svelte runtimes, and hydration
|
|
44
|
+
// dies with "reading 'call'". The content-hashed name is already the buster.
|
|
45
|
+
const ENTRY = `${DIST}/${distManifest.entry}`;
|
|
46
|
+
|
|
41
47
|
/**
|
|
42
48
|
* Handed to the client bundle so its router strips the same prefix the server
|
|
43
49
|
* added. Emitted before the module script, and omitted entirely at the origin
|
|
@@ -195,7 +201,7 @@ export function buildHtml(
|
|
|
195
201
|
: "";
|
|
196
202
|
|
|
197
203
|
const scripts = csr
|
|
198
|
-
? `${baseScript(nonce)}${envScript}${dataIslands}${sysScript}\n <script${n} type="module" src="${
|
|
204
|
+
? `${baseScript(nonce)}${envScript}${dataIslands}${sysScript}\n <script${n} type="module" src="${ENTRY}"></script>`
|
|
199
205
|
: isDev
|
|
200
206
|
? `\n <script${n}>!function r(){var e=new EventSource("${SSE}");e.addEventListener("reload",()=>location.reload());e.onopen=()=>r._ok||(r._ok=1);e.onerror=()=>{e.close();setTimeout(r,2000)}}()</script>`
|
|
201
207
|
: "";
|
|
@@ -264,7 +270,7 @@ export function buildHtmlShellOpen(
|
|
|
264
270
|
`\n ${faviconLine}${twCssLink()}\n` +
|
|
265
271
|
componentCssLinks() +
|
|
266
272
|
` <script${n}>${THEME_INIT_JS}</script>\n` +
|
|
267
|
-
` <link rel="modulepreload" href="${
|
|
273
|
+
` <link rel="modulepreload" href="${ENTRY}">`
|
|
268
274
|
);
|
|
269
275
|
}
|
|
270
276
|
|
|
@@ -276,7 +282,7 @@ export function buildHtmlShellOpen(
|
|
|
276
282
|
` ${twCssLink()}\n` +
|
|
277
283
|
componentCssLinks() +
|
|
278
284
|
` <script${n}>${THEME_INIT_JS}</script>\n` +
|
|
279
|
-
` <link rel="modulepreload" href="${
|
|
285
|
+
` <link rel="modulepreload" href="${ENTRY}">`
|
|
280
286
|
);
|
|
281
287
|
}
|
|
282
288
|
|
|
@@ -401,7 +407,7 @@ export function buildHtmlTail(
|
|
|
401
407
|
if (ssrFlag || depsInject) {
|
|
402
408
|
out += `\n<script${n}>${ssrFlag}${depsInject}</script>`;
|
|
403
409
|
}
|
|
404
|
-
out += `\n<script${n} type="module" src="${
|
|
410
|
+
out += `\n<script${n} type="module" src="${ENTRY}"></script>`;
|
|
405
411
|
} else if (isDev) {
|
|
406
412
|
out += `\n<script${n}>!function r(){var e=new EventSource("${SSE}");e.addEventListener("reload",()=>location.reload());e.onopen=()=>r._ok||(r._ok=1);e.onerror=()=>{e.close();setTimeout(r,2000)}}()</script>`;
|
|
407
413
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import type { Elysia } from "elysia";
|
|
4
3
|
import { createInspectorBunPlugin } from "./bun-plugin.ts";
|
|
5
4
|
import { getOverlayScript } from "./overlay.ts";
|
|
6
5
|
import { resolveFrame, resolveStack } from "./sourcemap.ts";
|
|
@@ -142,7 +141,7 @@ function installProcessListeners() {
|
|
|
142
141
|
// live in a separately-bundled module graph from this disk-loaded plugin —
|
|
143
142
|
// can notify the inspector without an import dependency. Renderer catches
|
|
144
143
|
// (page load, layout load, SSR load) swallow errors and return a rendered
|
|
145
|
-
// error page, so they never reach
|
|
144
|
+
// error page, so they never reach the backend `.onError()` on their own. The
|
|
146
145
|
// global is typed in core/devErrorReport.ts.
|
|
147
146
|
function installGlobalReporter() {
|
|
148
147
|
globalThis.__BOSIA_REPORT_ERROR__ = (e) => {
|
|
@@ -179,10 +178,7 @@ export function inspector(options: InspectorOptions = {}): BosiaPlugin | false {
|
|
|
179
178
|
|
|
180
179
|
backend: {
|
|
181
180
|
before(app) {
|
|
182
|
-
|
|
183
|
-
// the generic so the plugin return type drifts unless we widen back.
|
|
184
|
-
let chained: Elysia = app as unknown as Elysia;
|
|
185
|
-
chained = chained.post(endpoint, async ({ body }: { body: unknown }) => {
|
|
181
|
+
let chained = app.post(endpoint, async ({ body }) => {
|
|
186
182
|
const data = (body ?? {}) as {
|
|
187
183
|
file?: string;
|
|
188
184
|
line?: number;
|
|
@@ -270,7 +266,7 @@ export function inspector(options: InspectorOptions = {}): BosiaPlugin | false {
|
|
|
270
266
|
installProcessListeners();
|
|
271
267
|
installGlobalReporter();
|
|
272
268
|
|
|
273
|
-
//
|
|
269
|
+
// Backend onError — runs because server.ts registers plugin
|
|
274
270
|
// backend.before() ahead of the base .onError() responder.
|
|
275
271
|
chained = chained.onError(({ error }) => {
|
|
276
272
|
const e = error as Error;
|
|
@@ -281,12 +277,12 @@ export function inspector(options: InspectorOptions = {}): BosiaPlugin | false {
|
|
|
281
277
|
});
|
|
282
278
|
// Fall through to the base handler.
|
|
283
279
|
return undefined;
|
|
284
|
-
})
|
|
280
|
+
});
|
|
285
281
|
|
|
286
282
|
// Live SSE stream. New clients also get a flush of the bounded
|
|
287
283
|
// replay buffer so errors that fired during a failing render
|
|
288
284
|
// (before the 500 page's overlay could subscribe) are visible.
|
|
289
|
-
chained = chained.get("/__bosia/errors", ({ request }
|
|
285
|
+
chained = chained.get("/__bosia/errors", ({ request }) => {
|
|
290
286
|
const stream = new ReadableStream<Uint8Array>({
|
|
291
287
|
start(ctrl) {
|
|
292
288
|
sseClients.add(ctrl);
|
|
@@ -322,7 +318,7 @@ export function inspector(options: InspectorOptions = {}): BosiaPlugin | false {
|
|
|
322
318
|
Connection: "keep-alive",
|
|
323
319
|
},
|
|
324
320
|
});
|
|
325
|
-
})
|
|
321
|
+
});
|
|
326
322
|
}
|
|
327
323
|
|
|
328
324
|
return chained;
|
|
@@ -39,7 +39,7 @@ export function serverTiming(options: ServerTimingOptions = {}): BosiaPlugin {
|
|
|
39
39
|
return;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
// Otherwise, push into
|
|
42
|
+
// Otherwise, push into the outbound header bag.
|
|
43
43
|
const headers = (set.headers ??= {}) as Record<string, string>;
|
|
44
44
|
headers[headerName] = value;
|
|
45
45
|
});
|
package/src/core/server.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { BosiaApp } from "./backend.ts";
|
|
2
2
|
|
|
3
3
|
import { findMatch, compileRoutes, canonicalPathname } from "./matcher.ts";
|
|
4
4
|
import { resolveApiMatch } from "./apiResolver.ts";
|
|
@@ -1205,7 +1205,7 @@ export function inFlightCount(): number {
|
|
|
1205
1205
|
return inFlight;
|
|
1206
1206
|
}
|
|
1207
1207
|
|
|
1208
|
-
// ───
|
|
1208
|
+
// ─── Backend App ──────────────────────────────────────────
|
|
1209
1209
|
|
|
1210
1210
|
// Read the build-time route manifest so plugins.backend.after can introspect routes.
|
|
1211
1211
|
function loadBuiltManifest(): RouteManifest {
|
|
@@ -1233,12 +1233,13 @@ function loadBuiltManifest(): RouteManifest {
|
|
|
1233
1233
|
export type CreateAppOptions = {
|
|
1234
1234
|
/** `handle` from the user's `src/hooks.server.ts`. */
|
|
1235
1235
|
handle?: Handle | null;
|
|
1236
|
-
/** Elysia adapter. Omitted → Bun (`Bun.serve`). */
|
|
1237
|
-
adapter?: ElysiaAdapter;
|
|
1238
1236
|
};
|
|
1239
1237
|
|
|
1240
|
-
|
|
1241
|
-
|
|
1238
|
+
const frameworkHandler = ({ request }: { request: Request }) =>
|
|
1239
|
+
handleRequest(request, new URL(request.url));
|
|
1240
|
+
|
|
1241
|
+
/** Build the backend app: plugins, the framework's catch-all routes, error handling. */
|
|
1242
|
+
export async function createApp(options: CreateAppOptions = {}): Promise<BosiaApp> {
|
|
1242
1243
|
userHandle = options.handle ?? null;
|
|
1243
1244
|
|
|
1244
1245
|
const plugins = await loadPlugins(process.cwd());
|
|
@@ -1246,25 +1247,22 @@ export async function createApp(options: CreateAppOptions = {}): Promise<Elysia>
|
|
|
1246
1247
|
console.log(`🔌 Loaded ${plugins.length} plugin(s): ${plugins.map((p) => p.name).join(", ")}`);
|
|
1247
1248
|
}
|
|
1248
1249
|
|
|
1249
|
-
//
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
reusePort: process.env.BOSIA_REUSE_PORT === "1",
|
|
1261
|
-
},
|
|
1262
|
-
})) as unknown as Elysia;
|
|
1250
|
+
// serve options only apply on Bun (`listen`); Workers calls `fetch` directly.
|
|
1251
|
+
let app = new BosiaApp({
|
|
1252
|
+
serve: {
|
|
1253
|
+
maxRequestBodySize: BODY_SIZE_LIMIT,
|
|
1254
|
+
idleTimeout: IDLE_TIMEOUT,
|
|
1255
|
+
// SO_REUSEPORT lets a second server silently join the port and the kernel
|
|
1256
|
+
// splits traffic between two different builds. Opt in only for deliberate
|
|
1257
|
+
// N-worker clustering.
|
|
1258
|
+
reusePort: process.env.BOSIA_REUSE_PORT === "1",
|
|
1259
|
+
},
|
|
1260
|
+
});
|
|
1263
1261
|
|
|
1264
1262
|
// Plugins.backend.before — runs before framework middleware/routes.
|
|
1265
1263
|
// Plugin-registered routes here BYPASS the framework (CSRF, hooks, etc.).
|
|
1266
|
-
// Plugins register their own `.onError()` handlers here.
|
|
1267
|
-
//
|
|
1264
|
+
// Plugins register their own `.onError()` handlers here. onError handlers
|
|
1265
|
+
// fire in registration order; plugin handlers run first and (when they
|
|
1268
1266
|
// return undefined) fall through to the base 500 responder chained after this
|
|
1269
1267
|
// loop. Registering the base responder before the loop would short-circuit
|
|
1270
1268
|
// every plugin handler.
|
|
@@ -1279,40 +1277,20 @@ export async function createApp(options: CreateAppOptions = {}): Promise<Elysia>
|
|
|
1279
1277
|
}
|
|
1280
1278
|
}
|
|
1281
1279
|
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
else console.error("Uncaught server error:", (error as Error)?.message ?? error);
|
|
1285
|
-
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
|
1286
|
-
}) as unknown as Elysia;
|
|
1287
|
-
|
|
1280
|
+
// Static files are served by resolve() with path traversal protection and security headers.
|
|
1281
|
+
// Only the verbs Bosia answers are routed — anything else (TRACE, …) gets a 405.
|
|
1288
1282
|
app = app
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
return handleRequest(request, url);
|
|
1294
|
-
})
|
|
1295
|
-
// Non-GET catch-alls route every method through handleRequest()
|
|
1296
|
-
.post("*", ({ request }: { request: Request }) => {
|
|
1297
|
-
const url = new URL(request.url);
|
|
1298
|
-
return handleRequest(request, url);
|
|
1299
|
-
})
|
|
1300
|
-
.put("*", ({ request }: { request: Request }) => {
|
|
1301
|
-
const url = new URL(request.url);
|
|
1302
|
-
return handleRequest(request, url);
|
|
1303
|
-
})
|
|
1304
|
-
.patch("*", ({ request }: { request: Request }) => {
|
|
1305
|
-
const url = new URL(request.url);
|
|
1306
|
-
return handleRequest(request, url);
|
|
1307
|
-
})
|
|
1308
|
-
.delete("*", ({ request }: { request: Request }) => {
|
|
1309
|
-
const url = new URL(request.url);
|
|
1310
|
-
return handleRequest(request, url);
|
|
1283
|
+
.onError(({ error }) => {
|
|
1284
|
+
if (isDev) console.error("Uncaught server error:", error);
|
|
1285
|
+
else console.error("Uncaught server error:", (error as Error)?.message ?? error);
|
|
1286
|
+
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
|
1311
1287
|
})
|
|
1312
|
-
.
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1288
|
+
.get("*", frameworkHandler)
|
|
1289
|
+
.post("*", frameworkHandler)
|
|
1290
|
+
.put("*", frameworkHandler)
|
|
1291
|
+
.patch("*", frameworkHandler)
|
|
1292
|
+
.delete("*", frameworkHandler)
|
|
1293
|
+
.options("*", frameworkHandler);
|
|
1316
1294
|
|
|
1317
1295
|
// Plugins.backend.after — runs after framework routes; receives the route manifest.
|
|
1318
1296
|
for (const plugin of plugins) {
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
// config arrive as static imports (workersCodegen.ts), static files are served
|
|
4
4
|
// by Workers Static Assets before the worker runs.
|
|
5
5
|
|
|
6
|
-
import { CloudflareAdapter } from "elysia/adapter/cloudflare-worker";
|
|
7
6
|
import { config, handle } from "bosia:workers-runtime";
|
|
8
7
|
import { twMerge } from "tailwind-merge";
|
|
9
8
|
|
|
@@ -19,7 +18,7 @@ disableCompression();
|
|
|
19
18
|
// isolate startup, which has its own CPU budget, not in the first request.
|
|
20
19
|
twMerge("");
|
|
21
20
|
|
|
22
|
-
const app =
|
|
21
|
+
const app = await createApp({ handle });
|
|
23
22
|
|
|
24
23
|
// The first request in an isolate paid ~5ms of V8 compiling the render path, on a
|
|
25
24
|
// 10ms CPU budget. Render `/` once now instead — startup CPU is budgeted apart.
|
package/src/core/types/plugin.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// ─── Bosia Plugin Types ──────────────────────────────────
|
|
2
2
|
// Public surface for first-party and third-party plugins.
|
|
3
3
|
|
|
4
|
-
import type {
|
|
4
|
+
import type { BosiaApp } from "../backend.ts";
|
|
5
5
|
import type { BunPlugin } from "bun";
|
|
6
6
|
import type { RouteManifest } from "../types.ts";
|
|
7
7
|
import type { Metadata } from "../hooks.ts";
|
|
@@ -34,15 +34,14 @@ export interface BosiaPlugin {
|
|
|
34
34
|
name: string;
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
|
-
* Mount points around the framework's HTTP backend
|
|
38
|
-
*
|
|
39
|
-
* abstract so the API survives a future backend swap.
|
|
37
|
+
* Mount points around the framework's HTTP backend — Bosia's own small,
|
|
38
|
+
* Elysia-shaped app (`get/post/…`, `onRequest`, `onAfterHandle`, `onError`).
|
|
40
39
|
*/
|
|
41
40
|
backend?: {
|
|
42
41
|
/** Runs before framework middleware/routes — can register routes that bypass Bosia. */
|
|
43
|
-
before?: (app:
|
|
42
|
+
before?: (app: BosiaApp) => MaybePromise<BosiaApp>;
|
|
44
43
|
/** Runs after framework routes — receives the route manifest for introspection. */
|
|
45
|
-
after?: (app:
|
|
44
|
+
after?: (app: BosiaApp, ctx: { manifest: RouteManifest }) => MaybePromise<BosiaApp>;
|
|
46
45
|
};
|
|
47
46
|
|
|
48
47
|
/** Build pipeline hooks. */
|
package/src/lib/server.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# {{PROJECT_NAME}}
|
|
2
2
|
|
|
3
|
-
A [Bosia](https://github.com/bosapi/bosia) project — SSR · Svelte 5 · Bun
|
|
3
|
+
A [Bosia](https://github.com/bosapi/bosia) project — SSR · Svelte 5 · Bun.
|
|
4
4
|
|
|
5
5
|
Bosia is **production-ready out of the box** — security (CSRF, XSS, secure cookies, headers), performance (response cache, gzip, prerendering), and reliability (graceful shutdown, request backpressure, crash backoff) are all built in.
|
|
6
6
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
<div class="flex flex-col items-center gap-3 text-center">
|
|
3
3
|
<img src="/favicon.svg" alt="" class="size-16" />
|
|
4
4
|
<h1 class="text-4xl font-bold tracking-tight">Welcome to Bosia</h1>
|
|
5
|
-
<p class="text-muted-foreground text-lg">A Bosia project — SSR + Svelte 5 + Bun
|
|
5
|
+
<p class="text-muted-foreground text-lg">A Bosia project — SSR + Svelte 5 + Bun</p>
|
|
6
6
|
</div>
|
|
7
7
|
|
|
8
8
|
<div class="mt-4 flex gap-3">
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
|
|
39
39
|
<svelte:head>
|
|
40
40
|
<title>Bosia Demo</title>
|
|
41
|
-
<meta name="description" content="Bosia Demo — SSR + Svelte 5 + Bun
|
|
41
|
+
<meta name="description" content="Bosia Demo — SSR + Svelte 5 + Bun" />
|
|
42
42
|
</svelte:head>
|
|
43
43
|
|
|
44
44
|
<div class="space-y-12">
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
Bosia Demo
|
|
50
50
|
</h1>
|
|
51
51
|
<p class="text-xl text-muted-foreground max-w-xl">
|
|
52
|
-
A minimalist fullstack framework — SSR, Svelte 5 Runes,
|
|
52
|
+
A minimalist fullstack framework — SSR, Svelte 5 Runes, and Bun.
|
|
53
53
|
</p>
|
|
54
54
|
</div>
|
|
55
55
|
|
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
<div class="space-y-6 max-w-2xl">
|
|
6
6
|
<h1 class="text-4xl font-bold tracking-tight">About Bosia Demo</h1>
|
|
7
7
|
<p class="text-muted-foreground text-lg">
|
|
8
|
-
A minimalist fullstack framework built on Bun
|
|
8
|
+
A minimalist fullstack framework built on Bun and Svelte 5.
|
|
9
9
|
</p>
|
|
10
10
|
|
|
11
11
|
<ul class="space-y-2 text-foreground">
|
|
12
|
-
{#each ["Bun runtime — fast builds, native TypeScript", "
|
|
12
|
+
{#each ["Bun runtime — fast builds, native TypeScript", "Svelte 5 Runes — fine-grained reactivity", "Isomorphic SSR with client hydration", "File-based routing (SvelteKit-compatible conventions)", "Nested layouts and route groups (public), (auth), (admin)", "Dynamic params [slug] and catch-all [...rest]", "Server loaders with parent() data threading", "Hooks — sequence() middleware for auth, logging, etc.", "Component registry — bun x bosia@latest add button", "Feature registry — bosia feat login"] as item}
|
|
13
13
|
<li class="flex items-start gap-2">
|
|
14
14
|
<span class="text-primary mt-0.5">✓</span>
|
|
15
15
|
<span>{item}</span>
|