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
@@ -0,0 +1,129 @@
1
+ // Bun entry — the default runtime. Loads user hooks off disk, builds the app,
2
+ // binds the port, and owns the process lifecycle (drain on SIGTERM/SIGINT,
3
+ // fatal handlers). Cloudflare Workers uses server.workers.ts instead.
4
+
5
+ import { existsSync } from "fs";
6
+ import { join } from "path";
7
+
8
+ import { beginShutdown, createApp, inFlightCount } from "./server.ts";
9
+ import type { Handle } from "./hooks.ts";
10
+ import { isDev } from "./html.ts";
11
+ import { OUT_DIR } from "./paths.ts";
12
+ import { pidsOnPort } from "./port.ts";
13
+ import { CACHE_ENABLED, CACHE_KEYS } from "./cache.ts";
14
+
15
+ // ─── User Hooks ──────────────────────────────────────────
16
+ // Production prefers the pre-bundled `${OUT_DIR}/hooks.server.js` emitted by the
17
+ // build (single-file, all relative imports inlined, npm deps left external) so
18
+ // production images can ship only `dist/` + `node_modules/` without the `src/`
19
+ // tree. Dev (and any environment lacking the artifact) falls back to importing
20
+ // `src/hooks.server.ts` directly so edits hot-reload without a build.
21
+
22
+ let handle: Handle | null = null;
23
+
24
+ const prebuiltHooksPath = join(process.cwd(), OUT_DIR, "hooks.server.js");
25
+ const srcHooksPath = join(process.cwd(), "src", "hooks.server.ts");
26
+ const hooksPath = existsSync(prebuiltHooksPath)
27
+ ? prebuiltHooksPath
28
+ : existsSync(srcHooksPath)
29
+ ? srcHooksPath
30
+ : null;
31
+ if (hooksPath) {
32
+ try {
33
+ const mod = await import(hooksPath);
34
+ if (typeof mod.handle === "function") {
35
+ handle = mod.handle as Handle;
36
+ console.log(
37
+ `🪝 Loaded ${hooksPath === prebuiltHooksPath ? "dist/hooks.server.js" : "src/hooks.server.ts"}`,
38
+ );
39
+ }
40
+ } catch (err) {
41
+ console.warn("⚠️ Failed to load hooks.server:", err);
42
+ }
43
+ }
44
+
45
+ const app = await createApp({ handle });
46
+
47
+ // ─── Listen ──────────────────────────────────────────────
48
+
49
+ const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : isDev ? 9001 : 9000;
50
+
51
+ try {
52
+ app.listen(PORT, () => {
53
+ // In dev mode the proxy owns the user-facing port — don't print the internal port
54
+ if (!isDev) console.log(`⬡ Bosia server running at http://localhost:${PORT}`);
55
+ // Last line of startup on purpose — the cache identity contract is the one
56
+ // config mistake that leaks one user's page to another, so it stays visible.
57
+ if (CACHE_ENABLED) {
58
+ console.log(
59
+ `\n🔑 Response cache tells users apart ONLY by these cookies/headers: [${CACHE_KEYS.join(", ")}]\n` +
60
+ ` Using a different session cookie or auth header? Add its name to CACHE_KEYS,\n` +
61
+ ` or one user's personalised page can be served to another. Routes personalised\n` +
62
+ ` by anything else should set \`export const cache = false\`.\n` +
63
+ ` Note: the runtime auto-warns only on uncovered *cookie* reads — it CANNOT\n` +
64
+ ` detect header-based personalisation, so custom auth headers (X-Api-Key,\n` +
65
+ ` X-Auth-Token, …) must be added to CACHE_KEYS by hand.\n`,
66
+ );
67
+ }
68
+ });
69
+ } catch (err) {
70
+ // Bun.serve runs inline inside .listen(), so a failed bind lands here.
71
+ if ((err as { code?: string })?.code !== "EADDRINUSE") throw err;
72
+ const [pid] = await pidsOnPort(PORT);
73
+ console.error(
74
+ `\n❌ Port ${PORT} is already serving${pid ? ` (pid ${pid})` : ""}.\n` +
75
+ ` Stop it or set PORT to a free port.\n`,
76
+ );
77
+ process.exit(1);
78
+ }
79
+
80
+ // ─── Graceful Shutdown ───────────────────────────────────
81
+
82
+ let firstSignalAt = 0;
83
+
84
+ async function shutdown() {
85
+ if (firstSignalAt) {
86
+ // One ^C arrives multiple times (process group + `bun run` forwarding
87
+ // to its child) — only a genuinely later signal is a second ^C.
88
+ if (Date.now() - firstSignalAt > 200) process.exit(130); // second ^C = force quit
89
+ return;
90
+ }
91
+ firstSignalAt = Date.now();
92
+ const drained = beginShutdown();
93
+ // Dev: nothing worth draining — exit instantly so ^C feels immediate.
94
+ if (isDev) process.exit(0);
95
+ console.log("⏳ Shutting down — draining in-flight requests...");
96
+
97
+ await Promise.race([drained, Bun.sleep(10_000)]);
98
+
99
+ const left = inFlightCount();
100
+ if (left > 0) {
101
+ console.warn(`⚠️ Force shutdown with ${left} request(s) still in flight`);
102
+ } else {
103
+ console.log("✅ All requests drained");
104
+ }
105
+
106
+ app.stop(true).then(() => process.exit(0));
107
+ setTimeout(() => process.exit(1), 2_000);
108
+ }
109
+
110
+ process.on("SIGTERM", shutdown);
111
+ process.on("SIGINT", shutdown);
112
+
113
+ // Prod-only fatal handlers. The dev inspector plugin installs its own
114
+ // uncaughtException/unhandledRejection listeners that route errors into the
115
+ // overlay and let the dev runner's crash-backoff restart the process. In prod
116
+ // there's no inspector — without these handlers an unhandled rejection from a
117
+ // background timer or plugin hook orphans the process with no log context.
118
+ // Log + exit(1) lets the orchestrator (Podman/k8s) restart cleanly.
119
+ if (!isDev) {
120
+ process.on("uncaughtException", (err: Error) => {
121
+ console.error("[FATAL] uncaughtException:", err?.stack ?? err);
122
+ process.exit(1);
123
+ });
124
+ process.on("unhandledRejection", (reason: unknown) => {
125
+ const e = reason as Error | undefined;
126
+ console.error("[FATAL] unhandledRejection:", e?.stack ?? reason);
127
+ process.exit(1);
128
+ });
129
+ }
@@ -1,12 +1,11 @@
1
- import { Elysia } from "elysia";
2
-
3
- import { existsSync, readFileSync } from "fs";
4
- import { join } from "path";
1
+ import { BosiaApp } from "./backend.ts";
5
2
 
