Skip to content

Commit bdf8c84

Browse files
committed
fix(http)!: resolve handler name before merging defaults
1 parent 47731fb commit bdf8c84

7 files changed

Lines changed: 213 additions & 20 deletions

File tree

β€ŽAGENTS.mdβ€Ž

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Never touch contents inside `<!-- automd -->` in README.md. They are auto genera
2020
### Core caching (`cache.ts`)
2121

2222
- `defineCachedFunction(fn, opts)` β€” wraps any function with caching (SWR, TTL, integrity checks, deduplication of in-flight requests)
23-
- Cache key `name` resolves as `opts.name || fn.name || \`anon_<hash(fn)>\``. Resolved once and written back into `opts`so the internal read/write path and the`resolveKeys`/`invalidate`/`expire` helpers all agree. Named/`fn.name`functions get a stable, human-readable name; anonymous functions fall back to a source hash so distinct inline fns don't collide (they would otherwise share one key and thrash). Caveat: a source hash can't tell apart same-source fns that differ only by closed-over variables β€” pass an explicit`name`(or`getKey`) for those. When reconstructing keys via the standalone `resolveCacheKeys`/`invalidateCache`/`expireCache`helpers (which can't see`fn`), always pass the same `name` you cached with
23+
- Cache key `name` resolves as `opts.name || fn.name || \`anon_<hash(fn)>\``β€” in`_resolveName`, an internal exported from `cache.ts`and **shared with`http.ts`** (which passes the wrapped `EventHandler`as`fn`) so the two paths cannot drift; both call it on the caller's options *before* the `defaultCacheOptions()`merge, which is the whole point (see the`http.ts`bullet). Resolved once and written back into`opts`so the internal read/write path and the`resolveKeys`/`invalidate`/`expire` helpers all agree. Named/`fn.name`functions get a stable, human-readable name; anonymous functions fall back to a source hash so distinct inline fns don't collide (they would otherwise share one key and thrash). Caveat: a source hash can't tell apart same-source fns that differ only by closed-over variables β€” pass an explicit`name`(or`getKey`) for those. When reconstructing keys via the standalone `resolveCacheKeys`/`invalidateCache`/`expireCache`helpers (which can't see`fn`), always pass the same `name` you cached with
2424
- `getMaxAge(entry)` option β€” dynamic per-entry TTL: runs after the resolver, returns a number (seconds, shorthand for `maxAge`) or `{ maxAge?, staleMaxAge? }` that override the static options for that entry. Resolved values are persisted on the entry (`CacheEntry.maxAge` / `CacheEntry.staleMaxAge`) and drive both the read freshness check and the storage TTL. Absent field / `undefined` β†’ falls back to static options. Flows through to `defineCachedHandler`, where it runs before the internal `serialize` hook so `entry.value` is the live `Response` (inspect its headers/status; don't consume its body β€” `serialize` reads it exactly once)
2525
- `serialize(entry, { args })` option β€” write-side counterpart of `transform`: runs once right after the resolver (and after `getMaxAge`, so that hook still sees the raw value) and returns the value to persist. `transform` deserializes it back on read. Use for resolver outputs a storage backend can't persist as-is (raw `ReadableStream`, class instances). It runs **exactly once per resolution** β€” shared across concurrent, deduplicated calls (every caller observes the serialized value), so consuming a one-shot source like a stream is safe. A throwing `serialize` fails the call and evicts, like a rejected resolver. Both `getMaxAge` and `serialize` are folded into the shared in-flight (`pending`) promise so they never run more than once
2626
- The in-flight dedup registry (`pending`) is a **`Map`, never a plain object**: keys are caller-controlled (the documented `getKey: (id) => id`), and a plain object inherits from `Object.prototype`, so `pending["constructor"]` / `"toString"` / `"valueOf"` / `"hasOwnProperty"` / `"__proto__"` / `"isPrototypeOf"` / `"propertyIsEnumerable"` / `"toLocaleString"` read truthy with nothing in flight. Such a call was treated as a deduplicated follower, `await`ed the inherited member (not a thenable, so it resolved to itself), never called the resolver, and cached `undefined` β€” silently for `defineCachedFunction`, and as a hard, permanent `TypeError` for `defineCachedHandler` (whose `transform` then dereferences `undefined.headers`). Default keys can't hit it (the auto handler key always contains a `.`; `cache.ts`'s default is `hash(args)` or `""`), so it takes a custom `getKey`. Unrelated to prototype _pollution_ β€” there is none; this was unsafe prototype-chain _reading_
@@ -36,6 +36,7 @@ Never touch contents inside `<!-- automd -->` in README.md. They are auto genera
3636
### HTTP handler caching (`http.ts`)
3737

