bosia 0.9.8 → 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/core/appHtml.ts +3 -11
- package/src/core/artifacts.ts +21 -0
- package/src/core/build.ts +96 -4
- 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 +30 -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/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
|
-
|
|
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
|
+
}
|
package/src/core/plugin.ts
CHANGED
|
@@ -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(
|
|
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(
|
package/src/core/prerender.ts
CHANGED
|
@@ -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 (
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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 (
|
|
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" }];
|
package/src/core/renderer.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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))
|
|
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))
|
|
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,
|
package/src/core/routeFile.ts
CHANGED
|
@@ -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");
|
package/src/core/scanner.ts
CHANGED
|
@@ -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
|
|
|
@@ -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
|
+
}
|