bosia 1.2.4 → 1.2.6

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 CHANGED
@@ -144,16 +144,15 @@ const authHandle: Handle = async ({ event, resolve }) => {
144
144
  return resolve(event);
145
145
  };
146
146
 
147
- const loggingHandle: Handle = async ({ event, resolve }) => {
148
- const res = await resolve(event);
149
- console.log(`${event.request.method} ${event.url.pathname} ${res.status}`);
150
- return res;
147
+ const langHandle: Handle = async ({ event, resolve }) => {
148
+ event.locals.lang = event.request.headers.get("accept-language")?.split(",")[0] ?? "en";
149
+ return resolve(event);
151
150
  };
152
151
 
153
- export const handle = sequence(authHandle, loggingHandle);
152
+ export const handle = sequence(authHandle, langHandle);
154
153
  ```
155
154
 
156
- `locals` set here are available in every loader and API handler.
155
+ `locals` set here are available in every loader and API handler. For an access log, use your reverse proxy's or add `accessLog()` from `bosia/plugins/access-log` to `bosia.config.ts` — not a `console.log` per request.
157
156
 
158
157
  ## Public API
159
158
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosia",
3
- "version": "1.2.4",
3
+ "version": "1.2.6",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bun": ">=1.4"
@@ -37,6 +37,7 @@
37
37
  "./client": "./src/lib/client.ts",
38
38
  "./server": "./src/lib/server.ts",
39
39
  "./plugins/server-timing": "./src/core/plugins/server-timing.ts",
40
+ "./plugins/access-log": "./src/core/plugins/access-log.ts",
40
41
  "./plugins/inspector": "./src/core/plugins/inspector/index.ts"
41
42
  },
42
43
  "bin": {
@@ -1,6 +1,7 @@
1
1
  // ─── Bosia Backend App ────────────────────────────────────
2
2
  // A tiny Elysia-shaped HTTP app: chainable routes + onRequest / onAfterHandle /
3
- // onError hooks, one `fetch(request)` entry for Bun.serve and Workers alike.
3
+ // onError / onResponse hooks, one `fetch(request)` entry for Bun.serve and
4
+ // Workers alike.
4
5
  // Only the subset the framework and plugins use — not a general router.
5
6
 
6
7
  /** Outbound response bag — merged into whatever the handler returns. */
@@ -34,6 +35,19 @@ export type AfterHandleHook = (ctx: {
34
35
 
35
36
  export type ErrorHook = (ctx: { request: Request; error: unknown; set: ResponseSet }) => unknown;
36
37
 
38
+ /**
39
+ * Sees the final Response — after onRequest short-circuits, after-handle
40
+ * replacements, error hooks and the HEAD body drop — exactly once per request.
41
+ * `start`: `performance.now()` when the request entered `fetch`. Observe only:
42
+ * the return value is ignored and a throw or rejection is swallowed. `response`
43
+ * is the one the client gets — don't read its body or change its headers.
44
+ */
45
+ export type ResponseHook = (ctx: {
46
+ request: Request;
47
+ response: Response;
48
+ start: number;
49
+ }) => void | Promise<unknown>;
50
+
37
51
  export interface ServeOptions {
38
52
  maxRequestBodySize?: number;
39
53
  idleTimeout?: number;
@@ -55,6 +69,7 @@ export class BosiaApp {
55
69
  private requestHooks: RequestHook[] = [];
56
70
  private afterHooks: AfterHandleHook[] = [];
57
71
  private errorHooks: ErrorHook[] = [];
72
+ private responseHooks: ResponseHook[] = [];
58
73
  private server: ReturnType<typeof Bun.serve> | null = null;
59
74
 
60
75
  constructor(private config: { serve?: ServeOptions } = {}) {}
@@ -100,6 +115,12 @@ export class BosiaApp {
100
115
  return this;
101
116
  }
102
117
 
118
+ /** Observes the final response of every request (see ResponseHook). */
119
+ onResponse(hook: ResponseHook): this {
120
+ this.responseHooks.push(hook);
121
+ return this;
122
+ }
123
+
103
124
  // Paths match exactly, or "*" matches everything. Enough for plugin endpoints
104
125
  // like /__bosia/locate; add `:param` matching here when a plugin needs it.
105
126
  private route(method: Method, path: string, handler: Handler): this {
@@ -130,6 +151,39 @@ export class BosiaApp {
130
151
  }
131
152
 
132
153
  fetch = async (request: Request): Promise<Response> => {
154
+ if (this.responseHooks.length === 0) return this.respond(request);
155
+ const start = performance.now();
156
+ let response: Response;
157
+ try {
158
+ response = await this.respond(request);
159
+ } catch (error) {
160
+ // An error hook threw: the runtime answers 500, so observers see that.
161
+ this.observe(request, new Response(null, { status: 500 }), start);
162
+ throw error;
163
+ }
164
+ this.observe(request, response, start);
165
+ return response;
166
+ };
167
+
168
+ private observe(request: Request, response: Response, start: number): void {
169
+ for (const hook of this.responseHooks) {
170
+ try {
171
+ const out = hook({ request, response, start });
172
+ // An async observer's rejection would be unhandled — fatal in prod.
173
+ if (out instanceof Promise) out.catch(() => {});
174
+ } catch {
175
+ // Observers must not change what the client gets.
176
+ }
177
+ }
178
+ }
179
+
180
+ /** Answers without running onResponse observers — for the Workers warm-up
181
+ * render, which no client made and no access log should count. */
182
+ fetchUnobserved(request: Request): Promise<Response> {
183
+ return this.respond(request);
184
+ }
185
+
186
+ private async respond(request: Request): Promise<Response> {
133
187
  const set: ResponseSet = { headers: {} };
134
188
  const isHead = request.method === "HEAD";
135
189
  const method = isHead ? "GET" : request.method.toUpperCase();
@@ -143,7 +197,7 @@ export class BosiaApp {
143
197
  }
144
198
  return new Response("Internal Server Error", { status: 500 });
145
199
  }
146
- };
200
+ }
147
201
 
148
202
  private async dispatch(request: Request, method: string, set: ResponseSet): Promise<Response> {
149
203
  for (const hook of this.requestHooks) {
package/src/core/cache.ts CHANGED
@@ -11,7 +11,7 @@ import { createHash } from "node:crypto";
11
11
  import type { Cookies, LoaderDeps } from "./hooks.ts";
12
12
  import type { CookieJar } from "./cookies.ts";
13
13
  import { dedupKey } from "./dedup.ts";
14
- import { compressionOn, encodeBytes, PRECOMPRESSED } from "./html.ts";
14
+ import { compressionOn, encodeBytes, PRECOMPRESSED, varyWithEncoding } from "./html.ts";
15
15
  import { matchesEtag } from "./etag.ts";
16
16
 
17
17
  // ─── Config ──────────────────────────────────────────────
@@ -34,14 +34,59 @@ function parseMaxEntries(raw: string | undefined): number {
34
34
  return n;
35
35
  }
36
36
 
37
- function parseMaxBodyBytes(raw: string | undefined): number {
38
- const DEFAULT = 2_097_152; // 2MB; 0 = unlimited
39
- if (!raw) return DEFAULT;
37
+ function parseBytes(raw: string | undefined, fallback: number): number {
38
+ if (!raw) return fallback;
40
39
  const n = parseInt(raw, 10);
41
- if (!Number.isFinite(n) || n < 0) return DEFAULT;
40
+ if (!Number.isFinite(n) || n < 0) return fallback;
42
41
  return n;
43
42
  }
44
43
 
44
+ function parseMaxBodyBytes(raw: string | undefined): number {
45
+ return parseBytes(raw, 2_097_152); // 2MB; 0 = unlimited
46
+ }
47
+
48
+ const MAX_TIMER_MS = 2_147_483_647;
49
+
50
+ /**
51
+ * A duration env var in whole milliseconds. Unset or blank → `fallback`;
52
+ * `0` or `Infinity` → 0 (no limit). Anything else (`5s`, `1e4`, `-1`) warns
53
+ * and uses `fallback` — parseInt read `10s` as 10ms.
54
+ */
55
+ export function parseMs(raw: string | undefined, fallback: number, name: string): number {
56
+ const value = raw?.trim();
57
+ if (!value) return fallback;
58
+ if (value.toLowerCase() === "infinity") return 0;
59
+ // setTimeout overflows above 2^31-1 and fires at once.
60
+ if (/^\d+$/.test(value)) return Math.min(Number(value), MAX_TIMER_MS);
61
+ if (typeof console !== "undefined") {
62
+ console.warn(`⚠️ ${name}="${raw}" is not a number of milliseconds; using ${fallback}`);
63
+ }
64
+ return fallback;
65
+ }
66
+
67
+ /** Ad and social click parameters: they never change what a page shows. */
68
+ const DEFAULT_IGNORE_PARAMS =
69
+ "utm_*,fbclid,gclid,gbraid,wbraid,dclid,msclkid,twclid,ttclid,li_fat_id,igshid,yclid,mc_cid,mc_eid,_ga,_gl";
70
+
71
+ /** Comma-separated names; `name*` matches a prefix. `none` (or `-`) = ignore nothing. */
72
+ export function parseIgnoreParams(raw: string | undefined): {
73
+ names: Set<string>;
74
+ prefixes: string[];
75
+ } {
76
+ let value = raw?.trim();
77
+ if (value === undefined || value === "") value = DEFAULT_IGNORE_PARAMS;
78
+ const names = new Set<string>();
79
+ const prefixes: string[] = [];
80
+ if (value === "none" || value === "-") return { names, prefixes };
81
+ for (const part of value.split(",")) {
82
+ const p = part.trim();
83
+ if (!p || p === "*") continue;
84
+ if (p.endsWith("*")) prefixes.push(p.slice(0, -1));
85
+ else names.add(p);
86
+ }
87
+ return { names, prefixes };
88
+ }
89
+
45
90
  // Server-only module (imported by core/renderer.ts, core/server.ts,
46
91
  // lib/server.ts — never the client barrel; client `invalidate` lives in
47
92
  // core/client/navigation.ts). The `process` guards below are kept as
@@ -53,12 +98,60 @@ const isServer = typeof process !== "undefined";
53
98
  export const CACHE_KEYS: readonly string[] = parseCacheKeys(env.CACHE_KEYS);
54
99
  export const CACHE_MAX_ENTRIES = parseMaxEntries(env.CACHE_MAX_ENTRIES);
55
100
  export const CACHE_MAX_BODY_BYTES = parseMaxBodyBytes(env.CACHE_MAX_BODY_BYTES);
101
+ /** Budget for every entry together (raw + gzip + brotli). 0 = entry count only. */
102
+ export const CACHE_MAX_BYTES = parseBytes(env.CACHE_MAX_BYTES, 104_857_600); // 100MB
56
103
  export const CACHE_ENABLED = CACHE_MAX_ENTRIES > 0;
104
+ // Unset means the default; `0` or `Infinity` turns the timeout off. Unset
105
+ // used to mean no timeout: a hung loader held its URL's cache gate (and dedup
106
+ // slot) until restart. 30s/10s leave room for slow-but-healthy loaders; a
107
+ // page's timer also covers its `await parent()`. Used by renderer.ts.
108
+ export const LOAD_TIMEOUT = parseMs(env.LOAD_TIMEOUT, 30_000, "LOAD_TIMEOUT");
109
+ export const METADATA_TIMEOUT = parseMs(env.METADATA_TIMEOUT, 10_000, "METADATA_TIMEOUT");
110
+
111
+ /** The longest a healthy page build can take (metadata, then load, plus
112
+ * render); 0 when LOAD_TIMEOUT is off. */
113
+ const LOADER_BUDGET = LOAD_TIMEOUT > 0 ? METADATA_TIMEOUT + LOAD_TIMEOUT + 10_000 : 0;
114
+
115
+ /**
116
+ * How long a cache miss waits for another request already building the same
117
+ * key, counted from when that one started, before building itself (ms).
118
+ * Bounds a leader that never finishes (an API handler that hangs, or
119
+ * LOAD_TIMEOUT=0). When it fires every waiter builds at once, so it must
120
+ * outlast a healthy leader: at least LOADER_BUDGET. 0 = wait for the leader.
121
+ */
122
+ export const CACHE_WAIT_TIMEOUT = parseMs(
123
+ env.CACHE_WAIT_TIMEOUT,
124
+ Math.min(Math.max(60_000, LOADER_BUDGET), MAX_TIMER_MS),
125
+ "CACHE_WAIT_TIMEOUT",
126
+ );
127
+ if (
128
+ CACHE_WAIT_TIMEOUT > 0 &&
129
+ CACHE_WAIT_TIMEOUT < Math.min(LOADER_BUDGET, MAX_TIMER_MS) &&
130
+ typeof console !== "undefined"
131
+ ) {
132
+ console.warn(
133
+ `⚠️ CACHE_WAIT_TIMEOUT=${CACHE_WAIT_TIMEOUT} is shorter than METADATA_TIMEOUT + LOAD_TIMEOUT: requests waiting on a slow page build will start their own`,
134
+ );
135
+ }
136
+ const IGNORE_PARAMS = parseIgnoreParams(env.CACHE_IGNORE_PARAMS);
137
+ /** Largest body worth reading or compressing for the cache: the per-body cap,
138
+ * and at most a quarter of the budget (raw + two compressed copies still fit,
139
+ * and one entry can't flush the rest). 0 = no limit. */
140
+ const CACHE_BUDGET_SHARE = CACHE_MAX_BYTES > 0 ? Math.floor(CACHE_MAX_BYTES / 4) : 0;
141
+ const CACHE_BODY_CAP =
142
+ CACHE_MAX_BODY_BYTES > 0 && CACHE_BUDGET_SHARE > 0
143
+ ? Math.min(CACHE_MAX_BODY_BYTES, CACHE_BUDGET_SHARE)
144
+ : CACHE_MAX_BODY_BYTES || CACHE_BUDGET_SHARE;
145
+
146
+ /** Whether a body of `bytes` could be stored — checked before paying compression. */
147
+ export function bodyFitsCache(bytes: number): boolean {
148
+ return CACHE_BODY_CAP === 0 || bytes <= CACHE_BODY_CAP;
149
+ }
57
150
 
58
151
  if (isServer) {
59
152
  if (CACHE_ENABLED) {
60
153
  console.log(
61
- `💾 Response cache: max ${CACHE_MAX_ENTRIES} entries, max body ${CACHE_MAX_BODY_BYTES === 0 ? "unlimited" : `${CACHE_MAX_BODY_BYTES} bytes`}, identity keys [${CACHE_KEYS.join(", ")}]`,
154
+ `💾 Response cache: max ${CACHE_MAX_ENTRIES} entries${CACHE_MAX_BYTES === 0 ? "" : ` / ${CACHE_MAX_BYTES} bytes`}, max body ${CACHE_BODY_CAP === 0 ? "unlimited" : `${CACHE_BODY_CAP} bytes`}, identity keys [${CACHE_KEYS.join(", ")}]`,
62
155
  );
63
156
  } else {
64
157
  console.log("💾 Response cache: disabled (CACHE_MAX_ENTRIES=0)");
@@ -80,14 +173,27 @@ export type CacheEntry = {
80
173
  /** Digest of `raw`, filled in by `cacheSet`; the ETag is built from it per
81
174
  * encoding so a gzip and a brotli copy are never mistaken for each other. */
82
175
  etag?: string;
176
+ /** A loader read an ignored tracking param (or the whole query): not served
177
+ * to requests that carried one. See CACHE_IGNORE_PARAMS. */
178
+ queryAware?: boolean;
83
179
  };
84
180
 
85
181
  // ─── Tiny LRU ────────────────────────────────────────────
86
182
  // Uses Map's insertion-order iteration. get() promotes by re-inserting.
183
+ // Bounded by entry count and, when `maxBytes` > 0, by the summed `weigh()` —
184
+ // 500 entries of up to 2MB plus two compressed copies each could otherwise
185
+ // reach gigabytes of heap.
87
186
 
88
187
  class LRU<K, V> {
89
188
  private map = new Map<K, V>();
90
- constructor(private cap: number) {}
189
+ // Weighed once on set, so a later change to a value can't skew the total.
190
+ private weights = new Map<K, number>();
191
+ private bytes = 0;
192
+ constructor(
193
+ private cap: number,
194
+ private maxBytes: number,
195
+ private weigh: (value: V) => number,
196
+ ) {}
91
197
  get(key: K): V | undefined {
92
198
  const v = this.map.get(key);
93
199
  if (v === undefined) return undefined;
@@ -95,20 +201,31 @@ class LRU<K, V> {
95
201
  this.map.set(key, v);
96
202
  return v;
97
203
  }
98
- set(key: K, value: V): { key: K; value: V } | undefined {
99
- if (this.map.has(key)) this.map.delete(key);
204
+ /** Stores `value` and returns what was evicted to make room. A value
205
+ * heavier than the whole budget is not stored. */
206
+ set(key: K, value: V): { stored: boolean; evicted: { key: K; value: V }[] } {
207
+ this.delete(key);
208
+ const evicted: { key: K; value: V }[] = [];
209
+ const weight = this.weigh(value);
210
+ if (this.maxBytes > 0 && weight > this.maxBytes) return { stored: false, evicted };
100
211
  this.map.set(key, value);
101
- if (this.map.size > this.cap) {
102
- const oldest = this.map.keys().next().value as K | undefined;
103
- if (oldest !== undefined) {
104
- const evicted = this.map.get(oldest) as V;
105
- this.map.delete(oldest);
106
- return { key: oldest, value: evicted };
107
- }
212
+ this.weights.set(key, weight);
213
+ this.bytes += weight;
214
+ while (
215
+ this.map.size > this.cap ||
216
+ (this.maxBytes > 0 && this.bytes > this.maxBytes && this.map.size > 1)
217
+ ) {
218
+ const oldest = this.map.keys().next().value as K;
219
+ const old = this.map.get(oldest) as V;
220
+ this.delete(oldest);
221
+ evicted.push({ key: oldest, value: old });
108
222
  }
109
- return undefined;
223
+ return { stored: true, evicted };
110
224
  }
111
225
  delete(key: K): boolean {
226
+ if (!this.map.has(key)) return false;
227
+ this.bytes -= this.weights.get(key) ?? 0;
228
+ this.weights.delete(key);
112
229
  return this.map.delete(key);
113
230
  }
114
231
  keys(): IterableIterator<K> {
@@ -116,6 +233,8 @@ class LRU<K, V> {
116
233
  }
117
234
  clear(): void {
118
235
  this.map.clear();
236
+ this.weights.clear();
237
+ this.bytes = 0;
119
238
  }
120
239
  get size(): number {
121
240
  return this.map.size;
@@ -124,7 +243,11 @@ class LRU<K, V> {
124
243
 
125
244
  // ─── Storage ─────────────────────────────────────────────
126
245
 
127
- const htmlCache = new LRU<string, CacheEntry>(CACHE_MAX_ENTRIES || 1);
246
+ const htmlCache = new LRU<string, CacheEntry>(
247
+ CACHE_MAX_ENTRIES || 1,
248
+ CACHE_MAX_BYTES,
249
+ (e) => e.raw.byteLength + (e.gzip?.byteLength ?? 0) + (e.brotli?.byteLength ?? 0),
250
+ );
128
251
  const tagIndex = new Map<string, Set<string>>();
129
252
  const pathIndex = new Map<string, Set<string>>(); // pathname → cacheKeys
130
253
 
@@ -155,6 +278,63 @@ export function computeCacheKey(url: URL, req: Request, cookies: Pick<CookieJar,
155
278
  return `${dedupKey(url)}|i=${computeIdentityHash(req, cookies)}`;
156
279
  }
157
280
 
281
+ // ─── Tracking parameters (CACHE_IGNORE_PARAMS) ───────────
282
+ // `/blog?utm_source=x` and `/blog?fbclid=…` are the same page as `/blog`, but
283
+ // every value made its own entry: a full render per ad click, and the misses
284
+ // pushed hot pages out of the LRU. Pages (SSR and data endpoint) look such a
285
+ // request up under the URL without those params; API routes keep the full URL.
286
+ //
287
+ // A request that carried them is served the plain URL's entry when there is
288
+ // one; otherwise it caches under its full URL, as before. It never writes the
289
+ // plain entry: a component may render `page.url` (a canonical link, a hidden
290
+ // gclid field), so its HTML can hold this visitor's values. An entry whose
291
+ // loaders or metadata() read an ignored param (or the whole query) is marked
292
+ // `queryAware` and isn't served to such requests.
293
+
294
+ export function isIgnoredParam(name: string): boolean {
295
+ if (IGNORE_PARAMS.names.has(name)) return true;
296
+ for (const p of IGNORE_PARAMS.prefixes) if (name.startsWith(p)) return true;
297
+ return false;
298
+ }
299
+
300
+ /** `url` without ignored params, or `url` itself when it has none. */
301
+ export function stripIgnoredParams(url: URL): URL {
302
+ if (url.search === "") return url;
303
+ if (IGNORE_PARAMS.names.size === 0 && IGNORE_PARAMS.prefixes.length === 0) return url;
304
+ let kept: [string, string][] | null = null;
305
+ let i = 0;
306
+ for (const [name, value] of url.searchParams) {
307
+ if (isIgnoredParam(name)) {
308
+ kept ??= [...url.searchParams].slice(0, i);
309
+ } else if (kept) kept.push([name, value]);
310
+ i++;
311
+ }
312
+ if (!kept) return url;
313
+ const out = new URL(url.href);
314
+ out.search = new URLSearchParams(kept).toString();
315
+ return out;
316
+ }
317
+
318
+ /** Loaders that read the whole query string (`url.search`, `url.href`, iterating
319
+ * `url.searchParams`): their output may depend on any param. */
320
+ const wholeQueryDeps = new WeakSet<LoaderDeps>();
321
+
322
+ export function markWholeQueryRead(deps: LoaderDeps): void {
323
+ wholeQueryDeps.add(deps);
324
+ }
325
+
326
+ /** Whether any loader read an ignored param — its entry must not be served to a
327
+ * request that carried one. */
328
+ export function depsReadIgnoredParams(
329
+ layoutDeps: (LoaderDeps | null)[] | null,
330
+ pageDeps: LoaderDeps | null,
331
+ ): boolean {
332
+ const check = (d: LoaderDeps | null) =>
333
+ !!d && (wholeQueryDeps.has(d) || d.searchParams.some(isIgnoredParam));
334
+ if (check(pageDeps)) return true;
335
+ return !!layoutDeps && layoutDeps.some(check);
336
+ }
337
+
158
338
  /** Extract pathname portion of a cacheKey for path-based invalidation. */
159
339
  function pathOfKey(key: string): string {
160
340
  const qIdx = key.indexOf("?");
@@ -184,6 +364,26 @@ export function collectTags(
184
364
  return [...tags];
185
365
  }
186
366
 
367
+ /** A `Cache-Control` value that says not to reuse the response. */
368
+ export function isNoStoreCacheControl(cc: string | null | undefined): boolean {
369
+ return /no-store|no-cache|private/i.test(cc ?? "");
370
+ }
371
+
372
+ /**
373
+ * A `Vary` the cache key can't honor. The key covers the URL and CACHE_KEYS;
374
+ * Accept-Encoding is negotiated per hit. Anything else (`Cookie`, `Origin`
375
+ * from a handler doing its own CORS, `Accept-Language`, `*`) means one stored
376
+ * copy would be served to requests the response said differ. The framework's
377
+ * own `Vary: Origin` is added after this check, on the way out.
378
+ */
379
+ export function varyBlocksCache(vary: string | null | undefined): boolean {
380
+ if (!vary) return false;
381
+ return vary
382
+ .split(",")
383
+ .map((t) => t.trim().toLowerCase())
384
+ .some((t) => t !== "" && t !== "accept-encoding");
385
+ }
386
+
187
387
  // ─── Public-ish: read / write ────────────────────────────
188
388
 
189
389
  export function cacheGet(key: string): CacheEntry | undefined {
@@ -226,8 +426,10 @@ export function cacheSet(key: string, entry: CacheEntry, cookies?: Cookies): voi
226
426
  entry.etag ??= createHash("sha1").update(entry.raw).digest("base64url").slice(0, 22);
227
427
  // Drop any existing entry's index pointers first
228
428
  cacheDeleteKey(key);
229
- const evicted = htmlCache.set(key, entry);
230
- if (evicted) cacheDeleteIndexOnly(evicted.key, evicted.value);
429
+ const { stored, evicted } = htmlCache.set(key, entry);
430
+ for (const e of evicted) cacheDeleteIndexOnly(e.key, e.value);
431
+ // Heavier than the whole budget: not stored, so nothing to index.
432
+ if (!stored) return;
231
433
  for (const tag of entry.tags) {
232
434
  let set = tagIndex.get(tag);
233
435
  if (!set) {
@@ -330,6 +532,65 @@ export function deferCacheWrite(fn: () => void | Promise<void>): void {
330
532
  else queueMicrotask(fn);
331
533
  }
332
534
 
535
+ const textEncoder = new TextEncoder();
536
+
537
+ /** How long a cache write may spend reading a response body it copied. */
538
+ const CACHE_READ_TIMEOUT_MS = 10_000;
539
+
540
+ /**
541
+ * Read a copied response body for the cache, giving up (and cancelling the
542
+ * copy) once it can't fit the cache (`bodyFitsCache`) or takes longer than
543
+ * `CACHE_READ_TIMEOUT_MS`. `null` = don't cache.
544
+ *
545
+ * `arrayBuffer()` on the copy held the whole body before any size check: a
546
+ * 1GB file served from a `+server.ts` GET took 1GB of RAM per download, and an
547
+ * endless non-SSE stream kept the miss gate (and every waiter) for good.
548
+ */
549
+ export async function readBodyForCache(res: Response): Promise<Bytes | null> {
550
+ const stream = res.body;
551
+ if (!stream) return new Uint8Array(new ArrayBuffer(0));
552
+ const reader = stream.getReader();
553
+ const chunks: Uint8Array[] = [];
554
+ let total = 0;
555
+ // Cancelling ends the pending read() as `done`; the flag tells that apart
556
+ // from a body that really ended.
557
+ let timedOut = false;
558
+ const timer = setTimeout(() => {
559
+ timedOut = true;
560
+ reader.cancel().catch(() => {});
561
+ }, CACHE_READ_TIMEOUT_MS);
562
+ try {
563
+ for (;;) {
564
+ const step = await reader.read();
565
+ if (timedOut) return null;
566
+ if (step.done) return concatChunks(chunks);
567
+ // A handler's stream may enqueue strings; Bun passes them through a clone.
568
+ const value: unknown = step.value;
569
+ const chunk =
570
+ typeof value === "string"
571
+ ? textEncoder.encode(value)
572
+ : value instanceof Uint8Array
573
+ ? value
574
+ : ArrayBuffer.isView(value)
575
+ ? new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
576
+ : value instanceof ArrayBuffer
577
+ ? new Uint8Array(value)
578
+ : null;
579
+ if (chunk === null) return null;
580
+ total += chunk.byteLength;
581
+ if (!bodyFitsCache(total)) return null;
582
+ chunks.push(chunk);
583
+ }
584
+ } catch {
585
+ return null;
586
+ } finally {
587
+ clearTimeout(timer);
588
+ // Stops the copy so the runtime no longer buffers it; the response the
589
+ // client reads is a separate branch and keeps going.
590
+ reader.cancel().catch(() => {});
591
+ }
592
+ }
593
+
333
594
  /** Concatenate multiple Uint8Array chunks into one buffer. */
334
595
  export function concatChunks(chunks: Uint8Array[]): Bytes {
335
596
  let total = 0;
@@ -347,20 +608,59 @@ export function concatChunks(chunks: Uint8Array[]): Bytes {
347
608
  // Stampede protection for cache misses. The first miss on a key becomes the
348
609
  // leader (gets `release`); concurrent misses become waiters (get `wait`).
349
610
  // Waiters re-check cacheGet after the wait resolves — hit: serve it; miss
350
- // (leader skipped the write): build independently, no re-leadering. Distinct
351
- // from dedup(), which shares the *result*; this shares only the *wait*.
611
+ // (leader skipped the write): build independently. Distinct from dedup(),
612
+ // which shares the *result*; this shares only the *wait*.
352
613
  //
353
614
  // INVARIANT: the leader must call release() exactly once, after its cacheSet
354
- // attempt completed or was skipped. A missed release() hangs waiters for the
355
- // process lifetime — callers guard with try/finally.
615
+ // attempt completed or was skipped — callers guard with try/finally.
616
+ //
617
+ // A leader that never finishes (an API handler that hangs, LOAD_TIMEOUT=0)
618
+ // held its key for the process lifetime: every later request waited on it
619
+ // and the handler never ran again. A gate expires CACHE_WAIT_TIMEOUT after
620
+ // its leader started: waiters resume with `false` and re-claim through
621
+ // claimMiss(); the first replaces the gate and leads with `stale: true` (and
622
+ // must not join the stuck leader's dedup() call), the rest wait on it.
623
+
624
+ type MissGate = {
625
+ promise: Promise<void>;
626
+ release: () => void;
627
+ start: number;
628
+ /** Set by an expiry timer, so a waiter it woke never sees the gate as
629
+ * live (the timer may fire a little before the clock reads expired). */
630
+ expired: boolean;
631
+ };
356
632
 
357
- const missGates = new Map<string, { promise: Promise<void>; release: () => void }>();
633
+ const missGates = new Map<string, MissGate>();
634
+
635
+ function gateExpired(gate: MissGate, now = performance.now()): boolean {
636
+ return gate.expired || (CACHE_WAIT_TIMEOUT > 0 && now - gate.start >= CACHE_WAIT_TIMEOUT);
637
+ }
638
+
639
+ // An expired gate stays until its key misses again (it tells that miss it is
640
+ // `stale`). Keys that never come back are swept once the map grows; by twice
641
+ // the timeout the stuck dedup() call is too old to be joined anyway.
642
+ const SWEEP_AT = 256;
643
+ let lastSweep = 0;
644
+ function sweepGates(now: number): void {
645
+ if (missGates.size < SWEEP_AT || now - lastSweep < CACHE_WAIT_TIMEOUT) return;
646
+ lastSweep = now;
647
+ for (const [key, gate] of missGates) {
648
+ if (now - gate.start >= 2 * CACHE_WAIT_TIMEOUT) missGates.delete(key);
649
+ }
650
+ }
651
+
652
+ /** `staleStart`: when the expired gate this lead replaced started (else -Infinity). */
653
+ export type MissLead = { release: () => void; stale: boolean; staleStart: number };
358
654
 
359
655
  export function coalesceMiss(
360
656
  key: string,
361
- ): { release: () => void; wait?: undefined } | { wait: Promise<void>; release?: undefined } {
657
+ ): (MissLead & { wait?: undefined }) | { wait: Promise<boolean>; release?: undefined } {
658
+ const now = performance.now();
362
659
  const existing = missGates.get(key);
363
- if (existing) return { wait: existing.promise };
660
+ if (existing && !gateExpired(existing, now)) {
661
+ return { wait: waitWithTimeout(existing) };
662
+ }
663
+ if (CACHE_WAIT_TIMEOUT > 0) sweepGates(now);
364
664
  let resolve!: () => void;
365
665
  const promise = new Promise<void>((r) => (resolve = r));
366
666
  const release = () => {
@@ -368,8 +668,56 @@ export function coalesceMiss(
368
668
  if (missGates.get(key)?.promise === promise) missGates.delete(key);
369
669
  resolve();
370
670
  };
371
- missGates.set(key, { promise, release });
372
- return { release };
671
+ // Replaces an expired gate; the old leader's release() won't touch this one.
672
+ missGates.set(key, { promise, release, start: now, expired: false });
673
+ return { release, stale: existing !== undefined, staleStart: existing?.start ?? -Infinity };
674
+ }
675
+
676
+ /**
677
+ * Claim a cache miss: a hit some other request wrote while this one waited;
678
+ * the lead (`lead.release()` exactly once, after the write or its skip);
679
+ * `lead: null` — build without leading; or `aborted` — the client left while
680
+ * waiting, don't build. A waiter whose gate expired re-claims once: the first
681
+ * becomes the new (`stale`) leader and the rest wait on it, so a stuck leader
682
+ * doesn't set every waiter building at once. After a second expiry a waiter
683
+ * builds on its own: it has waited 2× CACHE_WAIT_TIMEOUT.
684
+ */
685
+ export async function claimMiss(
686
+ key: string,
687
+ signal?: AbortSignal,
688
+ ): Promise<
689
+ | { hit: CacheEntry; lead?: undefined; aborted?: undefined }
690
+ | { lead: MissLead | null; hit?: undefined; aborted?: undefined }
691
+ | { aborted: true; hit?: undefined; lead?: undefined }
692
+ > {
693
+ for (let round = 0; ; round++) {
694
+ const gate = coalesceMiss(key);
695
+ if (!gate.wait) return { lead: gate };
696
+ const finished = await gate.wait;
697
+ const hit = cacheGet(key);
698
+ if (hit) return { hit };
699
+ if (signal?.aborted) return { aborted: true };
700
+ if (finished || round >= 1) return { lead: null };
701
+ }
702
+ }
703
+
704
+ /** True when the leader finished, false when the gate expired first. One
705
+ * timer per waiter, not per gate: on Workers a timer belongs to the request
706
+ * that set it and dies with it, which would strand the other waiters. */
707
+ function waitWithTimeout(gate: MissGate): Promise<boolean> {
708
+ if (CACHE_WAIT_TIMEOUT <= 0) return gate.promise.then(() => true);
709
+ const left = Math.max(0, CACHE_WAIT_TIMEOUT - (performance.now() - gate.start));
710
+ return new Promise<boolean>((resolve) => {
711
+ const timer = setTimeout(() => {
712
+ gate.expired = true;
713
+ resolve(false);
714
+ }, left);
715
+ (timer as { unref?: () => void }).unref?.();
716
+ gate.promise.then(() => {
717
+ clearTimeout(timer);
718
+ resolve(true);
719
+ });
720
+ });
373
721
  }
374
722
 
375
723
  // ─── Serve a cache hit ───────────────────────────────────
@@ -380,9 +728,9 @@ export function serveCached(entry: CacheEntry, req: Request): Response {
380
728
  // override them instead of getting comma-joined by Headers.
381
729
  const headers: Record<string, string> = {
382
730
  "content-type": entry.contentType,
383
- vary: "Accept-Encoding",
384
731
  "x-bosia-cache": "HIT",
385
732
  ...entry.extraHeaders,
733
+ vary: varyWithEncoding(entry.extraHeaders.vary),
386
734
  };
387
735
  let body: Bytes = entry.raw;
388
736
  let encoding: "br" | "gzip" | null = null;