bosia 0.9.7 → 0.9.9

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.
@@ -1,12 +1,11 @@
1
- import { Elysia } from "elysia";
2
-
3
- import { existsSync, readFileSync } from "fs";
4
- import { join } from "path";
1
+ import { Elysia, type ElysiaAdapter } from "elysia";
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
+ // ─── Elysia 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,108 @@ 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();
1233
+ export type CreateAppOptions = {
1234
+ /** `handle` from the user's `src/hooks.server.ts`. */
1235
+ handle?: Handle | null;
1236
+ /** Elysia adapter. Omitted → Bun (`Bun.serve`). */
1237
+ adapter?: ElysiaAdapter;
1238
+ };
1239
1239
 
1240
- // ─── Elysia App ───────────────────────────────────────────
1240
+ /** Build the Elysia app: plugins, the framework's catch-all routes, error handling. */
1241
+ export async function createApp(options: CreateAppOptions = {}): Promise<Elysia> {
1242
+ userHandle = options.handle ?? null;
1241
1243
 
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
- }
1244
+ const plugins = await loadPlugins(process.cwd());
1245
+ if (plugins.length > 0) {
1246
+ console.log(`🔌 Loaded ${plugins.length} plugin(s): ${plugins.map((p) => p.name).join(", ")}`);
1272
1247
  }
1273
- }
1274
1248
 
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;
1249
+ // Elysia's chained generics drift when plugins add routes — track the app as a
1250
+ // loose `Elysia` so plugin-extended types stay assignable.
1251
+ let app: Elysia = (options.adapter
1252
+ ? new Elysia({ adapter: options.adapter })
1253
+ : new Elysia({
1254
+ serve: {
1255
+ maxRequestBodySize: BODY_SIZE_LIMIT,
1256
+ idleTimeout: IDLE_TIMEOUT,
1257
+ // Elysia's Bun adapter defaults reusePort:true — SO_REUSEPORT lets a second
1258
+ // server silently join the port and the kernel splits traffic between two
1259
+ // different builds. Opt in only for deliberate N-worker clustering.
1260
+ reusePort: process.env.BOSIA_REUSE_PORT === "1",
1261
+ },
1262
+ })) as unknown as Elysia;
1263
+
1264
+ // Plugins.backend.before — runs before framework middleware/routes.
1265
+ // Plugin-registered routes here BYPASS the framework (CSRF, hooks, etc.).
1266
+ // Plugins register their own `.onError()` handlers here. Elysia fires onError
1267
+ // handlers in registration order; plugin handlers run first and (when they
1268
+ // return undefined) fall through to the base 500 responder chained after this
1269
+ // loop. Registering the base responder before the loop would short-circuit
1270
+ // every plugin handler.
1271
+ for (const plugin of plugins) {
1272
+ if (plugin.backend?.before) {
1273
+ try {
1274
+ app = (await plugin.backend.before(app)) ?? app;
1275
+ } catch (err) {
1276
+ console.error(`❌ Plugin "${plugin.name}" backend.before failed:`, err);
1277
+ throw err;
1278
+ }
1318
1279
  }
1319
1280
  }
1320
- }
1321
1281
 
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
- }
1339
- });
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
-
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
- ]);
1371
- }
1282
+ app = app.onError(({ error }) => {
1283
+ if (isDev) console.error("Uncaught server error:", error);
1284
+ else console.error("Uncaught server error:", (error as Error)?.message ?? error);
1285
+ return Response.json({ error: "Internal Server Error" }, { status: 500 });
1286
+ }) as unknown as Elysia;
1372
1287
 
