diff --git a/.changeset/cache-key-overhaul.md b/.changeset/cache-key-overhaul.md new file mode 100644 index 0000000..3012d69 --- /dev/null +++ b/.changeset/cache-key-overhaul.md @@ -0,0 +1,13 @@ +--- +'@web-widget/shared-cache': major +--- + +Fix cache key safety, HTTP semantics, and Vary handling: + +- **Reduce wrong cache hits from digest collisions** — cookie/header/device values are hashed with a single standard SHA-1 digest over a canonical string, instead of per-value truncated digests that could collide at scale. +- **Stop `http` and `https` from sharing the same entry** — URL scheme is included in default keys so representations are not mixed across schemes (RFC 9111 URI identity). +- **Make Vary-aware keys unambiguous** — use explicit `|v|` / `|vary|` suffixes so base keys, Vary metadata, and variants cannot be parsed incorrectly. +- **Keep keys debuggable without exposing secrets** — fragment key names stay visible (`#cookie:a|header:x-id@…`); only normalized values are digested. +- **Align cache lookup options with Workers Cache API** — `match()` / `delete()` support `ignoreMethod` only; ignore query strings via `cacheKeyRules.search: false`, and bypass Vary via `sharedCache.ignoreVary`. + +**Breaking:** Existing cache entries miss until they expire or are revalidated. Set `scheme: false` to keep the previous host-first URL shape. Remove `cacheKeyPartDefiners` / `SharedCacheKeyPartDefiners`; use built-in `cacheKeyRules` only. diff --git a/README.md b/README.md index 71b8bd4..19c097a 100644 --- a/README.md +++ b/README.md @@ -692,13 +692,7 @@ sharedCache: { } ``` -**Default cache key rules:** - -```typescript -{ - search: true, -} -``` +**Default cache key rules:** `scheme`, `host`, `pathname`, and `search` are all enabled. Keys look like `https://example.com/path?a=1`. Set `scheme: false` only if a reverse proxy always presents `http:` internally. ### Cache Key Components @@ -1256,24 +1250,7 @@ interface Cache { **Options Parameter Differences:** -SharedCache's `CacheQueryOptions` interface differs from the standard Web Cache API: - -```typescript -interface CacheQueryOptions { - ignoreSearch?: boolean; // ❌ Not implemented - throws error - ignoreMethod?: boolean; // ✅ Supported - ignoreVary?: boolean; // ❌ Not implemented - throws error -} -``` - -**Supported Options:** - -- **✅ `ignoreMethod`**: Treat request as GET regardless of actual HTTP method - -**Unsupported Options (throw errors):** - -- **❌ `ignoreSearch`**: Query string handling not customizable -- **❌ `ignoreVary`**: Vary header processing not bypassable (Note: This option is actually supported in SharedCache) +`match()` and `delete()` support `ignoreMethod` only—the same subset as the [Cloudflare Workers Cache API](https://developers.cloudflare.com/workers/runtime-apis/cache/). To ignore query strings, set `cacheKeyRules.search: false`. To bypass Vary processing, set `sharedCache.ignoreVary: true`. ### 📊 Compliance Summary @@ -1287,7 +1264,7 @@ interface CacheQueryOptions { ### 🛡️ Production-Grade Implementation - **Professional HTTP Semantics**: Powered by `http-cache-semantics` for RFC compliance -- **Intelligent Cache Strategies**: Advanced cache key generation with URL normalization +- **Configurable Cache Keys**: Rules for URL parts, cookies, headers, and custom fragments - **Robust Error Handling**: Comprehensive exception handling with graceful degradation - **Performance Optimized**: Efficient storage backends with configurable TTL diff --git a/src/cache-key.test.ts b/src/cache-key.test.ts index c697e81..6a35f72 100644 --- a/src/cache-key.test.ts +++ b/src/cache-key.test.ts @@ -3,6 +3,7 @@ import { createCacheKeyGenerator, header, vary, + type SharedCacheKeyRules, } from './cache-key'; it('should support base: host + pathname + search', async () => { @@ -15,6 +16,42 @@ it('should support base: host + pathname + search', async () => { expect(key).toBe('localhost/?a=1'); }); +it('should include scheme by default', async () => { + const keyGenerator = createCacheKeyGenerator(); + const key = await keyGenerator(new Request('http://localhost/?a=1')); + expect(key).toBe('http://localhost/?a=1'); +}); + +it('should distinguish http and https schemes', async () => { + const keyGenerator = createCacheKeyGenerator(); + const httpKey = await keyGenerator(new Request('http://localhost/')); + const httpsKey = await keyGenerator(new Request('https://localhost/')); + expect(httpKey).toBe('http://localhost/'); + expect(httpsKey).toBe('https://localhost/'); +}); + +describe('should support scheme', () => { + it('should work with basic functionality', async () => { + const keyGenerator = createCacheKeyGenerator(); + const key = await keyGenerator(new Request('https://localhost/api'), { + scheme: true, + host: true, + pathname: true, + }); + expect(key).toBe('https://localhost/api'); + }); + + it('should allow scheme to be disabled for reverse-proxy setups', async () => { + const keyGenerator = createCacheKeyGenerator(); + const key = await keyGenerator(new Request('https://localhost/api'), { + scheme: false, + host: true, + pathname: true, + }); + expect(key).toBe('localhost/api'); + }); +}); + it('should support built-in rules', async () => { const keyGenerator = createCacheKeyGenerator(); const key = await keyGenerator( @@ -38,7 +75,9 @@ it('should support built-in rules', async () => { search: true, } ); - expect(key).toBe('localhost/?a=1#a=356a19:desktop:x-id=a9993e'); + expect(key).toBe( + 'localhost/?a=1#cookie:a|device|header:x-id@cb15d91aab694816b937006f086f312ba6ddcce7' + ); }); it('should support filtering', async () => { @@ -59,7 +98,9 @@ it('should support filtering', async () => { header: { include: ['x-id'] }, } ); - expect(key).toBe('localhost/?a=1#x-id=a9993e'); + expect(key).toBe( + 'localhost/?a=1#header:x-id@794bdd5e049e0f23827f2b396a5f29854697d4e7' + ); }); it('should support presence or absence without including its actual value', async () => { @@ -72,25 +113,77 @@ it('should support presence or absence without including its actual value', asyn expect(key).toBe('localhost/?a&b=2'); }); -describe('should support cacheName', () => { - it('should override "default" value to empty', async () => { - const keyGenerator = createCacheKeyGenerator('default'); - const key = await keyGenerator(new Request('http://localhost/?a=1&b=2'), { +describe('should support normalize', () => { + it('should rely on URL parsing for scheme and host normalization', async () => { + const keyGenerator = createCacheKeyGenerator(); + const key = await keyGenerator(new Request('HTTP://LOCALHOST:80/api/'), { + scheme: true, + host: true, + pathname: true, + }); + expect(key).toBe('http://localhost/api/'); + }); + + it('should allow optional normalization to be disabled', async () => { + const enabled = createCacheKeyGenerator({ + pathnameLowerCase: true, + }); + const disabled = createCacheKeyGenerator(false); + + const request = new Request('http://localhost/API'); + + expect(await enabled(request, { host: true, pathname: true })).toBe( + 'localhost/api' + ); + expect(await disabled(request, { host: true, pathname: true })).toBe( + 'localhost/API' + ); + }); + + it('should remove trailing slashes when explicitly enabled', async () => { + const keyGenerator = createCacheKeyGenerator({ + trailingSlash: true, + }); + const key = await keyGenerator(new Request('http://localhost/api/'), { + host: true, + pathname: true, + }); + expect(key).toBe('localhost/api'); + }); + + it('should omit default ports via URL parsing', async () => { + const keyGenerator = createCacheKeyGenerator(); + const key = await keyGenerator(new Request('http://localhost:80/api'), { host: true, pathname: true, - search: { include: ['a', 'b'], checkPresence: ['a'] }, }); - expect(key).toBe('localhost/?a&b=2'); + expect(key).toBe('localhost/api'); + }); + + it('should strip spaces only when explicitly enabled', async () => { + const keyGenerator = createCacheKeyGenerator({ + ignoreSpaces: true, + }); + const key = await keyGenerator( + new Request('http://localhost/a%20b/?q=hello%20world'), + { + host: true, + pathname: true, + search: true, + } + ); + expect(key).toBe('localhost/ab/?q=helloworld'); }); - it('should make cacheName appear in the prefix', async () => { - const keyGenerator = createCacheKeyGenerator('custom'); - const key = await keyGenerator(new Request('http://localhost/?a=1&b=2'), { + it('should merge custom normalization options', async () => { + const keyGenerator = createCacheKeyGenerator({ + trailingSlash: true, + }); + const key = await keyGenerator(new Request('http://localhost:80/api/'), { host: true, pathname: true, - search: { include: ['a', 'b'], checkPresence: ['a'] }, }); - expect(key).toBe('custom/localhost/?a&b=2'); + expect(key).toBe('localhost/api'); }); }); @@ -107,7 +200,7 @@ describe('should support cookie', () => { cookie: true, } ); - expect(key).toBe('#a=aaf4c6'); + expect(key).toBe('#cookie:a@8d1c4eaf99062d83c7688f680ae9b16b34589a3d'); }); it('should be sorted', async () => { @@ -122,7 +215,7 @@ describe('should support cookie', () => { cookie: true, } ); - expect(key).toBe('#a=356a19&b=da4b92&c=77de68'); + expect(key).toBe('#cookie:a&b&c@32e746be69da5f9de1c1a8bbb2557c0dd59e7743'); }); it('should support filtering', async () => { @@ -137,7 +230,7 @@ describe('should support cookie', () => { cookie: { include: ['a'] }, } ) - ).toBe('#a=356a19'); + ).toBe('#cookie:a@5e5504de3f06749cb4b8b8a56c8bc4de901a0134'); expect( await createCacheKeyGenerator()( @@ -150,7 +243,7 @@ describe('should support cookie', () => { cookie: { exclude: ['a'] }, } ) - ).toBe('#b=da4b92&c=77de68'); + ).toBe('#cookie:b&c@cd9b0524a5caa8b5017cee14ea64c4e80cf28de3'); }); it('should support check presence', async () => { @@ -165,7 +258,7 @@ describe('should support cookie', () => { cookie: { include: ['a', 'b', 'c'], checkPresence: ['a'] }, } ); - expect(key).toBe('#a&b=da4b92&c=77de68'); + expect(key).toBe('#cookie:a&b&c@bda2a14e0a2ca9f346bdf2bf5db136d1b62332b5'); }); }); @@ -175,7 +268,7 @@ describe('should support device', () => { const key = await keyGenerator(new Request('http://localhost/'), { device: true, }); - expect(key).toBe('#desktop'); + expect(key).toBe('#device@6a5bb591869b46097c846f743c03e569c344330f'); }); it('should detect desktop device type', async () => { @@ -191,7 +284,7 @@ describe('should support device', () => { device: true, } ); - expect(key).toBe('#desktop'); + expect(key).toBe('#device@6a5bb591869b46097c846f743c03e569c344330f'); }); it('should detect mobile device type', async () => { @@ -207,7 +300,7 @@ describe('should support device', () => { device: true, } ); - expect(key).toBe('#mobile'); + expect(key).toBe('#device@328a8d87058f2fadfad274b5f67e92e7c63c9748'); }); it('should detect tablet device type', async () => { @@ -223,7 +316,7 @@ describe('should support device', () => { device: true, } ); - expect(key).toBe('#tablet'); + expect(key).toBe('#device@ab722fd73619a3750187f2e40468e28648d8ef0f'); }); }); @@ -240,7 +333,7 @@ describe('should support header', () => { header: true, } ); - expect(key).toBe('#a=aaf4c6'); + expect(key).toBe('#header:a@5dc69cb58b513f628315466940be67a0406d7a2b'); }); it('should be sorted', async () => { @@ -257,7 +350,7 @@ describe('should support header', () => { header: true, } ); - expect(key).toBe('#a=356a19&b=da4b92&c=77de68'); + expect(key).toBe('#header:a&b&c@5ebeaf153d436d0fab85716878214628d574188a'); }); it('should support filtering', async () => { @@ -274,7 +367,7 @@ describe('should support header', () => { header: { include: ['a'] }, } ) - ).toBe('#a=356a19'); + ).toBe('#header:a@2a9c02967216f590cbea1d4d4b8bf54345411506'); expect( await createCacheKeyGenerator()( @@ -289,7 +382,7 @@ describe('should support header', () => { header: { exclude: ['a'] }, } ) - ).toBe('#b=da4b92&c=77de68'); + ).toBe('#header:b&c@ed3ee26abfe1037a88b05351c87e5d139b2ee58d'); }); it('should support check presence', async () => { @@ -306,7 +399,7 @@ describe('should support header', () => { header: { include: ['a', 'b', 'c'], checkPresence: ['a'] }, } ); - expect(key).toBe('#a&b=da4b92&c=77de68'); + expect(key).toBe('#header:a&b&c@670f9f5b3cbd9505a360703fe0467649ff8d958d'); }); it('should ignore case for header keys', async () => { @@ -322,7 +415,7 @@ describe('should support header', () => { header: true, } ); - expect(key).toBe('#a=ca9fd0&x-id=a9993e'); + expect(key).toBe('#header:a&x-id@e8acc0884f73c5034549a7afb53bde2d7266f0f4'); }); it('should not allow some headers to be included', async () => { @@ -424,53 +517,17 @@ describe('should support search', () => { }); }); -describe('should support custom key', () => { - it('should extract the contents of the header into a variable', async () => { - const keyGenerator = createCacheKeyGenerator(undefined, { - foo: async (request) => request.headers.get('x-id') || '', - }); - const key = await keyGenerator( - new Request('http://localhost/', { - headers: { - 'x-id': 'custom', - }, - }), - { - foo: true, - } - ); - expect(key).toBe('#custom'); - }); - - it('should require custom part to exist', async () => { +describe('should reject unknown cache key parts', () => { + it('should throw for unsupported rule names', async () => { const keyGenerator = createCacheKeyGenerator(); + await expect(() => keyGenerator(new Request('http://localhost/'), { foo: true, - }) + } as SharedCacheKeyRules) ).rejects.toThrow( - 'Unknown cache key part: "foo". Register a custom part definer or use a built-in part (cookie, device, header).' - ); - }); - - it('should ignore empty parts', async () => { - const keyGenerator = createCacheKeyGenerator(undefined, { - foo: async () => '', - }); - const key = await keyGenerator( - new Request('http://localhost/', { - headers: { - 'x-id': 'custom', - }, - }), - { - foo: true, - header: { - include: ['x-id'], - }, - } + 'Unknown cache key part: "foo". Use built-in parts (scheme, host, pathname, search, cookie, device, header).' ); - expect(key).toBe('#x-id=f9ac14'); }); }); @@ -485,7 +542,7 @@ describe('get header part', () => { }, }) ); - expect(key).toBe('a=356a19&b=da4b92&c=77de68'); + expect(key).toBe('a&b&c@147cb5937edc2fa8cb06a802bf0d64e0419a0fb1'); }); it('should include some', async () => { @@ -501,7 +558,7 @@ describe('get header part', () => { include: ['a', 'b'], } ); - expect(key).toBe('a=356a19&b=da4b92'); + expect(key).toBe('a&b@d53cf64e768f4ef09c806bbe12258c78211b2690'); }); it('should ignore case when filtering', async () => { @@ -517,7 +574,7 @@ describe('get header part', () => { include: ['A', 'B'], } ); - expect(key).toBe('a=356a19&b=da4b92'); + expect(key).toBe('a&b@d53cf64e768f4ef09c806bbe12258c78211b2690'); }); }); @@ -532,7 +589,7 @@ describe('get vary part', () => { }, }) ); - expect(key).toBe('a=356a19&b=da4b92&c=77de68'); + expect(key).toBe('a&b&c@147cb5937edc2fa8cb06a802bf0d64e0419a0fb1'); }); it('should include some', async () => { @@ -548,7 +605,7 @@ describe('get vary part', () => { include: ['a', 'b'], } ); - expect(key).toBe('a=356a19&b=da4b92'); + expect(key).toBe('a&b@d53cf64e768f4ef09c806bbe12258c78211b2690'); }); it('should ignore case when filtering', async () => { @@ -564,6 +621,45 @@ describe('get vary part', () => { include: ['A', 'B'], } ); - expect(key).toBe('a=356a19&b=da4b92'); + expect(key).toBe('a&b@d53cf64e768f4ef09c806bbe12258c78211b2690'); + }); +}); + +describe('sync cache key generator', () => { + it('should build default URL keys synchronously', () => { + const generator = createCacheKeyGenerator(); + const request = new Request('http://localhost/?a=1'); + + expect(generator.sync(request)).toBe('http://localhost/?a=1'); + }); + + it('should return undefined when fragments are required', () => { + const generator = createCacheKeyGenerator(); + const request = new Request('http://localhost/', { + headers: { cookie: 'a=1' }, + }); + + expect(generator.sync(request, { cookie: true })).toBeUndefined(); + }); + + it('should treat normalize true the same as the default generator', async () => { + const defaultGenerator = createCacheKeyGenerator(); + const explicitGenerator = createCacheKeyGenerator(true); + const request = new Request('http://localhost/API'); + + expect( + await explicitGenerator(request, { host: true, pathname: true }) + ).toBe(await defaultGenerator(request, { host: true, pathname: true })); + }); + + it('should omit fragments when whitelisted headers are absent', async () => { + const generator = createCacheKeyGenerator(); + const key = await generator(new Request('http://localhost/'), { + host: true, + pathname: true, + header: { include: ['x-missing'] }, + }); + + expect(key).toBe('localhost/'); }); }); diff --git a/src/cache-key.ts b/src/cache-key.ts index daeab2c..6541311 100644 --- a/src/cache-key.ts +++ b/src/cache-key.ts @@ -3,6 +3,21 @@ import { deviceType as getDeviceType } from './utils/user-agent'; import { CACHE_KEY_HEADER_NAME, CACHE_STATUS_HEADER_NAME } from './constants'; import { RequestCookies } from './utils/cookies'; +/** Separator between named cache key fragments. */ +const CACHE_KEY_FRAGMENT_SEPARATOR = '|'; + +/** Separator between fragment key names and the combined value digest. */ +const CACHE_KEY_VALUE_DIGEST_SEPARATOR = '@'; + +/** Separator between key names within a fragment. */ +const CACHE_KEY_INTRA_FRAGMENT_SEPARATOR = '&'; + +/** Separator between a base cache key and its Vary-derived suffix. @internal */ +export const CACHE_KEY_VARY_SEPARATOR = '|v|'; + +/** Suffix used to store Vary filter metadata for a base cache key. @internal */ +export const CACHE_KEY_VARY_META_SUFFIX = '|vary|'; + /** * Filter options for controlling which keys to include/exclude in cache key generation. * Used to fine-tune cache key granularity and avoid cache pollution. @@ -16,6 +31,20 @@ export interface FilterOptions { checkPresence?: string[]; } +/** + * Optional cache-key normalization beyond what the URL API already applies when + * parsing `request.url` (scheme/host case, default ports, etc.). + * @internal + */ +interface CacheKeyNormalizeOptions { + /** Remove trailing slashes from pathname (except the root path `/`) */ + trailingSlash?: boolean; + /** Lowercase pathname segments */ + pathnameLowerCase?: boolean; + /** Remove whitespace from pathname and search parameter values */ + ignoreSpaces?: boolean; +} + /** * Configuration rules for generating cache keys. * Defines which parts of the request should contribute to the cache key. @@ -32,52 +61,177 @@ export interface SharedCacheKeyRules { device?: FilterOptions | boolean; /** Use request headers as part of cache key for content negotiation */ header?: FilterOptions | boolean; + /** Use URL scheme as part of cache key (`http://`, `https://`) per RFC 9111 */ + scheme?: FilterOptions | boolean; /** Use request host as part of cache key for multi-tenant applications */ host?: FilterOptions | boolean; /** Use URL pathname as part of cache key for resource identification */ pathname?: FilterOptions | boolean; /** Use URL search parameters as part of cache key for dynamic content */ search?: FilterOptions | boolean; - /** Use custom part of cache key for application-specific logic */ - [customPart: string]: unknown | boolean | undefined; } -/** - * Function signature for custom cache key part definers. - * Allows extending cache key generation with application-specific logic. - */ -export interface SharedCacheKeyPartDefiners { - [customPart: string]: ( +type RuleValue = FilterOptions | boolean | undefined; + +/** Built-in URL part keys in processing order. @internal */ +const URL_PART_KEYS = ['scheme', 'host', 'pathname', 'search'] as const; + +/** Built-in request part keys in processing order. @internal */ +const REQUEST_PART_KEYS = ['cookie', 'device', 'header'] as const; + +/** @internal */ +const CACHE_KEY_RULE_KEYS = new Set([ + ...URL_PART_KEYS, + ...REQUEST_PART_KEYS, +]); + +type URLPartKey = (typeof URL_PART_KEYS)[number]; +type RequestPartKey = (typeof REQUEST_PART_KEYS)[number]; +type KeyValueSource = 'cookie' | 'header'; + +/** @internal */ +interface CompiledFilter { + includeOnly: boolean; + exclude?: ReadonlySet; + include?: ReadonlySet; + /** Sorted include keys for whitelist fast paths. */ + includeList?: readonly string[]; + checkPresence?: ReadonlySet; +} + +/** @internal */ +interface CompiledRule { + filter?: CompiledFilter; +} + +/** @internal */ +interface CollectedKeyValues { + keys: string; + canonicalValues: string; + displayValues: string; +} + +/** @internal */ +interface FragmentContribution { + keys: string; + canonical: string; +} + +/** @internal */ +interface CompiledFragment { + name: RequestPartKey; + filter?: CompiledFilter; +} + +/** @internal */ +interface CompiledCacheKeyPlan { + url: Partial>; + fragments: CompiledFragment[]; + /** True when the plan only needs synchronous URL assembly. */ + syncOnly: boolean; +} + +/** Per-request cache key context that lazily parses headers and cookies once. @internal */ +interface CacheKeyRequestContext { + getHeaderEntries(): [string, string][]; + getCookieEntries(): [string, string][]; +} + +/** Cache key generator with an optional synchronous fast path for URL-only rules. */ +export interface CacheKeyGenerator { + (request: Request, cacheKeyRules?: SharedCacheKeyRules): Promise; + /** + * Builds a cache key synchronously when rules only include URL parts. + * Returns `undefined` when fragments or hashing are required. + */ + sync: ( request: Request, - options?: unknown - ) => Promise | undefined; + cacheKeyRules?: SharedCacheKeyRules + ) => string | undefined; } +/** @internal */ +const cacheKeyContexts = new WeakMap(); + /** - * Internal type for built-in cache key part definers that accept FilterOptions. - * @internal + * List of HTTP headers that should not be included in cache keys. + * + * These headers are excluded for the following reasons: + * - High cardinality: Risk of cache fragmentation (Accept-*, User-Agent, Referer) + * - Cache/proxy features: Would interfere with caching logic (Cache-Control, If-*) + * - Covered by other features: Handled by dedicated cache key components (Cookie, Host) + * - Implementation details: Not relevant for cache key generation (Content-Length, Connection) + * + * Based on best practices from CDN implementations and HTTP caching specifications. */ -type BuiltInExpandedPartDefiner = ( - request: Request, - options?: FilterOptions -) => Promise; +export const CANNOT_INCLUDE_HEADERS = [ + 'accept', + 'accept-charset', + 'accept-encoding', + 'accept-datetime', + 'accept-language', + 'referer', + 'user-agent', + 'connection', + 'content-length', + 'cache-control', + 'if-match', + 'if-modified-since', + 'if-none-match', + 'if-unmodified-since', + 'range', + 'upgrade', + 'cookie', + 'host', + 'vary', + CACHE_STATUS_HEADER_NAME, + CACHE_KEY_HEADER_NAME, +] as const; + +/** @internal */ +const FORBIDDEN_HEADERS = new Set(CANNOT_INCLUDE_HEADERS); /** - * Internal registry of built-in cache key part definers. + * Returns a per-request cache key context for reusing parsed headers and cookies. * @internal */ -interface BuiltInExpandedCacheKeyPartDefiners { - [part: string]: BuiltInExpandedPartDefiner | undefined; +export function getCacheKeyContext(request: Request): CacheKeyRequestContext { + let context = cacheKeyContexts.get(request); + + if (!context) { + let headerEntries: [string, string][] | undefined; + let cookieEntries: [string, string][] | undefined; + + context = { + getHeaderEntries() { + if (!headerEntries) { + headerEntries = Array.from(request.headers.entries()).map( + ([key, value]) => [key.toLowerCase(), value] as [string, string] + ); + } + + return headerEntries; + }, + getCookieEntries() { + if (!cookieEntries) { + cookieEntries = new RequestCookies(request.headers) + .getAll() + .map(({ name, value }) => [name, value]); + } + + return cookieEntries; + }, + }; + + cacheKeyContexts.set(request, context); + } + + return context; } /** * Filters an array of key-value pairs based on include/exclude rules. * - * This function implements a filtering algorithm that: - * 1. First applies exclusion rules (blacklist) - * 2. Then applies inclusion rules (whitelist) - * 3. Finally applies presence check rules (keys without values) - * * @param array - Array of [key, value] tuples to filter * @param options - Filtering options * @returns Filtered array of [key, value] tuples @@ -86,415 +240,661 @@ export function filter( array: [key: string, value: string][], options?: FilterOptions ) { - let result = array; - const exclude = options?.exclude; - const include = options?.include; - const checkPresence = options?.checkPresence; + return applyCompiledFilter(array, compileFilterOptions(options)); +} + +/** + * Sorts an array of key-value pairs by key name (case-sensitive). + * @internal + */ +function sortEntries(array: [key: string, value: string][]) { + return array.sort((a, b) => a[0].localeCompare(b[0])); +} + +/** + * Compiles filter options into reusable lookup structures. + * @internal + */ +function compileFilterOptions( + options?: FilterOptions, + lowercaseKeys = false +): CompiledFilter | undefined { + if (!options) { + return undefined; + } + + const normalize = (values?: string[]) => + values?.map((name) => (lowercaseKeys ? name.toLowerCase() : name)); + + const include = normalize(options.include); + const exclude = normalize(options.exclude); + const checkPresence = normalize(options.checkPresence); + const includeOnly = Boolean( + include?.length && !exclude?.length && !checkPresence?.length + ); + + return { + includeOnly, + exclude: exclude ? new Set(exclude) : undefined, + include: include ? new Set(include) : undefined, + includeList: includeOnly + ? [...include!].sort((a, b) => a.localeCompare(b)) + : undefined, + checkPresence: checkPresence ? new Set(checkPresence) : undefined, + }; +} + +/** @internal */ +function compileRule(rule: RuleValue): CompiledRule | undefined { + if (!isEnabled(rule)) { + return undefined; + } + + return { + filter: rule === true ? undefined : compileFilterOptions(rule), + }; +} + +/** + * Applies compiled filter options to key/value entries. + * @internal + */ +function applyCompiledFilter( + entries: [string, string][], + compiled?: CompiledFilter, + { prefiltered = false }: { prefiltered?: boolean } = {} +) { + if (prefiltered || compiled?.includeOnly) { + return entries; + } - // Apply exclusion filter (blacklist) - if (exclude?.length) { - result = result.filter(([key]) => !exclude.includes(key)); + let result = entries; + + if (compiled?.exclude?.size) { + result = result.filter(([key]) => !compiled.exclude!.has(key)); } - // Apply inclusion filter (whitelist) - if (include?.length) { - result = result.filter(([key]) => include.includes(key)); + if (compiled?.include?.size) { + result = result.filter(([key]) => compiled.include!.has(key)); } - // Apply presence check filter (keys without values) - if (checkPresence?.length) { + if (compiled?.checkPresence?.size) { result = result.map((item) => - checkPresence.includes(item[0]) ? [item[0], ''] : item + compiled.checkPresence!.has(item[0]) ? [item[0], ''] : item ); } - return result; + return sortEntries(result); } /** - * Generates a short hash (6 characters) from input data. - * Used to create compact cache key components while maintaining uniqueness. - * - * @param data - Data to hash - * @returns Promise resolving to 6-character hash string + * Resolves normalize options for cache key generation. * @internal */ -async function shortHash(data: string) { - return (await sha1(data))?.slice(0, 6); +function resolveNormalizeOptions( + normalize?: boolean | CacheKeyNormalizeOptions +): CacheKeyNormalizeOptions { + if (normalize === false) { + return {}; + } + + if (typeof normalize === 'object') { + return { ...normalize }; + } + + return {}; } /** - * Sorts an array of key-value pairs by key name (case-sensitive). - * Ensures consistent cache key generation regardless of input order. - * - * @param array - Array of [key, value] tuples to sort - * @returns Sorted array of [key, value] tuples + * Applies optional normalization on top of the URL returned by `new URL()`. * @internal */ -function sort(array: [key: string, value: string][]) { - return array.sort((a, b) => a[0].localeCompare(b[0])); +function normalizeUrl(url: URL, options: CacheKeyNormalizeOptions = {}): URL { + if ( + !options.trailingSlash && + !options.pathnameLowerCase && + !options.ignoreSpaces + ) { + return url; + } + + const normalized = new URL(url); + let pathname = normalized.pathname; + + if (options.pathnameLowerCase) { + pathname = pathname.toLowerCase(); + } + + if (options.trailingSlash && pathname.length > 1 && pathname.endsWith('/')) { + pathname = pathname.replace(/\/+$/, '') || '/'; + } + + if (options.ignoreSpaces) { + pathname = pathname.replace(/%20/gi, '').replace(/\s+/g, ''); + } + + normalized.pathname = pathname; + + if (options.ignoreSpaces && normalized.search) { + const params = new URLSearchParams(normalized.search); + const canonical = new URLSearchParams(); + + for (const [key, value] of params.entries()) { + canonical.append(key, value.replace(/\s+/g, '')); + } + + canonical.sort(); + normalized.search = canonical.toString(); + } + + return normalized; +} + +/** @internal */ +function isEnabled(rule: RuleValue): rule is true | FilterOptions { + return rule !== false && rule !== undefined; +} + +async function formatHashedSegment( + keys: string, + canonicalValues: string +): Promise { + return `${keys}${CACHE_KEY_VALUE_DIGEST_SEPARATOR}${await sha1(canonicalValues)}`; } /** - * Converts FilterOptions string arrays to lowercase for case-insensitive matching. - * Used for HTTP headers which are case-insensitive per RFC 7230. - * - * @param options - Filter options to convert - * @returns New FilterOptions with lowercase strings + * Reads whitelisted cookie or header entries without scanning all values. * @internal */ -function toLowerCase(options?: FilterOptions) { - if (typeof options === 'object') { - const newOptions: FilterOptions = { - include: options.include?.map((name) => name.toLowerCase()), - exclude: options.exclude?.map((name) => name.toLowerCase()), - checkPresence: options.checkPresence?.map((name) => name.toLowerCase()), - }; - return newOptions; +function readIncludedKeyValueEntries( + request: Request, + source: KeyValueSource, + includeList: readonly string[] +): [string, string][] { + const entries: [string, string][] = []; + + if (source === 'cookie') { + const cookies = new RequestCookies(request.headers); + + for (const name of includeList) { + const cookie = cookies.get(name); + + if (cookie) { + entries.push([name, cookie.value]); + } + } + + return entries; } - return options; + + for (const name of includeList) { + const value = request.headers.get(name); + + if (value !== null) { + entries.push([name, value]); + } + } + + return entries; } /** - * Generates a cache key component based on request cookies. - * - * This function creates a deterministic cache key part from HTTP cookies, - * which is useful for personalized content caching. Cookie values are hashed - * to protect sensitive information while maintaining cache effectiveness. - * - * @param request - The HTTP request containing cookies - * @param options - Optional filtering rules for cookie selection - * @returns Promise resolving to cookie-based cache key component (format: "name1=hash1&name2=hash2") + * Reads cookie or header entries for cache key generation. + * @internal */ -export async function cookie(request: Request, options?: FilterOptions) { - const cookie = new RequestCookies(request.headers); - const entries: [string, string][] = cookie - .getAll() - .map(({ name, value }) => [name, value]); +function readKeyValueEntries( + request: Request, + source: KeyValueSource, + compiled?: CompiledFilter, + context = getCacheKeyContext(request) +): [string, string][] { + if (compiled?.includeOnly && compiled.includeList) { + return readIncludedKeyValueEntries(request, source, compiled.includeList); + } + + if (source === 'cookie') { + return context.getCookieEntries(); + } - return ( - await Promise.all( - sort(filter(entries, options)).map(async ([key, value]) => - value ? `${key}=${await shortHash(value)}` : key - ) - ) - ).join('&'); + return context.getHeaderEntries(); } /** - * Generates a cache key component based on device type detection. - * - * This function identifies the device type from User-Agent headers - * and includes it in the cache key for responsive content delivery. - * Useful for serving different content to mobile, tablet, and desktop devices. - * - * @param request - The HTTP request containing User-Agent header - * @param options - Optional filtering rules for device type - * @returns Promise resolving to device-based cache key component + * Collects sorted key names and canonical/display value pairs from filtered entries. + * @internal */ -export async function device(request: Request, options?: FilterOptions) { - const device = getDeviceType(request.headers); - return filter([[device, '']], options) - .map(([key]) => key) - .join(''); +function prepareKeyValueEntries( + entries: [string, string][], + compiled?: CompiledFilter, + { + prefiltered = false, + forbiddenKeys, + }: { + prefiltered?: boolean; + forbiddenKeys?: ReadonlySet; + } = {} +): CollectedKeyValues | undefined { + const filtered = applyCompiledFilter(entries, compiled, { prefiltered }); + + if (!filtered.length) { + return undefined; + } + + const keyParts: string[] = []; + const canonicalParts: string[] = []; + const displayParts: string[] = []; + + for (const [key, value] of filtered) { + if (forbiddenKeys?.has(key)) { + throw new TypeError( + `Cannot include header "${key}" in cache key. This header is excluded to prevent cache fragmentation or conflicts with other cache features.` + ); + } + + keyParts.push(key); + canonicalParts.push(`${key}=${value}`); + displayParts.push(value ? `${key}=${value}` : key); + } + + const separator = CACHE_KEY_INTRA_FRAGMENT_SEPARATOR; + + return { + keys: keyParts.join(separator), + canonicalValues: canonicalParts.join(separator), + displayValues: displayParts.join(separator), + }; } /** - * Generates a cache key component based on the request host. - * - * This function extracts the host from the URL for multi-tenant applications - * where different hosts serve different content. - * - * @param url - The request URL containing the host - * @param options - Optional filtering rules for host inclusion - * @returns Host-based cache key component + * Formats request key/value pairs as a hashed `keys@digest` segment. + * @internal */ -export function host(url: URL, options?: FilterOptions) { - const host = url.host; - return filter([[host, '']], options) - .map(([key]) => key) - .join(''); +async function formatHashedKeyValues( + request: Request, + compiled: CompiledFilter | undefined, + source: KeyValueSource, + forbidden = false +): Promise { + const collected = prepareKeyValueEntries( + readKeyValueEntries(request, source, compiled), + compiled, + { + prefiltered: compiled?.includeOnly, + forbiddenKeys: forbidden ? FORBIDDEN_HEADERS : undefined, + } + ); + + if (!collected) { + return ''; + } + + return formatHashedSegment(collected.keys, collected.canonicalValues); } /** - * Generates a cache key component based on the URL pathname. - * - * This function extracts the pathname for resource-based cache differentiation. - * Essential for most caching scenarios as different paths represent different resources. - * - * @param url - The request URL containing the pathname - * @param options - Optional filtering rules for pathname inclusion - * @returns Pathname-based cache key component + * Applies FilterOptions to a single scalar cache key component. + * @internal */ -export function pathname(url: URL, options?: FilterOptions) { - const pathname = url.pathname; - return filter([[pathname, '']], options) - .map(([key]) => key) - .join(''); +function scalarPart(value: string, compiled?: CompiledFilter) { + if (!compiled) { + return value; + } + + if (compiled.includeOnly && compiled.include) { + return compiled.include.has(value) ? value : ''; + } + + return applyCompiledFilter([[value, '']], compiled)[0]?.[0] ?? ''; +} + +/** @internal */ +function validateCacheKeyRules(rules: SharedCacheKeyRules) { + for (const key of Object.keys(rules)) { + if (!CACHE_KEY_RULE_KEYS.has(key)) { + throw new TypeError( + `Unknown cache key part: "${key}". Use built-in parts (${[...CACHE_KEY_RULE_KEYS].join(', ')}).` + ); + } + } +} + +/** @internal */ +function compileCacheKeyPlan(rules: SharedCacheKeyRules): CompiledCacheKeyPlan { + validateCacheKeyRules(rules); + + const { scheme, host, pathname, search, cookie, device, header } = rules; + const fragments: CompiledFragment[] = []; + + for (const name of REQUEST_PART_KEYS) { + const rule = { cookie, device, header }[name]; + + if (!isEnabled(rule)) { + continue; + } + + fragments.push({ + name, + filter: + name === 'header' + ? compileFilterOptions(rule === true ? undefined : rule, true) + : compileFilterOptions(rule === true ? undefined : rule), + }); + } + + return { + url: { + scheme: compileRule(scheme), + host: compileRule(host), + pathname: compileRule(pathname), + search: compileRule(search), + }, + fragments, + syncOnly: fragments.length === 0, + }; } /** - * Generates a cache key component based on URL search parameters. - * - * This function extracts and sorts query parameters to create consistent - * cache keys for dynamic content. Parameters are sorted alphabetically - * to ensure consistent key generation regardless of parameter order. - * - * @param url - The request URL containing search parameters - * @param options - Optional filtering rules for parameter selection - * @returns Search parameter-based cache key component (format: "?param1=value1¶m2=value2") + * Generates a cache key component based on the URL scheme. + * @internal */ -export function search(url: URL, options?: FilterOptions) { - const { searchParams } = url; - searchParams.sort(); +export function scheme(url: URL, compiled?: CompiledRule) { + return scalarPart(`${url.protocol}//`, compiled?.filter); +} - const entries = Array.from(searchParams.entries()); - const search = filter(entries, options) - .map(([key, value]) => { - return value ? `${key}=${value}` : key; - }) - .join('&'); - return search ? `?${search}` : ''; +/** + * Generates a cache key component based on the request host. + * @internal + */ +export function host(url: URL, compiled?: CompiledRule) { + return scalarPart(url.host, compiled?.filter); } /** - * Generates a cache key component based on HTTP Vary header processing. - * - * This function implements the HTTP Vary header semantics as defined in RFC 7231. - * It creates cache key components from request headers that are listed in the - * response's Vary header, enabling proper content negotiation caching. - * - * Header names are converted to lowercase for case-insensitive comparison - * as per HTTP specification (RFC 7230 Section 3.2). - * - * @param request - The HTTP request containing headers to process - * @param options - Optional filtering rules for header selection - * @returns Promise resolving to vary-based cache key component (format: "header1=hash1&header2=hash2") + * Generates a cache key component based on the URL pathname. + * @internal */ -export async function vary(request: Request, options?: FilterOptions) { - const entries = Array.from(request.headers.entries()); - return ( - await Promise.all( - sort(filter(entries, toLowerCase(options))).map( - async ([key, value]) => `${key}=${await shortHash(value)}` - ) - ) - ).join('&'); +export function pathname(url: URL, compiled?: CompiledRule) { + return scalarPart(url.pathname, compiled?.filter); } /** - * List of HTTP headers that should not be included in cache keys. - * - * These headers are excluded for the following reasons: - * - High cardinality: Risk of cache fragmentation (Accept-*, User-Agent, Referer) - * - Cache/proxy features: Would interfere with caching logic (Cache-Control, If-*) - * - Covered by other features: Handled by dedicated cache key components (Cookie, Host) - * - Implementation details: Not relevant for cache key generation (Content-Length, Connection) - * - * Based on best practices from CDN implementations and HTTP caching specifications. + * Generates a cache key component based on URL search parameters. + * @internal */ -export const CANNOT_INCLUDE_HEADERS = [ - // Headers that have high cardinality and risk cache fragmentation - 'accept', - 'accept-charset', - 'accept-encoding', - 'accept-datetime', - 'accept-language', - 'referer', - 'user-agent', - // Headers that implement cache or proxy features - 'connection', - 'content-length', - 'cache-control', - 'if-match', - 'if-modified-since', - 'if-none-match', - 'if-unmodified-since', - 'range', - 'upgrade', - // Headers that are covered by other cache key features - 'cookie', - 'host', - 'vary', - // Headers that contain cache status information - CACHE_STATUS_HEADER_NAME, - CACHE_KEY_HEADER_NAME, -] as const; +export function search(url: URL, compiled?: CompiledRule) { + const searchParams = new URLSearchParams(url.search); + const filter = compiled?.filter; + let entries: [string, string][]; + + if (filter?.includeOnly && filter.includeList) { + entries = []; + + for (const key of filter.includeList) { + const value = searchParams.get(key); + + if (value !== null) { + entries.push([key, value]); + } + } + } else { + searchParams.sort(); + entries = Array.from(searchParams.entries()); + } + + const collected = prepareKeyValueEntries(entries, filter, { + prefiltered: filter?.includeOnly, + }); + + return collected ? `?${collected.displayValues}` : ''; +} /** - * Generates a cache key component based on request headers. - * - * This function creates cache key components from HTTP request headers, - * useful for content negotiation and custom header-based caching. - * Header values are hashed to keep cache keys compact while preventing - * cache pollution from high-cardinality headers. - * - * Certain headers are automatically excluded to prevent cache fragmentation - * and conflicts with other cache features. - * - * @param request - The HTTP request containing headers to process - * @param options - Optional filtering rules for header selection - * @returns Promise resolving to header-based cache key component - * @throws {TypeError} When attempting to include a forbidden header - */ -export async function header(request: Request, options?: FilterOptions) { - const entries = Array.from(request.headers.entries()); - return ( - await Promise.all( - sort(filter(entries, toLowerCase(options))).map(async ([key, value]) => { - if ((CANNOT_INCLUDE_HEADERS as readonly string[]).includes(key)) { - throw new TypeError( - `Cannot include header "${key}" in cache key. This header is excluded to prevent cache fragmentation or conflicts with other cache features.` - ); - } - return value ? `${key}=${await shortHash(value)}` : key; - }) - ) - ).join('&'); + * Generates a cache key component based on request cookies. + * @internal + */ +export function cookie(request: Request, options?: FilterOptions) { + return formatHashedKeyValues( + request, + compileFilterOptions(options), + 'cookie' + ); } /** - * Registry of built-in URL-based cache key part definers. - * These functions operate on URL components and don't require async operations. + * Generates a cache key component based on device type detection. * @internal */ -const BUILT_IN_URL_PART_DEFINERS: { - [key: string]: (url: URL, options?: FilterOptions) => string; -} = { - host, - pathname, - search, -}; +export function device(request: Request, options?: FilterOptions) { + return scalarPart( + getDeviceType(request.headers), + compileFilterOptions(options) + ); +} /** - * List of built-in URL part keys in processing order. + * Generates a cache key component based on request headers. * @internal */ -const BUILT_IN_URL_PART_KEYS = ['host', 'pathname', 'search']; +export function header(request: Request, options?: FilterOptions) { + return formatHashedKeyValues( + request, + compileFilterOptions(options, true), + 'header', + true + ); +} /** - * Registry of built-in expanded cache key part definers. - * These functions require async operations and work with request data. + * Generates a cache key component based on HTTP Vary header processing. * @internal */ -const BUILT_IN_EXPANDED_PART_DEFINERS: BuiltInExpandedCacheKeyPartDefiners = { - cookie, - device, - header, -}; +export function vary(request: Request, options?: FilterOptions) { + return formatHashedKeyValues( + request, + compileFilterOptions(options, true), + 'header' + ); +} /** * Default cache key generation rules. - * - * Includes the most common cache key components that work for most HTTP caching scenarios: - * - host: Enables multi-tenant caching - * - pathname: Differentiates resources - * - search: Handles query parameters - * - * These defaults provide a good balance between cache effectiveness and key uniqueness. */ export const DEFAULT_CACHE_KEY_RULES: SharedCacheKeyRules = { + scheme: true, host: true, pathname: true, search: true, }; /** - * Creates a cache key generator function with customizable rules and part definers. - * - * This factory function creates a highly configurable cache key generator that can - * be tailored for specific application needs. The generated function follows a - * consistent key format: `[cacheName/]host+pathname+search[#fragment1:fragment2:...]` - * - * Cache key structure: - * - Base URL parts (host, pathname, search) are concatenated directly - * - Fragment parts (cookie, device, header, custom) are hashed and joined with ":" - * - Fragments are appended after "#" if any exist - * - Cache name prefix is added if specified (except for "default") - * - * @param cacheName - Optional cache namespace (omitted if "default") - * @param cacheKeyPartDefiners - Optional custom part definers for extending functionality - * @returns A cache key generator function that accepts requests and rules - * - * @example - * ```typescript - * const generator = createCacheKeyGenerator('api-cache'); - * const key = await generator(request, { host: true, pathname: true, cookie: true }); - * // Result: "api-cache/example.com/api/users?limit=10#cookie=abc123" - * ``` + * Builds the URL portion of a cache key from enabled URL rules. + * @internal + */ +function buildUrlSegment(url: URL, urlRules: CompiledCacheKeyPlan['url']) { + const segments: string[] = []; + + for (const name of URL_PART_KEYS) { + const rule = urlRules[name]; + if (!rule) { + continue; + } + + switch (name) { + case 'scheme': + segments.push(scheme(url, rule)); + break; + case 'host': + segments.push(host(url, rule)); + break; + case 'pathname': + segments.push(pathname(url, rule)); + break; + case 'search': + segments.push(search(url, rule)); + break; + } + } + + return segments.join(''); +} + +/** + * Collects a named key/value fragment contribution. + * @internal + */ +function collectNamedKeyValuesFragment( + name: KeyValueSource, + request: Request, + compiled?: CompiledFilter +): FragmentContribution | undefined { + const collected = prepareKeyValueEntries( + readKeyValueEntries(request, name, compiled), + compiled, + { + prefiltered: compiled?.includeOnly, + forbiddenKeys: name === 'header' ? FORBIDDEN_HEADERS : undefined, + } + ); + + if (!collected) { + return undefined; + } + + return { + keys: `${name}:${collected.keys}`, + canonical: `${name}:${collected.canonicalValues}`, + }; +} + +/** + * Collects a scalar fragment contribution. + * @internal + */ +function collectScalarFragment( + name: string, + value: string, + compiled?: CompiledFilter +): FragmentContribution | undefined { + if (!scalarPart(value, compiled)) { + return undefined; + } + + return { + keys: name, + canonical: `${name}:${value}`, + }; +} + +/** + * Builds a named fragment segment for request-based cache key parts. + * @internal + */ +function buildFragmentContribution( + fragment: CompiledFragment, + request: Request +): FragmentContribution | undefined { + if (fragment.name === 'device') { + return collectScalarFragment( + fragment.name, + getDeviceType(request.headers), + fragment.filter + ); + } + + return collectNamedKeyValuesFragment(fragment.name, request, fragment.filter); +} + +/** + * Builds the fragment suffix with visible key names and one combined digest. + * @internal + */ +async function buildFragmentSuffix( + contributions: FragmentContribution[] +): Promise { + const keys = contributions + .map((part) => part.keys) + .join(CACHE_KEY_FRAGMENT_SEPARATOR); + const canonical = contributions + .map((part) => part.canonical) + .join(CACHE_KEY_FRAGMENT_SEPARATOR); + + return `${keys}${CACHE_KEY_VALUE_DIGEST_SEPARATOR}${await sha1(canonical)}`; +} + +/** + * Builds a cache key synchronously when only URL parts are enabled. + * @internal + */ +function buildCacheKeySync( + request: Request, + cacheKeyRules: SharedCacheKeyRules, + resolvedNormalize: CacheKeyNormalizeOptions +): string | undefined { + const plan = compileCacheKeyPlan(cacheKeyRules); + + if (!plan.syncOnly) { + return undefined; + } + + const url = normalizeUrl(new URL(request.url), resolvedNormalize); + return buildUrlSegment(url, plan.url); +} + +/** + * Builds a cache key, using the synchronous fast path when possible. + * @internal + */ +async function buildCacheKey( + request: Request, + cacheKeyRules: SharedCacheKeyRules, + resolvedNormalize: CacheKeyNormalizeOptions +): Promise { + const plan = compileCacheKeyPlan(cacheKeyRules); + const url = normalizeUrl(new URL(request.url), resolvedNormalize); + const baseKey = buildUrlSegment(url, plan.url); + + if (plan.syncOnly) { + return baseKey; + } + + getCacheKeyContext(request); + + const contributions: FragmentContribution[] = []; + + for (const fragment of plan.fragments) { + const contribution = buildFragmentContribution(fragment, request); + + if (contribution) { + contributions.push(contribution); + } + } + + return contributions.length + ? `${baseKey}#${await buildFragmentSuffix(contributions)}` + : baseKey; +} + +/** + * Creates a cache key generator function with customizable rules. */ export function createCacheKeyGenerator( - cacheName?: string, - cacheKeyPartDefiners?: SharedCacheKeyPartDefiners -) { - return async function cacheKeyGenerator( + cacheKeyNormalize?: boolean | CacheKeyNormalizeOptions +): CacheKeyGenerator { + const resolvedNormalize = resolveNormalizeOptions(cacheKeyNormalize); + + const cacheKeyGenerator = async function cacheKeyGenerator( request: Request, cacheKeyRules: SharedCacheKeyRules = DEFAULT_CACHE_KEY_RULES ): Promise { - // Separate URL parts from fragment parts for different processing - const { host, pathname, search, ...fragmentRules } = cacheKeyRules; - - // Generate cache name prefix (empty for "default" cache) - const prefix = cacheName - ? cacheName === 'default' - ? '' - : `${cacheName}/` - : ''; - - const urlRules: SharedCacheKeyRules = { host, pathname, search }; - const url = new URL(request.url); - - // Process URL-based parts (synchronous, concatenated directly) - const urlPart: string[] = BUILT_IN_URL_PART_KEYS.filter( - (name) => urlRules[name] - ).map((name) => { - const urlPartDefiner = BUILT_IN_URL_PART_DEFINERS[name]; - const options = cacheKeyRules[name]; - - if (options === true) { - return urlPartDefiner(url); - } else if (options === false) { - return ''; - } else { - return urlPartDefiner(url, options as FilterOptions); - } - }); + return buildCacheKey(request, cacheKeyRules, resolvedNormalize); + }; - // Process fragment parts (asynchronous, hashed and joined with ":") - const fragmentPart = ( - await Promise.all( - Object.keys(fragmentRules) - .sort() // Ensure consistent ordering - .map((name) => { - const expandedCacheKeyPartDefiners = - BUILT_IN_EXPANDED_PART_DEFINERS[name] ?? - cacheKeyPartDefiners?.[name]; - - if (expandedCacheKeyPartDefiners) { - const options = cacheKeyRules[name]; - - if (options === true) { - return expandedCacheKeyPartDefiners(request); - } else if (options === false) { - return ''; - } else { - return expandedCacheKeyPartDefiners( - request, - options as FilterOptions - ); - } - } - - throw new TypeError( - `Unknown cache key part: "${name}". Register a custom part definer or use a built-in part (${Object.keys(BUILT_IN_EXPANDED_PART_DEFINERS).join(', ')}).` - ); - }) - ) - ).filter(Boolean); // Remove empty parts - - // Combine URL parts and fragment parts into final cache key - return fragmentPart.length - ? `${prefix}${urlPart.join('')}#${fragmentPart.join(':')}` - : `${prefix}${urlPart.join('')}`; + cacheKeyGenerator.sync = function cacheKeyGeneratorSync( + request: Request, + cacheKeyRules: SharedCacheKeyRules = DEFAULT_CACHE_KEY_RULES + ) { + return buildCacheKeySync(request, cacheKeyRules, resolvedNormalize); }; + + return cacheKeyGenerator; } diff --git a/src/cache-storage.test.ts b/src/cache-storage.test.ts index 6eb15af..75ece43 100644 --- a/src/cache-storage.test.ts +++ b/src/cache-storage.test.ts @@ -27,6 +27,37 @@ describe('SharedCacheStorage', () => { cacheStorage = new SharedCacheStorage(createCacheStore()); }); + it('should require a storage backend', () => { + expect(() => new SharedCacheStorage(null as unknown as KVStorage)).toThrow( + 'Storage backend is required for SharedCacheStorage.' + ); + }); + + it('should reject unimplemented CacheStorage methods', async () => { + await expect(cacheStorage.delete('api')).rejects.toThrow( + 'SharedCacheStorage.delete() is not implemented.' + ); + await expect(cacheStorage.has('api')).rejects.toThrow( + 'SharedCacheStorage.has() is not implemented.' + ); + await expect(cacheStorage.keys()).rejects.toThrow( + 'SharedCacheStorage.keys() is not implemented.' + ); + await expect(cacheStorage.match('http://localhost/')).rejects.toThrow( + 'SharedCacheStorage.match() is not implemented.' + ); + }); + + it('should inherit default options when opening caches', async () => { + const storage = new SharedCacheStorage(createCacheStore(), { + cacheKeyRules: { search: false }, + }); + const cache = await storage.open('api'); + + const key = await cache.getCacheKey(new Request('http://localhost/?a=1')); + expect(key).toBe('http://localhost/'); + }); + it('should open a cache', async () => { const cache1 = await cacheStorage.open('1'); const cache2 = await cacheStorage.open('2'); @@ -40,4 +71,27 @@ describe('SharedCacheStorage', () => { const cache2 = await cacheStorage.open('1'); expect(cache2).toBe(cache1); }); + + it('should isolate named caches while keeping URL-shaped keys', async () => { + const cache1 = await cacheStorage.open('api-v1'); + const cache2 = await cacheStorage.open('static'); + + const request = new Request('http://localhost/resource'); + const key1 = await cache1.getCacheKey(request); + const key2 = await cache2.getCacheKey(request); + + expect(key1).toBe('http://localhost/resource'); + expect(key2).toBe(key1); + + const cacheable = (body: string) => + new Response(body, { + headers: { 'cache-control': 'max-age=300' }, + }); + + await cache1.put(request, cacheable('from api')); + await cache2.put(request, cacheable('from static')); + + expect(await (await cache1.match(request))!.text()).toBe('from api'); + expect(await (await cache2.match(request))!.text()).toBe('from static'); + }); }); diff --git a/src/cache.test.ts b/src/cache.test.ts index 86f2f70..2eb5938 100644 --- a/src/cache.test.ts +++ b/src/cache.test.ts @@ -401,6 +401,26 @@ describe('SharedCache', () => { expect(matched!.status).toBe(200); }); + it('should return 200 when If-None-Match is present but the cached response has no ETag', async () => { + const request = new Request('https://example.com/conditional-no-etag'); + await cache.put( + request, + createTestResponse('cached data', 200, { + 'cache-control': 'max-age=300', + }) + ); + + const matched = await cache.match( + new Request('https://example.com/conditional-no-etag', { + headers: { 'if-none-match': '"anything"' }, + }) + ); + + expect(matched).toBeDefined(); + expect(matched!.status).toBe(200); + expect(matched!.headers.get(CACHE_STATUS_HEADER_NAME)).toBe(HIT); + }); + it('should return 200 when If-None-Match does not match the cached ETag', async () => { const request = new Request('https://example.com/conditional-etag-miss'); await cache.put( @@ -708,6 +728,23 @@ describe('SharedCache', () => { const result = await cache.delete(request, { ignoreMethod: true }); expect(result).toBe(true); }); + + it('should delete Vary-aware cache entries and their base keys', async () => { + const request = new Request('https://example.com/vary-delete', { + headers: { 'accept-language': 'en' }, + }); + + await cache.put( + request, + createTestResponse('localized body', 200, { + vary: 'accept-language', + 'cache-control': 'max-age=300', + }) + ); + + await cache.delete(request); + expect(await cache.match(request)).toBeUndefined(); + }); }); describe('Vary header support', () => { @@ -770,6 +807,15 @@ describe('SharedCache', () => { expect(customCache).toBeInstanceOf(SharedCache); }); + + it('should reject ignoreSearch in match options', async () => { + const request = new Request('https://example.com/items?sort=asc'); + await expect( + cache.match(request, { ignoreSearch: true }) + ).rejects.toThrow( + 'SharedCache.match() not implemented option: "ignoreSearch".' + ); + }); }); describe('HTTP compliance', () => { diff --git a/src/cache.ts b/src/cache.ts index 8e1f515..ce93fe2 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -13,11 +13,18 @@ import type { import type { CachePolicyObject } from '@web-widget/http-cache-semantics'; import { createLogger, StructuredLogger } from './utils/logger'; import { + CACHE_KEY_VARY_META_SUFFIX, + CACHE_KEY_VARY_SEPARATOR, createCacheKeyGenerator, DEFAULT_CACHE_KEY_RULES, + getCacheKeyContext, vary as getVary, } from './cache-key'; -import type { FilterOptions } from './cache-key'; +import type { + CacheKeyGenerator, + FilterOptions, + SharedCacheKeyRules, +} from './cache-key'; import { CACHE_STATUS_HEADER_NAME, EXPIRED, @@ -28,6 +35,31 @@ import { } from './constants'; import { CachePolicy } from './utils/cache-semantics'; +/** Separates cache namespace from the URL-shaped cache key in storage. */ +const CACHE_NAMESPACE_SEPARATOR = '\x1f'; + +/** + * Prefixes storage keys with a cache namespace without changing the logical cache key. + * @internal + */ +function createNamespacedStorage( + storage: KVStorage, + cacheName?: string +): KVStorage { + if (!cacheName || cacheName === 'default') { + return storage; + } + + const prefix = `${cacheName}${CACHE_NAMESPACE_SEPARATOR}`; + + return { + get: (cacheKey) => storage.get(`${prefix}${cacheKey}`), + set: (cacheKey, value, ttl) => + storage.set(`${prefix}${cacheKey}`, value, ttl), + delete: (cacheKey) => storage.delete(`${prefix}${cacheKey}`), + }; +} + /** * SharedCache implements the Cache interface with additional features for shared caching. * It provides HTTP-compliant caching with support for revalidation, stale-while-revalidate, @@ -36,8 +68,11 @@ import { CachePolicy } from './utils/cache-semantics'; * This implementation follows HTTP caching semantics as defined in RFC 7234 and related specifications. */ export class SharedCache implements WebCache { - /** Cache key generator function for creating consistent cache keys */ - #cacheKeyGenerator: (request: SharedCacheRequest) => Promise; + /** Cache key generator factory */ + #cacheKeyGeneratorFactory: CacheKeyGenerator; + + /** Base cache key rules configured for this cache instance */ + #defaultCacheKeyRules: SharedCacheKeyRules; /** Structured logger instance with consistent formatting */ #structuredLogger: ReturnType>; @@ -62,16 +97,14 @@ export class SharedCache implements WebCache { }; const cacheKeyGenerator = createCacheKeyGenerator( - resolvedOptions._cacheName, - resolvedOptions.cacheKeyPartDefiners + resolvedOptions._cacheKeyNormalize ); - this.#cacheKeyGenerator = async (request) => - cacheKeyGenerator(request, { - ...DEFAULT_CACHE_KEY_RULES, - ...resolvedOptions.cacheKeyRules, - ...request.sharedCache?.cacheKeyRules, - }); + this.#cacheKeyGeneratorFactory = cacheKeyGenerator; + this.#defaultCacheKeyRules = { + ...DEFAULT_CACHE_KEY_RULES, + ...resolvedOptions.cacheKeyRules, + }; // Optimize logger initialization: avoid double wrapping if already a StructuredLogger if (resolvedOptions.logger instanceof StructuredLogger) { @@ -84,7 +117,10 @@ export class SharedCache implements WebCache { resolvedOptions.logger ); } - this.#storage = storage; + this.#storage = createNamespacedStorage( + storage, + resolvedOptions._cacheName + ); } /** @@ -100,6 +136,24 @@ export class SharedCache implements WebCache { return this.#cacheKeyGenerator(resolvedRequest); } + /** + * Generates a cache key for a request using resolved rules. + * @internal + */ + #cacheKeyGenerator(request: SharedCacheRequest): Promise { + const rules = { + ...this.#defaultCacheKeyRules, + ...request.sharedCache?.cacheKeyRules, + }; + const syncKey = this.#cacheKeyGeneratorFactory.sync(request, rules); + + if (syncKey !== undefined) { + return Promise.resolve(syncKey); + } + + return this.#cacheKeyGeneratorFactory(request, rules); + } + /** * The add() method is not implemented in this cache implementation. * This method is part of the Cache interface but not commonly used in practice. @@ -733,7 +787,7 @@ export class SharedCache implements WebCache { /** * Validates cache query options, throwing errors for unsupported features. - * Currently ignoreSearch and ignoreVary are not implemented. + * Aligns with Cloudflare Workers Cache API: only `ignoreMethod` is supported. * * @param options - Cache query options to validate * @throws {Error} If unsupported options are specified @@ -1021,7 +1075,7 @@ async function getVaryFilterOptions( storage: KVStorage, customCacheKey: string ): Promise { - const varyKey = `${customCacheKey}:vary`; + const varyKey = `${customCacheKey}${CACHE_KEY_VARY_META_SUFFIX}`; return (await storage.get(varyKey)) as FilterOptions | undefined; } @@ -1050,7 +1104,7 @@ async function getAndSaveVaryFilterOptions( return; } - const varyKey = `${customCacheKey}:vary`; + const varyKey = `${customCacheKey}${CACHE_KEY_VARY_META_SUFFIX}`; const varyFilterOptions: FilterOptions = { include: vary.split(',').map((field) => field.trim()), }; @@ -1077,6 +1131,9 @@ async function getVaryCacheKey( return customCacheKey; } + getCacheKeyContext(request); const varyPart = await getVary(request, varyFilterOptions); - return varyPart ? `${customCacheKey}:${varyPart}` : customCacheKey; + return varyPart + ? `${customCacheKey}${CACHE_KEY_VARY_SEPARATOR}${varyPart}` + : customCacheKey; } diff --git a/src/constants.ts b/src/constants.ts index 09c37c7..6eeb359 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,42 +1,45 @@ -import { SharedCacheStatus } from './types'; - /** - * HTTP header name for cache status information. - * This non-standard header is used to communicate cache hit/miss status. + * HTTP header names and cache-status values for SharedCache. + * + * Cache-key domain constants (`DEFAULT_CACHE_KEY_RULES`, `CANNOT_INCLUDE_HEADERS`) + * live in `cache-key.ts` and are re-exported from the package entry. */ + +/** HTTP header name for cache status information. */ export const CACHE_STATUS_HEADER_NAME = 'x-cache-status'; -/** - * HTTP header name for debugging cache key information. - * This non-standard header is used to expose the computed cache key. - */ +/** HTTP header name for debugging cache key information. */ export const CACHE_KEY_HEADER_NAME = 'x-cache-key'; -/** - * Cache status constants as defined in HTTP caching specifications. - * These represent the various states of cache operations. - */ - -/** Response served from cache without validation */ -export const HIT: SharedCacheStatus = 'HIT'; - -/** Response not found in cache, fetched from origin */ -export const MISS: SharedCacheStatus = 'MISS'; - -/** Cached response was expired, fresh response fetched */ -export const EXPIRED: SharedCacheStatus = 'EXPIRED'; - -/** Stale response served when origin is unreachable (stale-if-error) */ -export const STALE: SharedCacheStatus = 'STALE'; - -/** Expired response served while revalidating in the background (stale-while-revalidate) */ -export const UPDATING: SharedCacheStatus = 'UPDATING'; - -/** Cache was bypassed due to cache-control directives */ -export const BYPASS: SharedCacheStatus = 'BYPASS'; - -/** Cached response was revalidated and determined still fresh */ -export const REVALIDATED: SharedCacheStatus = 'REVALIDATED'; - -/** Response is dynamic and cannot be cached */ -export const DYNAMIC: SharedCacheStatus = 'DYNAMIC'; +/** Canonical cache status literals. */ +export const SHARED_CACHE_STATUS = { + /** Response served from cache without validation */ + HIT: 'HIT', + /** Response not found in cache, fetched from origin */ + MISS: 'MISS', + /** Cached response was expired, fresh response fetched */ + EXPIRED: 'EXPIRED', + /** Stale response served when origin is unreachable (stale-if-error) */ + STALE: 'STALE', + /** Expired response served while revalidating in the background */ + UPDATING: 'UPDATING', + /** Cache was bypassed due to cache-control directives */ + BYPASS: 'BYPASS', + /** Cached response was revalidated and determined still fresh */ + REVALIDATED: 'REVALIDATED', + /** Response is dynamic and cannot be cached */ + DYNAMIC: 'DYNAMIC', +} as const; + +/** Cache status values as defined in HTTP caching standards. */ +export type SharedCacheStatus = + (typeof SHARED_CACHE_STATUS)[keyof typeof SHARED_CACHE_STATUS]; + +export const HIT = SHARED_CACHE_STATUS.HIT; +export const MISS = SHARED_CACHE_STATUS.MISS; +export const EXPIRED = SHARED_CACHE_STATUS.EXPIRED; +export const STALE = SHARED_CACHE_STATUS.STALE; +export const UPDATING = SHARED_CACHE_STATUS.UPDATING; +export const BYPASS = SHARED_CACHE_STATUS.BYPASS; +export const REVALIDATED = SHARED_CACHE_STATUS.REVALIDATED; +export const DYNAMIC = SHARED_CACHE_STATUS.DYNAMIC; diff --git a/src/fetch.test.ts b/src/fetch.test.ts index 3bfbb47..0702c32 100644 --- a/src/fetch.test.ts +++ b/src/fetch.test.ts @@ -1920,13 +1920,17 @@ describe('Vary Header Handling', () => { sharedCache: { debugCacheKey: true }, }); expect(first.headers.get('x-cache-status')).toBe(MISS); - expect(first.headers.get(CACHE_KEY_HEADER_NAME)).toBe('localhost/'); + expect(first.headers.get(CACHE_KEY_HEADER_NAME)).toBe( + 'http://localhost/' + ); const second = await fetch(TEST_URL, { sharedCache: { debugCacheKey: true }, }); expect(second.headers.get('x-cache-status')).toBe(HIT); - expect(second.headers.get(CACHE_KEY_HEADER_NAME)).toBe('localhost/'); + expect(second.headers.get(CACHE_KEY_HEADER_NAME)).toBe( + 'http://localhost/' + ); }); it('should not expose cache key when debugCacheKey is disabled', async () => { @@ -1969,7 +1973,7 @@ describe('Vary Header Handling', () => { expect(response.headers.get('x-cache-status')).toBe(MISS); expect(response.headers.get(CACHE_KEY_HEADER_NAME)).toMatch( - /^localhost\/:accept-language=[a-f0-9]{6}$/ + /^http:\/\/localhost\/\|v\|accept-language@[a-f0-9]{40}$/ ); }); @@ -1992,7 +1996,7 @@ describe('Vary Header Handling', () => { }); const cacheKeyHeader = response.headers.get(CACHE_KEY_HEADER_NAME); - expect(cacheKeyHeader).toBe('localhost/?q=hello%0Aworld'); + expect(cacheKeyHeader).toBe('http://localhost/?q=hello%0Aworld'); expect(() => new Headers().set(CACHE_KEY_HEADER_NAME, cacheKeyHeader!) ).not.toThrow(); diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 0000000..8001c4f --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,28 @@ +import { + BYPASS, + CACHE_KEY_HEADER_NAME, + CACHE_STATUS_HEADER_NAME, + Cache, + CacheStorage, + CANNOT_INCLUDE_HEADERS, + createCacheKeyGenerator, + createFetch, + DEFAULT_CACHE_KEY_RULES, + HIT, + SHARED_CACHE_STATUS, +} from './index'; + +describe('package entry', () => { + it('should re-export the public API', () => { + expect(Cache).toBeDefined(); + expect(CacheStorage).toBeDefined(); + expect(createFetch).toEqual(expect.any(Function)); + expect(createCacheKeyGenerator).toEqual(expect.any(Function)); + expect(DEFAULT_CACHE_KEY_RULES.scheme).toBe(true); + expect(CANNOT_INCLUDE_HEADERS).toContain('cookie'); + expect(HIT).toBe(SHARED_CACHE_STATUS.HIT); + expect(BYPASS).toBe(SHARED_CACHE_STATUS.BYPASS); + expect(CACHE_STATUS_HEADER_NAME).toBe('x-cache-status'); + expect(CACHE_KEY_HEADER_NAME).toBe('x-cache-key'); + }); +}); diff --git a/src/index.ts b/src/index.ts index f68ae37..3351fb4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,31 +35,19 @@ export { SharedCache as Cache } from './cache'; export { SharedCacheStorage as CacheStorage } from './cache-storage'; -// Fetch integration export { createSharedCacheFetch as createFetch, sharedCacheFetch as fetch, } from './fetch'; -// Middleware-friendly cache resolution export { createCacheHandler, resolveWithCache } from './resolve'; -// Cache key utilities export { createCacheKeyGenerator, DEFAULT_CACHE_KEY_RULES, - filter, - cookie, - device, - header, - host, - pathname, - search, - vary, CANNOT_INCLUDE_HEADERS, } from './cache-key'; -// Logger utilities export { createLogger, createSharedCacheLogger, @@ -68,7 +56,6 @@ export { LogLevel, } from './utils/logger'; -// Type definitions export type { KVStorage, SharedCacheLogContext, @@ -82,18 +69,12 @@ export type { CacheOriginHandler, CacheResolveOptions, CacheHandler, -} from './types'; - -export type { Logger } from './utils/logger'; - -// Cache key types -export type { + CacheKeyGenerator, FilterOptions, SharedCacheKeyRules, - SharedCacheKeyPartDefiners, -} from './cache-key'; + Logger, +} from './types'; -// Constants export { BYPASS, CACHE_KEY_HEADER_NAME, @@ -103,6 +84,7 @@ export { HIT, MISS, REVALIDATED, + SHARED_CACHE_STATUS, STALE, UPDATING, } from './constants'; diff --git a/src/resolve.test.ts b/src/resolve.test.ts index 96625f8..5ff7314 100644 --- a/src/resolve.test.ts +++ b/src/resolve.test.ts @@ -197,7 +197,7 @@ describe('resolveWithCache', () => { }); expect(response.headers.get('x-cache-status')).toBe(HIT); - expect(response.headers.get('x-cache-key')).toContain('localhost/'); + expect(response.headers.get('x-cache-key')).toContain('http://localhost/'); }); it('should skip vary-specific cache key suffixes when ignoreVary is enabled', async () => { @@ -219,7 +219,7 @@ describe('resolveWithCache', () => { ignoreVary: true, }); - expect(response.headers.get('x-cache-key')).toBe('localhost/'); + expect(response.headers.get('x-cache-key')).toBe('http://localhost/'); }); it('should keep the base cache key when vary is wildcard', async () => { @@ -239,7 +239,7 @@ describe('resolveWithCache', () => { debugCacheKey: true, }); - expect(response.headers.get('x-cache-key')).toBe('localhost/'); + expect(response.headers.get('x-cache-key')).toBe('http://localhost/'); }); it('should serve stale content when revalidation aborts via outer signal', async () => { @@ -288,6 +288,6 @@ describe('createCacheHandler', () => { expect(response.headers.get('cache-control')).toBe( 'max-age=10, s-maxage=30' ); - expect(response.headers.get('x-cache-key')).toBe('localhost/'); + expect(response.headers.get('x-cache-key')).toBe('http://localhost/'); }); }); diff --git a/src/resolve.ts b/src/resolve.ts index 68c8d91..a137bf5 100644 --- a/src/resolve.ts +++ b/src/resolve.ts @@ -1,4 +1,8 @@ -import { vary as getVaryCachePart } from './cache-key'; +import { + CACHE_KEY_VARY_SEPARATOR, + getCacheKeyContext, + vary as getVaryCachePart, +} from './cache-key'; import { vary } from './utils/vary'; import { cacheControl } from './utils/cache-control'; import { @@ -83,8 +87,11 @@ async function getEffectiveCacheKey( return cacheKey; } + getCacheKeyContext(request); const varyPart = await getVaryCachePart(request, { include }); - return varyPart ? `${cacheKey}:${varyPart}` : cacheKey; + return varyPart + ? `${cacheKey}${CACHE_KEY_VARY_SEPARATOR}${varyPart}` + : cacheKey; } /** diff --git a/src/types.ts b/src/types.ts index 7876470..b115d4c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,11 +1,31 @@ /* c8 ignore start */ +/** + * Package-level type barrel. Domain types (cache key, logger) are defined in + * their modules and re-exported here; `index.ts` exposes types only via this file. + * + * Constants: HTTP/status → `constants.ts`; cache-key defaults → `cache-key.ts`. + */ import CachePolicy, { CachePolicyObject, } from '@web-widget/http-cache-semantics'; -import { SharedCacheKeyPartDefiners, SharedCacheKeyRules } from './cache-key'; +import type { SharedCacheKeyRules } from './cache-key'; import type { Logger } from './utils/logger'; -export type { SharedCacheKeyRules, SharedCacheKeyPartDefiners }; +/** Public type surface — re-exported from the package entry via `./types`. */ +export type { + CacheKeyGenerator, + FilterOptions, + SharedCacheKeyRules, +} from './cache-key'; +export type { Logger } from './utils/logger'; +export type { SharedCacheStatus } from './constants'; + +/** @internal URL normalization options passed via `SharedCacheOptions._cacheKeyNormalize`. */ +interface CacheKeyNormalizeOptions { + trailingSlash?: boolean; + pathnameLowerCase?: boolean; + ignoreSpaces?: boolean; +} /** * Log context structure for SharedCache operations @@ -57,10 +77,12 @@ export interface SharedCacheOptions { cacheKeyRules?: SharedCacheKeyRules; /** - * Custom functions for generating cache key parts. - * Allows extending cache key generation with custom logic. + * URL normalization applied before cache key generation. + * Defaults to the normalization performed by `new URL(request.url)`. + * Set to `false` to skip optional extra normalization. + * @internal */ - cacheKeyPartDefiners?: SharedCacheKeyPartDefiners; + _cacheKeyNormalize?: boolean | CacheKeyNormalizeOptions; /** * Custom logger for debugging and monitoring cache operations. @@ -134,28 +156,6 @@ export interface PolicyResponse { storedBody?: string; } -/** - * Cache status values as defined in HTTP caching standards. - * These represent the result of cache operations. - */ -export type SharedCacheStatus = - /** Cache hit - response served from cache */ - | 'HIT' - /** Cache miss - response fetched from origin */ - | 'MISS' - /** Cached response expired, fetched fresh response */ - | 'EXPIRED' - /** Stale response served when origin is unreachable (stale-if-error) */ - | 'STALE' - /** Expired response served while revalidating in the background (stale-while-revalidate) */ - | 'UPDATING' - /** Cache bypassed due to cache-control directives */ - | 'BYPASS' - /** Cached response revalidated and still fresh */ - | 'REVALIDATED' - /** Dynamic response that cannot be cached */ - | 'DYNAMIC'; - /** * Extended cache query options for shared cache operations. * Extends standard SharedCacheQueryOptions with shared cache specific options. diff --git a/src/utils/crypto.ts b/src/utils/crypto.ts index 4115fca..40216ad 100644 --- a/src/utils/crypto.ts +++ b/src/utils/crypto.ts @@ -1,3 +1,4 @@ +/** Computes a standard SHA-1 hex digest (40 characters). */ export const sha1 = async (data: string): Promise => { const sourceBuffer = new TextEncoder().encode(String(data)); @@ -6,8 +7,7 @@ export const sha1 = async (data: string): Promise => { } const buffer = await crypto.subtle.digest('SHA-1', sourceBuffer); - const hash = Array.prototype.map + return Array.prototype.map .call(new Uint8Array(buffer), (x) => ('00' + x.toString(16)).slice(-2)) .join(''); - return hash; }; diff --git a/src/utils/vary.test.ts b/src/utils/vary.test.ts index f831847..6cf01b0 100644 --- a/src/utils/vary.test.ts +++ b/src/utils/vary.test.ts @@ -17,6 +17,22 @@ describe('vary(res, field)', function () { }).toThrow('headers argument is required'); }); }); + + describe('field', function () { + it('should be required', function () { + const headers = new Headers(); + expect(() => { + vary(headers, ''); + }).toThrow('field argument is required'); + }); + + it('should reject invalid header names', function () { + const headers = new Headers(); + expect(() => { + vary(headers, 'bad field name'); + }).toThrow('field argument contains an invalid header name'); + }); + }); }); describe('when no Vary', function () {