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.
Files changed (44) hide show
  1. package/README.md +3 -3
  2. package/package.json +2 -4
  3. package/src/ambient.d.ts +7 -0
  4. package/src/cli/build.ts +6 -1
  5. package/src/cli/index.ts +3 -3
  6. package/src/cli/start.ts +9 -1
  7. package/src/core/appHtml.ts +3 -11
  8. package/src/core/artifacts.ts +21 -0
  9. package/src/core/backend.ts +238 -0
  10. package/src/core/build.ts +97 -6
  11. package/src/core/cache.ts +10 -6
  12. package/src/core/client/App.svelte +5 -2
  13. package/src/core/client/prefetch.ts +3 -1
  14. package/src/core/config.ts +20 -9
  15. package/src/core/dev.ts +2 -3
  16. package/src/core/env.ts +0 -20
  17. package/src/core/hooks.ts +28 -0
  18. package/src/core/html.ts +29 -24
  19. package/src/core/paths.ts +4 -2
  20. package/src/core/platform.ts +24 -0
  21. package/src/core/plugin.ts +26 -1
  22. package/src/core/plugins/inspector/index.ts +6 -10
  23. package/src/core/plugins/server-timing.ts +1 -1
  24. package/src/core/prerender.ts +6 -6
  25. package/src/core/renderer.ts +15 -8
  26. package/src/core/routeFile.ts +4 -0
  27. package/src/core/scanner.ts +30 -0
  28. package/src/core/server.bun.ts +129 -0
  29. package/src/core/server.ts +108 -204
  30. package/src/core/server.workers.ts +46 -0
  31. package/src/core/types/plugin.ts +10 -6
  32. package/src/core/types.ts +2 -0
  33. package/src/core/workersCodegen.ts +110 -0
  34. package/src/core/workersGuard.ts +107 -0
  35. package/src/lib/index.ts +3 -0
  36. package/src/lib/server.ts +1 -0
  37. package/templates/default/README.md +1 -1
  38. package/templates/default/_gitignore +2 -0
  39. package/templates/default/src/routes/(public)/+page.svelte +1 -1
  40. package/templates/demo/_gitignore +2 -0
  41. package/templates/demo/src/routes/(public)/+page.svelte +2 -2
  42. package/templates/demo/src/routes/(public)/about/+page.svelte +2 -2
  43. package/templates/shop/_gitignore +2 -0
  44. 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 · ElysiaJS.
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 and ElysiaJS HTTP server. No Node.js, no Vite, no adapters.
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 | [ElysiaJS](https://elysiajs.com) |
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.8",
3
+ "version": "0.9.10",
4
4
  "type": "module",
5
- "description": "A fast, batteries-included fullstack framework — SSR · Svelte 5 Runes · Bun · ElysiaJS. File-based routing No Node.js, no Vite, no adapters.",
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 build for production
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
- const proc = spawn(["bun", "run", `${OUT_DIR}/server/${serverEntry}`], {
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(),
@@ -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 = readPersistedSegments(cwd) ?? loadAppHtmlTemplate(cwd);
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", "elysia", "bun", "svelte", "svelte/server"];
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
- naming: { entry: "[name]-[hash].[ext]", chunk: "[name]-[hash].[ext]" },
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
- import { brotliCompressSync, constants as zlibConstants } from "node:zlib";
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 new Bun.CryptoHasher("sha256").update(s).digest("hex").slice(0, 16);
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 = Bun.gzipSync(body) as Bytes;
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
- const snapshots = buildParentSnapshots(path, maskBits);
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.