1373
- if (inFlight > 0) {
1374
- console.warn(`⚠️ Force shutdown with ${inFlight} request(s) still in flight`);
1375
- } else {
1376
- console.log("✅ All requests drained");
1288
+ app = app
1289
+ // Static files are served by resolve() with path traversal protection and security headers
1290
+ // SSR pages
1291
+ .get("*", ({ request }: { request: Request }) => {
1292
+ const url = new URL(request.url);
1293
+ return handleRequest(request, url);
1294
+ })
1295
+ // Non-GET catch-alls route every method through handleRequest()
1296
+ .post("*", ({ request }: { request: Request }) => {
1297
+ const url = new URL(request.url);
1298
+ return handleRequest(request, url);
1299
+ })
1300
+ .put("*", ({ request }: { request: Request }) => {
1301
+ const url = new URL(request.url);
1302
+ return handleRequest(request, url);
1303
+ })
1304
+ .patch("*", ({ request }: { request: Request }) => {
1305
+ const url = new URL(request.url);
1306
+ return handleRequest(request, url);
1307
+ })
1308
+ .delete("*", ({ request }: { request: Request }) => {
1309
+ const url = new URL(request.url);
1310
+ return handleRequest(request, url);
1311
+ })
1312
+ .options("*", ({ request }: { request: Request }) => {
1313
+ const url = new URL(request.url);
1314
+ return handleRequest(request, url);
1315
+ }) as unknown as Elysia;
1316
+
1317
+ // Plugins.backend.after — runs after framework routes; receives the route manifest.
1318
+ for (const plugin of plugins) {
1319
+ if (plugin.backend?.after) {
1320
+ try {
1321
+ app = (await plugin.backend.after(app, { manifest: loadBuiltManifest() })) ?? app;
1322
+ } catch (err) {
1323
+ console.error(`❌ Plugin "${plugin.name}" backend.after failed:`, err);
1324
+ throw err;
1325
+ }
1326
+ }
1377
1327
  }
1378
1328
 
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
- });
1329
+ return app;
1402
1330
  }
1403
-
1404
- export { app };
@@ -0,0 +1,47 @@
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 { CloudflareAdapter } from "elysia/adapter/cloudflare-worker";
7
+ import { config, handle } from "bosia:workers-runtime";
8
+ import { twMerge } from "tailwind-merge";
9
+
10
+ import { setBosiaConfig } from "./config.ts";
11
+ import type { PlatformEnv } from "./hooks.ts";
12
+ import { disableCompression } from "./html.ts";
13
+ import { getPlatform, setPlatform, setWarmingUp } from "./platform.ts";
14
+ import { createApp } from "./server.ts";
15
+
16
+ setBosiaConfig(config);
17
+ disableCompression();
18
+ // tailwind-merge builds its class map on the first cn() call (~5ms). Pay it at
19
+ // isolate startup, which has its own CPU budget, not in the first request.
20
+ twMerge("");
21
+
22
+ const app = (await createApp({ handle, adapter: CloudflareAdapter })).compile();
23
+
24
+ // The first request in an isolate paid ~5ms of V8 compiling the render path, on a
25
+ // 10ms CPU budget. Render `/` once now instead — startup CPU is budgeted apart.
26
+ // The page renders before fetch() resolves; its body is never read, since
27
+ // startup can't do stream I/O. Any failure only means less is warm, so it's
28
+ // swallowed, and console is muted so it doesn't look like a real request.
29
+ const quiet = { log: console.log, warn: console.warn, error: console.error };
30
+ console.log = console.warn = console.error = () => {};
31
+ setWarmingUp(true);
32
+ try {
33
+ await app.fetch(new Request("http://localhost/"));
34
+ } catch {
35
+ // best effort
36
+ } finally {
37
+ setWarmingUp(false);
38
+ Object.assign(console, quiet);
39
+ }
40
+
41
+ export default {
42
+ fetch(request: Request, env: PlatformEnv): Response | Promise<Response> {
43
+ // Bindings are one object per isolate — capture on the first request.
44
+ if (!getPlatform()) setPlatform({ env });
45
+ return app.fetch(request);
46
+ },
47
+ };
@@ -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;
@@ -92,6 +95,8 @@ export type StrictImportsOption =
92
95
  export interface BosiaConfig {
93
96
  plugins?: (BosiaPlugin | false | null | undefined)[];
94
97
  strictImports?: StrictImportsOption;
98
+ /** Production runtime. `"workers"` also emits a Cloudflare Worker + wrangler.jsonc. Default: `"bun"`. */
99
+ target?: RuntimeTarget;
95
100
  }
96
101
 
97
102
  /** 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 */