6
3
  import { findMatch, compileRoutes, canonicalPathname } from "./matcher.ts";
7
4
  import { resolveApiMatch } from "./apiResolver.ts";
8
5
  import { apiRoutes, serverRoutes } from "bosia:routes";
9
6
  import { loadPlugins } from "./config.ts";
7
+ import { readArtifact } from "./artifacts.ts";
8
+ import { getPlatform, warmingUp } from "./platform.ts";
10
9
  import type { RouteManifest } from "./types.ts";
11
10
 
12
11
  // Pre-compile route patterns into RegExp at startup (shared by renderer.ts via module reference)
@@ -21,17 +20,15 @@ import type { CsrfConfig } from "./csrf.ts";
21
20
  import { applyCorsVary, getCorsHeaders, handlePreflight } from "./cors.ts";
22
21
  import type { CorsConfig } from "./cors.ts";
23
22
  import { buildCspHeader, CSP_DIRECTIVES_TEMPLATE, CSP_ENABLED, generateNonce } from "./csp.ts";
24
- import { isDev, compress, isStaticPath, distManifest } from "./html.ts";
23
+ import { isDev, compress, isStaticPath, distManifest, PRECOMPRESSED } from "./html.ts";
25
24
  import { dev500WithPlugins } from "./dev-500.ts";
