bosia 0.5.13 → 0.6.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosia",
3
- "version": "0.5.13",
3
+ "version": "0.6.1",
4
4
  "type": "module",
5
5
  "description": "A fast, batteries-included fullstack framework — SSR · Svelte 5 Runes · Bun · ElysiaJS. File-based routing inspired by SvelteKit. No Node.js, no Vite, no adapters.",
6
6
  "keywords": [
@@ -33,6 +33,7 @@
33
33
  "exports": {
34
34
  ".": "./src/lib/index.ts",
35
35
  "./client": "./src/lib/client.ts",
36
+ "./server": "./src/lib/server.ts",
36
37
  "./plugins/server-timing": "./src/core/plugins/server-timing.ts",
37
38
  "./plugins/inspector": "./src/core/plugins/inspector/index.ts"
38
39
  },
@@ -0,0 +1,367 @@
1
+ // ─── Server-side Response Cache ──────────────────────────
2
+ // Skips load() + render() + compression on cache hit. Keyed by URL + identity
3
+ // (cookies/headers from CACHE_KEYS). Invalidated by tag (LoaderDeps.keys),
4
+ // fetch URL (LoaderDeps.urls), or exact/prefix path.
5
+ //
6
+ // See docs/guides/response-cache.md.
7
+
8
+ import type { Cookies, LoaderDeps } from "./hooks.ts";
9
+ import { dedupKey } from "./dedup.ts";
10
+
11
+ // ─── Config ──────────────────────────────────────────────
12
+
13
+ function parseCacheKeys(raw: string | undefined): string[] {
14
+ const value = raw?.trim();
15
+ if (value === undefined || value === "") {
16
+ return ["session", "sid", "auth", "token", "jwt", "Authorization"];
17
+ }
18
+ return value
19
+ .split(",")
20
+ .map((s) => s.trim())
21
+ .filter(Boolean);
22
+ }
23
+
24
+ function parseMaxEntries(raw: string | undefined): number {
25
+ if (!raw) return 500;
26
+ const n = parseInt(raw, 10);
27
+ if (!Number.isFinite(n) || n < 0) return 500;
28
+ return n;
29
+ }
30
+
31
+ // `invalidate` / `invalidateAll` are re-exported from the public `bosia`
32
+ // barrel, so this module also evaluates in the browser bundle. Guard every
33
+ // `process.env` read — otherwise hydration throws `ReferenceError: Can't
34
+ // find variable: process` (Safari) the moment the barrel is imported.
35
+ const env: Record<string, string | undefined> =
36
+ typeof process !== "undefined" && process.env ? process.env : {};
37
+ const isServer = typeof process !== "undefined";
38
+
39
+ export const CACHE_KEYS: readonly string[] = parseCacheKeys(env.CACHE_KEYS);
40
+ export const CACHE_MAX_ENTRIES = parseMaxEntries(env.CACHE_MAX_ENTRIES);
41
+ export const CACHE_ENABLED = CACHE_MAX_ENTRIES > 0;
42
+
43
+ if (isServer) {
44
+ if (CACHE_ENABLED) {
45
+ console.log(
46
+ `💾 Response cache: max ${CACHE_MAX_ENTRIES} entries, identity keys [${CACHE_KEYS.join(", ")}]`,
47
+ );
48
+ } else {
49
+ console.log("💾 Response cache: disabled (CACHE_MAX_ENTRIES=0)");
50
+ }
51
+ }
52
+
53
+ // ─── Entry shape ─────────────────────────────────────────
54
+
55
+ type Bytes = Uint8Array<ArrayBuffer>;
56
+
57
+ export type CacheEntry = {
58
+ raw: Bytes;
59
+ gzip: Bytes | null;
60
+ brotli: Bytes | null;
61
+ contentType: string;
62
+ status: number;
63
+ extraHeaders: Record<string, string>;
64
+ tags: string[];
65
+ };
66
+
67
+ // ─── Tiny LRU ────────────────────────────────────────────
68
+ // Uses Map's insertion-order iteration. get() promotes by re-inserting.
69
+
70
+ class LRU<K, V> {
71
+ private map = new Map<K, V>();
72
+ constructor(private cap: number) {}
73
+ get(key: K): V | undefined {
74
+ const v = this.map.get(key);
75
+ if (v === undefined) return undefined;
76
+ this.map.delete(key);
77
+ this.map.set(key, v);
78
+ return v;
79
+ }
80
+ set(key: K, value: V): K | undefined {
81
+ if (this.map.has(key)) this.map.delete(key);
82
+ this.map.set(key, value);
83
+ if (this.map.size > this.cap) {
84
+ const oldest = this.map.keys().next().value as K | undefined;
85
+ if (oldest !== undefined) {
86
+ this.map.delete(oldest);
87
+ return oldest;
88
+ }
89
+ }
90
+ return undefined;
91
+ }
92
+ delete(key: K): boolean {
93
+ return this.map.delete(key);
94
+ }
95
+ keys(): IterableIterator<K> {
96
+ return this.map.keys();
97
+ }
98
+ clear(): void {
99
+ this.map.clear();
100
+ }
101
+ get size(): number {
102
+ return this.map.size;
103
+ }
104
+ }
105
+
106
+ // ─── Storage ─────────────────────────────────────────────
107
+
108
+ const htmlCache = new LRU<string, CacheEntry>(CACHE_MAX_ENTRIES || 1);
109
+ const tagIndex = new Map<string, Set<string>>();
110
+ const pathIndex = new Map<string, Set<string>>(); // pathname → cacheKeys
111
+
112
+ // ─── Key building ────────────────────────────────────────
113
+
114
+ /** FNV-1a 32-bit hash. Compact, no dep, collision-tolerant for an identity bucket. */
115
+ function fnv1a(s: string): string {
116
+ let h = 0x811c9dc5;
117
+ for (let i = 0; i < s.length; i++) {
118
+ h ^= s.charCodeAt(i);
119
+ h = Math.imul(h, 0x01000193);
120
+ }
121
+ return (h >>> 0).toString(36);
122
+ }
123
+
124
+ export function computeIdentityHash(req: Request, cookies: Cookies): string {
125
+ const headers = req.headers;
126
+ const parts: string[] = [];
127
+ for (const name of CACHE_KEYS) {
128
+ const cv = cookies.get(name);
129
+ if (cv) parts.push(`c:${name}=${cv}`);
130
+ const hv = headers.get(name);
131
+ if (hv) parts.push(`h:${name}=${hv}`);
132
+ }
133
+ if (parts.length === 0) return "0";
134
+ parts.sort();
135
+ return fnv1a(parts.join("&"));
136
+ }
137
+
138
+ export function computeCacheKey(url: URL, req: Request, cookies: Cookies): string {
139
+ return `${dedupKey(url)}|i=${computeIdentityHash(req, cookies)}`;
140
+ }
141
+
142
+ /** Extract pathname portion of a cacheKey for path-based invalidation. */
143
+ function pathOfKey(key: string): string {
144
+ const qIdx = key.indexOf("?");
145
+ const pIdx = key.indexOf("|");
146
+ const end = qIdx === -1 ? pIdx : Math.min(qIdx, pIdx);
147
+ return end === -1 ? key : key.slice(0, end);
148
+ }
149
+
150
+ // ─── Tag collection ──────────────────────────────────────
151
+
152
+ export function collectTags(
153
+ layoutDeps: (LoaderDeps | null)[] | null,
154
+ pageDeps: LoaderDeps | null,
155
+ ): string[] {
156
+ const tags = new Set<string>();
157
+ if (layoutDeps) {
158
+ for (const deps of layoutDeps) {
159
+ if (!deps) continue;
160
+ for (const k of deps.keys) tags.add(`k:${k}`);
161
+ for (const u of deps.urls) tags.add(`u:${u}`);
162
+ }
163
+ }
164
+ if (pageDeps) {
165
+ for (const k of pageDeps.keys) tags.add(`k:${k}`);
166
+ for (const u of pageDeps.urls) tags.add(`u:${u}`);
167
+ }
168
+ return [...tags];
169
+ }
170
+
171
+ // ─── Public-ish: read / write ────────────────────────────
172
+
173
+ export function cacheGet(key: string): CacheEntry | undefined {
174
+ if (!CACHE_ENABLED) return undefined;
175
+ return htmlCache.get(key);
176
+ }
177
+
178
+ export function cacheSet(key: string, entry: CacheEntry): void {
179
+ if (!CACHE_ENABLED) return;
180
+ // Drop any existing entry's index pointers first
181
+ cacheDeleteKey(key);
182
+ const evicted = htmlCache.set(key, entry);
183
+ if (evicted) cacheDeleteIndexOnly(evicted);
184
+ for (const tag of entry.tags) {
185
+ let set = tagIndex.get(tag);
186
+ if (!set) {
187
+ set = new Set();
188
+ tagIndex.set(tag, set);
189
+ }
190
+ set.add(key);
191
+ }
192
+ const path = pathOfKey(key);
193
+ let pset = pathIndex.get(path);
194
+ if (!pset) {
195
+ pset = new Set();
196
+ pathIndex.set(path, pset);
197
+ }
198
+ pset.add(key);
199
+ }
200
+
201
+ /** Remove a key from htmlCache AND its index pointers. */
202
+ function cacheDeleteKey(key: string): void {
203
+ const entry = htmlCache.get(key);
204
+ if (entry) {
205
+ for (const tag of entry.tags) {
206
+ const set = tagIndex.get(tag);
207
+ if (set) {
208
+ set.delete(key);
209
+ if (set.size === 0) tagIndex.delete(tag);
210
+ }
211
+ }
212
+ }
213
+ const path = pathOfKey(key);
214
+ const pset = pathIndex.get(path);
215
+ if (pset) {
216
+ pset.delete(key);
217
+ if (pset.size === 0) pathIndex.delete(path);
218
+ }
219
+ htmlCache.delete(key);
220
+ }
221
+
222
+ /** Cleanup index pointers for a key after LRU evicted it. */
223
+ function cacheDeleteIndexOnly(key: string): void {
224
+ for (const set of tagIndex.values()) set.delete(key);
225
+ for (const [tag, set] of tagIndex) if (set.size === 0) tagIndex.delete(tag);
226
+ const path = pathOfKey(key);
227
+ const pset = pathIndex.get(path);
228
+ if (pset) {
229
+ pset.delete(key);
230
+ if (pset.size === 0) pathIndex.delete(path);
231
+ }
232
+ }
233
+
234
+ // ─── Compression helpers ─────────────────────────────────
235
+
236
+ /** Build gzip + brotli copies of body. Sync, runs in microtask. */
237
+ export function buildCompressedVariants(body: Bytes): {
238
+ gzip: Bytes | null;
239
+ brotli: Bytes | null;
240
+ } {
241
+ const COMPRESS_MIN_BYTES = 2048;
242
+ if (body.length < COMPRESS_MIN_BYTES) return { gzip: null, brotli: null };
243
+ let gzip: Bytes | null = null;
244
+ let brotli: Bytes | null = null;
245
+ try {
246
+ gzip = Bun.gzipSync(body) as Bytes;
247
+ } catch {
248
+ gzip = null;
249
+ }
250
+ // brotliCompressSync exists in Bun >= 1.1.5 but is missing from older
251
+ // @types/bun shipments — cast through any so the call stays loose.
252
+ const brotliFn = (Bun as unknown as { brotliCompressSync?: (b: Bytes) => Uint8Array })
253
+ .brotliCompressSync;
254
+ if (brotliFn) {
255
+ try {
256
+ brotli = brotliFn(body) as Bytes;
257
+ } catch {
258
+ brotli = null;
259
+ }
260
+ }
261
+ return { gzip, brotli };
262
+ }
263
+
264
+ /** Concatenate multiple Uint8Array chunks into one buffer. */
265
+ export function concatChunks(chunks: Uint8Array[]): Bytes {
266
+ let total = 0;
267
+ for (const c of chunks) total += c.length;
268
+ const out = new Uint8Array(new ArrayBuffer(total));
269
+ let off = 0;
270
+ for (const c of chunks) {
271
+ out.set(c, off);
272
+ off += c.length;
273
+ }
274
+ return out;
275
+ }
276
+
277
+ // ─── Serve a cache hit ───────────────────────────────────
278
+
279
+ export function serveCached(entry: CacheEntry, req: Request): Response {
280
+ const accept = req.headers.get("accept-encoding") ?? "";
281
+ const headers: Record<string, string> = {
282
+ "Content-Type": entry.contentType,
283
+ Vary: "Accept-Encoding",
284
+ "X-Bosia-Cache": "HIT",
285
+ ...entry.extraHeaders,
286
+ };
287
+ if (entry.brotli && accept.includes("br")) {
288
+ headers["Content-Encoding"] = "br";
289
+ return new Response(entry.brotli, { status: entry.status, headers });
290
+ }
291
+ if (entry.gzip && accept.includes("gzip")) {
292
+ headers["Content-Encoding"] = "gzip";
293
+ return new Response(entry.gzip, { status: entry.status, headers });
294
+ }
295
+ return new Response(entry.raw, { status: entry.status, headers });
296
+ }
297
+
298
+ // ─── Invalidation API ────────────────────────────────────
299
+
300
+ /**
301
+ * Evict all cache entries matching `key`.
302
+ *
303
+ * - `invalidate("app:user")` → evict entries tagged with depends("app:user")
304
+ * (matches the loader's tag list).
305
+ * - `invalidate("/api/posts")` → evict entries tagged with a fetch URL whose
306
+ * path equals `/api/posts`, AND entries whose own path equals `/api/posts`.
307
+ */
308
+ export function invalidate(key: string): number {
309
+ if (!CACHE_ENABLED) return 0;
310
+ let count = 0;
311
+ const tagKey = key.startsWith("/") ? `u:${key}` : `k:${key}`;
312
+ const fromTag = tagIndex.get(tagKey);
313
+ if (fromTag) {
314
+ // Also collect URL tag matches where the loader fetched an absolute URL
315
+ // whose pathname == key. Conservative: also match the bare `k:` tag in
316
+ // case the user uses a key that starts with `/` but isn't a URL.
317
+ for (const k of [...fromTag]) {
318
+ cacheDeleteKey(k);
319
+ count++;
320
+ }
321
+ }
322
+ if (key.startsWith("/")) {
323
+ // Match absolute URL tags too: any `u:<origin><key>` recorded by trackedFetch.
324
+ for (const [tag, set] of tagIndex) {
325
+ if (tag.startsWith("u:") && tag.endsWith(key)) {
326
+ for (const k of [...set]) {
327
+ cacheDeleteKey(k);
328
+ count++;
329
+ }
330
+ }
331
+ }
332
+ // Match cache entries whose own path equals key
333
+ const pset = pathIndex.get(key);
334
+ if (pset) {
335
+ for (const k of [...pset]) {
336
+ cacheDeleteKey(k);
337
+ count++;
338
+ }
339
+ }
340
+ }
341
+ return count;
342
+ }
343
+
344
+ /**
345
+ * Evict every entry whose path starts with `prefix`.
346
+ * Use for bulk eviction (e.g. `invalidateAll("/products/")`).
347
+ */
348
+ export function invalidateAll(prefix: string): number {
349
+ if (!CACHE_ENABLED) return 0;
350
+ let count = 0;
351
+ for (const [path, set] of [...pathIndex]) {
352
+ if (path.startsWith(prefix)) {
353
+ for (const k of [...set]) {
354
+ cacheDeleteKey(k);
355
+ count++;
356
+ }
357
+ }
358
+ }
359
+ return count;
360
+ }
361
+
362
+ /** Test-only: clear everything. */
363
+ export function cacheClear(): void {
364
+ htmlCache.clear();
365
+ tagIndex.clear();
366
+ pathIndex.clear();
367
+ }
package/src/core/dev.ts CHANGED
@@ -247,6 +247,12 @@ const devServer = Bun.serve({
247
247
  const forwardedHeaders = new Headers(req.headers);
248
248
  forwardedHeaders.set("x-forwarded-host", reqUrl.host);
249
249
  forwardedHeaders.set("x-forwarded-proto", reqUrl.protocol.replace(":", ""));
250
+ // Force inner app to respond uncompressed. Bun's `fetch()` auto-decodes
251
+ // gzip/br bodies but leaves the original `Content-Encoding` header on
252
+ // the Response, so passing it through made Safari throw -1015 ("cannot
253
+ // decode raw data") on every HTML navigation. Identity on the dev wire
254
+ // is fine — it's localhost.
255
+ forwardedHeaders.set("accept-encoding", "identity");
250
256
 
251
257
  // HMR-driven reloads can land on the proxy before the freshly-respawned
252
258
  // inner has bound APP_PORT. Retry for a few seconds on idempotent HTML
@@ -20,7 +20,7 @@ export interface InspectorOptions {
20
20
  interface ServerError {
21
21
  id: string;
22
22
  ts: number;
23
- source: "elysia" | "uncaught" | "rejection";
23
+ source: "server" | "uncaught" | "rejection";
24
24
  message: string;
25
25
  stack?: string;
26
26
  file?: string;
@@ -147,7 +147,7 @@ function installProcessListeners() {
147
147
  function installGlobalReporter() {
148
148
  globalThis.__BOSIA_REPORT_ERROR__ = (e) => {
149
149
  pushServerError({
150
- source: e.source ?? "elysia",
150
+ source: e.source ?? "server",
151
151
  message: e.message,
152
152
  stack: e.stack,
153
153
  });
@@ -282,7 +282,7 @@ export function inspector(options: InspectorOptions = {}): BosiaPlugin | false {
282
282
  chained = chained.onError(({ error }) => {
283
283
  const e = error as Error;
284
284
  pushServerError({
285
- source: "elysia",
285
+ source: "server",
286
286
  message: e?.message ?? String(error),
287
287
  stack: e?.stack,
288
288
  });
@@ -5,6 +5,16 @@ import { serverRoutes, errorPage } from "bosia:routes";
5
5
  import type { RouteMatch } from "./types.ts";
6
6
  import type { Cookies, LoaderDeps } from "./hooks.ts";
7
7
  import { CSP_ENABLED } from "./csp.ts";
8
+ import {
9
+ CACHE_ENABLED,
10
+ buildCompressedVariants,
11
+ cacheGet,
12
+ cacheSet,
13
+ collectTags,
14
+ computeCacheKey,
15
+ concatChunks,
16
+ serveCached,
17
+ } from "./cache.ts";
8
18
  import { HttpError, Redirect } from "./errors.ts";
9
19
  import { pickErrorPage, type ErrorOrigin } from "./errorMatch.ts";
10
20
  import App from "./client/App.svelte";
@@ -503,6 +513,28 @@ export async function renderSSRStream(
503
513
  const { route, params } = match;
504
514
  const nonce = CSP_ENABLED && typeof locals.nonce === "string" ? locals.nonce : undefined;
505
515
 
516
+ // ── Response cache: short-circuit on hit ──
517
+ // Look up cached HTML before doing anything expensive (metadata, load,
518
+ // render, compress). Key includes URL + identity hash (cookies/headers
519
+ // from CACHE_KEYS), so per-user pages stay isolated. Routes opt out via
520
+ // `export const cache = false`. See cache.ts and docs/guides/response-cache.md.
521
+ const pageMod: any = await route.pageModule();
522
+ const cacheBypass = url.searchParams.has("_invalidated");
523
+ // CSP is incompatible with response cache — the per-request nonce is baked
524
+ // into the cached HTML but the CSP header is re-derived each request, so a
525
+ // cached page would ship with a dead nonce and the browser would block its
526
+ // inline scripts. Operators who turn on CSP_DIRECTIVES forfeit the cache.
527
+ const cacheable =
528
+ CACHE_ENABLED && !CSP_ENABLED && pageMod.cache !== false && req.method === "GET";
529
+ let cacheKey: string | null = null;
530
+ if (cacheable) {
531
+ cacheKey = computeCacheKey(url, req, cookies);
532
+ if (!cacheBypass) {
533
+ const hit = cacheGet(cacheKey);
534
+ if (hit) return serveCached(hit, req);
535
+ }
536
+ }
537
+
506
538
  // ── Pre-stream phase: resolve metadata before committing to a 200 ──
507
539
  // Errors here return a proper error response with correct status code.
508
540
  let metadata: Metadata | null = null;
@@ -535,13 +567,11 @@ export async function renderSSRStream(
535
567
  // This ensures HttpError/Redirect from load() can return a proper response before any bytes are sent.
536
568
  const metadataData = metadata?.data ?? null;
537
569
  let data: Awaited<ReturnType<typeof loadRouteData>>;
538
- let pageMod: any;
539
570
  let layoutMods: any[];
540
571
 
541
572
  try {
542
- [data, pageMod, layoutMods] = await Promise.all([
573
+ [data, layoutMods] = await Promise.all([
543
574
  loadRouteData(url, locals, req, cookies, metadataData, match),
544
- route.pageModule(),
545
575
  Promise.all(route.layoutModules.map((l: () => Promise<any>) => l())),
546
576
  ]);
547
577
  } catch (err) {
@@ -692,6 +722,28 @@ export async function renderSSRStream(
692
722
  ),
693
723
  ];
694
724
 
725
+ // ── Response cache: write after chunks built, before stream creation ──
726
+ // Skip if the handler set cookies — cached response can't reproduce
727
+ // per-request Set-Cookie headers. Compression runs in a microtask so
728
+ // the response goes out first.
729
+ if (cacheable && cacheKey && (cookies as any).outgoing?.length === 0) {
730
+ const fullBody = concatChunks(chunks);
731
+ const tags = collectTags(data.layoutDeps ?? null, data.pageDeps ?? null);
732
+ const keyForWrite = cacheKey;
733
+ queueMicrotask(() => {
734
+ const { gzip, brotli } = buildCompressedVariants(fullBody);
735
+ cacheSet(keyForWrite, {
736
+ raw: fullBody,
737
+ gzip,
738
+ brotli,
739
+ contentType: "text/html; charset=utf-8",
740
+ status: 200,
741
+ extraHeaders: {},
742
+ tags,
743
+ });
744
+ });
745
+ }
746
+
695
747
  let i = 0;
696
748
  let cancelled = false;
697
749
  const onAbort = () => {
@@ -84,6 +84,10 @@ export function generateRouteTypes(manifest: RouteManifest): void {
84
84
  lines.push(``);
85
85
  lines.push(`export type Params = ${paramsType};`);
86
86
  lines.push(``);
87
+ lines.push(`/** Set \`export const cache: CacheOption = false;\` in +page(.server).ts`);
88
+ lines.push(` * or +server.ts to opt the route out of the server response cache. */`);
89
+ lines.push(`export type CacheOption = false;`);
90
+ lines.push(``);
87
91
  lines.push(`type _LoadEvent = Omit<LoadEvent, 'params'> & { params: Params };`);
88
92
  lines.push(`type _MetadataEvent = Omit<MetadataEvent, 'params'> & { params: Params };`);
89
93
  lines.push(`type _RequestEvent = Omit<RequestEvent, 'params'> & { params: Params };`);
@@ -25,6 +25,14 @@ import { isDev, compress, isStaticPath } from "./html.ts";
25
25
  import { dev500WithPlugins } from "./dev-500.ts";
26
26
  import { OUT_DIR } from "./paths.ts";
27
27
  import { dedup, dedupKey } from "./dedup.ts";
28
+ import {
29
+ CACHE_ENABLED,
30
+ buildCompressedVariants,
31
+ cacheGet,
32
+ cacheSet,
33
+ computeCacheKey,
34
+ serveCached,
35
+ } from "./cache.ts";
28
36
  import { reportDevErrorFromCatch } from "./devErrorReport.ts";
29
37
  import {
30
38
  loadRouteData,
@@ -56,6 +64,27 @@ if (existsSync(hooksPath)) {
56
64
 
57
65
  // ─── Env Helpers ─────────────────────────────────────────
58
66
 
67
+ // Headers that must not be baked into a cache entry. Content-Length is
68
+ // recomputed by Bun, content-encoding/transfer-encoding depend on the chosen
69
+ // variant, and security/CORS/Set-Cookie headers are applied by handleRequest.
70
+ const NON_CACHEABLE_HEADERS = new Set([
71
+ "content-length",
72
+ "content-encoding",
73
+ "transfer-encoding",
74
+ "content-type",
75
+ "set-cookie",
76
+ "vary",
77
+ "x-bosia-cache",
78
+ ]);
79
+
80
+ function captureCacheableHeaders(headers: Headers): Record<string, string> {
81
+ const out: Record<string, string> = {};
82
+ for (const [k, v] of headers) {
83
+ if (!NON_CACHEABLE_HEADERS.has(k.toLowerCase())) out[k] = v;
84
+ }
85
+ return out;
86
+ }
87
+
59
88
  function splitCsvEnv(key: string): string[] | undefined {
60
89
  return (
61
90
  process.env[key]
@@ -379,7 +408,63 @@ async function resolve(event: RequestEvent): Promise<Response> {
379
408
  }
380
409
 
381
410
  event.params = apiMatch.params;
382
- return await handler({ request, params: apiMatch.params, url, locals, cookies });
411
+
412
+ // ── Response cache for +server.ts GET handlers ──
413
+ // CSP is skipped because cached responses would ship with a stale
414
+ // nonce (see renderer.ts for the same gate). The cache key includes
415
+ // URL + identity so per-user responses stay isolated.
416
+ const apiCacheable =
417
+ CACHE_ENABLED && !CSP_ENABLED && (mod as any).cache !== false && method === "GET";
418
+ let apiCacheKey: string | null = null;
419
+ if (apiCacheable) {
420
+ apiCacheKey = computeCacheKey(url, request, cookies);
421
+ if (!url.searchParams.has("_invalidated")) {
422
+ const hit = cacheGet(apiCacheKey);
423
+ if (hit) return serveCached(hit, request);
424
+ }
425
+ }
426
+
427
+ const response = await handler({
428
+ request,
429
+ params: apiMatch.params,
430
+ url,
431
+ locals,
432
+ cookies,
433
+ });
434
+
435
+ if (
436
+ apiCacheable &&
437
+ apiCacheKey &&
438
+ response.status === 200 &&
439
+ (cookies as CookieJar).outgoing.length === 0
440
+ ) {
441
+ const cloned = response.clone();
442
+ const extraHeaders = captureCacheableHeaders(response.headers);
443
+ const contentType =
444
+ response.headers.get("content-type") ?? "application/octet-stream";
445
+ const keyForWrite = apiCacheKey;
446
+ queueMicrotask(async () => {
447
+ try {
448
+ const buf = new Uint8Array(await cloned.arrayBuffer());
449
+ const { gzip, brotli } = buildCompressedVariants(buf);
450
+ // API endpoints have no LoaderDeps in v0.6 — invalidation is
451
+ // URL/prefix only. See ROADMAP for deferred tag support.
452
+ cacheSet(keyForWrite, {
453
+ raw: buf,
454
+ gzip,
455
+ brotli,
456
+ contentType,
457
+ status: 200,
458
+ extraHeaders,
459
+ tags: [],
460
+ });
461
+ } catch {
462
+ /* drop silently — cache population is best-effort */
463
+ }
464
+ });
465
+ }
466
+
467
+ return response;
383
468
  } catch (err) {
384
469
  if (isDev) console.error("API route error:", err);
385
470
  else console.error("API route error:", (err as Error).message ?? err);
@@ -630,6 +715,16 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
630
715
  });
631
716
  }
632
717
 
718
+ // Shed load above MAX_INFLIGHT. Checked before any work so the 503 is
719
+ // cheap. /_health stays available so the orchestrator can still tell the
720
+ // process is alive (and decide whether to restart or scale out).
721
+ if (inFlight >= MAX_INFLIGHT && url.pathname !== "/_health") {
722
+ return new Response("Service Unavailable", {
723
+ status: 503,
724
+ headers: { "Retry-After": "1" },
725
+ });
726
+ }
727
+
633
728
  inFlight++;
634
729
  try {
635
730
  // Handle CORS preflight before CSRF check (OPTIONS is CSRF-exempt)
@@ -762,6 +857,29 @@ const IDLE_TIMEOUT = parseIdleTimeout(process.env.IDLE_TIMEOUT);
762
857
 
763
858
  console.log(`⏱ Idle timeout: ${IDLE_TIMEOUT}s`);
764
859
 
860
+ // ─── Concurrency Ceiling ──────────────────────────────────
861
+ // Soft cap on in-flight requests, parsed from MAX_INFLIGHT env var.
862
+ // Default is unlimited so existing apps see no behavior change. When set,
863
+ // requests above the cap get a fast 503 + Retry-After before any work is
864
+ // done — protects single-replica container deploys from OOM under spike
865
+ // traffic. /_health is exempt so orchestrator liveness probes still work
866
+ // while the app sheds load.
867
+
868
+ function parseMaxInflight(value?: string): number {
869
+ if (!value) return Infinity;
870
+ const trimmed = value.trim();
871
+ if (trimmed === "" || trimmed.toLowerCase() === "infinity") return Infinity;
872
+ const n = parseInt(trimmed, 10);
873
+ if (!Number.isFinite(n) || n <= 0) throw new Error(`Invalid MAX_INFLIGHT: "${value}"`);
874
+ return n;
875
+ }
876
+
877
+ const MAX_INFLIGHT = parseMaxInflight(process.env.MAX_INFLIGHT);
878
+
879
+ if (Number.isFinite(MAX_INFLIGHT)) {
880
+ console.log(`🚦 Max in-flight requests: ${MAX_INFLIGHT}`);
881
+ }
882
+
765
883
  // ─── Graceful Shutdown State ──────────────────────────────
766
884
 
767
885
  let shuttingDown = false;
@@ -909,4 +1027,22 @@ async function shutdown() {
909
1027
  process.on("SIGTERM", shutdown);
910
1028
  process.on("SIGINT", shutdown);
911
1029
 
1030
+ // Prod-only fatal handlers. The dev inspector plugin installs its own
1031
+ // uncaughtException/unhandledRejection listeners that route errors into the
1032
+ // overlay and let the dev runner's crash-backoff restart the process. In prod
1033
+ // there's no inspector — without these handlers an unhandled rejection from a
1034
+ // background timer or plugin hook orphans the process with no log context.
1035
+ // Log + exit(1) lets the orchestrator (Podman/k8s) restart cleanly.
1036
+ if (!isDev) {
1037
+ process.on("uncaughtException", (err: Error) => {
1038
+ console.error("[FATAL] uncaughtException:", err?.stack ?? err);
1039
+ process.exit(1);
1040
+ });
1041
+ process.on("unhandledRejection", (reason: unknown) => {
1042
+ const e = reason as Error | undefined;
1043
+ console.error("[FATAL] unhandledRejection:", e?.stack ?? reason);
1044
+ process.exit(1);
1045
+ });
1046
+ }
1047
+
912
1048
  export { app };
package/src/lib/index.ts CHANGED
@@ -6,6 +6,9 @@
6
6
  export { cn, getServerTime } from "./utils.ts";
7
7
  export { sequence } from "../core/hooks.ts";
8
8
  export { error, redirect, fail } from "../core/errors.ts";
9
+ // `invalidate` / `invalidateAll` (server response-cache eviction) live in
10
+ // "bosia/server" — they touch server-process state and pulling them into
11
+ // the shared barrel leaks `process.env` reads into client bundles.
9
12
  export type { HttpError, Redirect, RedirectOptions, ActionFailure } from "../core/errors.ts";
10
13
  export type {
11
14
  RequestEvent,
@@ -0,0 +1,12 @@
1
+ // ─── Bosia Server API ─────────────────────────────────────
2
+ // Server-only helpers — import from "bosia/server".
3
+ // Kept separate from "bosia" because these modules touch server-process
4
+ // state (the response-cache Map, `process.env`) and have no meaning in
5
+ // the browser. Importing them through the shared `bosia` barrel would
6
+ // drag `process.env` reads into client bundles via the lib re-export
7
+ // graph (see ROADMAP v0.6.0 entry on the Safari hydration ReferenceError).
8
+ //
9
+ // Usage in user apps (form actions, +server.ts, hooks):
10
+ // import { invalidate, invalidateAll } from "bosia/server";
11
+
12
+ export { invalidate, invalidateAll } from "../core/cache.ts";
@@ -14,7 +14,7 @@
14
14
  # Import in your code:
15
15
  # import { PUBLIC_STATIC_APP_NAME, DB_PASSWORD } from '$env';
16
16
  #
17
- # Framework vars (PORT, NODE_ENV, BODY_SIZE_LIMIT, CSRF_ALLOWED_ORIGINS,
17
+ # Framework vars (PORT, NODE_ENV, BODY_SIZE_LIMIT, MAX_INFLIGHT, CSRF_ALLOWED_ORIGINS,
18
18
  # CORS_*, LOAD_TIMEOUT, METADATA_TIMEOUT, PRERENDER_TIMEOUT) are NOT exposed via $env —
19
19
  # access them via process.env directly.
20
20
  # ────────────────────────────────────────────────────────────────────────────────
@@ -30,6 +30,25 @@ PUBLIC_STATIC_APP_NAME=My Bosia App
30
30
  # Maximum request body size. Supports K/M/G suffixes or "Infinity". Defaults to 512K.
31
31
  # BODY_SIZE_LIMIT=512K
32
32
 
33
+ # Soft cap on concurrent in-flight requests. Requests above the cap get a fast
34
+ # 503 + Retry-After before any work is done — protects single-replica container
35
+ # deploys from OOM under traffic spikes. Defaults to Infinity (no cap).
36
+ # /_health is exempt so orchestrator liveness probes keep working under load.
37
+ # MAX_INFLIGHT=500
38
+
39
+ # Server response cache (skip-render). Caches HTML SSR pages and +server.ts GET
40
+ # handlers in-memory, keyed by URL + identity (cookies/headers from CACHE_KEYS).
41
+ # Per-user pages stay isolated. Disable per-route with `export const cache = false`.
42
+ # See guides/response-cache for details.
43
+ #
44
+ # Identity keys: cookies AND headers with any of these names contribute to the
45
+ # cache key, so logged-in users never see each other's pages. Default covers
46
+ # common session cookie/header names. Leave blank to use the default.
47
+ # CACHE_KEYS=session,sid,auth,token,jwt,Authorization
48
+ #
49
+ # Max cache entries (LRU). Set to 0 to disable the cache entirely. Defaults to 500.
50
+ # CACHE_MAX_ENTRIES=500
51
+
33
52
  # Timeout for load() functions (layout + page) in milliseconds. Defaults to 5000 (5s).
34
53
  # Set to 0 or Infinity to disable.
35
54
  # LOAD_TIMEOUT=5000
@@ -4,6 +4,18 @@ PORT=9000
4
4
  # Maximum request body size. Supports K/M/G suffixes or "Infinity". Defaults to 512K.
5
5
  BODY_SIZE_LIMIT=512K
6
6
 
7
+ # Soft cap on concurrent in-flight requests. Requests above the cap get a fast
8
+ # 503 + Retry-After before any work is done — protects single-replica container
9
+ # deploys from OOM under traffic spikes. Defaults to Infinity (no cap).
10
+ # /_health is exempt so orchestrator liveness probes keep working under load.
11
+ # MAX_INFLIGHT=500
12
+
13
+ # Server response cache (skip-render). Caches HTML SSR pages and +server.ts GET
14
+ # handlers in-memory, keyed by URL + identity (cookies/headers from CACHE_KEYS).
15
+ # Per-user pages stay isolated. Disable per-route with `export const cache = false`.
16
+ # CACHE_KEYS=session,sid,auth,token,jwt,Authorization
17
+ # CACHE_MAX_ENTRIES=500
18
+
7
19
  # Comma-separated list of allowed CORS origins for CSRF validation.
8
20
  # Leave unset to allow same-origin requests only.
9
21
  # Example: https://app.example.com, https://admin.example.com