bosia 0.9.8 → 0.9.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +2 -4
- package/src/ambient.d.ts +7 -0
- package/src/cli/build.ts +6 -1
- package/src/cli/index.ts +3 -3
- package/src/cli/start.ts +9 -1
- package/src/core/appHtml.ts +3 -11
- package/src/core/artifacts.ts +21 -0
- package/src/core/backend.ts +238 -0
- package/src/core/build.ts +97 -6
- package/src/core/cache.ts +10 -6
- package/src/core/client/App.svelte +5 -2
- package/src/core/client/prefetch.ts +3 -1
- package/src/core/config.ts +20 -9
- package/src/core/dev.ts +2 -3
- package/src/core/env.ts +0 -20
- package/src/core/hooks.ts +28 -0
- package/src/core/html.ts +29 -24
- package/src/core/paths.ts +4 -2
- package/src/core/platform.ts +24 -0
- package/src/core/plugin.ts +26 -1
- package/src/core/plugins/inspector/index.ts +6 -10
- package/src/core/plugins/server-timing.ts +1 -1
- package/src/core/prerender.ts +6 -6
- package/src/core/renderer.ts +15 -8
- package/src/core/routeFile.ts +4 -0
- package/src/core/scanner.ts +30 -0
- package/src/core/server.bun.ts +129 -0
- package/src/core/server.ts +108 -204
- package/src/core/server.workers.ts +46 -0
- package/src/core/types/plugin.ts +10 -6
- package/src/core/types.ts +2 -0
- package/src/core/workersCodegen.ts +110 -0
- package/src/core/workersGuard.ts +107 -0
- package/src/lib/index.ts +3 -0
- package/src/lib/server.ts +1 -0
- package/templates/default/README.md +1 -1
- package/templates/default/_gitignore +2 -0
- package/templates/default/src/routes/(public)/+page.svelte +1 -1
- package/templates/demo/_gitignore +2 -0
- package/templates/demo/src/routes/(public)/+page.svelte +2 -2
- package/templates/demo/src/routes/(public)/about/+page.svelte +2 -2
- package/templates/shop/_gitignore +2 -0
- package/templates/store/_gitignore +2 -0
|
@@ -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
|
+
}
|
package/src/core/server.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
import {
|
|
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
|
-
//
|
|
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
|
+
// ─── 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
|
|
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,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
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
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
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
})
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
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
|
-
|
|
1323
|
-
app
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
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
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
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
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
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
|
|
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
|
+
};
|
package/src/core/types/plugin.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
35
|
-
*
|
|
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:
|
|
42
|
+
before?: (app: BosiaApp) => MaybePromise<BosiaApp>;
|
|
41
43
|
/** Runs after framework routes — receives the route manifest for introspection. */
|
|
42
|
-
after?: (app:
|
|
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 */
|