26
25
  import { OUT_DIR } from "./paths.ts";
27
26
  import { stripBase, withBase } from "./basePath.ts";
28
27
  import { currentBase } from "./appBase.ts";
29
- import { pidsOnPort } from "./port.ts";
30
28
  import { buildPrerenderManifest, buildStaticManifest, lookupStatic } from "./staticManifest.ts";
31
29
  import { dedup } from "./dedup.ts";
32
30
  import {
33
31
  CACHE_ENABLED,
34
- CACHE_KEYS,
35
32
  CACHE_MAX_BODY_BYTES,
36
33
  buildCompressedVariants,
37
34
  cacheGet,
@@ -52,35 +49,11 @@ import {
52
49
  import { getServerTime } from "../lib/utils.ts";
53
50
 
54
51
  // ─── User Hooks ──────────────────────────────────────────
55
- // Load user hooks. Production prefers the pre-bundled `${OUT_DIR}/hooks.server.js`
56
- // emitted by the build (single-file, all relative imports inlined, npm deps left
57
- // external) so production images can ship only `dist/` + `node_modules/` without
58
- // the `src/` tree. Dev (and any environment lacking the artifact) falls back to
59
- // importing `src/hooks.server.ts` directly so edits hot-reload without a build.
52
+ // Set by createApp(). Each runtime entry finds `src/hooks.server.ts` its own
53
+ // way: server.bun.ts imports it off disk, server.workers.ts statically.
60
54
 
61
55
  let userHandle: Handle | null = null;
62
56
 
63
- const prebuiltHooksPath = join(process.cwd(), OUT_DIR, "hooks.server.js");
64
- const srcHooksPath = join(process.cwd(), "src", "hooks.server.ts");
65
- const hooksPath = existsSync(prebuiltHooksPath)
66
- ? prebuiltHooksPath
67
- : existsSync(srcHooksPath)
68
- ? srcHooksPath
69
- : null;
70
- if (hooksPath) {
71
- try {
72
- const mod = await import(hooksPath);
73
- if (typeof mod.handle === "function") {
74
- userHandle = mod.handle as Handle;
75
- console.log(
76
- `🪝 Loaded ${hooksPath === prebuiltHooksPath ? "dist/hooks.server.js" : "src/hooks.server.ts"}`,
77
- );
78
- }
79
- } catch (err) {
80
- console.warn("⚠️ Failed to load hooks.server:", err);
81
- }
82
- }
83
-
84
57
  // ─── Env Helpers ─────────────────────────────────────────
85
58
 
86
59
  // Headers that must not be baked into a cache entry. Content-Length is
@@ -452,6 +425,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
452
425
  const apiMaybe = resolveApiMatch(apiRoutes, path);
453
426
  const apiMatch = apiMaybe instanceof Promise ? await apiMaybe : apiMaybe;
454
427
  if (apiMatch) {
428
+ if (warmingUp) return new Response(null, { status: 404 });
455
429
  // INVARIANT: once set, releaseApiMiss must fire exactly once — a missed
456
430
  // release() hangs coalesced waiters for the process lifetime. The cache
457
431
  // write path hands it off to its microtask by nulling it first.
@@ -506,6 +480,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
506
480
  // An API route is reached through its own URL, never through the
507
481
  // client router's data endpoint.
508
482
  isDataRequest: false,
483
+ platform: event.platform,
509
484
  });
510
485
 
511
486
  // Redirect returned (not thrown) — convert to a 303 Response.
@@ -937,6 +912,13 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
937
912
  url.pathname = appPath;
938
913
  }
939
914
 
915
+ // Bun.serve enforces maxRequestBodySize itself; Workers has no such knob, so
916
+ // check the declared length here. A chunked upload without Content-Length
917
+ // falls back to the host's own request cap (Cloudflare: 100MB on free).
918
+ if (BODY_SIZE_LIMIT > 0 && Number(request.headers.get("content-length")) > BODY_SIZE_LIMIT) {
919
+ return new Response("Payload Too Large", { status: 413 });
920
+ }
921
+
940
922
  // Reject new non-health requests during shutdown
