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/src/core/html.ts CHANGED
@@ -1,13 +1,17 @@
1
- import { existsSync, readFileSync } from "fs";
2
- import { getDeclaredEnvKeys } from "./env.ts";
1
+ import { gzipSync } from "node:zlib";
2
+
3
+ import { readArtifact } from "./artifacts.ts";
3
4
  import { nonceAttr } from "./csp.ts";
4
- import { OUT_DIR } from "./paths.ts";
5
5
  import { rebaseHtmlAttrs } from "./basePath.ts";
6
6
  import { currentBase } from "./appBase.ts";
7
7
  import type { AppHtmlSegments } from "./appHtml.ts";
8
8
  import { interpolateSegment } from "./appHtml.ts";
9
9
  import type { Metadata } from "./hooks.ts";
10
10
 
11
+ // Workers compresses any body sent with a Content-Encoding header — again, if it
12
+ // already is — unless told the bytes are final. Bun ignores the key.
13
+ export const PRECOMPRESSED = { encodeBody: "manual" } as ResponseInit;
14
+
11
15
  // ─── Dist Manifest ───────────────────────────────────────
12
16
  // Maps hashed filenames → script/link tags.
13
17
  // Cached at startup; server restarts on rebuild in dev anyway.
@@ -18,12 +22,9 @@ export const distManifest: {
18
22
  entry: string;
19
23
  tw?: string;
20
24
  basePath?: string;
21
- } = (() => {
22
- const p = `${OUT_DIR}/manifest.json`;
23
- return existsSync(p)
24
- ? JSON.parse(readFileSync(p, "utf-8"))
25
- : { js: [], css: [], entry: "hydrate.js" };
26
- })();
25
+ /** PUBLIC_* (non-static) names declared in .env files, stamped by the build. */
26
+ publicEnv?: string[];
27
+ } = readArtifact("manifest.json") ?? { js: [], css: [], entry: "hydrate.js" };
27
28
 
28
29
  export const isDev = process.env.NODE_ENV !== "production";
29
30
  const cacheBust = isDev ? `?v=${Date.now()}` : "";
