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.
- package/package.json +2 -2
- package/src/ambient.d.ts +7 -0
- package/src/cli/build.ts +6 -1
- package/src/cli/index.ts +3 -3
- package/src/cli/start.ts +9 -1
- package/src/cli/sync.ts +9 -2
- package/src/core/appHtml.ts +3 -11
- package/src/core/artifacts.ts +21 -0
- package/src/core/build.ts +105 -6
- package/src/core/cache.ts +10 -6
- package/src/core/client/App.svelte +5 -2
- package/src/core/client/prefetch.ts +3 -1
- package/src/core/config.ts +19 -8
- package/src/core/dev.ts +1 -2
- package/src/core/env.ts +0 -20
- package/src/core/hooks.ts +28 -0
- package/src/core/html.ts +29 -24
- package/src/core/paths.ts +4 -2
- package/src/core/platform.ts +24 -0
- package/src/core/plugin.ts +26 -1
- package/src/core/prerender.ts +6 -6
- package/src/core/renderer.ts +15 -8
- package/src/core/routeFile.ts +4 -0
- package/src/core/scanner.ts +64 -0
- package/src/core/server.bun.ts +129 -0
- package/src/core/server.ts +129 -203
- package/src/core/server.workers.ts +47 -0
- package/src/core/types/plugin.ts +5 -0
- package/src/core/types.ts +2 -0
- package/src/core/workersCodegen.ts +110 -0
- package/src/core/workersGuard.ts +107 -0
- package/src/lib/index.ts +3 -0
- package/templates/default/_gitignore +2 -0
- package/templates/demo/_gitignore +2 -0
- package/templates/shop/_gitignore +2 -0
- package/templates/store/_gitignore +2 -0
package/src/core/server.ts
CHANGED
|
@@ -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
|
-
//
|
|
56
|
-
//
|
|
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 =
|
|
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(
|
|
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 —
|
|
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
|
-
|
|
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
|
-
|
|
1208
|
-
|
|
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
|
|
1215
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1243
|
-
|
|
1244
|
-
|
|
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
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
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
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
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
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
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
|
|
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
|
+
};
|
package/src/core/types/plugin.ts
CHANGED
|
@@ -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
|
+
}
|