3838
- `defineCachedHandler<E extends HTTPEvent>(handler, opts)` β€” wraps an `EventHandler` with response caching (generic over event type)
39+
- Cache key `name`: resolved with `cache.ts`'s shared `_resolveName(opts.name, handler)` **before** the `defaultCacheOptions()` merge, exactly like `defineCachedFunction`. Merging first (the old order) left `opts.name` permanently `"_"` by the time `cachedFunction` re-derived it, so issue #53's fix never reached the HTTP layer and **every** handler keyed as `name: "_"`. Two handlers sharing one `storage` (the configuration `types.ts` recommends) that can see the same path then collided: identical source β‡’ identical integrity β‡’ one served the other's cached response (cross-handler leak); differing source β‡’ every read failed the other's integrity check β‡’ permanent thrash at 0% hit rate. **Breaking key change**, not a behavior change: every existing handler entry goes cold once (the key gains a real name segment). The `HEAD:` method component still sits directly after the name segment, so key shape is otherwise unchanged. Same **documented caveat** as `defineCachedFunction`, and it bites harder here because the shared-`storage` setup turns it into a leak: `hash(handler)` sees only source, so handlers from one factory (`(tenant) => defineCachedHandler(() => render(tenant), { storage })`) share a name _and_ an integrity β€” pass an explicit `name` per instance. A per-instance discriminator (counter/WeakMap/randomness) is inadmissible: keys must be deterministic across process restarts for persistent/shared backends, which is exactly why a source hash is the right fallback
3940
- Split along the `serialize` seam (built on `cache.ts`'s `cachedFunction<Response>`): the **resolver** narrows the request and returns the handler's live `Response`; the internal **`serialize`** hook consumes the body (`res.arrayBuffer()`), synthesizes `etag`/`last-modified`/`cache-control`/`Vary`, strips every `Set-Cookie`, and builds the stored `ResponseCacheEntry`; **`transform`** reconstructs the servable shape and injects the cache-status header on read. `serialize`/`validate`/`transform` are `Omit`ted from the user-facing `CachedEventHandlerOptions` so internal use doesn't collide
4041
- Binary responses: `serialize` reads the body as bytes and decides by **byte validity, not content-type** β€” a valid-UTF-8 body (fatal `TextDecoder` with `ignoreBOM`, lossless roundtrip) is stored verbatim as a string (unchanged text behavior, stable text etags); anything else (images, protobuf/MVT tiles, arbitrary binary) is base64-encoded and flagged `base64: true` on the `ResponseCacheEntry`. Base64 (not a raw `Uint8Array`) so binary bodies survive JSON-serializing storage backends. On read, a `base64` entry is decoded back to a `Uint8Array` before `createResponse`, so the exact bytes replay untouched. `createResponse` therefore receives `string | Uint8Array | null` (widened from `string | null`)
4142
- Cache key = **resource identity + method component**. `_resolveKey` derives the method-free resource part (auto: URL path + `varies` header values + allowlisted cookies; or the caller's `getKey`), then `_methodKey` prefixes it: **GET is the implicit default and carries no component**, every other cacheable method contributes `<METHOD>:` β€” today exactly `HEAD:` (`_cacheableMethods`, the enumerable counterpart of `_shouldBypassCache`; making another method cacheable is a one-line addition there, not a key redesign). Closes the h3#1524 audit finding #3 poisoning: GET and HEAD used to share one entry, and a spec-compliant host `toResponse` (h3's, and every real framework's) nulls the body of a HEAD response β€” exactly the `Response` `serialize` stores β€” so one anonymous `HEAD /page` seeded the shared entry with a zero-byte body, a synthesized `public, max-age=N` and a weak etag over that empty body, and every GET for the rest of the TTL got a blank 200 that CDNs/browsers cached and successfully revalidated. Applied around **both** key branches (a custom `getKey` expresses content identity; preventing the method collision is ocache's job either way). Deliberate scoping: GET keys are byte-identical to pre-fix ones so existing entries stay warm, and the cost is one origin dispatch per method per resource per TTL. Non-cacheable methods never reach `getKey`

β€Ždocs/1.guide/4.storage.mdβ€Ž

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ const getPosts = defineCachedFunction(fetchPosts, { maxAge: 60, storage });
8181

8282
They still key entries by `base`/`group`/`name`, so sharing a backend does not mean sharing entries β€” it means sharing one connection, one eviction budget, and one place to purge.
8383

84+
> [!IMPORTANT]
85+
> The `name` is what separates them, and it defaults to the function's or [handler's](/guide/handler#cache-key-name) own name, falling back to a hash of its source. Instances built by a factory or a loop have identical source, so they resolve to the **same** name β€” on a shared storage they then share entries. Give those an explicit `name`.
86+
8487
## Late Binding
8588

8689
Handlers are usually defined at module load, while the real backend only exists once the server has started. Pass a **factory** instead of an instance: it is called on the first actual cache read or write β€” never at definition time β€” and at most once per cached function or handler.

β€Ždocs/1.guide/5.handler.mdβ€Ž

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ The first request runs your handler and stores the response; subsequent requests
3434
3535
## What it does
3636

37-
- **Cache keys** β€” auto-generated from the request URL path (plus any query params and headers you opt into varying, see below) and the request method.
37+
- **Cache keys** β€” auto-generated from the handler's [name](#cache-key-name), the request URL path (plus any query params and headers you opt into varying, see below) and the request method.
3838
- **Conditional requests** β€” answers `304 Not Modified` when the client sends a matching `if-none-match` / `if-modified-since`.
3939
- **Response headers** β€” synthesizes `etag`, `last-modified`, and `cache-control` when the handler didn't set them.
4040
- **`Vary`** β€” advertises which request headers the response varies on (from `varies`, plus `Cookie` when `allowCookies` is set).
@@ -69,6 +69,41 @@ const handler = defineCachedHandler(myHandler, {
6969
});
7070
```
7171

72+
## Cache key name
73+
74+
Every key carries a `name` component, resolved exactly like a [cached function's](/guide/functions#cache-keys): `opts.name || handler.name || anon_<hash(handler)>`. It is what keeps two handlers that share one `storage` and can see the same path from colliding.
75+
76+
```ts
77+
const storage = createMemoryStorage();
78+
79+
// Named handlers β†’ "/cache:handlers:dashboard:..." and "/cache:handlers:profile:..."
80+
const dashboard = defineCachedHandler(
81+
async function dashboard(event) {
82+
return renderDashboard(event);
83+
},
84+
{ maxAge: 60, storage },
85+
);
86+
const profile = defineCachedHandler(
87+
async function profile(event) {
88+
return renderProfile(event);
89+
},
90+
{ maxAge: 60, storage },
91+
);
92+
```
93+
94+
> [!IMPORTANT]
95+
> The fallback hashes the handler **source**, so handlers produced by the same factory β€” identical source, different closed-over variables β€” share a name. Sharing a `storage` then means sharing entries: one tenant's cached response is served to another. Pass an explicit `name` (or `getKey`) whenever you build handlers in a loop or a factory.
96+
>
97+
> ```ts
98+
> // βœ— both handlers key identically
99+
> const make = (tenant) => defineCachedHandler(() => render(tenant), { maxAge: 60, storage });
100+
> // βœ“ named per instance
101+
> const make = (tenant) =>
102+
> defineCachedHandler(() => render(tenant), { maxAge: 60, name: `page-${tenant}`, storage });
103+
> ```
104+
105+
The name is derived from the handler, so a source hash is stable across restarts β€” which is what makes keys usable with a persistent backend.
106+
72107
## Varying by request headers
73108
74109
By default the cache key depends only on the URL path β€” every client shares one cached entry. List header names in `varies` to cache a separate variant per header value:

β€Žsrc/cache.tsβ€Ž

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,18 +36,8 @@ export function defineCachedFunction<T, ArgsT extends unknown[] = any[]>(
3636
fn: (...args: ArgsT) => T | Promise<T>,
3737
opts: CacheOptions<T, ArgsT> = {},
3838
): CachedFunction<T, ArgsT> {
39-
// Resolve `name` from the caller's opts BEFORE merging defaults: defaultCacheOptions()
40-
// sets a truthy `name: "_"`, so if defaults were merged first `opts.name` would always
41-
// be `"_"` and the `fn.name` fallback would be unreachable (silent cache-key collision
42-
// across every unnamed cached function β€” https://github.com/unjs/ocache/issues/53).
43-
//
44-
// For anonymous functions (no `opts.name`, no `fn.name`) fall back to a hash of the
45-
// function source instead of a shared literal: two distinct inline arrows would
46-
// otherwise resolve to the same key and thrash each other (each read fails the
47-
// integrity check and re-resolves). This can't disambiguate same-source functions
48-
// that only differ by closed-over variables β€” pass an explicit `name`/`getKey` for
49-
// those (the integrity hash collides there too, so it's unfixable from the source alone).
50-
const name = opts.name || fn.name || `anon_${hash(fn).slice(0, 16)}`;
39+
// Resolve `name` from the caller's opts BEFORE merging defaults β€” see `_resolveName`.
40+
const name = _resolveName(opts.name, fn);
5141
// Keep a handle on the caller's own options object *before* the defaults merge clones
5242
// it: that object is the memo slot for the resolved storage, so a caller who hands the
5343
// same object to `invalidateCache`/`expireCache` reaches this instance's store (see
@@ -520,6 +510,34 @@ export async function expireCache<ArgsT extends unknown[] = any[]>(
520510

521511
// --- Internal helpers ---
522512

513+
// Cache-key `name` resolution, shared by `defineCachedFunction` and `defineCachedHandler`
514+
// (which passes the wrapped `EventHandler` as `fn`) so the two paths cannot drift.
515+
// Deliberately commented with `//`, not JSDoc, so docs4ts keeps it out of the API docs.
516+
//
517+
// MUST be called on the *caller's* options, BEFORE `defaultCacheOptions()` is merged in:
518+
// those defaults set a truthy `name: "_"`, so merging first makes `opts.name` always `"_"`
519+
// and the `fn.name` fallback dead code β€” a silent cache-key collision across every unnamed
520+
// cached function/handler (https://github.com/unjs/ocache/issues/53). `defineCachedHandler`
521+
// merged its defaults first and so shipped exactly that bug for handlers: every handler
522+
// keyed as `_`, and two handlers sharing one `storage` (the configuration the `storage`
523+
// docs recommend) either thrashed each other's entries or β€” when their sources match, so
524+
// the integrity hash matches too β€” served each other's cached responses.
525+
//
526+
// For anonymous functions (no `opts.name`, no `fn.name`) fall back to a hash of the
527+
// function source instead of a shared literal: two distinct inline arrows would
528+
// otherwise resolve to the same key and thrash each other (each read fails the
529+
// integrity check and re-resolves). A source hash is the right fallback precisely because
530+
// it is *stable* β€” keys must survive a process restart for persistent/shared backends, so
531+
// nothing per-instance (counter, WeakMap, randomness) is admissible here. The cost is that
532+
// it can't disambiguate same-source functions that only differ by closed-over variables
533+
// (the classic factory: `const make = (t) => defineCachedHandler(() => render(t))`) β€” pass
534+
// an explicit `name`/`getKey` for those. The integrity hash collides there too, so it is
535+
// unfixable from the source alone, and with a shared `storage` it is a cross-instance leak
536+
// rather than mere thrash.
537+
export function _resolveName(name: string | undefined, fn: (...args: any[]) => any): string {
538+
return name || fn.name || `anon_${hash(fn).slice(0, 16)}`;
539+
}
540+
523541
// Storage for the standalone purge helpers, which β€” unlike `resolveCacheKeys` (pure key
524542
// derivation, no storage) β€” are useless without the backend the entries were written to.
525543
// Since storage became per-instance there is no ambient store to fall back on, so an unset

β€Žsrc/http.tsβ€Ž

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { hash } from "ohash";
2-
import { cachedFunction, expireCache, invalidateCache, resolveCacheKeys } from "./cache.ts";
2+
import {
3+
_resolveName,
4+
cachedFunction,
5+
expireCache,
6+
invalidateCache,
7+
resolveCacheKeys,
8+
} from "./cache.ts";
39
import { _resolveStorage } from "./storage.ts";
410

511
import type {
@@ -41,7 +47,18 @@ export function defineCachedHandler<E extends HTTPEvent = HTTPEvent>(
4147
handler: EventHandler<E>,
4248
opts: CachedEventHandlerOptions<E> = {},
4349
): CachedEventHandler<E> {
44-
opts = { ...defaultCacheOptions(), ...opts };
50+
// Resolve `name` from the caller's opts BEFORE merging the defaults below, which set a
51+
// truthy `name: "_"`. Merging first (the previous behavior) made `opts.name` always `"_"`
52+
// by the time `cachedFunction` re-derived it, so the `fn.name`/source-hash fallback was
53+
// unreachable and *every* handler keyed as `_` β€” issue #53's fix never reached the HTTP
54+
// layer. Two handlers sharing one `storage` on the same path then collided: identical
55+
// sources hash to the same integrity too, so one served the other's cached response;
56+
// differing sources failed each other's integrity check on every read (0% hit rate).
57+
// Same rule as `defineCachedFunction`, via the same helper so the two cannot drift β€” see
58+
// `_resolveName` in `cache.ts`, including the documented caveat that same-source handlers
59+
// from one factory still share a name (pass an explicit `name`).
60+
const name = _resolveName(opts.name, handler);
61+
opts = { ...defaultCacheOptions(), ...opts, name };
4562

4663
// Allowlist of cookie names that may participate in caching β€” the *request* side only.
4764
// `undefined` means "no cookies allowed": the Cookie request header is stripped before

β€Žsrc/types.tsβ€Ž

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,16 @@ export interface CacheEntry<T = any> {
8989
* Options for configuring cached functions created by `defineCachedFunction`.
9090
*/
9191
export interface CacheOptions<T = any, ArgsT extends unknown[] = any[]> {
92-
/** Name used as part of the cache key. Defaults to the function name or `"_"`. */
92+
/**
93+
* Name used as part of the cache key.
94+
*
95+
* Defaults to the cached function's (or handler's) own `name`, falling back to a hash of
96+
* its source for anonymous ones. A source hash cannot distinguish same-source functions
97+
* that differ only by a closed-over variable (the classic factory:
98+
* `const make = (tenant) => defineCachedHandler(() => render(tenant), { storage })`) β€”
99+
* those share a key, and with a shared `storage` they share entries outright. Pass an
100+
* explicit `name` whenever instances are built in a factory or a loop.
101+
*/
93102
name?: string;
94103
/** Custom cache key generator. Receives the same arguments as the cached function. */
95104
getKey?: (...args: ArgsT) => string | Promise<string>;

0 commit comments

Comments
Β (0)