diff --git a/docs/content/1.guide/5.cache.md b/docs/content/1.guide/5.cache.md index d14ec492d3..256aa29456 100644 --- a/docs/content/1.guide/5.cache.md +++ b/docs/content/1.guide/5.cache.md @@ -75,6 +75,10 @@ The response will be cached for 10 second and a stale value will be sent to the The cached answer will be store in development inside `.nitro/cache/handlers/_/*.json`. +::alert{type=primary} +By default, all incoming request headers are dropped when handling cached responses. If you define the `varies` option, only the specified headers will be considered when caching and serving the responses. +:: + ### Function Cache for 1 hour the result of a function fetching the GitHub stars for a repository: @@ -221,3 +225,5 @@ The `cachedEventHandler` and `cachedFunction` functions accept the following opt - Type: `Function` - `shouldBypassCache`: A function that returns a boolean to bypass the current cache without invalidating the existing entry. - Type: `Function` +- `varies`: An array of request headers to be considered for the cache + - Type: `string[]` diff --git a/src/runtime/cache.ts b/src/runtime/cache.ts index eeeca41bd6..881abe205e 100644 --- a/src/runtime/cache.ts +++ b/src/runtime/cache.ts @@ -182,31 +182,39 @@ export interface CachedEventHandlerOptions shouldBypassCache?: (event: H3Event) => boolean; getKey?: (event: H3Event) => string | Promise; headersOnly?: boolean; + varies?: string[]; } -function escapeKey(key: string) { - // eslint-disable-next-line unicorn/prefer-string-replace-all - return key.replace(/[^\dA-Za-z]/g, ""); +function escapeKey(key: string | string[]) { + return String(key).replace(/\W/g, ""); } export function defineCachedEventHandler( handler: EventHandler, opts: CachedEventHandlerOptions = defaultCacheOptions ): EventHandler { + const variableHeaderNames = (opts.varies || []) + .filter(Boolean) + .map((h) => h.toLowerCase()) + .sort(); + const _opts: CacheOptions> = { ...opts, getKey: async (event: H3Event) => { - const key = await opts.getKey?.(event); - if (key) { - return escapeKey(key); + // Custom user-defined key + const customKey = await opts.getKey?.(event); + if (customKey) { + return escapeKey(customKey); } - const url = event._originalPath || event.path; - const friendlyName = escapeKey(decodeURI(parseURL(url).pathname)).slice( - 0, - 16 - ); - const urlHash = hash(url); - return `${friendlyName}.${urlHash}`; + // Auto-generated key + const _path = event._originalPath || event.path; + const _pathname = + escapeKey(decodeURI(parseURL(_path).pathname)).slice(0, 16) || "index"; + const _hashedPath = `${_pathname}.${hash(_path)}`; + const _headers = variableHeaderNames + .map((header) => [header, event.node.req.headers[header]]) + .map(([name, value]) => `${escapeKey(name)}.${hash(value)}`); + return [_hashedPath, ..._headers].join(":"); }, validate: (entry) => { if (entry.value.code >= 400) { @@ -223,8 +231,16 @@ export function defineCachedEventHandler( const _cachedHandler = cachedFunction>( async (incomingEvent: H3Event) => { + // Only pass headers which are defined in opts.varies + const variableHeaders: Record = {}; + for (const header of variableHeaderNames) { + variableHeaders[header] = incomingEvent.node.req.headers[header]; + } + // Create proxies to avoid sharing state with user request - const reqProxy = cloneWithProxy(incomingEvent.node.req, { headers: {} }); + const reqProxy = cloneWithProxy(incomingEvent.node.req, { + headers: variableHeaders, + }); const resHeaders: Record = {}; let _resSendBody; const resProxy = cloneWithProxy(incomingEvent.node.res, {