941
923
  if (shuttingDown && url.pathname !== "/_health") {
942
924
  return new Response("Service Unavailable", {
@@ -997,8 +979,10 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
997
979
  params: {},
998
980
  cookies: cookieJar,
999
981
  isDataRequest: dataReq !== null,
982
+ platform: getPlatform(),
1000
983
  };
1001
- let response = userHandle ? await userHandle({ event, resolve }) : await resolve(event);
984
+ let response =
985
+ userHandle && !warmingUp ? await userHandle({ event, resolve }) : await resolve(event);
1002
986
 
1003
987
  // A hook that short-circuits a data request with a redirect is answering
1004
988
  // the client router, which speaks JSON — an unconverted 3xx is followed by
@@ -1048,6 +1032,10 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
1048
1032
  // Apply any Set-Cookie headers accumulated during the request
1049
1033
  for (const cookie of cookieJar.outgoing) headers.append("Set-Cookie", cookie);
1050
1034
  return new Response(response.body, {
1035
+ // A Content-Encoding here means the body is already compressed (cache
1036
+ // hit, compress()); rebuilding drops that flag and Workers would
1037
+ // compress it again.
1038
+ ...(headers.has("content-encoding") ? PRECOMPRESSED : {}),
1051
1039
  status: response.status,
1052
1040
  statusText: response.statusText,
1053
1041
  headers,
@@ -1063,7 +1051,8 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
1063
1051
  ? // Shape-identical to the loader conversion below, so the
1064
1052
  // router has one payload contract regardless of who redirected.
1065
1053
  Response.json({ redirect: err.location, status: err.status })
1066
- : Response.redirect(err.location, err.status)
1054
+ : // Not Response.redirect(): it rejects relative URLs on Workers.
1055
+ new Response(null, { status: err.status, headers: { Location: err.location } })
1067
1056
  : dataReq
1068
1057
  ? Response.json(
1069
1058
  {
@@ -1085,7 +1074,7 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
1085
1074
  nonce,
1086
1075
  );
1087
1076
  // A hook that expires the session before throwing must not lose the
1088
- // Set-Cookie that does it — `Response.redirect` builds a fresh Response,
1077
+ // Set-Cookie that does it — the redirect above is a fresh Response,
1089
1078
  // so the jar is re-applied by hand here.
1090
1079
  if (cookieJar) {
1091
1080
  for (const cookie of cookieJar.outgoing) out.headers.append("Set-Cookie", cookie);
@@ -1196,27 +1185,32 @@ if (Number.isFinite(MAX_INFLIGHT)) {
1196
1185
  }
1197
1186
 
1198
1187
  // ─── Graceful Shutdown State ──────────────────────────────
1188
+ // Drained by the Bun entry on SIGTERM/SIGINT. Never set on Workers, where an
1189
+ // isolate has no shutdown to announce.
1199
1190
 
1200
1191
  let shuttingDown = false;
1201
- let firstSignalAt = 0;
1202
1192
  let inFlight = 0;
1203
1193
  let drainResolve: (() => void) | null = null;
1204
1194
 
1205
- // ─── Plugin Loading ───────────────────────────────────────
1195
+ /** Stop taking new requests; resolves once in-flight ones have finished. */
1196
+ export function beginShutdown(): Promise<void> {
1197
+ shuttingDown = true;
1198
+ if (inFlight === 0) return Promise.resolve();
1199
+ return new Promise<void>((r) => {
1200
+ drainResolve = r;
1201
+ });
1202
+ }
1206
1203
 
1207
- const plugins = await loadPlugins(process.cwd());
1208
- if (plugins.length > 0) {
1209
- console.log(`🔌 Loaded ${plugins.length} plugin(s): ${plugins.map((p) => p.name).join(", ")}`);
1204
+ export function inFlightCount(): number {
1205
+ return inFlight;
1210
1206
  }
1211
1207
 
1208
+ // ─── Backend App ──────────────────────────────────────────
1209
+
1212
1210
  // Read the build-time route manifest so plugins.backend.after can introspect routes.
1213
1211
  function loadBuiltManifest(): RouteManifest {
1214
- const path = `${OUT_DIR}/route-manifest.json`;
1215
- if (existsSync(path)) {
1216
- try {
1217
- return JSON.parse(readFileSync(path, "utf-8"));
1218
- } catch {}
1219
- }
1212
+ const built = readArtifact<RouteManifest>("route-manifest.json");
1213
+ if (built) return built;
1220
1214
  // Fallback: synthesize from runtime arrays (no file paths, just patterns).
1221
1215
  return {
1222
1216
  pages: serverRoutes.map((r: any) => ({
@@ -1229,176 +1223,86 @@ function loadBuiltManifest(): RouteManifest {
1229
1223
  errorPages: [],
1230
1224
  trailingSlash: r.trailingSlash,
1231
1225
  cache: r.cache ?? null,
1226
+ prerender: false,
1232
1227
  })),
1233
1228
  apis: apiRoutes.map((r: any) => ({ pattern: r.pattern, server: "" })),
1234
1229
  errorPage: null,
1235
1230
  };
1236
1231
  }
1237
1232
 
1238
- const routeManifest = loadBuiltManifest();
1239
-
1240
- // ─── Elysia App ───────────────────────────────────────────
1241
-
1242
- const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : isDev ? 9001 : 9000;
1243
-
1244
- // Elysia's chained generics drift when plugins add routes — track the app as a
1245
- // loose `Elysia` so plugin-extended types stay assignable.
1246
- let app: Elysia = new Elysia({
1247
- serve: {
1248
- maxRequestBodySize: BODY_SIZE_LIMIT,
1249
- idleTimeout: IDLE_TIMEOUT,
1250
- // Elysia's Bun adapter defaults reusePort:true — SO_REUSEPORT lets a second
1251
- // server silently join the port and the kernel splits traffic between two
1252
- // different builds. Opt in only for deliberate N-worker clustering.
1253
- reusePort: process.env.BOSIA_REUSE_PORT === "1",
1254
- },
1255
- }) as unknown as Elysia;
1256
-
1257
- // Plugins.backend.before — runs before framework middleware/routes.
1258
- // Plugin-registered routes here BYPASS the framework (CSRF, hooks, etc.).
1259
- // Plugins register their own `.onError()` handlers here. Elysia fires onError
1260
- // handlers in registration order; plugin handlers run first and (when they
1261
- // return undefined) fall through to the base 500 responder chained after this
1262
- // loop. Registering the base responder before the loop would short-circuit
1263
- // every plugin handler.
1264
- for (const plugin of plugins) {
1265
- if (plugin.backend?.before) {
1266
- try {
1267
- app = (await plugin.backend.before(app)) ?? app;
1268
- } catch (err) {
1269
- console.error(`❌ Plugin "${plugin.name}" backend.before failed:`, err);
1270
- throw err;
1271
- }
1272
- }
1273
- }
1233
+ export type CreateAppOptions = {
1234
+ /** `handle` from the user's `src/hooks.server.ts`. */
1235
+ handle?: Handle | null;
1236
+ };
1274
1237
 
1275
- app = app.onError(({ error }) => {
1276
- if (isDev) console.error("Uncaught server error:", error);
1277
- else console.error("Uncaught server error:", (error as Error)?.message ?? error);
1278
- return Response.json({ error: "Internal Server Error" }, { status: 500 });
1279
- }) as unknown as Elysia;
1280
-
1281
- app = app
1282
- // Static files are served by resolve() with path traversal protection and security headers
1283
- // SSR pages
1284
- .get("*", ({ request }: { request: Request }) => {
1285
- const url = new URL(request.url);
1286
- return handleRequest(request, url);
1287
- })
1288
- // Non-GET catch-alls route every method through handleRequest()
1289
- .post("*", ({ request }: { request: Request }) => {
1290
- const url = new URL(request.url);
1291
- return handleRequest(request, url);
1292
- })
1293
- .put("*", ({ request }: { request: Request }) => {
1294
- const url = new URL(request.url);
1295
- return handleRequest(request, url);
1296
- })
1297
- .patch("*", ({ request }: { request: Request }) => {
1298
- const url = new URL(request.url);
1299
- return handleRequest(request, url);
1300
- })
1301
- .delete("*", ({ request }: { request: Request }) => {
1302
- const url = new URL(request.url);
1303
- return handleRequest(request, url);
1304
- })
1305
- .options("*", ({ request }: { request: Request }) => {
1306
- const url = new URL(request.url);
1307
- return handleRequest(request, url);
1308
- }) as unknown as Elysia;
1309
-
1310
- // Plugins.backend.after — runs after framework routes; receives the route manifest.
1311
- for (const plugin of plugins) {
1312
- if (plugin.backend?.after) {
1313
- try {
1314
- app = (await plugin.backend.after(app, { manifest: routeManifest })) ?? app;
1315
- } catch (err) {
1316
- console.error(`❌ Plugin "${plugin.name}" backend.after failed:`, err);
1317
- throw err;
1318
- }
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> {
1243
+ userHandle = options.handle ?? null;
1244
+
1245
+ const plugins = await loadPlugins(process.cwd());
1246
+ if (plugins.length > 0) {
1247
+ console.log(`🔌 Loaded ${plugins.length} plugin(s): ${plugins.map((p) => p.name).join(", ")}`);
1319
1248
  }
1320
- }
1321
1249
 
1322
- try {
1323
- app.listen(PORT, () => {
1324
- // In dev mode the proxy owns the user-facing port — don't print the internal port
1325
- if (!isDev) console.log(`⬡ Bosia server running at http://localhost:${PORT}`);
1326
- // Last line of startup on purpose — the cache identity contract is the one
1327
- // config mistake that leaks one user's page to another, so it stays visible.
1328
- if (CACHE_ENABLED) {
1329
- console.log(
1330
- `\n🔑 Response cache tells users apart ONLY by these cookies/headers: [${CACHE_KEYS.join(", ")}]\n` +
1331
- ` Using a different session cookie or auth header? Add its name to CACHE_KEYS,\n` +
1332
- ` or one user's personalised page can be served to another. Routes personalised\n` +
1333
- ` by anything else should set \`export const cache = false\`.\n` +
1334
- ` Note: the runtime auto-warns only on uncovered *cookie* reads — it CANNOT\n` +
1335
- ` detect header-based personalisation, so custom auth headers (X-Api-Key,\n` +
1336
- ` X-Auth-Token, …) must be added to CACHE_KEYS by hand.\n`,
1337
- );
1338
- }
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
+ },
1339
1260
  });
1340
- } catch (err) {
1341
- // Bun.serve runs inline inside .listen(), so a failed bind lands here.
1342
- if ((err as { code?: string })?.code !== "EADDRINUSE") throw err;
1343
- const [pid] = await pidsOnPort(PORT);
1344
- console.error(
1345
- `\n❌ Port ${PORT} is already serving${pid ? ` (pid ${pid})` : ""}.\n` +
1346
- ` Stop it or set PORT to a free port.\n`,
1347
- );
1348
- process.exit(1);
1349
- }
1350
1261
 
1351
- async function shutdown() {
1352
- if (shuttingDown) {
1353
- // One ^C arrives multiple times (process group + `bun run` forwarding
1354
- // to its child) — only a genuinely later signal is a second ^C.
1355
- if (Date.now() - firstSignalAt > 200) process.exit(130); // second ^C = force quit
1356
- return;
1357
- }
1358
- shuttingDown = true;
1359
- firstSignalAt = Date.now();
1360
- // Dev: nothing worth draining — exit instantly so ^C feels immediate.
1361
- if (isDev) process.exit(0);
1362
- console.log("⏳ Shutting down — draining in-flight requests...");
1363
-
1364
- if (inFlight > 0) {
1365
- await Promise.race([
1366
- new Promise<void>((r) => {
1367
- drainResolve = r;
1368
- }),
1369
- Bun.sleep(10_000),
1370
- ]);
1262
+ // Plugins.backend.before — runs before framework middleware/routes.
1263
+ // Plugin-registered routes here BYPASS the framework (CSRF, hooks, etc.).
1264
+ // Plugins register their own `.onError()` handlers here. onError handlers
1265
+ // fire in registration order; plugin handlers run first and (when they
1266
+ // return undefined) fall through to the base 500 responder chained after this
1267
+ // loop. Registering the base responder before the loop would short-circuit
1268
+ // every plugin handler.
1269
+ for (const plugin of plugins) {
1270
+ if (plugin.backend?.before) {
1271
+ try {
1272
+ app = (await plugin.backend.before(app)) ?? app;
1273
+ } catch (err) {
1274
+ console.error(`❌ Plugin "${plugin.name}" backend.before failed:`, err);
1275
+ throw err;
1276
+ }
1277
+ }
1371
1278
  }
1372
1279
 
1373
- if (inFlight > 0) {
1374
- console.warn(`⚠️ Force shutdown with ${inFlight} request(s) still in flight`);
1375
- } else {
1376
- console.log("✅ All requests drained");
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.
1282
+ app = app
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 });
1287
+ })
1288
+ .get("*", frameworkHandler)
1289
+ .post("*", frameworkHandler)
1290
+ .put("*", frameworkHandler)
1291
+ .patch("*", frameworkHandler)
1292
+ .delete("*", frameworkHandler)
1293
+ .options("*", frameworkHandler);
1294
+
1295
+ // Plugins.backend.after — runs after framework routes; receives the route manifest.
1296
+ for (const plugin of plugins) {
1297
+ if (plugin.backend?.after) {
1298
+ try {
1299
+ app = (await plugin.backend.after(app, { manifest: loadBuiltManifest() })) ?? app;
1300
+ } catch (err) {
1301
+ console.error(`❌ Plugin "${plugin.name}" backend.after failed:`, err);
1302
+ throw err;
1303
+ }
1304
+ }
1377
1305
  }
1378
1306
 
1379
- app.stop(true).then(() => process.exit(0));
1380
- setTimeout(() => process.exit(1), 2_000);
1381
- }
1382
-
1383
- process.on("SIGTERM", shutdown);
1384
- process.on("SIGINT", shutdown);
1385
-
1386
- // Prod-only fatal handlers. The dev inspector plugin installs its own
1387
- // uncaughtException/unhandledRejection listeners that route errors into the
1388
- // overlay and let the dev runner's crash-backoff restart the process. In prod
1389
- // there's no inspector — without these handlers an unhandled rejection from a
1390
- // background timer or plugin hook orphans the process with no log context.
1391
- // Log + exit(1) lets the orchestrator (Podman/k8s) restart cleanly.
1392
- if (!isDev) {
1393
- process.on("uncaughtException", (err: Error) => {
1394
- console.error("[FATAL] uncaughtException:", err?.stack ?? err);
1395
- process.exit(1);
1396
- });
1397
- process.on("unhandledRejection", (reason: unknown) => {
1398
- const e = reason as Error | undefined;
1399
- console.error("[FATAL] unhandledRejection:", e?.stack ?? reason);
1400
- process.exit(1);
1401
- });
1307
+ return app;
1402
1308
  }
1403
-
1404
- export { app };
@@ -0,0 +1,46 @@
1
+ // Cloudflare Workers entry. Same app as server.bun.ts, minus what an isolate
2
+ // doesn't have: no port to bind, no signals, no filesystem. User hooks and
3
+ // config arrive as static imports (workersCodegen.ts), static files are served
4
+ // by Workers Static Assets before the worker runs.
5
+
6
+ import { config, handle } from "bosia:workers-runtime";
7
+ import { twMerge } from "tailwind-merge";
8
+
9
+ import { setBosiaConfig } from "./config.ts";
10
+ import type { PlatformEnv } from "./hooks.ts";
11
+ import { disableCompression } from "./html.ts";
12
+ import { getPlatform, setPlatform, setWarmingUp } from "./platform.ts";
13
+ import { createApp } from "./server.ts";
14
+
15
+ setBosiaConfig(config);
16
+ disableCompression();
17
+ // tailwind-merge builds its class map on the first cn() call (~5ms). Pay it at
18
+ // isolate startup, which has its own CPU budget, not in the first request.
19
+ twMerge("");
20
+
21
+ const app = await createApp({ handle });
22
+
23
+ // The first request in an isolate paid ~5ms of V8 compiling the render path, on a
24
+ // 10ms CPU budget. Render `/` once now instead — startup CPU is budgeted apart.
25
+ // The page renders before fetch() resolves; its body is never read, since
26
+ // startup can't do stream I/O. Any failure only means less is warm, so it's
27
+ // swallowed, and console is muted so it doesn't look like a real request.
28
+ const quiet = { log: console.log, warn: console.warn, error: console.error };
29
+ console.log = console.warn = console.error = () => {};
30
+ setWarmingUp(true);
31
+ try {
32
+ await app.fetch(new Request("http://localhost/"));
33
+ } catch {
34
+ // best effort
35
+ } finally {
36
+ setWarmingUp(false);
37
+ Object.assign(console, quiet);
38
+ }
39
+
40
+ export default {
41
+ fetch(request: Request, env: PlatformEnv): Response | Promise<Response> {
42
+ // Bindings are one object per isolate — capture on the first request.
43
+ if (!getPlatform()) setPlatform({ env });
44
+ return app.fetch(request);
45
+ },
46
+ };
@@ -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 { Elysia } from "elysia";
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";
@@ -10,6 +10,9 @@ type MaybePromise<T> = T | Promise<T>;
10
10
 
11
11
  export type BuildTarget = "browser" | "bun";
12
12
 
13
+ /** Where the production server runs. Not a BuildTarget — that one means client vs server bundle. */
14
+ export type RuntimeTarget = "bun" | "workers";
15
+
13
16
  export interface BuildContext {
14
17
  mode: "production" | "development";
15
18
  cwd: string;
@@ -31,15 +34,14 @@ export interface BosiaPlugin {
31
34
  name: string;
32
35
 
33
36
  /**
34
- * Mount points around the framework's HTTP backend. Currently typed as
35
- * `Elysia` (the underlying backend), but the namespace is intentionally
36
- * abstract so the API survives a future backend swap.
37
+ * Mount points around the framework's HTTP backend — Bosia's own small,
38
+ * Elysia-shaped app (`get/post/…`, `onRequest`, `onAfterHandle`, `onError`).
37
39
  */
38
40
  backend?: {
39
41
  /** Runs before framework middleware/routes — can register routes that bypass Bosia. */
40
- before?: (app: Elysia) => MaybePromise<Elysia>;
42
+ before?: (app: BosiaApp) => MaybePromise<BosiaApp>;
41
43
  /** Runs after framework routes — receives the route manifest for introspection. */
42
- after?: (app: Elysia, ctx: { manifest: RouteManifest }) => MaybePromise<Elysia>;
44
+ after?: (app: BosiaApp, ctx: { manifest: RouteManifest }) => MaybePromise<BosiaApp>;
43
45
  };
44
46
 
45
47
  /** Build pipeline hooks. */
@@ -92,6 +94,8 @@ export type StrictImportsOption =
92
94
  export interface BosiaConfig {
93
95
  plugins?: (BosiaPlugin | false | null | undefined)[];
94
96
  strictImports?: StrictImportsOption;
97
+ /** Production runtime. `"workers"` also emits a Cloudflare Worker + wrangler.jsonc. Default: `"bun"`. */
98
+ target?: RuntimeTarget;
95
99
  }
96
100
 
97
101
  /** Identity helper for type inference in `bosia.config.ts`. */
package/src/core/types.ts CHANGED
@@ -34,6 +34,8 @@ export interface PageRoute {
34
34
  * `null` — a `cache` export exists but isn't a literal `false` (dynamic) → import at runtime
35
35
  */
36
36
  cache: boolean | null;
37
+ /** `export const prerender = true` in +page.server.ts — its data is a static file at runtime. */
38
+ prerender: boolean;
37
39
  }
38
40
 
39
41
  /** An API route discovered from the file system */