@@ -115,23 +116,19 @@ export function safeJsonForScript(data: unknown): string {
115
116
  // ─── Public Env Injection ─────────────────────────────────
116
117
 
117
118
  /**
118
- * Collect PUBLIC_* (non-static) vars that were declared in .env files.
119
- * Only exposes keys tracked by loadEnv() — never leaks system env vars
120
- * that happen to start with PUBLIC_.
119
+ * PUBLIC_* (non-static) vars declared in .env files, with their current values.
120
+ * The names come from the build (`distManifest.publicEnv`), never from
121
+ * process.env — system env vars that happen to start with PUBLIC_ don't leak.
122
+ * The server runs as its own process (and on Workers, with no .env on disk),
123
+ * so the names must travel with the build rather than in loadEnv()'s memory.
121
124
  */
122
- const _publicDynamicEnv: Record<string, string> = (() => {
123
- const declared = getDeclaredEnvKeys();
125
+ export function getPublicDynamicEnv(): Record<string, string> {
124
126
  const result: Record<string, string> = {};
125
- for (const key of declared) {
126
- if (key.startsWith("PUBLIC_") && !key.startsWith("PUBLIC_STATIC_")) {
127
- const value = process.env[key];
128
- if (value !== undefined) result[key] = value;
129
- }
127
+ for (const key of distManifest.publicEnv ?? []) {
128
+ const value = process.env[key];
129
+ if (value !== undefined) result[key] = value;
130
130
  }
131
131
  return result;
132
- })();
133
- function getPublicDynamicEnv(): Record<string, string> {
134
- return _publicDynamicEnv;
135
132
  }
136
133
 
137
134
  // ─── Lang Validation ──────────────────────────────────────
@@ -428,6 +425,13 @@ export function buildHtmlTail(
428
425
 
429
426
  const GZIP_MIN_BYTES = 2048;
430
427
 
428
+ // Off on Workers: Cloudflare's edge compresses responses itself, outside the
429
+ // worker's CPU budget. The first brotli call alone cost ~4ms of a 10ms limit.
430
+ export let compressionOn = true;
431
+ export function disableCompression(): void {
432
+ compressionOn = false;
433
+ }
434
+
431
435
  // Shared, stateless — one instance instead of a fresh allocation per response.
432
436
  const textEncoder = new TextEncoder();
433
437
 
@@ -449,8 +453,9 @@ export function compress(
449
453
  const bytes = textEncoder.encode(body);
450
454
  // Skip compression in dev — the dev proxy's fetch() auto-decompresses gzip
451
455
  // responses but keeps the Content-Encoding header, causing ERR_CONTENT_DECODING_FAILED.
452
- if (!isDev && bytes.length > GZIP_MIN_BYTES && accept.includes("gzip")) {
453
- return new Response(Bun.gzipSync(bytes), {
456
+ if (compressionOn && !isDev && bytes.length > GZIP_MIN_BYTES && accept.includes("gzip")) {
457
+ return new Response(gzipSync(bytes), {
458
+ ...PRECOMPRESSED,
454
459
  status,
455
460
  headers: { ...headers, "content-encoding": "gzip" },
456
461
  });
package/src/core/paths.ts CHANGED
@@ -2,7 +2,9 @@ import { join, dirname, delimiter } from "path";
2
2
  import { existsSync } from "fs";
3
3
 
4
4
  // This file lives at src/core/paths.ts → package root is ../..
5
- const BOSIA_PKG_DIR = join(import.meta.dir, "..", "..");
5
+ // `import.meta.dir` is undefined on Workers — no package dir and no
6
+ // node_modules there, so every lookup below just comes back empty.
7
+ const BOSIA_PKG_DIR = import.meta.dir ? join(import.meta.dir, "..", "..") : "";
6
8
 
7
9
  const NESTED_NM = join(BOSIA_PKG_DIR, "node_modules");
8
10
 
@@ -22,7 +24,7 @@ function collectAncestorNodeModules(start: string): string[] {
22
24
  return out;
23
25
  }
24
26
 
25
- const ANCESTOR_NM = collectAncestorNodeModules(dirname(BOSIA_PKG_DIR));
27
+ const ANCESTOR_NM = BOSIA_PKG_DIR ? collectAncestorNodeModules(dirname(BOSIA_PKG_DIR)) : [];
26
28
  const ALL_NM = [NESTED_NM, ...ANCESTOR_NM];
27
29
 
28
30
  /** NODE_PATH value covering nested and every ancestor node_modules */
@@ -0,0 +1,24 @@
1
+ import type { Platform } from "./hooks.ts";
2
+
3
+ // Set once at boot by the runtime entry — on Workers, the bindings. Isolate-wide,
4
+ // so events read it here instead of threading it through every loader call.
5
+ let current: Platform | undefined;
6
+
7
+ export function setPlatform(platform: Platform | undefined): void {
8
+ current = platform;
9
+ }
10
+
11
+ export function getPlatform(): Platform | undefined {
12
+ return current;
13
+ }
14
+
15
+ // True only while server.workers.ts renders `/` once at isolate startup, so V8
16
+ // compiles the render path before the first real request. That render runs no
17
+ // user hooks, loaders, metadata() or +server.ts handlers — startup has no
18
+ // bindings, and a hook caching a failed `connect()` would break the isolate —
19
+ // and never touches the response cache.
20
+ export let warmingUp = false;
21
+
22
+ export function setWarmingUp(on: boolean): void {
23
+ warmingUp = on;
24
+ }
@@ -1,3 +1,4 @@
1
+ import { builtinModules } from "module";
1
2
  import { join, dirname } from "path";
2
3
 
3
4
  import { resolveImportPath } from "./resolveImport.ts";
@@ -7,8 +8,13 @@ import { resolveImportPath } from "./resolveImport.ts";
7
8
  // bosia:routes → .bosia/routes.ts (generated route map)
8
9
  // $env → .bosia/env.server.ts (bun) or .bosia/env.client.ts (browser)
9
10
  // $* → resolved dynamically via tsconfig.json compilerOptions.paths
11
+ // ./artifacts.ts → .bosia/artifacts.ts (workers runtime — no filesystem)
12
+ // bosia:workers-runtime → .bosia/runtime.workers.ts (workers runtime)
10
13
 
11
- export function makeBosiaPlugin(target: "browser" | "bun" = "bun") {
14
+ export function makeBosiaPlugin(
15
+ target: "browser" | "bun" = "bun",
16
+ runtime: "bun" | "workers" = "bun",
17
+ ) {
12
18
  return {
13
19
  name: "bosia-resolver",
14
20
  setup(build: import("bun").PluginBuilder) {
@@ -23,6 +29,25 @@ export function makeBosiaPlugin(target: "browser" | "bun" = "bun") {
23
29
  ),
24
30
  }));
25
31
 
32
+ // Workers has no filesystem: the core's build-artifact reader becomes
33
+ // the generated module with the same JSON inlined.
34
+ if (runtime === "workers") {
35
+ build.onResolve({ filter: /^\.\/artifacts\.ts$/ }, (args) =>
36
+ args.importer.startsWith(import.meta.dir)
37
+ ? { path: join(process.cwd(), ".bosia", "artifacts.ts") }
38
+ : undefined,
39
+ );
40
+ build.onResolve({ filter: /^bosia:workers-runtime$/ }, () => ({
41
+ path: join(process.cwd(), ".bosia", "runtime.workers.ts"),
42
+ }));
43
+ // workerd provides Node builtins under nodejs_compat. Keep them external
44
+ // as `node:*`: a browser-target build would swap a bare `fs` for `{}`.
45
+ const builtins = new Set(builtinModules);
46
+ build.onResolve({ filter: /^[a-z_]+(\/[a-z_]+)?$/ }, (args) =>
47
+ builtins.has(args.path) ? { path: `node:${args.path}`, external: true } : undefined,
48
+ );
49
+ }
50
+
26
51
  // $env → .bosia/env.client.ts (browser) or .bosia/env.server.ts (bun)
27
52
  build.onResolve({ filter: /^\$env$/ }, () => ({
28
53
  path: join(
@@ -5,6 +5,7 @@ import type { RouteManifest, TrailingSlash } from "./types.ts";
5
5
 
6
6
  import { BOSIA_NODE_PATH, OUT_DIR } from "./paths.ts";
7
7
  import { currentBase } from "./appBase.ts";
8
+ import { prerenderSkipReason, wantsPrerender } from "./scanner.ts";
8
9
 
9
10
  /** Acquire an OS-assigned ephemeral port. Tiny TOCTOU race window; acceptable for build-time use. */
10
11
  export function getEphemeralPort(): Promise<number> {
@@ -129,11 +130,10 @@ async function detectPrerenderRoutes(manifest: RouteManifest): Promise<Prerender
129
130
  if (!route.pageServer) return [];
130
131
  const filePath = join("src", "routes", route.pageServer);
131
132
  const content = await Bun.file(filePath).text();
132
- if (!/export\s+const\s+prerender\s*=\s*true/.test(content)) return [];
133
- if (/export\s+const\s+ssr\s*=\s*false/.test(content)) {
134
- console.warn(
135
- ` ⚠️ ${route.pattern} has prerender=true && ssr=false — contradictory, skipped`,
136
- );
133
+ if (!wantsPrerender(content)) return [];
134
+ const skip = prerenderSkipReason(content);
135
+ if (skip) {
136
+ console.warn(` ⚠️ ${route.pattern} has prerender=true && ${skip}, skipped`);
137
137
  return [];
138
138
  }
139
139
  const ts = route.trailingSlash;
@@ -144,7 +144,7 @@ async function detectPrerenderRoutes(manifest: RouteManifest): Promise<Prerender
144
144
  const apiTasks = manifest.apis.map(async (route): Promise<PrerenderTarget[]> => {
145
145
  const filePath = join("src", "routes", route.server);
146
146
  const content = await Bun.file(filePath).text();
147
- if (!/export\s+const\s+prerender\s*=\s*true/.test(content)) return [];
147
+ if (!wantsPrerender(content)) return [];
148
148
  if (route.pattern.includes("["))
149
149
  return expandDynamicRoute(route.pattern, filePath, "api", "never");
150
150
  return [{ path: route.pattern, kind: "api", trailingSlash: "never" }];
@@ -36,6 +36,7 @@ import {
36
36
  } from "./html.ts";
37
37
  import type { Metadata } from "./hooks.ts";
38
38
  import { loadPlugins } from "./config.ts";
39
+ import { getPlatform, warmingUp } from "./platform.ts";
39
40
  import { reportDevErrorFromCatch } from "./devErrorReport.ts";
40
41
  import { dev500Response } from "./dev-500.ts";
41
42
  import type { BosiaPlugin, RenderContext } from "./types/plugin.ts";
@@ -419,7 +420,7 @@ export async function loadRouteData(
419
420
  continue;
420
421
  }
421
422
  const mod = await ls.loader();
422
- if (typeof mod.load === "function") {
423
+ if (typeof mod.load === "function" && !warmingUp) {
423
424
  // Snapshot per layer so loaders cannot mutate the shared accumulator,
424
425
  // preserving the same isolation semantics as the previous merge-on-call code.
425
426
  const snapshot = { ...parentData };
@@ -436,6 +437,7 @@ export async function loadRouteData(
436
437
  fetch: trackedFetch(fetch, origin, deps),
437
438
  metadata: null,
438
439
  depends: makeDepends(deps),
440
+ platform: getPlatform(),
439
441
  setHeaders,
440
442
  }),
441
443
  LOAD_TIMEOUT,
@@ -487,7 +489,7 @@ export async function loadRouteData(
487
489
  if (skipPage) {
488
490
  pageData = null;
489
491
  pageDeps = null;
490
- } else if (typeof mod.load === "function") {
492
+ } else if (typeof mod.load === "function" && !warmingUp) {
491
493
  const snapshot = { ...parentData };
492
494
  const parent = async () => snapshot;
493
495
  const deps = emptyDeps();
@@ -502,6 +504,7 @@ export async function loadRouteData(
502
504
  fetch: trackedFetch(fetch, origin, deps),
503
505
  metadata: metadataData,
504
506
  depends: makeDepends(deps),
507
+ platform: getPlatform(),
505
508
  setHeaders,
506
509
  }),
507
510
  LOAD_TIMEOUT,
@@ -560,11 +563,11 @@ export async function loadMetadata(
560
563
  if (!route.pageServer) return null;
561
564
  try {
562
565
  const mod = await route.pageServer();
563
- if (typeof mod.metadata === "function") {
566
+ if (typeof mod.metadata === "function" && !warmingUp) {
564
567
  const fetch = makeFetch(req, url);
565
568
  return (
566
569
  (await withTimeout(
567
- mod.metadata({ params, url, locals, cookies, fetch }),
570
+ mod.metadata({ params, url, locals, cookies, fetch, platform: getPlatform() }),
568
571
  METADATA_TIMEOUT,
569
572
  `metadata (${url.pathname})`,
570
573
  )) ?? null
@@ -618,7 +621,8 @@ export async function renderSSRStream(
618
621
  // into the cached HTML but the CSP header is re-derived each request, so a
619
622
  // cached page would ship with a dead nonce and the browser would block its
620
623
  // inline scripts. Operators who turn on CSP_DIRECTIVES forfeit the cache.
621
- const cacheable = CACHE_ENABLED && !CSP_ENABLED && routeCacheable && req.method === "GET";
624
+ const cacheable =
625
+ CACHE_ENABLED && !CSP_ENABLED && !warmingUp && routeCacheable && req.method === "GET";
622
626
  let cacheKey: string | null = null;
623
627
  let releaseMiss: (() => void) | null = null;
624
628
  if (cacheable) {
@@ -652,7 +656,8 @@ export async function renderSSRStream(
652
656
  metadata = await loadMetadata(route, params, url, locals, cookies, req);
653
657
  } catch (err) {
654
658
  if (isRedirect(err)) {
655
- return Response.redirect(err.location, err.status);
659
+ // Not Response.redirect(): it rejects relative URLs on Workers.
660
+ return new Response(null, { status: err.status, headers: { Location: err.location } });
656
661
  }
657
662
  if (isHttpError(err)) {
658
663
  return renderErrorPage(
@@ -690,7 +695,8 @@ export async function renderSSRStream(
690
695
  ]);
691
696
  pageMod = pm;
692
697
  } catch (err) {
693
- if (isRedirect(err)) return Response.redirect(err.location, err.status);
698
+ if (isRedirect(err))
699
+ return new Response(null, { status: err.status, headers: { Location: err.location } });
694
700
  if (isHttpError(err)) {
695
701
  const e = err as HttpError & {
696
702
  errorDepth?: number;
@@ -950,7 +956,8 @@ export async function renderPageWithFormData(
950
956
  try {
951
957
  metadata = await loadMetadata(route, params, url, locals, cookies, req);
952
958
  } catch (err) {
953
- if (isRedirect(err)) return Response.redirect(err.location, err.status);
959
+ if (isRedirect(err))
960
+ return new Response(null, { status: err.status, headers: { Location: err.location } });
954
961
  if (isHttpError(err)) {
955
962
  return renderErrorPage(
956
963
  err.status,
@@ -68,6 +68,7 @@ export function generateRoutesFile(manifest: RouteManifest): void {
68
68
  lines.push(" layoutIds: (string | null)[];");
69
69
  lines.push(" loading: (() => Promise<any>) | null;");
70
70
  lines.push(" layoutPaths: string[];");
71
+ lines.push(" prerender: boolean;");
71
72
  lines.push("}> = [");
72
73
  for (const r of pages) {
73
74
  const layoutImports = r.layouts
@@ -102,6 +103,7 @@ export function generateRoutesFile(manifest: RouteManifest): void {
102
103
  ` loading: ${r.loading ? `() => import(${JSON.stringify(toImportPath(r.loading))})` : "null"},`,
103
104
  );
104
105
  lines.push(` layoutPaths: ${JSON.stringify(r.layouts)},`);
106
+ lines.push(` prerender: ${r.prerender},`);
105
107
  lines.push(" },");
106
108
  }
107
109
  lines.push("];\n");
@@ -204,6 +206,7 @@ function generateClientRoutesFile(
204
206
  lines.push(" layoutIds: (string | null)[];");
205
207
  lines.push(" loading: (() => Promise<any>) | null;");
206
208
  lines.push(" layoutPaths: string[];");
209
+ lines.push(" prerender: boolean;");
207
210
  lines.push("}> = [");
208
211
  for (const r of pages) {
209
212
  const layoutImports = r.layouts
@@ -235,6 +238,7 @@ function generateClientRoutesFile(
235
238
  ` loading: ${r.loading ? `() => import(${JSON.stringify(toImportPath(r.loading))})` : "null"},`,
236
239
  );
237
240
  lines.push(` layoutPaths: ${JSON.stringify(r.layouts)},`);
241
+ lines.push(` prerender: ${r.prerender},`);
238
242
  lines.push(" },");
239
243
  }
240
244
  lines.push("];\n");
@@ -42,6 +42,35 @@ function readPageCache(filePath: string): boolean | null {
42
42
  }
43
43
  }
44
44
 
45
+ /** Literal `export const prerender = true` in a server module's source. */
46
+ export function wantsPrerender(src: string): boolean {
47
+ return /export\s+const\s+prerender\s*=\s*true/.test(src);
48
+ }
49
+
50
+ /** Why a page that asks for prerender can't have it, or null when it can. */
51
+ export function prerenderSkipReason(src: string): string | null {
52
+ if (/export\s+const\s+ssr\s*=\s*false/.test(src)) return "ssr=false — contradictory";
53
+ // A static file can't run an action: Bun would answer the POST with the
54
+ // prerendered HTML, Workers' asset server with 405. Render it live instead.
55
+ if (
56
+ /export\s+(const|let|var|async\s+function|function)\s+actions\b|export\s*\{[^}]*\bactions\b/.test(
57
+ src,
58
+ )
59
+ )
60
+ return "actions — forms need a live page";
61
+ return null;
62
+ }
63
+
64
+ /** True when the page will really be prerendered — the client fetches its data as a static file. */
65
+ function readPrerender(filePath: string): boolean {
66
+ try {
67
+ const src = readFileSync(filePath, "utf-8");
68
+ return wantsPrerender(src) && prerenderSkipReason(src) === null;
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
45
74
  function readTrailingSlash(filePath: string): TrailingSlash | null {
46
75
  try {
47
76
  const src = readFileSync(filePath, "utf-8");
@@ -137,6 +166,7 @@ export function scanRoutes(): RouteManifest {
137
166
  errorPages: [...currentErrorPages],
138
167
  trailingSlash: effectiveTs,
139
168
  cache: readPageCache(join(ROUTES_DIR, pageFile)),
169
+ prerender: pageServerFile ? readPrerender(join(ROUTES_DIR, pageServerFile)) : false,
140
170
  });
141
171
  }
142
172
 
@@ -181,11 +211,45 @@ export function scanRoutes(): RouteManifest {
181
211
  }
182
212
  }
183
213
 
214
+ preventConflicts(pages, (p) => p.page);
215
+ preventConflicts(apis, (a) => a.server);
216
+
184
217
  const errorPage = existsSync(join(ROUTES_DIR, "+error.svelte")) ? "+error.svelte" : null;
185
218
 
186
219
  return { pages, apis, errorPage };
187
220
  }
188
221
 
222
+ /**
223
+ * Fail when two route files serve the same URL — e.g. `+page.svelte` next to
224
+ * `(public)/+page.svelte`, or `blog/[id]` next to `(app)/blog/[slug]`. Route
225
+ * groups vanish from the URL and param names don't affect matching, so one of
226
+ * the two could never be reached. Same rule as SvelteKit's prevent_conflicts
227
+ * and Next.js's "two parallel pages that resolve to the same path".
228
+ */
229
+ function preventConflicts<T extends { pattern: string }>(routes: T[], fileOf: (r: T) => string) {
230
+ const seen = new Map<string, T>();
231
+ for (const r of routes) {
232
+ const key = r.pattern.replace(/\[\.\.\.\w+\]/g, "[...]").replace(/\[\w+\]/g, "[]");
233
+ const first = seen.get(key);
234
+ if (!first) {
235
+ seen.set(key, r);
236
+ continue;
237
+ }
238
+ throw new RouteConflictError(
239
+ `The "${first.pattern}" and "${r.pattern}" routes conflict with each other:\n` +
240
+ ` src/routes/${fileOf(first)}\n` +
241
+ ` src/routes/${fileOf(r)}\n` +
242
+ ` Route groups like (public) are not part of the URL, and [id] matches the same URLs as [slug].\n` +
243
+ ` Delete or move one of them.`,
244
+ );
245
+ }
246
+ }
247
+
248
+ /** Two route files resolve to the same URL. Callers print `message` and exit. */
249
+ export class RouteConflictError extends Error {
250
+ override name = "RouteConflictError";
251
+ }
252
+
189
253
  function toUrlPath(segments: string[]): string {
190
254
  if (segments.length === 0) return "/";
191
255
  return "/" + segments.join("/");
@@ -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
+ }