bosia 0.9.8 → 0.9.10
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 +3 -3
- package/package.json +2 -4
- package/src/ambient.d.ts +7 -0
- package/src/cli/build.ts +6 -1
- package/src/cli/index.ts +3 -3
- package/src/cli/start.ts +9 -1
- package/src/core/appHtml.ts +3 -11
- package/src/core/artifacts.ts +21 -0
- package/src/core/backend.ts +238 -0
- package/src/core/build.ts +97 -6
- package/src/core/cache.ts +10 -6
- package/src/core/client/App.svelte +5 -2
- package/src/core/client/prefetch.ts +3 -1
- package/src/core/config.ts +20 -9
- package/src/core/dev.ts +2 -3
- package/src/core/env.ts +0 -20
- package/src/core/hooks.ts +28 -0
- package/src/core/html.ts +29 -24
- package/src/core/paths.ts +4 -2
- package/src/core/platform.ts +24 -0
- package/src/core/plugin.ts +26 -1
- package/src/core/plugins/inspector/index.ts +6 -10
- package/src/core/plugins/server-timing.ts +1 -1
- package/src/core/prerender.ts +6 -6
- package/src/core/renderer.ts +15 -8
- package/src/core/routeFile.ts +4 -0
- package/src/core/scanner.ts +30 -0
- package/src/core/server.bun.ts +129 -0
- package/src/core/server.ts +108 -204
- package/src/core/server.workers.ts +46 -0
- package/src/core/types/plugin.ts +10 -6
- package/src/core/types.ts +2 -0
- package/src/core/workersCodegen.ts +110 -0
- package/src/core/workersGuard.ts +107 -0
- package/src/lib/index.ts +3 -0
- package/src/lib/server.ts +1 -0
- package/templates/default/README.md +1 -1
- package/templates/default/_gitignore +2 -0
- package/templates/default/src/routes/(public)/+page.svelte +1 -1
- package/templates/demo/_gitignore +2 -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/_gitignore +2 -0
- package/templates/store/_gitignore +2 -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
|
|
|
@@ -53,7 +53,7 @@ bun run start
|
|
|
53
53
|
| Layer | Technology |
|
|
54
54
|
| ----------- | ------------------------------------------ |
|
|
55
55
|
| Runtime | [Bun](https://bun.sh) |
|
|
56
|
-
| HTTP Server |
|
|
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,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bosia",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.10",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "A fast, batteries-included fullstack framework — SSR · Svelte 5 Runes · Bun
|
|
5
|
+
"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
6
|
"keywords": [
|
|
7
7
|
"bun",
|
|
8
8
|
"svelte",
|
|
9
9
|
"ssr",
|
|
10
|
-
"elysia",
|
|
11
10
|
"fullstack",
|
|
12
11
|
"framework"
|
|
13
12
|
],
|
|
@@ -55,7 +54,6 @@
|
|
|
55
54
|
"@clack/prompts": "^1.1.0",
|
|
56
55
|
"@jridgewell/trace-mapping": "^0.3.31",
|
|
57
56
|
"@tailwindcss/cli": "^4.2.1",
|
|
58
|
-
"elysia": "^1.4.26",
|
|
59
57
|
"magic-string": "^0.30.0",
|
|
60
58
|
"svelte": "^5.56.3",
|
|
61
59
|
"tailwind-merge": "^3.5.0",
|
package/src/ambient.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ declare module "bosia:routes" {
|
|
|
11
11
|
layouts: Loader[];
|
|
12
12
|
hasServerData: boolean;
|
|
13
13
|
trailingSlash: TrailingSlash;
|
|
14
|
+
prerender: boolean;
|
|
14
15
|
}>;
|
|
15
16
|
|
|
16
17
|
export const serverRoutes: Array<{
|
|
@@ -29,3 +30,9 @@ declare module "bosia:routes" {
|
|
|
29
30
|
|
|
30
31
|
export const errorPage: Loader | null;
|
|
31
32
|
}
|
|
33
|
+
|
|
34
|
+
// Workers only — backed by .bosia/runtime.workers.ts (see core/workersCodegen.ts).
|
|
35
|
+
declare module "bosia:workers-runtime" {
|
|
36
|
+
export const handle: import("./core/hooks.ts").Handle | null;
|
|
37
|
+
export const config: import("./core/types/plugin.ts").BosiaConfig;
|
|
38
|
+
}
|
package/src/cli/build.ts
CHANGED
|
@@ -2,8 +2,13 @@ import { spawn } from "bun";
|
|
|
2
2
|
import { resolve } from "path";
|
|
3
3
|
import { loadEnv } from "../core/env.ts";
|
|
4
4
|
|
|
5
|
-
export async function runBuild() {
|
|
5
|
+
export async function runBuild(args: string[] = []) {
|
|
6
6
|
loadEnv("production");
|
|
7
|
+
// --target=workers or --target workers; overrides bosia.config's `target`.
|
|
8
|
+
const eq = args.find((a) => a.startsWith("--target="));
|
|
9
|
+
const i = args.indexOf("--target");
|
|
10
|
+
const target = eq ? eq.slice("--target=".length) : i !== -1 ? args[i + 1] : undefined;
|
|
11
|
+
if (target) process.env.BOSIA_TARGET = target;
|
|
7
12
|
const buildScript = resolve(import.meta.dir, "../core/build.ts");
|
|
8
13
|
const proc = spawn(["bun", "run", buildScript], {
|
|
9
14
|
stdout: "inherit",
|
package/src/cli/index.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// ─── Bosia CLI ────────────────────────────────────────────
|
|
3
3
|
// bun x bosia@latest create <name> scaffold a new project
|
|
4
4
|
// bun x bosia dev start the development server
|
|
5
|
-
// bun x bosia build
|
|
5
|
+
// bun x bosia build [--target=workers] build for production (Bun or Cloudflare Workers)
|
|
6
6
|
// bun x bosia start run the production server
|
|
7
7
|
// bun x bosia@latest add <name> add a UI component from the registry
|
|
8
8
|
// bun x bosia@latest feat <name> add a feature scaffold from the registry
|
|
@@ -40,7 +40,7 @@ async function main() {
|
|
|
40
40
|
}
|
|
41
41
|
case "build": {
|
|
42
42
|
const { runBuild } = await import("./build.ts");
|
|
43
|
-
await runBuild();
|
|
43
|
+
await runBuild(args);
|
|
44
44
|
break;
|
|
45
45
|
}
|
|
46
46
|
case "sync": {
|
|
@@ -114,7 +114,7 @@ Usage:
|
|
|
114
114
|
Commands:
|
|
115
115
|
create <name> [--template <t>] Scaffold a new Bosia project
|
|
116
116
|
dev Start the development server
|
|
117
|
-
build Build for production
|
|
117
|
+
build Build for production (--target=workers for Cloudflare)
|
|
118
118
|
sync Generate .bosia/ codegen (routes, $types, env) without building
|
|
119
119
|
start Run the production server
|
|
120
120
|
test [args] Run tests with bun test (auto-loads .env.test, sets BOSIA_ENV=test)
|
package/src/cli/start.ts
CHANGED
|
@@ -6,12 +6,20 @@ export async function runStart() {
|
|
|
6
6
|
loadEnv("production");
|
|
7
7
|
|
|
8
8
|
let serverEntry = "index.js";
|
|
9
|
+
let target = "bun";
|
|
9
10
|
try {
|
|
10
11
|
const manifest = await Bun.file(`${OUT_DIR}/manifest.json`).json();
|
|
11
12
|
serverEntry = manifest.serverEntry ?? "index.js";
|
|
13
|
+
target = manifest.target ?? "bun";
|
|
12
14
|
} catch {}
|
|
13
15
|
|
|
14
|
-
|
|
16
|
+
// A Workers build runs in workerd, locally via wrangler (fetched on first use).
|
|
17
|
+
const cmd =
|
|
18
|
+
target === "workers"
|
|
19
|
+
? ["bunx", "wrangler", "dev", ...(process.env.PORT ? ["--port", process.env.PORT] : [])]
|
|
20
|
+
: ["bun", "run", `${OUT_DIR}/server/${serverEntry}`];
|
|
21
|
+
|
|
22
|
+
const proc = spawn(cmd, {
|
|
15
23
|
stdout: "inherit",
|
|
16
24
|
stderr: "inherit",
|
|
17
25
|
cwd: process.cwd(),
|
package/src/core/appHtml.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
|
2
2
|
import { join, dirname } from "path";
|
|
3
3
|
|
|
4
4
|
import { OUT_DIR } from "./paths.ts";
|
|
5
|
+
import { readArtifact } from "./artifacts.ts";
|
|
5
6
|
import { rebaseHtmlAttrs } from "./basePath.ts";
|
|
6
7
|
import { currentBase } from "./appBase.ts";
|
|
7
8
|
|
|
@@ -86,16 +87,6 @@ export function writeAppHtmlSegments(segments: AppHtmlSegments, outDir: string =
|
|
|
86
87
|
return target;
|
|
87
88
|
}
|
|
88
89
|
|
|
89
|
-
function readPersistedSegments(cwd: string): AppHtmlSegments | undefined {
|
|
90
|
-
const persistedPath = join(cwd, OUT_DIR, "app-html.json");
|
|
91
|
-
if (!existsSync(persistedPath)) return undefined;
|
|
92
|
-
try {
|
|
93
|
-
return JSON.parse(readFileSync(persistedPath, "utf-8")) as AppHtmlSegments;
|
|
94
|
-
} catch {
|
|
95
|
-
return undefined;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
90
|
// ─── Cached Getter ────────────────────────────────────────
|
|
100
91
|
|
|
101
92
|
export function getAppHtmlSegments(cwd: string = process.cwd()): AppHtmlSegments {
|
|
@@ -104,7 +95,8 @@ export function getAppHtmlSegments(cwd: string = process.cwd()): AppHtmlSegments
|
|
|
104
95
|
}
|
|
105
96
|
// Prefer persisted dist artifact (production runtime — no `src/` in image).
|
|
106
97
|
// Fall back to parsing `src/app.html` directly (dev mode, build step).
|
|
107
|
-
cachedSegments =
|
|
98
|
+
cachedSegments =
|
|
99
|
+
readArtifact<AppHtmlSegments>("app-html.json", join(cwd, OUT_DIR)) ?? loadAppHtmlTemplate(cwd);
|
|
108
100
|
return cachedSegments;
|
|
109
101
|
}
|
|
110
102
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
import { OUT_DIR } from "./paths.ts";
|
|
5
|
+
|
|
6
|
+
// ─── Build Artifacts ─────────────────────────────────────
|
|
7
|
+
// The one place the runtime reads the JSON the build left in OUT_DIR
|
|
8
|
+
// (manifest.json, app-html.json, route-manifest.json). Runtimes without a
|
|
9
|
+
// filesystem (Cloudflare Workers) swap this module for `.bosia/artifacts.ts`,
|
|
10
|
+
// which holds the same JSON inlined — see artifactCodegen.ts and plugin.ts.
|
|
11
|
+
|
|
12
|
+
/** Parsed `<dir>/<name>`, or undefined when it is missing or unreadable. */
|
|
13
|
+
export function readArtifact<T>(name: string, dir: string = OUT_DIR): T | undefined {
|
|
14
|
+
const p = join(dir, name);
|
|
15
|
+
if (!existsSync(p)) return undefined;
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(readFileSync(p, "utf-8")) as T;
|
|
18
|
+
} catch {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -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
|
@@ -14,15 +14,21 @@ import { generateEnvModules } from "./envCodegen.ts";
|
|
|
14
14
|
import { BOSIA_NODE_PATH, OUT_DIR, resolveBosiaBin, toPosix } from "./paths.ts";
|
|
15
15
|
import { currentBase } from "./appBase.ts";
|
|
16
16
|
import { finalizeTailwindCss, TW_TEMP_BASENAME } from "./twHash.ts";
|
|
17
|
-
import { loadPlugins } from "./config.ts";
|
|
18
|
-
import type { BuildContext } from "./types/plugin.ts";
|
|
17
|
+
import { loadBosiaConfig, loadPlugins } from "./config.ts";
|
|
18
|
+
import type { BuildContext, RuntimeTarget } from "./types/plugin.ts";
|
|
19
19
|
import { loadAppHtmlTemplate, writeAppHtmlSegments } from "./appHtml.ts";
|
|
20
|
+
import {
|
|
21
|
+
generateArtifactsModule,
|
|
22
|
+
generateWorkersRuntime,
|
|
23
|
+
generateWranglerConfig,
|
|
24
|
+
} from "./workersCodegen.ts";
|
|
25
|
+
import { workersGuardReport } from "./workersGuard.ts";
|
|
20
26
|
|
|
21
27
|
// Resolved from this file's location inside the bosia package
|
|
22
28
|
const CORE_DIR = import.meta.dir;
|
|
23
29
|
|
|
24
30
|
// Runtime externals: never bundled into dist/hooks.server.js or dist/bosia.config.js
|
|
25
|
-
const BOSIA_RUNTIME_EXTERNALS = ["bosia", "
|
|
31
|
+
const BOSIA_RUNTIME_EXTERNALS = ["bosia", "bun", "svelte", "svelte/server"];
|
|
26
32
|
|
|
27
33
|
// ─── Entry Point ─────────────────────────────────────────
|
|
28
34
|
|
|
@@ -42,6 +48,23 @@ const buildCtx: BuildContext = {
|
|
|
42
48
|
cwd: process.cwd(),
|
|
43
49
|
};
|
|
44
50
|
|
|
51
|
+
// 0-bis. Runtime target: `bosia build --target=` (BOSIA_TARGET) beats bosia.config.
|
|
52
|
+
const target = (process.env.BOSIA_TARGET ||
|
|
53
|
+
(await loadBosiaConfig()).target ||
|
|
54
|
+
"bun") as RuntimeTarget;
|
|
55
|
+
if (target !== "bun" && target !== "workers") {
|
|
56
|
+
console.error(`❌ Unknown target "${target}". Use "bun" or "workers".`);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
if (target !== "bun") console.log(`🎯 Target: ${target}`);
|
|
60
|
+
if (target === "workers") {
|
|
61
|
+
const guard = workersGuardReport();
|
|
62
|
+
if (guard) {
|
|
63
|
+
console.error(`❌ ${guard}`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
45
68
|
for (const p of userPlugins) {
|
|
46
69
|
if (p.build?.preBuild) {
|
|
47
70
|
await p.build.preBuild(buildCtx);
|
|
@@ -77,6 +100,8 @@ for (const p of [
|
|
|
77
100
|
".bosia/routes.client.ts",
|
|
78
101
|
".bosia/env.server.ts",
|
|
79
102
|
".bosia/env.client.ts",
|
|
103
|
+
".bosia/artifacts.ts",
|
|
104
|
+
".bosia/runtime.workers.ts",
|
|
80
105
|
".bosia/types",
|
|
81
106
|
]) {
|
|
82
107
|
try {
|
|
@@ -187,7 +212,9 @@ const clientPromise = Bun.build({
|
|
|
187
212
|
target: "browser",
|
|
188
213
|
conditions: ["svelte"],
|
|
189
214
|
splitting: true,
|
|
190
|
-
|
|
215
|
+
// Chunks are named after their source, which for routes is `+page` — and
|
|
216
|
+
// Cloudflare's asset server answers a `+` in the path with a redirect.
|
|
217
|
+
naming: { entry: "[name]-[hash].[ext]", chunk: "chunk-[hash].[ext]" },
|
|
191
218
|
minify: isProduction,
|
|
192
219
|
sourcemap: isProduction ? "none" : "linked",
|
|
193
220
|
define: {
|
|
@@ -198,7 +225,7 @@ const clientPromise = Bun.build({
|
|
|
198
225
|
});
|
|
199
226
|
|
|
200
227
|
const serverPromise = Bun.build({
|
|
201
|
-
entrypoints: [join(CORE_DIR, "server.ts")],
|
|
228
|
+
entrypoints: [join(CORE_DIR, "server.bun.ts")],
|
|
202
229
|
outdir: `${OUT_DIR}/server`,
|
|
203
230
|
target: "bun",
|
|
204
231
|
conditions: ["svelte"],
|
|
@@ -206,7 +233,6 @@ const serverPromise = Bun.build({
|
|
|
206
233
|
naming: { entry: "index.[ext]", chunk: "[name]-[hash].[ext]" },
|
|
207
234
|
minify: isProduction,
|
|
208
235
|
sourcemap: isProduction ? "none" : "linked",
|
|
209
|
-
external: ["elysia"],
|
|
210
236
|
plugins: [serverPlugin, ...userServerBunPlugins, makeBosiaSvelteCompiler("bun")],
|
|
211
237
|
});
|
|
212
238
|
|
|
@@ -292,10 +318,14 @@ const distManifest = {
|
|
|
292
318
|
jsFiles.find((f) => f.startsWith("hydrate")) ??
|
|
293
319
|
"hydrate.js",
|
|
294
320
|
serverEntry,
|
|
321
|
+
target,
|
|
295
322
|
tw: twFile,
|
|
296
323
|
// The CSS urls and the client route table are baked in with this prefix.
|
|
297
324
|
// Stamped so the server can warn when it boots with a different one.
|
|
298
325
|
basePath: currentBase(),
|
|
326
|
+
// Names of PUBLIC_* runtime vars the page may expose to the browser. The
|
|
327
|
+
// server is its own process, so it can't see which names loadEnv() declared.
|
|
328
|
+
publicEnv: Object.keys(classifiedEnv.publicDynamic),
|
|
299
329
|
};
|
|
300
330
|
writeFileSync(`${OUT_DIR}/manifest.json`, JSON.stringify(distManifest, null, 2));
|
|
301
331
|
console.log(`✅ Client bundle: ${jsFiles.join(", ")}`);
|
|
@@ -322,6 +352,10 @@ await prerenderStaticRoutes(manifest);
|
|
|
322
352
|
// 10. Generate static site output (HTML + client assets + public → dist/static/)
|
|
323
353
|
generateStaticSite();
|
|
324
354
|
|
|
355
|
+
// 11. Workers target: a second server bundle for Cloudflare. The Bun one above
|
|
356
|
+
// still exists — prerender just booted it to crawl static routes.
|
|
357
|
+
if (target === "workers") await buildWorker();
|
|
358
|
+
|
|
325
359
|
for (const p of userPlugins) {
|
|
326
360
|
if (p.build?.postBuild) {
|
|
327
361
|
await p.build.postBuild(buildCtx);
|
|
@@ -332,6 +366,63 @@ console.log(`\n🎉 Build complete in ${Math.round(performance.now() - buildStar
|
|
|
332
366
|
|
|
333
367
|
// ─── Helpers ─────────────────────────────────────────────
|
|
334
368
|
|
|
369
|
+
// Dev-only plugins in bosia.config.ts (the inspector) import svelte/compiler, which
|
|
370
|
+
// would put ~820KB of never-run code in the worker, 60% of the demo's bundle.
|
|
371
|
+
// Nothing compiles Svelte at runtime, so every export becomes a function that throws.
|
|
372
|
+
function stubSvelteCompiler(): import("bun").BunPlugin {
|
|
373
|
+
return {
|
|
374
|
+
name: "bosia-stub-svelte-compiler",
|
|
375
|
+
setup(build) {
|
|
376
|
+
build.onResolve({ filter: /^svelte\/compiler$/ }, () => ({
|
|
377
|
+
path: "svelte/compiler",
|
|
378
|
+
namespace: "bosia-stub",
|
|
379
|
+
}));
|
|
380
|
+
build.onLoad({ filter: /.*/, namespace: "bosia-stub" }, async () => {
|
|
381
|
+
const names = Object.keys(await import("svelte/compiler"));
|
|
382
|
+
const fail = `() => { throw new Error("The Svelte compiler isn't available on Cloudflare Workers"); }`;
|
|
383
|
+
return {
|
|
384
|
+
loader: "js",
|
|
385
|
+
contents: names.map((n) => `export const ${n} = ${fail};`).join("\n"),
|
|
386
|
+
};
|
|
387
|
+
});
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function buildWorker(): Promise<void> {
|
|
393
|
+
// The isolate has no filesystem: inline the artifacts and static-import the
|
|
394
|
+
// user's hooks + config instead of reading them off disk at boot.
|
|
395
|
+
generateArtifactsModule();
|
|
396
|
+
generateWorkersRuntime();
|
|
397
|
+
// target "node" would emit createRequire(import.meta.url), and import.meta.url
|
|
398
|
+
// is undefined in workerd. Node builtins stay `node:` imports (nodejs_compat).
|
|
399
|
+
const result = await Bun.build({
|
|
400
|
+
entrypoints: [join(CORE_DIR, "server.workers.ts")],
|
|
401
|
+
outdir: `${OUT_DIR}/worker`,
|
|
402
|
+
target: "browser",
|
|
403
|
+
format: "esm",
|
|
404
|
+
conditions: ["workerd", "worker", "svelte"],
|
|
405
|
+
naming: { entry: "index.[ext]" },
|
|
406
|
+
minify: isProduction,
|
|
407
|
+
external: ["node:*"],
|
|
408
|
+
define: { "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development") },
|
|
409
|
+
plugins: [
|
|
410
|
+
stubSvelteCompiler(),
|
|
411
|
+
makeBosiaPlugin("bun", "workers"),
|
|
412
|
+
...userServerBunPlugins,
|
|
413
|
+
makeBosiaSvelteCompiler("bun"),
|
|
414
|
+
],
|
|
415
|
+
});
|
|
416
|
+
if (!result.success) {
|
|
417
|
+
console.error("❌ Worker build failed:");
|
|
418
|
+
for (const msg of result.logs) console.error(msg);
|
|
419
|
+
process.exit(1);
|
|
420
|
+
}
|
|
421
|
+
const kb = Math.round((result.outputs[0]?.size ?? 0) / 1024);
|
|
422
|
+
console.log(`✅ Worker entry: ${OUT_DIR}/worker/index.js (${kb}KB)`);
|
|
423
|
+
if (generateWranglerConfig()) console.log("☁️ Wrote wrangler.jsonc");
|
|
424
|
+
}
|
|
425
|
+
|
|
335
426
|
async function readUserDependencyNames(cwd: string): Promise<string[]> {
|
|
336
427
|
try {
|
|
337
428
|
const pkg = (await Bun.file(join(cwd, "package.json")).json()) as {
|
package/src/core/cache.ts
CHANGED
|
@@ -5,10 +5,14 @@
|
|
|
5
5
|
//
|
|
6
6
|
// See docs/guides/response-cache.md.
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
// node:crypto / node:zlib rather than Bun.* — the same code runs on Bun and on
|
|
9
|
+
// Cloudflare Workers (nodejs_compat), and both stay sync there.
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { brotliCompressSync, gzipSync, constants as zlibConstants } from "node:zlib";
|
|
9
12
|
import type { Cookies, LoaderDeps } from "./hooks.ts";
|
|
10
13
|
import type { CookieJar } from "./cookies.ts";
|
|
11
14
|
import { dedupKey } from "./dedup.ts";
|
|
15
|
+
import { compressionOn, PRECOMPRESSED } from "./html.ts";
|
|
12
16
|
|
|
13
17
|
// ─── Config ──────────────────────────────────────────────
|
|
14
18
|
|
|
@@ -125,7 +129,7 @@ const pathIndex = new Map<string, Set<string>>(); // pathname → cacheKeys
|
|
|
125
129
|
|
|
126
130
|
/** SHA-256 truncated to 64 bits — identity buckets must not collide across users. */
|
|
127
131
|
function identityDigest(s: string): string {
|
|
128
|
-
return
|
|
132
|
+
return createHash("sha256").update(s).digest("hex").slice(0, 16);
|
|
129
133
|
}
|
|
130
134
|
|
|
131
135
|
export function computeIdentityHash(req: Request, cookies: Pick<CookieJar, "peek">): string {
|
|
@@ -282,11 +286,11 @@ export function buildCompressedVariants(body: Bytes): {
|
|
|
282
286
|
brotli: Bytes | null;
|
|
283
287
|
} {
|
|
284
288
|
const COMPRESS_MIN_BYTES = 2048;
|
|
285
|
-
if (body.length < COMPRESS_MIN_BYTES) return { gzip: null, brotli: null };
|
|
289
|
+
if (!compressionOn || body.length < COMPRESS_MIN_BYTES) return { gzip: null, brotli: null };
|
|
286
290
|
let gzip: Bytes | null = null;
|
|
287
291
|
let brotli: Bytes | null = null;
|
|
288
292
|
try {
|
|
289
|
-
gzip =
|
|
293
|
+
gzip = new Uint8Array(gzipSync(body)) as Bytes;
|
|
290
294
|
} catch {
|
|
291
295
|
gzip = null;
|
|
292
296
|
}
|
|
@@ -360,11 +364,11 @@ export function serveCached(entry: CacheEntry, req: Request): Response {
|
|
|
360
364
|
};
|
|
361
365
|
if (entry.brotli && accept.includes("br")) {
|
|
362
366
|
headers["content-encoding"] = "br";
|
|
363
|
-
return new Response(entry.brotli, { status: entry.status, headers });
|
|
367
|
+
return new Response(entry.brotli, { ...PRECOMPRESSED, status: entry.status, headers });
|
|
364
368
|
}
|
|
365
369
|
if (entry.gzip && accept.includes("gzip")) {
|
|
366
370
|
headers["content-encoding"] = "gzip";
|
|
367
|
-
return new Response(entry.gzip, { status: entry.status, headers });
|
|
371
|
+
return new Response(entry.gzip, { ...PRECOMPRESSED, status: entry.status, headers });
|
|
368
372
|
}
|
|
369
373
|
return new Response(entry.raw, { status: entry.status, headers });
|
|
370
374
|
}
|
|
@@ -219,7 +219,10 @@
|
|
|
219
219
|
// Forward cached parent data for skipped layers so downstream loaders see
|
|
220
220
|
// real parent() data, not {}. POST only when there's something to carry —
|
|
221
221
|
// keeps the no-skip case a cacheable/dedupable GET.
|
|
222
|
-
|
|
222
|
+
// A prerendered route's data is a fixed file, so there is nothing to skip —
|
|
223
|
+
// and on Workers the asset server answers anything but GET with 405.
|
|
224
|
+
const prerendered = match.route.prerender;
|
|
225
|
+
const snapshots = prerendered ? {} : buildParentSnapshots(path, maskBits);
|
|
223
226
|
const dataInit: RequestInit =
|
|
224
227
|
Object.keys(snapshots).length > 0
|
|
225
228
|
? {
|
|
@@ -231,7 +234,7 @@
|
|
|
231
234
|
const dataFetch = cached
|
|
232
235
|
? Promise.resolve(cached)
|
|
233
236
|
: match.route.hasServerData
|
|
234
|
-
? fetch(dataUrl(path, maskBits), dataInit)
|
|
237
|
+
? fetch(dataUrl(path, prerendered ? undefined : maskBits), dataInit)
|
|
235
238
|
.then(readDataResponse)
|
|
236
239
|
// Only a failed request reaches here now — offline, DNS, aborted.
|
|
237
240
|
// A response that arrived is read for what it says, not discarded.
|