@@ -0,0 +1,110 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
+ import { basename, join } from "path";
3
+
4
+ import { readArtifact } from "./artifacts.ts";
5
+ import { findConfigPath } from "./config.ts";
6
+ import { OUT_DIR, toPosix } from "./paths.ts";
7
+
8
+ // ─── Workers Codegen ─────────────────────────────────────
9
+ // Cloudflare Workers has no filesystem and no dynamic `import(<path>)`, so
10
+ // everything the Bun server reads or imports off disk at boot is generated
11
+ // into `.bosia/` as static code the worker bundle can inline.
12
+
13
+ // Build artifacts the runtime reads at boot. Keep in sync with readArtifact() callers.
14
+ export const RUNTIME_ARTIFACTS = ["manifest.json", "app-html.json", "route-manifest.json"];
15
+
16
+ const HEADER = "// Auto-generated by Bosia. Do not edit.\n";
17
+
18
+ function writeBosiaFile(cwd: string, name: string, code: string): string {
19
+ const target = join(cwd, ".bosia", name);
20
+ mkdirSync(join(cwd, ".bosia"), { recursive: true });
21
+ writeFileSync(target, HEADER + code);
22
+ return target;
23
+ }
24
+
25
+ /**
26
+ * Write `.bosia/artifacts.ts` — a filesystem-free drop-in for artifacts.ts with
27
+ * every runtime artifact inlined. Call after the build has written them.
28
+ */
29
+ export function generateArtifactsModule(outDir: string = OUT_DIR, cwd = process.cwd()): string {
30
+ const files: Record<string, unknown> = {};
31
+ for (const name of RUNTIME_ARTIFACTS) {
32
+ const value = readArtifact(name, outDir);
33
+ if (value !== undefined) files[name] = value;
34
+ }
35
+ return writeBosiaFile(
36
+ cwd,
37
+ "artifacts.ts",
38
+ `const files: Record<string, unknown> = ${JSON.stringify(files)};\n\n` +
39
+ "export function readArtifact<T>(name: string, _dir?: string): T | undefined {\n" +
40
+ "\treturn files[name] as T | undefined;\n" +
41
+ "}\n",
42
+ );
43
+ }
44
+
45
+ /**
46
+ * Write `.bosia/runtime.workers.ts` (`bosia:workers-runtime`) — static imports
47
+ * of the app's `src/hooks.server.ts` and `bosia.config.*`, which the Bun server
48
+ * loads with a dynamic `import()` of an absolute path.
49
+ */
50
+ export function generateWorkersRuntime(cwd = process.cwd()): string {
51
+ const hooksPath = join(cwd, "src", "hooks.server.ts");
52
+ const configPath = findConfigPath(cwd);
53
+ const lines: string[] = [];
54
+ if (existsSync(hooksPath)) {
55
+ lines.push(`import * as hooks from ${JSON.stringify(toPosix(hooksPath))};`);
56
+ lines.push(`export const handle = typeof hooks.handle === "function" ? hooks.handle : null;`);
57
+ } else {
58
+ lines.push("export const handle = null;");
59
+ }
60
+ if (configPath) {
61
+ lines.push(`export { default as config } from ${JSON.stringify(toPosix(configPath))};`);
62
+ } else {
63
+ lines.push("export const config = {};");
64
+ }
65
+ return writeBosiaFile(cwd, "runtime.workers.ts", lines.join("\n") + "\n");
66
+ }
67
+
68
+ /**
69
+ * Write `wrangler.jsonc` at the app root, unless one exists — it's where the
70
+ * user adds bindings (D1, KV, vars), so a rebuild must never overwrite it.
71
+ * Returns the path when written, null when left alone.
72
+ */
73
+ export function generateWranglerConfig(
74
+ cwd = process.cwd(),
75
+ outDir: string = OUT_DIR,
76
+ ): string | null {
77
+ const target = join(cwd, "wrangler.jsonc");
78
+ if (
79
+ existsSync(target) ||
80
+ existsSync(join(cwd, "wrangler.json")) ||
81
+ existsSync(join(cwd, "wrangler.toml"))
82
+ )
83
+ return null;
84
+ let name = basename(cwd);
85
+ try {
86
+ name = JSON.parse(readFileSync(join(cwd, "package.json"), "utf-8")).name || name;
87
+ } catch {}
88
+ // Worker names: lowercase letters, digits and dashes. Drops an npm @scope/.
89
+ name =
90
+ name
91
+ .replace(/^@[^/]+\//, "")
92
+ .toLowerCase()
93
+ .replace(/[^a-z0-9-]+/g, "-") || "bosia-app";
94
+ const dir = toPosix(outDir).replace(/^\.\//, "");
95
+ const config = {
96
+ name,
97
+ main: `./${dir}/worker/index.js`,
98
+ compatibility_date: "2025-09-01",
99
+ compatibility_flags: ["nodejs_compat"],
100
+ // Served before the worker runs; misses fall through to it.
101
+ assets: { directory: `./${dir}/static` },
102
+ };
103
+ writeFileSync(
104
+ target,
105
+ "// Generated once by `bosia build --target=workers`. Yours to edit — add bindings here.\n" +
106
+ JSON.stringify(config, null, "\t") +
107
+ "\n",
108
+ );
109
+ return target;
110
+ }