diff --git a/.changeset/fuzzy-garlic-clean.md b/.changeset/fuzzy-garlic-clean.md new file mode 100644 index 000000000..2dc954c1d --- /dev/null +++ b/.changeset/fuzzy-garlic-clean.md @@ -0,0 +1,15 @@ +--- +'@hono/universal-cache': minor +--- + +Add `@hono/universal-cache`, a universal cache toolkit for Hono with: + +- `cacheMiddleware()` for response caching +- `cacheDefaults()` for scoped defaults +- `cacheFunction()` for caching async function results +- stale-if-error response fallback and stale-while-revalidate function caching +- bounded in-flight deduplication for response and function cache fills +- bounded TTL-aware in-memory storage by default +- safe response streaming, header replay, and persisted-entry validation +- storage/default accessors (`set/getCacheStorage`, `set/getCacheDefaults`) +- custom keying, serialization, validation, and invalidation hooks diff --git a/deno.jsonc b/deno.jsonc index f62aa3621..f654abb81 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -39,6 +39,7 @@ // "packages/tsyringe", "packages/typebox-validator", "packages/typia-validator", + "packages/universal-cache", "packages/valibot-validator", "packages/zod-openapi", "packages/zod-validator", diff --git a/packages/universal-cache/CHANGELOG.md b/packages/universal-cache/CHANGELOG.md new file mode 100644 index 000000000..e1e3abf31 --- /dev/null +++ b/packages/universal-cache/CHANGELOG.md @@ -0,0 +1 @@ +# @hono/universal-cache diff --git a/packages/universal-cache/README.md b/packages/universal-cache/README.md new file mode 100644 index 000000000..7b9abe01f --- /dev/null +++ b/packages/universal-cache/README.md @@ -0,0 +1,191 @@ +# @hono/universal-cache + +[![codecov](https://codecov.io/github/honojs/middleware/graph/badge.svg?flag=universal-cache)](https://codecov.io/github/honojs/middleware) + +Storage-agnostic response and function caching for Hono. + +## Features + +- Response caching with `cacheMiddleware()` +- Function result caching with `cacheFunction()` +- Request-scoped defaults with `cacheDefaults()` +- In-flight deduplication for response and function cache fills +- Stale-while-revalidate for cached functions +- Custom storage, keys, integrity values, serialization, and validation +- Explicit bypass, invalidation, and manual revalidation hooks +- Node.js, Bun, Deno, and Cloudflare Workers-compatible Web APIs + +## Installation + +```sh +pnpm add @hono/universal-cache +``` + +## Response caching + +```ts +import { Hono } from 'hono' +import { cacheMiddleware } from '@hono/universal-cache' + +const app = new Hono() + +app.get('/items', cacheMiddleware(60), (c) => c.json({ ok: true })) +``` + +Passing a number is shorthand for `{ maxAge: number }`. `GET` and `HEAD` are cached by default. + +## Storage and defaults + +The default storage is an in-memory `unstorage` instance scoped to the current process or isolate. It expires entries and is limited to 1,000 entries, 50 MiB total, and 5 MiB per entry. Configure a persistent or distributed driver for multi-instance deployments. + +Cache reads and removals fail open after five seconds. Cache mutations for the same key are ordered within one process or isolate, so a slower older write cannot replace a newer result. Storage operations cannot be cancelled, however, and coordination is scoped to the same `Storage` object. Distributed deployments and separate storage clients must use a backend with appropriate atomic writes, compare-and-set, or locking when multiple writers can update the same key. Custom serializers and storage drivers should still bound their own work. + +```ts +import { Hono } from 'hono' +import { cacheDefaults, cacheMiddleware } from '@hono/universal-cache' +import { createStorage } from 'unstorage' +import redisDriver from 'unstorage/drivers/redis' + +const app = new Hono() + +app.use( + '/api/*', + cacheDefaults({ + storage: createStorage({ driver: redisDriver({ url: process.env.REDIS_URL }) }), + maxAge: 60, + staleMaxAge: 30, + }) +) + +app.get('/api/items', cacheMiddleware(), (c) => c.json({ ok: true })) +``` + +`cacheDefaults()` applies defaults to downstream cache middleware for the current request. Route-local options override request-scoped and process-wide defaults. Use `setCacheDefaults()` and `setCacheStorage()` for process-wide defaults, including `cacheFunction()`. + +`setCacheDefaults()` replaces the current defaults. Call `setCacheDefaults({})` to reset them. Cached functions resolve global defaults when called, so later changes apply to existing wrappers. Options passed directly to `cacheFunction()` continue to take precedence. + +## Cache keys + +Default response keys include: + +- HTTP method and origin +- URL path and query +- request body for explicitly enabled non-`GET`/`HEAD` methods +- configured `varies` headers + +Requests containing `authorization` or `cookie` are not cached by default. To cache them, explicitly include the header in `varies` or provide a custom `getKey`. + +Range, conditional, and client no-cache requests bypass cache reads and writes so the application can apply their HTTP semantics. + +Use `getKey` when the cache identity depends on application-specific context: + +```ts +cacheMiddleware({ + getKey: (c) => `${c.req.param('tenant')}:${c.req.query('page') ?? '1'}`, + maxAge: 60, +}) +``` + +`getKey` replaces the complete default key. Include every relevant tenant, authorization, cookie, method, body, and variation value when providing one. A custom key opts credentialed requests into caching, so it owns their isolation. + +When a response uses `Vary`, list every corresponding request header in `varies`, including when using `getKey`. Responses with `Vary: *` or an unlisted `Vary` field are never cached. + +## Manual revalidation + +Manual revalidation is disabled by default. Enable it with a private header name and gate it with `shouldRevalidate`: + +```ts +cacheMiddleware({ + revalidateHeader: 'x-my-cache-revalidate', + shouldRevalidate: (c) => c.req.header('x-cache-token') === process.env.CACHE_TOKEN, +}) +``` + +A request with `x-my-cache-revalidate: 1` refreshes the entry only when `shouldRevalidate` allows it. Do not expose an ungated revalidation header on public endpoints. +Use a dedicated gate header when possible. An authorized revalidation runs the route handler with the original request headers, so do not cache a personalized response under a public key. + +## Bypass and invalidation + +```ts +cacheMiddleware({ + shouldBypassCache: (c) => c.req.header('x-preview') === '1', + shouldInvalidateCache: (c) => c.req.query('refresh') === '1', + keepPreviousOn5xx: true, +}) +``` + +- `shouldBypassCache` skips both cache reads and writes. +- `shouldInvalidateCache` skips the current entry and refreshes it. +- `keepPreviousOn5xx` preserves the previous entry when an invalidation refresh returns a 5xx response. It preserves function entries when the wrapped function throws. + +## Stale-while-revalidate + +```ts +cacheMiddleware({ + maxAge: 60, + staleMaxAge: 300, +}) +``` + +After `maxAge`, middleware entries refresh synchronously on every runtime. If the refresh throws or returns a 5xx response, the middleware serves the previous response while it remains within `staleMaxAge`. Use `staleMaxAge: -1` for unlimited stale fallback with a persistent storage driver. + +Function caches use `swr: true` by default. They serve stale values and refresh them in the background. Set `swr: false` on `cacheFunction()` to refresh synchronously. + +## Function caching + +```ts +import { cacheFunction } from '@hono/universal-cache' + +const getStats = cacheFunction(async (id: string) => ({ id, ts: Date.now() }), { + name: 'get-stats', + maxAge: 60, + getKey: (id) => id, +}) +``` + +Without `getKey`, arguments are deterministically serialized with type information and hashed. This distinguishes values such as a `Date` from the same ISO string, `0` from `-0`, and supports common values including `Map`, `Set`, and `BigInt`. `Map` and `Set` insertion order is part of that identity. Provide `getKey` for identity-sensitive values such as symbols, functions, or sparse arrays. + +Implicit function names are process-local to prevent separate closures from sharing cached values. Set an explicit stable `name` for persistent or distributed caching across processes, and keep that name unique for each logical function. + +Concurrent calls for the same storage, key, and integrity value share one in-flight operation. Different storage instances remain isolated. + +Default function serialization uses JSON through `unstorage`. It persists only values that round-trip without changing meaning: `null`, strings, booleans, finite numbers other than negative zero, dense arrays, and plain objects containing those values. Unsupported results are returned normally but are not cached. Values such as `NaN`, `Infinity`, negative zero, `Date`, `Map`, `Set`, class instances, and `BigInt` require custom `serialize` and `deserialize` functions. + +## Custom serialization and validation + +`serialize`, `deserialize`, and `validate` can adapt stored entries or reject obsolete data. Custom response serializers must return the `CachedResponseEntry` shape, including `encoding: 'base64'`. The default response serializer stops after 3 MiB or one second and does not delay delivery while caching. Custom serializers own equivalent body and time limits. Use `integrity` to invalidate entries when their schema or behavior changes. + +## API + +- `cacheMiddleware(options | maxAge)` +- `cacheDefaults(options)` +- `cacheFunction(fn, options | maxAge)` +- `createCacheStorage({ maxEntries?, maxSize?, maxEntrySize? })` +- `setCacheStorage(storage)` / `getCacheStorage()` +- `setCacheDefaults(options)` / `getCacheDefaults()` +- `stableStringify(value)` + +Exported types include `CacheBaseOptions`, `CacheDefaults`, `CacheStorageOptions`, `CacheMiddlewareOptions`, `CacheFunctionOptions`, `CachedResponseEntry`, and `CachedFunctionEntry`. + +## Response safety + +The middleware does not cache: + +- responses outside the 2xx range or HTTP 206 partial responses +- responses containing `set-cookie` +- responses marked `private`, `no-store`, or `no-cache` +- common streaming responses such as SSE, NDJSON, JSON sequences, and mixed multipart streams +- responses containing `Vary: *` or a `Vary` header not covered by `varies` +- malformed persisted entries + +Cached responses exclude `set-cookie` and hop-by-hop headers. `Content-Length` is preserved, including for cached `HEAD` responses. +Cache hits include an `Age` header based on the stored age plus resident time. +Set `Cache-Control: no-store` on custom streaming response types so they are not buffered for caching. + +## Author + +Raed B. + +## License + +MIT diff --git a/packages/universal-cache/deno.json b/packages/universal-cache/deno.json new file mode 100644 index 000000000..ce9398c6a --- /dev/null +++ b/packages/universal-cache/deno.json @@ -0,0 +1,18 @@ +{ + "name": "@hono/universal-cache", + "version": "0.0.0", + "license": "MIT", + "exports": { + ".": "./src/index.ts" + }, + "imports": { + "hono": "jsr:@hono/hono@^4.8.3", + "lru-cache": "npm:lru-cache@^10.4.3", + "ohash": "npm:ohash@^2.0.11", + "unstorage": "npm:unstorage@1.17.3" + }, + "publish": { + "include": ["deno.json", "README.md", "src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] + } +} diff --git a/packages/universal-cache/eslint-suppressions.json b/packages/universal-cache/eslint-suppressions.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/packages/universal-cache/eslint-suppressions.json @@ -0,0 +1 @@ +{} diff --git a/packages/universal-cache/package.json b/packages/universal-cache/package.json new file mode 100644 index 000000000..e005ae332 --- /dev/null +++ b/packages/universal-cache/package.json @@ -0,0 +1,66 @@ +{ + "name": "@hono/universal-cache", + "version": "0.0.0", + "description": "Universal cache middleware and helpers for Hono", + "sideEffects": false, + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.cts", + "files": [ + "dist" + ], + "scripts": { + "build": "pnpm -w run build:pkg", + "lint": "eslint", + "typecheck": "tsc -b tsconfig.json", + "test": "vitest", + "test:workerd": "vitest --config vitest.workerd.config.ts", + "version:jsr": "pnpm -w run version:set $npm_package_version" + }, + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "license": "MIT", + "publishConfig": { + "registry": "https://registry.npmjs.org", + "access": "public", + "provenance": true, + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" + }, + "./package.json": "./package.json" + } + }, + "repository": { + "type": "git", + "url": "git+https://github.com/honojs/middleware.git", + "directory": "packages/universal-cache" + }, + "homepage": "https://github.com/honojs/middleware", + "peerDependencies": { + "hono": ">=4.8.3" + }, + "dependencies": { + "lru-cache": "^10.4.3", + "unstorage": "^1.17.3" + }, + "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.16.10", + "@cloudflare/workers-types": "^4.20250612.0", + "hono": "^4.11.5", + "ohash": "^2.0.11", + "tsdown": "^0.22.3", + "typescript": "^6.0.3", + "vitest": "^4.1.7" + }, + "engines": { + "node": ">=16.0.0" + }, + "inlinedDependencies": { + "ohash": "2.0.11" + } +} diff --git a/packages/universal-cache/src/cache.ts b/packages/universal-cache/src/cache.ts new file mode 100644 index 000000000..c9ba50575 --- /dev/null +++ b/packages/universal-cache/src/cache.ts @@ -0,0 +1,1330 @@ +import type { Context, MiddlewareHandler, Next } from 'hono' +import { getRuntimeKey } from 'hono/adapter' +import { decodeBase64, encodeBase64 } from 'hono/utils/encode' +import { LRUCache } from 'lru-cache' +import { hash as ohash } from 'ohash' +import { createStorage } from 'unstorage' +import type { Driver, Storage } from 'unstorage' +import type { + CacheDefaults, + CachedFunctionEntry, + CachedResponseEntry, + CacheFunctionOptions, + CacheMiddlewareOptions, + CacheStorageOptions, +} from './types' +import { + computeTtlSeconds, + DEFAULT_CACHE_BASE, + DEFAULT_FUNCTION_GROUP, + DEFAULT_HANDLER_GROUP, + DEFAULT_MAX_AGE, + DEFAULT_STALE_MAX_AGE, + isExpired, + isStaleValid, + normalizePathToName, + stableStringify, + toLower, +} from './utils' + +const HOP_BY_HOP_HEADERS = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', + 'proxy-connection', +]) + +const CACHE_MISS = Symbol('cache-miss') +const DEFAULT_MEMORY_MAX_ENTRIES = 1000 +const DEFAULT_MEMORY_MAX_SIZE = 50 * 1024 * 1024 +const DEFAULT_MEMORY_MAX_ENTRY_SIZE = 5 * 1024 * 1024 +const DEFAULT_MAX_RESPONSE_BODY_SIZE = 3 * 1024 * 1024 +const DEFAULT_MAX_RESPONSE_BODY_TIME = 1000 +const DEFAULT_PENDING_REQUEST_TIME = 5000 +const DEFAULT_STORAGE_READ_TIME = 5000 +const CONDITIONAL_REQUEST_HEADERS = [ + 'range', + 'if-range', + 'if-match', + 'if-none-match', + 'if-modified-since', + 'if-unmodified-since', +] as const +const STREAMING_CONTENT_TYPES = [ + 'text/event-stream', + 'application/x-ndjson', + 'application/ndjson', + 'application/json-seq', + 'application/stream+json', + 'multipart/x-mixed-replace', +] + +const createMemoryDriver = (options: CacheStorageOptions = {}): Driver => { + const cache = new LRUCache({ + max: options.maxEntries ?? DEFAULT_MEMORY_MAX_ENTRIES, + maxSize: options.maxSize ?? DEFAULT_MEMORY_MAX_SIZE, + maxEntrySize: options.maxEntrySize ?? DEFAULT_MEMORY_MAX_ENTRY_SIZE, + sizeCalculation: (value) => new TextEncoder().encode(value).byteLength, + }) + return { + name: 'hono-universal-cache-memory', + flags: { ttl: true }, + getInstance: () => cache, + hasItem: (key) => cache.has(key), + getItem: (key) => cache.get(key) ?? null, + setItem: (key, value, options) => { + const ttl = options['ttl'] as number | undefined + cache.set(key, value, ttl ? { ttl: ttl * 1000 } : undefined) + }, + removeItem: (key) => { + cache.delete(key) + }, + getKeys: () => [...cache.keys()], + clear: () => { + cache.clear() + }, + dispose: () => { + cache.clear() + }, + } +} + +let defaultStorage: Storage = createStorage({ + driver: createMemoryDriver(), +}) + +let defaultCacheOptions: CacheDefaults = {} +const requestCacheDefaults = new WeakMap() + +type PendingRequests = WeakMap>> +interface CacheMutationState { + generation: number + pending: boolean + tail: Promise +} + +const pendingMiddlewareRequests: PendingRequests = new WeakMap() +const pendingFunctionRequests: PendingRequests = new WeakMap() +const cacheMutations = new WeakMap>() +const functionNamespace = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` +let functionNamespaceIndex = 0 +let cacheMutationGeneration = 0 + +const getPendingRequests = (pendingRequests: PendingRequests, storage: Storage) => { + let requests = pendingRequests.get(storage) + if (!requests) { + requests = new Map() + pendingRequests.set(storage, requests) + } + return requests +} + +const getCacheMutations = (storage: Storage) => { + let mutations = cacheMutations.get(storage) + if (!mutations) { + mutations = new Map() + cacheMutations.set(storage, mutations) + } + return mutations +} + +const beginCacheMutation = (storage: Storage, key: string) => { + const mutations = getCacheMutations(storage) + const current = mutations.get(key) + const state = { + generation: ++cacheMutationGeneration, + pending: current?.pending ?? false, + tail: current?.tail ?? Promise.resolve(), + } + mutations.set(key, state) + return state.generation +} + +const queueCacheMutation = ( + storage: Storage, + key: string, + generation: number, + operation: () => Promise +) => { + const mutations = getCacheMutations(storage) + const state = mutations.get(key) + if (!state || state.generation !== generation) { + return Promise.resolve() + } + const run = async () => { + if (mutations.get(key)?.generation !== generation) { + return + } + try { + await operation() + } catch { + // Cache failures must not fail application work. + } + } + const queued = state.pending ? state.tail.catch(() => undefined).then(run) : run() + state.pending = true + state.tail = queued + void queued.then(() => { + if (state.tail === queued) { + state.pending = false + } + }) + return queued +} + +const waitForCacheMutation = async (mutation: Promise) => { + let timeout: ReturnType | undefined + await Promise.race([ + mutation, + new Promise((resolve) => { + timeout = setTimeout(resolve, DEFAULT_STORAGE_READ_TIME) + }), + ]).finally(() => { + if (timeout) { + clearTimeout(timeout) + } + }) +} + +const finishCacheMutation = (storage: Storage, key: string, generation: number) => { + const mutations = getCacheMutations(storage) + const state = mutations.get(key) + if (!state || state.generation !== generation) { + return + } + const tail = state.tail + void tail.finally(() => { + const current = mutations.get(key) + if (current?.generation === generation && current.tail === tail) { + mutations.delete(key) + } + }) +} + +const readCacheEntry = async (storage: Storage, key: string) => { + let timeout: ReturnType | undefined + try { + return await Promise.race([ + storage.getItem(key), + new Promise((_, reject) => { + timeout = setTimeout(() => { + reject(new Error('Cache storage read timed out')) + }, DEFAULT_STORAGE_READ_TIME) + }), + ]) + } catch { + return null + } finally { + if (timeout) { + clearTimeout(timeout) + } + } +} + +const removeCacheEntry = async (storage: Storage, key: string) => { + let timeout: ReturnType | undefined + try { + await Promise.race([ + storage.removeItem(key), + new Promise((_, reject) => { + timeout = setTimeout(() => { + reject(new Error('Cache storage removal timed out')) + }, DEFAULT_STORAGE_READ_TIME) + }), + ]) + } catch { + // Cache failures must not fail the request. + } finally { + if (timeout) { + clearTimeout(timeout) + } + } +} + +const setRequestCacheDefaults = (ctx: Context, options: CacheDefaults = {}) => { + const current = requestCacheDefaults.get(ctx) ?? {} + requestCacheDefaults.set(ctx, { + ...current, + ...options, + }) +} + +const getRequestCacheDefaults = (ctx: Context): CacheDefaults => requestCacheDefaults.get(ctx) ?? {} + +/** + * Set the default storage instance used by cache middleware and functions. + */ +export const setCacheStorage = (storage: Storage): void => { + defaultStorage = storage +} + +/** + * Get the default storage instance used by cache middleware and functions. + */ +export const getCacheStorage = (): Storage => defaultStorage + +/** + * Replace the global defaults applied to middleware and cached functions. + * Pass an empty object to reset them. + */ +export const setCacheDefaults = (options: CacheDefaults): void => { + defaultCacheOptions = { ...options } +} + +/** + * Get the global cache defaults applied to middleware and cached functions. + */ +export const getCacheDefaults = (): CacheDefaults => ({ ...defaultCacheOptions }) + +/** + * Configure request-scoped cache defaults through Hono `app.use(...)`. + * This allows global defaults and per-prefix overrides. + */ +export const cacheDefaults = (options: CacheDefaults = {}): MiddlewareHandler => { + return async (ctx, next) => { + setRequestCacheDefaults(ctx, options) + await next() + } +} + +/** + * Create a new in-memory storage instance. + */ +export const createCacheStorage = (options: CacheStorageOptions = {}): Storage => + createStorage({ + driver: createMemoryDriver(options), + }) + +const createStorageKey = (base: string, group: string, name: string, key: string) => { + const segments = [base, group, name, key] + return `${segments + .map((segment) => (segment === '' ? '%00' : encodeURIComponent(segment))) + .join(':')}.json` +} + +const escapeKey = (value: string) => value.replace(/\W/g, '') + +const getDefaultHandlerKey = async ( + ctx: Context, + varies: string[] | undefined, + hashFn: (value: string) => string | Promise +) => { + const url = new URL(ctx.req.url) + const method = ctx.req.method.toUpperCase() + const body = + method === 'GET' || method === 'HEAD' + ? '' + : `:${encodeBase64(await ctx.req.raw.clone().arrayBuffer())}` + const fullPath = `${method}:${url.origin}${url.pathname}${url.search}${body}` + + let pathPrefix = '-' + try { + pathPrefix = escapeKey(decodeURI(url.pathname)).slice(0, 16) || 'index' + } catch { + pathPrefix = '-' + } + + const hashedPath = `${pathPrefix}.${await hashFn(fullPath)}` + const varyHeaders = [...new Set(varies?.map(toLower) ?? [])].sort() + + if (varyHeaders.length === 0) { + return hashedPath + } + + const varyParts = await Promise.all( + varyHeaders.map(async (header) => { + const value = ctx.req.header(header) ?? '' + return `${encodeURIComponent(header)}.${await hashFn(value)}` + }) + ) + const varyKey = varyParts.join(':') + + return `${hashedPath}:${varyKey}` +} + +const getDefaultHandlerName = (ctx: Context) => { + const url = new URL(ctx.req.url) + return normalizePathToName(url.pathname) +} + +const sanitizeResponseHeaders = (source: HeadersInit) => { + const headers = new Headers(source) + const connectionHeaders = headers + .get('connection') + ?.split(',') + .map((header) => header.trim().toLowerCase()) + .filter(Boolean) + for (const header of connectionHeaders ?? []) { + headers.delete(header) + } + for (const header of HOP_BY_HOP_HEADERS) { + headers.delete(header) + } + headers.delete('set-cookie') + return headers +} + +const createCachedResponse = (entry: CachedResponseEntry) => { + const headers = sanitizeResponseHeaders(entry.headers) + const storedAge = Number.parseInt(headers.get('age') ?? '0', 10) + const responseDate = Date.parse(headers.get('date') ?? '') + const apparentAge = Number.isFinite(responseDate) + ? Math.max(0, Math.floor((entry.mtime - responseDate) / 1000)) + : 0 + const residentAge = Math.max(0, Math.floor((Date.now() - entry.mtime) / 1000)) + headers.set( + 'age', + String( + Math.max(apparentAge, Math.max(0, Number.isFinite(storedAge) ? storedAge : 0)) + residentAge + ) + ) + const body = entry.status === 204 || entry.status === 205 ? null : decodeBase64(entry.value) + return new Response(body, { + status: entry.status, + headers, + }) +} + +const getCacheHeaders = (response: Response): Record => { + const headers = sanitizeResponseHeaders(response.headers) + const entries: Record = {} + headers.forEach((value, key) => { + entries[key] = value + }) + return entries +} + +const isCacheableResponse = (response: Response, varies: string[] | undefined) => { + if (response.status < 200 || response.status >= 300 || response.status === 206) { + return false + } + if (response.headers.has('set-cookie')) { + return false + } + const contentType = response.headers.get('content-type')?.toLowerCase() + if (contentType && STREAMING_CONTENT_TYPES.some((type) => contentType.startsWith(type))) { + return false + } + const responseVaries = response.headers + .get('vary') + ?.split(',') + .map((header) => toLower(header.trim())) + .filter(Boolean) + if (responseVaries?.length) { + const keyedHeaders = new Set(varies?.map(toLower) ?? []) + if ( + responseVaries.includes('*') || + responseVaries.some((header) => !keyedHeaders.has(header)) + ) { + return false + } + } + const cacheControl = response.headers.get('cache-control') + if (!cacheControl) { + return true + } + const normalized = cacheControl.toLowerCase() + return !( + normalized.includes('no-store') || + normalized.includes('no-cache') || + normalized.includes('private') + ) +} + +const hasRequestNoCacheDirective = (value: string | undefined) => + value?.split(',').some((directive) => { + const separator = directive.indexOf('=') + const name = (separator === -1 ? directive : directive.slice(0, separator)).trim().toLowerCase() + if (name === 'no-cache' || name === 'no-store') { + return true + } + if (name !== 'max-age' || separator === -1) { + return false + } + const raw = directive.slice(separator + 1).trim() + const normalized = raw.startsWith('"') && raw.endsWith('"') ? raw.slice(1, -1) : raw + return /^0+$/.test(normalized) + }) ?? false + +const defaultSerializeResponse = async ( + response: Response, + context: { integrity: string; maxAge: number; staleMaxAge: number; now: number } +) => { + const { integrity, maxAge, staleMaxAge, now } = context + const buffer = await readResponseBody(response) + const value = encodeBase64(buffer.buffer) + const expires = now + maxAge * 1000 + const staleExpires = staleMaxAge < 0 ? null : now + (maxAge + Math.max(staleMaxAge, 0)) * 1000 + + return { + value, + encoding: 'base64', + status: response.status, + headers: getCacheHeaders(response), + mtime: now, + expires, + staleExpires, + integrity, + } satisfies CachedResponseEntry +} + +const defaultDeserializeResponse = (entry: CachedResponseEntry) => createCachedResponse(entry) + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null + +const readResponseBody = async (response: Response) => { + const body = response.body + if (!body) { + return new Uint8Array() + } + + const reader = body.getReader() + const chunks: Uint8Array[] = [] + let size = 0 + let complete = false + let timeout: ReturnType | undefined + const timedOut = new Promise((_, reject) => { + timeout = setTimeout(() => { + reject(new Error('Cache response body timed out')) + }, DEFAULT_MAX_RESPONSE_BODY_TIME) + }) + + try { + while (true) { + const { done, value } = await Promise.race([reader.read(), timedOut]) + if (done) { + complete = true + break + } + size += value.byteLength + if (size > DEFAULT_MAX_RESPONSE_BODY_SIZE) { + throw new Error('Cache response body is too large') + } + chunks.push(value) + } + } finally { + if (timeout) { + clearTimeout(timeout) + } + if (!complete) { + void reader + .cancel() + .catch(() => undefined) + .finally(() => { + reader.releaseLock() + }) + } else { + reader.releaseLock() + } + } + + const buffer = new Uint8Array(size) + let offset = 0 + for (const chunk of chunks) { + buffer.set(chunk, offset) + offset += chunk.byteLength + } + return buffer +} + +const hasValidCacheMetadata = (entry: Record) => + typeof entry['integrity'] === 'string' && + typeof entry['mtime'] === 'number' && + Number.isFinite(entry['mtime']) && + typeof entry['expires'] === 'number' && + Number.isFinite(entry['expires']) && + (entry['staleExpires'] === null || + (typeof entry['staleExpires'] === 'number' && Number.isFinite(entry['staleExpires']))) + +const isValidCachedResponseEntry = (entry: unknown): entry is CachedResponseEntry => { + if (!isRecord(entry)) { + return false + } + return ( + hasValidCacheMetadata(entry) && + entry['encoding'] === 'base64' && + typeof entry['value'] === 'string' && + typeof entry['status'] === 'number' && + Number.isInteger(entry['status']) && + entry['status'] >= 200 && + entry['status'] < 300 && + entry['status'] !== 206 && + isRecord(entry['headers']) && + Object.values(entry['headers']).every((value) => typeof value === 'string') + ) +} + +const isValidCachedFunctionEntry = ( + entry: unknown +): entry is CachedFunctionEntry => { + if (!isRecord(entry)) { + return false + } + return hasValidCacheMetadata(entry) && 'value' in entry +} + +const resolveHandlerCacheKey = async ( + ctx: Context, + options: CacheMiddlewareOptions, + base: string, + group: string, + hashFn: (value: string) => string | Promise +) => { + const name = options.name ?? getDefaultHandlerName(ctx) + const key = options.getKey + ? await options.getKey(ctx) + : await getDefaultHandlerKey(ctx, options.varies, hashFn) + const storageKey = createStorageKey(base, group, name, key) + const integrity = options.integrity ?? (await hashFn(stableStringify([group, name]))) + return { name, key, storageKey, integrity } +} + +const maybeServeCachedResponse = async ( + storage: Storage, + storageKey: string, + integrity: string, + cachedRaw: unknown, + deserialize: NonNullable, + validate?: CacheMiddlewareOptions['validate'] +): Promise<{ response: Response; stale: boolean } | null> => { + const cached = isValidCachedResponseEntry(cachedRaw) ? cachedRaw : null + if (!cached) { + if (cachedRaw !== null) { + await removeCacheEntry(storage, storageKey) + } + return null + } + if (cached.integrity !== integrity) { + await removeCacheEntry(storage, storageKey) + return null + } + if (validate && validate(cached) === false) { + await removeCacheEntry(storage, storageKey) + return null + } + + if (!isExpired(cached.expires)) { + return { response: await deserialize(cached), stale: false } + } + + if (isStaleValid(cached.staleExpires)) { + return { response: await deserialize(cached), stale: true } + } + + await removeCacheEntry(storage, storageKey) + return null +} + +const shouldBypassMiddlewareCache = async (ctx: Context, options: CacheMiddlewareOptions) => { + if (CONDITIONAL_REQUEST_HEADERS.some((header) => ctx.req.header(header) !== undefined)) { + return true + } + + if ( + hasRequestNoCacheDirective(ctx.req.header('cache-control')) || + hasRequestNoCacheDirective(ctx.req.header('pragma')) + ) { + return true + } + + if (!options.shouldBypassCache) { + return false + } + return await options.shouldBypassCache(ctx) +} + +const shouldInvalidateMiddlewareCache = async (ctx: Context, options: CacheMiddlewareOptions) => { + if (!options.shouldInvalidateCache) { + return false + } + return await options.shouldInvalidateCache(ctx) +} + +const shouldManualRevalidateMiddlewareCache = async ( + ctx: Context, + options: CacheMiddlewareOptions +) => { + if (!options.shouldRevalidate) { + return false + } + return await options.shouldRevalidate(ctx) +} + +const cacheResponseEntry = async ( + response: Response, + storage: Storage, + storageKey: string, + integrity: string, + maxAge: number, + staleMaxAge: number, + now: number, + serialize: NonNullable, + generation: number +) => { + try { + const rawEntry = await serialize(response, { integrity, maxAge, staleMaxAge, now }) + const ttl = computeTtlSeconds(maxAge, staleMaxAge) + if (ttl === 0) { + return + } + await queueCacheMutation(storage, storageKey, generation, () => + storage.setItem(storageKey, rawEntry, ttl ? { ttl } : undefined) + ) + } catch { + // Cache failures must not fail the response. + } finally { + if (response.body && !response.bodyUsed) { + void response.body.cancel().catch(() => undefined) + } + finishCacheMutation(storage, storageKey, generation) + } +} + +const readCachedResponse = async ( + storage: Storage, + storageKey: string, + integrity: string, + options: CacheMiddlewareOptions, + deserialize: NonNullable +) => { + const cachedRaw = await readCacheEntry(storage, storageKey) + try { + return await maybeServeCachedResponse( + storage, + storageKey, + integrity, + cachedRaw, + deserialize, + options.validate + ) + } catch { + await removeCacheEntry(storage, storageKey) + return null + } +} + +const writeCachedResponse = ( + ctx: Context, + storage: Storage, + storageKey: string, + integrity: string, + response: Response, + maxAge: number, + staleMaxAge: number, + now: number, + serialize: NonNullable, + generation: number +) => { + let cacheResponse: Response + try { + cacheResponse = response.clone() + } catch { + return Promise.resolve() + } + const cachePromise = cacheResponseEntry( + cacheResponse, + storage, + storageKey, + integrity, + maxAge, + staleMaxAge, + now, + serialize, + generation + ) + + if (getRuntimeKey() === 'workerd') { + ctx.executionCtx?.waitUntil?.(cachePromise) + } + return cachePromise +} + +/** + * Hono middleware that caches responses based on request data. + * Provide `hash` in options to use WebCrypto or node:crypto for key hashing. + */ +export const cacheMiddleware = ( + options: CacheMiddlewareOptions | number = {} +): MiddlewareHandler => { + const normalized: CacheMiddlewareOptions = + typeof options === 'number' + ? { maxAge: options } + : { + ...options, + ...(options.methods ? { methods: [...options.methods] } : {}), + ...(options.varies ? { varies: [...options.varies] } : {}), + } + + const handler: MiddlewareHandler = async (ctx: Context, next: Next) => { + const merged: CacheMiddlewareOptions = { + ...defaultCacheOptions, + ...getRequestCacheDefaults(ctx), + ...normalized, + } + + const maxAge = merged.maxAge ?? DEFAULT_MAX_AGE + const staleMaxAge = merged.staleMaxAge ?? DEFAULT_STALE_MAX_AGE + const keepPreviousOn5xx = merged.keepPreviousOn5xx ?? true + const base = merged.base ?? DEFAULT_CACHE_BASE + const group = merged.group ?? DEFAULT_HANDLER_GROUP + const methods = merged.methods?.map((method) => method.toUpperCase()) ?? ['GET', 'HEAD'] + const hashFn = merged.hash ?? ((value: string) => ohash(value)) + const serialize = merged.serialize ?? defaultSerializeResponse + const deserialize = merged.deserialize ?? defaultDeserializeResponse + const revalidateHeader = merged.revalidateHeader ?? false + + // Resolve storage at request time + const storage = merged.storage ?? defaultStorage + + if (!methods.includes(ctx.req.method.toUpperCase())) { + return next() + } + + if (maxAge <= 0) { + return next() + } + + const isManualRevalidateRequest = + revalidateHeader !== false && ctx.req.header(revalidateHeader) === '1' + + if (!merged.getKey) { + const keyedHeaders = new Set(merged.varies?.map(toLower) ?? []) + if ( + (ctx.req.header('authorization') !== undefined && !keyedHeaders.has('authorization')) || + (ctx.req.header('cookie') !== undefined && !keyedHeaders.has('cookie')) + ) { + return next() + } + } + + const isRevalidateRequest = + isManualRevalidateRequest && (await shouldManualRevalidateMiddlewareCache(ctx, merged)) + + const bypass = await shouldBypassMiddlewareCache(ctx, merged) + if (bypass) { + return next() + } + + const { storageKey, integrity } = await resolveHandlerCacheKey(ctx, merged, base, group, hashFn) + const shouldInvalidate = await shouldInvalidateMiddlewareCache(ctx, merged) + const requests = getPendingRequests(pendingMiddlewareRequests, storage) + const pendingKey = stableStringify([storageKey, integrity]) + const shouldCoalesce = !isRevalidateRequest && !shouldInvalidate + let staleResponse: Response | undefined + + const servePendingResponse = async (pending: Promise) => { + const shared = await pending + if (!shared) { + await next() + return true + } + const response = shared.clone() + ctx.res = response + return response + } + + if (shouldCoalesce) { + const pending = requests.get(pendingKey) as Promise | undefined + if (pending) { + const pendingResponse = await servePendingResponse(pending) + return pendingResponse === true ? ctx.res : pendingResponse + } + } + + if (shouldCoalesce) { + const cachedResult = await readCachedResponse( + storage, + storageKey, + integrity, + merged, + deserialize + ) + if (cachedResult && !cachedResult.stale) { + ctx.res = cachedResult.response + return cachedResult.response + } + staleResponse = cachedResult?.response + } + + if (shouldCoalesce) { + const pending = requests.get(pendingKey) as Promise | undefined + if (pending) { + const pendingResponse = await servePendingResponse(pending) + return pendingResponse === true ? ctx.res : pendingResponse + } + } + + let resolvePending: ((response: Response | null) => void) | undefined + let pendingPromise: Promise | undefined + let pendingTimeout: ReturnType | undefined + let sharedPendingResponse: Response | null = null + let pendingSettled = false + + const detachPending = () => { + if (requests.get(pendingKey) === pendingPromise) { + requests.delete(pendingKey) + } + } + + const clearPending = () => { + if (pendingTimeout) { + clearTimeout(pendingTimeout) + pendingTimeout = undefined + } + detachPending() + if (sharedPendingResponse?.body) { + setTimeout(() => { + void sharedPendingResponse?.body?.cancel().catch(() => undefined) + }, 0) + } + sharedPendingResponse = null + } + + if (shouldCoalesce) { + pendingPromise = new Promise((resolve) => { + resolvePending = resolve + }) + requests.set(pendingKey, pendingPromise) + pendingTimeout = setTimeout(() => { + pendingSettled = true + resolvePending?.(null) + clearPending() + }, DEFAULT_PENDING_REQUEST_TIME) + void pendingPromise.catch(() => undefined) + } + + const generation = beginCacheMutation(storage, storageKey) + + const settlePending = (response: Response | null, completion?: Promise) => { + if (!pendingPromise || !resolvePending || pendingSettled) { + return + } + pendingSettled = true + try { + sharedPendingResponse = response?.clone() ?? null + } catch { + sharedPendingResponse = null + } + resolvePending(sharedPendingResponse) + if (completion) { + void completion.finally(clearPending) + } else { + void Promise.resolve().then(clearPending) + } + } + + const releasePending = () => { + if (pendingPromise && resolvePending && !pendingSettled) { + pendingSettled = true + resolvePending(null) + clearPending() + } + } + + if (shouldInvalidate && !keepPreviousOn5xx) { + await waitForCacheMutation( + queueCacheMutation(storage, storageKey, generation, () => storage.removeItem(storageKey)) + ) + } + + try { + await next() + } catch (error) { + if (staleResponse) { + finishCacheMutation(storage, storageKey, generation) + settlePending(staleResponse) + ctx.res = staleResponse + return staleResponse + } + releasePending() + finishCacheMutation(storage, storageKey, generation) + throw error + } + const response = ctx.res + + if (!response) { + finishCacheMutation(storage, storageKey, generation) + settlePending(null) + return response + } + + if (response.status >= 500 && staleResponse) { + finishCacheMutation(storage, storageKey, generation) + settlePending(staleResponse) + ctx.res = staleResponse + return staleResponse + } + + if (!isCacheableResponse(response, merged.varies)) { + if (response.status < 500) { + await waitForCacheMutation( + queueCacheMutation(storage, storageKey, generation, () => storage.removeItem(storageKey)) + ) + } + finishCacheMutation(storage, storageKey, generation) + settlePending(response.status >= 500 && !ctx.req.raw.signal.aborted ? response : null) + return response + } + + const cacheWrite = writeCachedResponse( + ctx, + storage, + storageKey, + integrity, + response, + maxAge, + staleMaxAge, + Date.now(), + serialize, + generation + ) + detachPending() + void cacheWrite.then(async () => { + const cachedResult = await readCachedResponse( + storage, + storageKey, + integrity, + merged, + deserialize + ) + settlePending(cachedResult?.response ?? null) + }) + return response + } + + return handler +} + +const createFunctionEntry = ( + result: TResult, + integrity: string, + maxAge: number, + staleMaxAge: number, + now: number +): CachedFunctionEntry => { + return { + value: result, + mtime: now, + expires: now + maxAge * 1000, + staleExpires: staleMaxAge < 0 ? null : now + (maxAge + Math.max(staleMaxAge, 0)) * 1000, + integrity, + } +} + +const defaultSerializeFunctionEntry = ( + result: TResult, + context: { integrity: string; maxAge: number; staleMaxAge: number; now: number } +) => { + assertJsonSafe(result) + return createFunctionEntry( + result, + context.integrity, + context.maxAge, + context.staleMaxAge, + context.now + ) +} + +const assertJsonSafe = (value: unknown, ancestors = new WeakSet()): void => { + if ( + value === null || + typeof value === 'string' || + typeof value === 'boolean' || + (typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0)) + ) { + return + } + if (typeof value !== 'object' || value === null) { + throw new TypeError('Default function cache serialization requires JSON-safe values') + } + if (ancestors.has(value)) { + throw new TypeError('Default function cache serialization does not support cyclic values') + } + const isArray = Array.isArray(value) + const prototype = Object.getPrototypeOf(value) as object | null + if (!isArray && prototype !== Object.prototype && prototype !== null) { + throw new TypeError('Default function cache serialization requires plain objects and arrays') + } + if (Reflect.ownKeys(value).some((key) => typeof key === 'symbol')) { + throw new TypeError('Default function cache serialization does not support symbol keys') + } + ancestors.add(value) + if (isArray) { + for (let index = 0; index < value.length; index += 1) { + if (!(index in value)) { + throw new TypeError('Default function cache serialization does not support sparse arrays') + } + assertJsonSafe(value[index], ancestors) + } + } else { + for (const key of Object.keys(value)) { + assertJsonSafe((value as Record)[key], ancestors) + } + } + ancestors.delete(value) +} + +const defaultDeserializeFunctionEntry = (entry: CachedFunctionEntry) => + entry.value + +const shouldBypassFunctionCache = async ( + options: CacheFunctionOptions, + args: TArgs +) => { + if (!options.shouldBypassCache) { + return false + } + return await options.shouldBypassCache(...args) +} + +const shouldInvalidateFunctionCache = async ( + options: CacheFunctionOptions, + args: TArgs +) => { + if (!options.shouldInvalidateCache) { + return false + } + return await options.shouldInvalidateCache(...args) +} + +const getFunctionStorageKey = async ( + options: CacheFunctionOptions, + base: string, + group: string, + name: string, + args: TArgs, + hashFn: (value: string) => string | Promise +) => { + const key = options.getKey ? await options.getKey(...args) : await hashFn(stableStringify(args)) + return createStorageKey(base, group, name, key) +} + +const refreshFunctionCache = async ( + storage: Storage, + storageKey: string, + result: TResult, + integrity: string, + maxAge: number, + staleMaxAge: number, + now: number, + serialize: NonNullable['serialize']>, + generation: number +) => { + try { + const rawEntry = await serialize(result, { integrity, maxAge, staleMaxAge, now }) + const ttl = computeTtlSeconds(maxAge, staleMaxAge) + await queueCacheMutation(storage, storageKey, generation, () => + storage.setItem(storageKey, rawEntry, ttl ? { ttl } : undefined) + ) + } catch { + // Cache failures must not fail the function result. + } + finishCacheMutation(storage, storageKey, generation) + return result +} + +const maybeServeCachedFunctionValue = async ( + cached: CachedFunctionEntry | null, + storageKey: string, + integrity: string, + swr: boolean, + fetcher: () => Promise | TResult, + storage: Storage, + maxAge: number, + staleMaxAge: number, + serialize: NonNullable['serialize']>, + deserialize: NonNullable['deserialize']>, + pendingRequests: PendingRequests, + validate?: CacheFunctionOptions['validate'], + validateArgs?: TArgs +): Promise => { + if (!cached) { + return CACHE_MISS + } + if (cached.integrity !== integrity) { + await removeCacheEntry(storage, storageKey) + return CACHE_MISS + } + if (validate) { + const args = validateArgs ?? ([] as unknown as TArgs) + if (validate(cached, ...args) === false) { + await removeCacheEntry(storage, storageKey) + return CACHE_MISS + } + } + if (!isExpired(cached.expires)) { + return (await deserialize(cached)) as TResult + } + if (swr && isStaleValid(cached.staleExpires)) { + const requests = getPendingRequests(pendingRequests, storage) + const pendingKey = stableStringify([storageKey, integrity]) + if (!requests.has(pendingKey)) { + const generation = beginCacheMutation(storage, storageKey) + const refreshPromise = Promise.resolve().then(fetcher) + requests.set(pendingKey, refreshPromise) + const timeout = setTimeout(() => { + if (requests.get(pendingKey) === refreshPromise) { + requests.delete(pendingKey) + } + }, DEFAULT_PENDING_REQUEST_TIME) + void refreshPromise + .then((fresh) => + refreshFunctionCache( + storage, + storageKey, + fresh, + integrity, + maxAge, + staleMaxAge, + Date.now(), + serialize, + generation + ) + ) + .finally(() => { + clearTimeout(timeout) + if (requests.get(pendingKey) === refreshPromise) { + requests.delete(pendingKey) + } + }) + .catch(() => { + finishCacheMutation(storage, storageKey, generation) + }) + } + return (await deserialize(cached)) as TResult + } + await removeCacheEntry(storage, storageKey) + return CACHE_MISS +} + +/** + * Wrap a function with cache behavior. + * Provide `hash` in options to use WebCrypto or node:crypto for key hashing. + */ +export const cacheFunction = ( + fn: (...args: TArgs) => Promise | TResult, + options: CacheFunctionOptions | number = {} +): ((...args: TArgs) => Promise) => { + const normalized: CacheFunctionOptions = + typeof options === 'number' ? { maxAge: options } : { ...options } + const implicitName = `${fn.name || '_'}:${functionNamespace}:${functionNamespaceIndex++}` + + return async (...args: TArgs): Promise => { + const merged: CacheFunctionOptions = { + ...defaultCacheOptions, + ...normalized, + } + const maxAge = merged.maxAge ?? DEFAULT_MAX_AGE + const staleMaxAge = merged.staleMaxAge ?? DEFAULT_STALE_MAX_AGE + const swr = merged.swr ?? true + const keepPreviousOn5xx = merged.keepPreviousOn5xx ?? true + const base = merged.base ?? DEFAULT_CACHE_BASE + const name = merged.name ?? implicitName + const group = merged.group ?? DEFAULT_FUNCTION_GROUP + const hashFn = merged.hash ?? ((value: string) => ohash(value)) + const serialize = (merged.serialize ?? defaultSerializeFunctionEntry) as NonNullable< + CacheFunctionOptions['serialize'] + > + const deserialize = (merged.deserialize ?? defaultDeserializeFunctionEntry) as NonNullable< + CacheFunctionOptions['deserialize'] + > + const storage = merged.storage ?? defaultStorage + + if (maxAge <= 0) { + return await fn(...args) + } + + const bypass = await shouldBypassFunctionCache(merged, args) + if (bypass) { + return await fn(...args) + } + + const integrity = merged.integrity ?? (await hashFn(fn.toString())) + const storageKey = await getFunctionStorageKey(merged, base, group, name, args, hashFn) + const shouldInvalidate = await shouldInvalidateFunctionCache(merged, args) + + const cachedRaw = shouldInvalidate ? null : await readCacheEntry(storage, storageKey) + let cached: CachedFunctionEntry | null = null + try { + cached = isValidCachedFunctionEntry(cachedRaw) ? cachedRaw : null + } catch { + cached = null + } + if (!cached && cachedRaw !== null) { + await removeCacheEntry(storage, storageKey) + } + let cachedValue: TResult | typeof CACHE_MISS = CACHE_MISS + try { + cachedValue = await maybeServeCachedFunctionValue( + cached, + storageKey, + integrity, + swr, + () => fn(...args), + storage, + maxAge, + staleMaxAge, + serialize, + deserialize, + pendingFunctionRequests, + merged.validate, + args + ) + } catch { + await removeCacheEntry(storage, storageKey) + } + if (cachedValue !== CACHE_MISS) { + return cachedValue + } + + const requests = getPendingRequests(pendingFunctionRequests, storage) + const pendingKey = stableStringify([storageKey, integrity]) + if (!shouldInvalidate && requests.has(pendingKey)) { + return (await requests.get(pendingKey)) as TResult + } + + const generation = beginCacheMutation(storage, storageKey) + if (shouldInvalidate && !keepPreviousOn5xx) { + await waitForCacheMutation( + queueCacheMutation(storage, storageKey, generation, () => storage.removeItem(storageKey)) + ) + } + const resultPromise = Promise.resolve().then(() => fn(...args)) + requests.set(pendingKey, resultPromise) + const timeout = setTimeout(() => { + if (requests.get(pendingKey) === resultPromise) { + requests.delete(pendingKey) + } + }, DEFAULT_PENDING_REQUEST_TIME) + void resultPromise + .then((result) => + refreshFunctionCache( + storage, + storageKey, + result, + integrity, + maxAge, + staleMaxAge, + Date.now(), + serialize, + generation + ) + ) + .finally(() => { + clearTimeout(timeout) + if (requests.get(pendingKey) === resultPromise) { + requests.delete(pendingKey) + } + }) + .catch(() => { + finishCacheMutation(storage, storageKey, generation) + }) + return await resultPromise + } +} diff --git a/packages/universal-cache/src/index.test.ts b/packages/universal-cache/src/index.test.ts new file mode 100644 index 000000000..252151b8a --- /dev/null +++ b/packages/universal-cache/src/index.test.ts @@ -0,0 +1,2309 @@ +import { Hono } from 'hono' +import { + cacheDefaults, + cacheFunction, + cacheMiddleware, + createCacheStorage, + getCacheDefaults, + getCacheStorage, + setCacheDefaults, + setCacheStorage, + stableStringify, +} from '.' + +const resetDefaultOptions = () => { + setCacheDefaults({}) +} + +const flushPromises = async () => { + await Promise.resolve() + await Promise.resolve() +} + +const createTestStorageKey = (...segments: string[]) => + `${segments.map((segment) => encodeURIComponent(segment)).join(':')}.json` + +describe('@hono/universal-cache', () => { + const toBase64 = (value: string) => Buffer.from(value).toString('base64') + + beforeEach(() => { + resetDefaultOptions() + setCacheStorage(createCacheStorage()) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + vi.useRealTimers() + }) + + describe('cacheMiddleware', () => { + it('caches GET responses', async () => { + const app = new Hono() + let count = 0 + + app.get( + '/items', + cacheMiddleware({ + maxAge: 60, + }), + (c) => { + count += 1 + return c.text(String(count)) + } + ) + + const res1 = await app.request('http://localhost/items') + const res2 = await app.request('http://localhost/items') + + expect(await res1.text()).toBe('1') + expect(await res2.text()).toBe('1') + expect(count).toBe(1) + }) + + it('coalesces concurrent cache misses', async () => { + const app = new Hono() + let count = 0 + let release: (() => void) | undefined + const gate = new Promise((resolve) => { + release = resolve + }) + + app.get('/items', cacheMiddleware({ maxAge: 60 }), async (c) => { + count += 1 + await gate + return c.text(String(count)) + }) + + const pending = Array.from({ length: 200 }, () => + Promise.resolve(app.request('http://localhost/items')) + ) + await vi.waitFor(() => { + expect(count).toBe(1) + }) + release?.() + + const responses = await Promise.all(pending) + expect(await Promise.all(responses.map((response) => response.text()))).toEqual( + Array.from({ length: 200 }, () => '1') + ) + expect(count).toBe(1) + }) + + it('coalesces concurrent stale refreshes', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + const app = new Hono() + let count = 0 + let release: (() => void) | undefined + let gate = Promise.resolve() + + app.get('/items', cacheMiddleware({ maxAge: 1, staleMaxAge: 60 }), async (c) => { + count += 1 + await gate + return c.text(String(count)) + }) + + expect(await (await app.request('http://localhost/items')).text()).toBe('1') + vi.advanceTimersByTime(1100) + gate = new Promise((resolve) => { + release = resolve + }) + + const pending = Array.from({ length: 100 }, () => + Promise.resolve(app.request('http://localhost/items')) + ) + await vi.waitFor(() => { + expect(count).toBe(2) + }) + release?.() + + const responses = await Promise.all(pending) + expect(await Promise.all(responses.map((response) => response.text()))).toEqual( + Array.from({ length: 100 }, () => '2') + ) + expect(count).toBe(2) + }) + + it('coalesces concurrent 5xx responses without caching them', async () => { + const app = new Hono() + let count = 0 + let release: (() => void) | undefined + const gate = new Promise((resolve) => { + release = resolve + }) + + app.get('/items', cacheMiddleware({ maxAge: 60 }), async (c) => { + count += 1 + await gate + return c.text('unavailable', 503) + }) + + const pending = Array.from({ length: 200 }, () => + Promise.resolve(app.request('http://localhost/items')) + ) + await vi.waitFor(() => { + expect(count).toBe(1) + }) + release?.() + + const responses = await Promise.all(pending) + expect(responses.every((response) => response.status === 503)).toBe(true) + expect(count).toBe(1) + expect((await app.request('http://localhost/items')).status).toBe(503) + expect(count).toBe(2) + }) + + it('retries followers when the coalesced leader is aborted', async () => { + const app = new Hono() + const controller = new AbortController() + let count = 0 + + app.get('/items', cacheMiddleware({ maxAge: 60 }), async (c) => { + count += 1 + if (count === 1) { + await new Promise((resolve) => { + c.req.raw.signal.addEventListener( + 'abort', + () => { + resolve() + }, + { once: true } + ) + }) + return new Response('aborted', { status: 599 }) + } + return c.text('ok') + }) + + const leader = app.request('http://localhost/items', { signal: controller.signal }) + await vi.waitFor(() => { + expect(count).toBe(1) + }) + const follower = app.request('http://localhost/items') + controller.abort() + + expect((await leader).status).toBe(599) + expect(await (await follower).text()).toBe('ok') + expect(count).toBe(2) + }) + + it('prevents a slow expired leader from overwriting a newer response', async () => { + vi.useFakeTimers() + const app = new Hono() + let count = 0 + let release: (() => void) | undefined + const gate = new Promise((resolve) => { + release = resolve + }) + + app.get('/items', cacheMiddleware({ maxAge: 60 }), async (c) => { + count += 1 + if (count === 1) { + await gate + return c.text('old') + } + return c.text('new') + }) + + const oldRequest = app.request('http://localhost/items') + await vi.waitFor(() => { + expect(count).toBe(1) + }) + await vi.advanceTimersByTimeAsync(5100) + expect(await (await app.request('http://localhost/items')).text()).toBe('new') + release?.() + expect(await (await oldRequest).text()).toBe('old') + await flushPromises() + expect(await (await app.request('http://localhost/items')).text()).toBe('new') + }) + + it('orders delayed persistence so the newest response wins', async () => { + vi.useFakeTimers() + const storage = createCacheStorage() + const originalSetItem = storage.setItem.bind(storage) + let release: (() => void) | undefined + const gate = new Promise((resolve) => { + release = resolve + }) + let writes = 0 + vi.spyOn(storage, 'setItem').mockImplementation(async (...args) => { + writes += 1 + if (writes === 1) { + await gate + } + await originalSetItem(...args) + }) + const app = new Hono() + let count = 0 + app.get('/items', cacheMiddleware({ maxAge: 60, storage }), (c) => + c.text(count++ === 0 ? 'old' : 'new') + ) + + expect(await (await app.request('http://localhost/items')).text()).toBe('old') + await vi.advanceTimersByTimeAsync(5100) + expect(await (await app.request('http://localhost/items')).text()).toBe('new') + release?.() + await vi.waitFor(() => { + expect(writes).toBe(2) + }) + expect(await (await app.request('http://localhost/items')).text()).toBe('new') + }) + + it('prevents manual revalidation from being overwritten by an older fill', async () => { + const app = new Hono() + let count = 0 + let release: (() => void) | undefined + const gate = new Promise((resolve) => { + release = resolve + }) + app.get( + '/items', + cacheMiddleware({ + maxAge: 60, + revalidateHeader: 'x-revalidate', + shouldRevalidate: () => true, + }), + async (c) => { + count += 1 + if (count === 1) { + await gate + return c.text('old') + } + return c.text('new') + } + ) + + const oldRequest = app.request('http://localhost/items') + await vi.waitFor(() => { + expect(count).toBe(1) + }) + expect( + await ( + await app.request('http://localhost/items', { + headers: { 'x-revalidate': '1' }, + }) + ).text() + ).toBe('new') + release?.() + expect(await (await oldRequest).text()).toBe('old') + await flushPromises() + expect(await (await app.request('http://localhost/items')).text()).toBe('new') + }) + + it('does not cache methods outside GET/HEAD by default', async () => { + const app = new Hono() + let count = 0 + + app.post('/items', cacheMiddleware({ maxAge: 60 }), (c) => { + count += 1 + return c.text(String(count)) + }) + + const res1 = await app.request('http://localhost/items', { method: 'POST' }) + const res2 = await app.request('http://localhost/items', { method: 'POST' }) + + expect(await res1.text()).toBe('1') + expect(await res2.text()).toBe('2') + expect(count).toBe(2) + }) + + it('caches custom methods when configured', async () => { + const app = new Hono() + let count = 0 + + app.post( + '/items', + cacheMiddleware({ + maxAge: 60, + methods: ['POST'], + }), + (c) => { + count += 1 + return c.text(String(count)) + } + ) + + const res1 = await app.request('http://localhost/items', { method: 'POST' }) + const res2 = await app.request('http://localhost/items', { method: 'POST' }) + + expect(await res1.text()).toBe('1') + expect(await res2.text()).toBe('1') + expect(count).toBe(1) + }) + + it('includes request bodies in default keys for custom methods', async () => { + const app = new Hono() + let count = 0 + + app.post('/items', cacheMiddleware({ maxAge: 60, methods: ['POST'] }), async (c) => { + count += 1 + return c.text(`${await c.req.text()}:${count}`) + }) + + const request = (body: string) => + app.request('http://localhost/items', { body, method: 'POST' }) + + expect(await (await request('one')).text()).toBe('one:1') + expect(await (await request('two')).text()).toBe('two:2') + expect(await (await request('one')).text()).toBe('one:1') + expect(count).toBe(2) + }) + + it('refreshes expired custom methods synchronously', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + + const app = new Hono() + let count = 0 + app.post( + '/items', + cacheMiddleware({ maxAge: 1, methods: ['POST'], staleMaxAge: 60 }), + async (c) => { + count += 1 + return c.text(`${await c.req.text()}:${count}`) + } + ) + + const request = () => app.request('http://localhost/items', { body: 'one', method: 'POST' }) + + expect(await (await request()).text()).toBe('one:1') + vi.advanceTimersByTime(1100) + expect(await (await request()).text()).toBe('one:2') + expect(await (await request()).text()).toBe('one:2') + expect(count).toBe(2) + }) + + it('respects shouldBypassCache', async () => { + const app = new Hono() + let count = 0 + + app.get( + '/items', + cacheMiddleware({ + maxAge: 60, + shouldBypassCache: (c) => c.req.header('x-bypass') === '1', + }), + (c) => { + count += 1 + return c.text(String(count)) + } + ) + + const bypassed = await app.request('http://localhost/items', { + headers: { 'x-bypass': '1' }, + }) + const cached = await app.request('http://localhost/items') + const fromCache = await app.request('http://localhost/items') + + expect(await bypassed.text()).toBe('1') + expect(await cached.text()).toBe('2') + expect(await fromCache.text()).toBe('2') + expect(count).toBe(2) + }) + + it.each([ + ['range', 'bytes=0-2'], + ['if-range', '"v1"'], + ['if-match', '"v1"'], + ['if-none-match', '"v1"'], + ['if-modified-since', 'Wed, 01 Jan 2025 00:00:00 GMT'], + ['if-unmodified-since', 'Wed, 01 Jan 2025 00:00:00 GMT'], + ])('bypasses cached responses for %s requests', async (header, value) => { + const app = new Hono() + let count = 0 + + app.get('/items', cacheMiddleware({ maxAge: 60 }), (c) => { + count += 1 + return c.text(String(count), c.req.header('range') ? 206 : 200) + }) + + expect(await (await app.request('http://localhost/items')).text()).toBe('1') + const conditional = await app.request('http://localhost/items', { + headers: { [header]: value }, + }) + expect(await conditional.text()).toBe('2') + expect(await (await app.request('http://localhost/items')).text()).toBe('1') + expect(count).toBe(2) + }) + + it.each([ + ['cache-control', 'no-cache'], + ['cache-control', 'public, max-age=0'], + ['cache-control', 'no-store'], + ['cache-control', 'max-age=00'], + ['cache-control', 'max-age="0"'], + ['pragma', 'no-cache'], + ['pragma', 'foo, no-cache'], + ])('bypasses cached responses for %s: %s', async (header, value) => { + const app = new Hono() + let count = 0 + + app.get('/items', cacheMiddleware({ maxAge: 60 }), (c) => { + count += 1 + return c.text(String(count)) + }) + + expect(await (await app.request('http://localhost/items')).text()).toBe('1') + expect( + await (await app.request('http://localhost/items', { headers: { [header]: value } })).text() + ).toBe('2') + expect(await (await app.request('http://localhost/items')).text()).toBe('1') + expect(count).toBe(2) + }) + + it('keeps previous cache on failed invalidation refresh when keepPreviousOn5xx is true', async () => { + const app = new Hono() + let status = 200 + let value = 'v1' + + app.get( + '/items', + cacheMiddleware({ + maxAge: 60, + keepPreviousOn5xx: true, + revalidateHeader: 'x-internal-revalidate', + shouldInvalidateCache: (c) => c.req.header('x-invalidate') === '1', + }), + (c) => c.text(value, status as 200 | 500) + ) + + const first = await app.request('http://localhost/items') + expect(await first.text()).toBe('v1') + + status = 500 + value = 'v2' + const refresh = await app.request('http://localhost/items', { + headers: { 'x-invalidate': '1', 'x-internal-revalidate': '1' }, + }) + expect(refresh.status).toBe(500) + + status = 200 + value = 'v3' + const cached = await app.request('http://localhost/items') + expect(await cached.text()).toBe('v1') + }) + + it('drops previous cache on failed invalidation refresh when keepPreviousOn5xx is false', async () => { + const app = new Hono() + let status = 200 + let value = 'v1' + + app.get( + '/items', + cacheMiddleware({ + maxAge: 60, + keepPreviousOn5xx: false, + revalidateHeader: 'x-internal-revalidate', + shouldInvalidateCache: (c) => c.req.header('x-invalidate') === '1', + }), + (c) => c.text(value, status as 200 | 500) + ) + + const first = await app.request('http://localhost/items') + expect(await first.text()).toBe('v1') + + status = 500 + value = 'v2' + const refresh = await app.request('http://localhost/items', { + headers: { 'x-invalidate': '1', 'x-internal-revalidate': '1' }, + }) + expect(refresh.status).toBe(500) + + status = 200 + value = 'v3' + const fresh = await app.request('http://localhost/items') + expect(await fresh.text()).toBe('v3') + }) + + it('does not manually revalidate unless revalidateHeader is configured', async () => { + const app = new Hono() + let value = 'v1' + + app.get('/items', cacheMiddleware({ maxAge: 60 }), (c) => c.text(value)) + + const first = await app.request('http://localhost/items') + expect(await first.text()).toBe('v1') + + value = 'v2' + const revalidated = await app.request('http://localhost/items', { + headers: { 'x-cache-revalidate': '1' }, + }) + const cached = await app.request('http://localhost/items') + + expect(await revalidated.text()).toBe('v1') + expect(await cached.text()).toBe('v1') + }) + + it('does not trust a spoofed internal revalidation header', async () => { + const app = new Hono() + let value = 'v1' + + app.get('/items', cacheMiddleware({ maxAge: 60 }), (c) => c.text(value)) + + await app.request('http://localhost/items') + value = 'v2' + + const spoofed = await app.request('http://localhost/items', { + headers: { 'x-hono-universal-cache-revalidate': '1' }, + }) + + expect(await spoofed.text()).toBe('v1') + expect(await (await app.request('http://localhost/items')).text()).toBe('v1') + }) + + it('invalidates a fresh response without requiring a revalidation header', async () => { + const app = new Hono() + let value = 'v1' + let invalidate = false + + app.get( + '/items', + cacheMiddleware({ + maxAge: 60, + shouldInvalidateCache: () => invalidate, + }), + (c) => c.text(value) + ) + + expect(await (await app.request('http://localhost/items')).text()).toBe('v1') + value = 'v2' + invalidate = true + expect(await (await app.request('http://localhost/items')).text()).toBe('v2') + }) + + it('denies a custom revalidate header without shouldRevalidate', async () => { + const app = new Hono() + let value = 'v1' + + app.get( + '/items', + cacheMiddleware({ + maxAge: 60, + revalidateHeader: 'x-custom-revalidate', + }), + (c) => c.text(value) + ) + + await app.request('http://localhost/items') + value = 'v2' + await app.request('http://localhost/items', { + headers: { 'x-custom-revalidate': '1' }, + }) + const cached = await app.request('http://localhost/items') + + expect(await cached.text()).toBe('v1') + }) + + it('respects shouldRevalidate for manual revalidation', async () => { + const app = new Hono() + let value = 'v1' + let allowRevalidate = false + + app.get( + '/items', + cacheMiddleware({ + maxAge: 60, + revalidateHeader: 'x-custom-revalidate', + shouldRevalidate: () => allowRevalidate, + }), + (c) => c.text(value) + ) + + await app.request('http://localhost/items') + + value = 'v2' + const blocked = await app.request('http://localhost/items', { + headers: { 'x-custom-revalidate': '1' }, + }) + expect(await blocked.text()).toBe('v1') + + allowRevalidate = true + await app.request('http://localhost/items', { + headers: { 'x-custom-revalidate': '1' }, + }) + const cached = await app.request('http://localhost/items') + + expect(await cached.text()).toBe('v2') + }) + + it('allows manual revalidation with a dedicated gate header', async () => { + const app = new Hono() + let value = 'v1' + let checks = 0 + + app.get( + '/items', + cacheMiddleware({ + maxAge: 60, + revalidateHeader: 'x-cache-revalidate', + shouldRevalidate: (c) => { + checks += 1 + return c.req.header('x-cache-token') === 'secret' + }, + }), + (c) => c.text(value) + ) + + expect(await (await app.request('http://localhost/items')).text()).toBe('v1') + value = 'v2' + expect( + await ( + await app.request('http://localhost/items', { + headers: { + 'x-cache-token': 'secret', + 'x-cache-revalidate': '1', + }, + }) + ).text() + ).toBe('v2') + expect(checks).toBe(1) + expect(await (await app.request('http://localhost/items')).text()).toBe('v2') + }) + + it('does not let credentialed revalidation poison a public cache key', async () => { + const app = new Hono() + let count = 0 + + app.get( + '/profile', + cacheMiddleware({ + maxAge: 60, + revalidateHeader: 'x-cache-revalidate', + shouldRevalidate: () => true, + }), + (c) => { + count += 1 + return c.text(c.req.header('authorization') ? 'private' : 'public') + } + ) + + expect(await (await app.request('http://localhost/profile')).text()).toBe('public') + expect( + await ( + await app.request('http://localhost/profile', { + headers: { + authorization: 'Bearer secret', + 'x-cache-revalidate': '1', + }, + }) + ).text() + ).toBe('private') + expect(await (await app.request('http://localhost/profile')).text()).toBe('public') + expect(count).toBe(2) + }) + + it('applies defaults from cacheDefaults()', async () => { + const app = new Hono() + let count = 0 + + app.use('*', cacheDefaults({ maxAge: 60 })) + app.get('/items', cacheMiddleware(), (c) => { + count += 1 + return c.text(String(count)) + }) + + const res1 = await app.request('http://localhost/items') + const res2 = await app.request('http://localhost/items') + + expect(await res1.text()).toBe('1') + expect(await res2.text()).toBe('1') + expect(count).toBe(1) + }) + + it('keys by varies headers', async () => { + const app = new Hono() + let count = 0 + + app.get( + '/items', + cacheMiddleware({ + maxAge: 60, + varies: ['accept-language'], + }), + (c) => { + count += 1 + return c.text(String(count)) + } + ) + + const en1 = await app.request('http://localhost/items', { + headers: { 'accept-language': 'en' }, + }) + const ar1 = await app.request('http://localhost/items', { + headers: { 'accept-language': 'ar' }, + }) + const en2 = await app.request('http://localhost/items', { + headers: { 'accept-language': 'en' }, + }) + + expect(await en1.text()).toBe('1') + expect(await ar1.text()).toBe('2') + expect(await en2.text()).toBe('1') + expect(count).toBe(2) + }) + + it('separates default cache keys by origin and method', async () => { + const app = new Hono() + let count = 0 + + app.on( + ['GET', 'POST'], + '/items', + cacheMiddleware({ maxAge: 60, methods: ['GET', 'POST'] }), + (c) => { + count += 1 + return c.text(`${c.req.method}:${new URL(c.req.url).host}:${count}`) + } + ) + + expect(await (await app.request('http://one.example/items')).text()).toBe('GET:one.example:1') + expect(await (await app.request('http://two.example/items')).text()).toBe('GET:two.example:2') + expect(await (await app.request('http://one.example/items', { method: 'POST' })).text()).toBe( + 'POST:one.example:3' + ) + expect(await (await app.request('http://one.example/items')).text()).toBe('GET:one.example:1') + }) + + it.each([ + ['user?one', 'user?two'], + ['a/b', 'a:b'], + ['a\\b', 'a:b'], + ])('keeps normalized custom keys %s and %s isolated', async (firstKey, secondKey) => { + const app = new Hono() + let count = 0 + + app.get( + '/items', + cacheMiddleware({ + getKey: (c) => c.req.header('x-key') ?? '', + maxAge: 60, + }), + (c) => { + count += 1 + return c.text(`${c.req.header('x-key')}:${count}`) + } + ) + + const request = (key: string) => + app.request('http://localhost/items', { headers: { 'x-key': key } }) + + expect(await (await request(firstKey)).text()).toBe(`${firstKey}:1`) + expect(await (await request(secondKey)).text()).toBe(`${secondKey}:2`) + expect(await (await request(firstKey)).text()).toBe(`${firstKey}:1`) + expect(await (await request(secondKey)).text()).toBe(`${secondKey}:2`) + expect(count).toBe(2) + }) + + it('keeps legal Vary header names isolated', async () => { + const storage = createCacheStorage() + const first = new Hono() + const second = new Hono() + let firstCalls = 0 + let secondCalls = 0 + + first.get( + '/items', + cacheMiddleware({ maxAge: 60, name: 'shared', storage, varies: ['x-a'] }), + (c) => { + firstCalls += 1 + c.header('vary', 'x-a') + return c.text(`first:${c.req.header('x-a')}`) + } + ) + second.get( + '/items', + cacheMiddleware({ maxAge: 60, name: 'shared', storage, varies: ['xa'] }), + (c) => { + secondCalls += 1 + c.header('vary', 'xa') + return c.text(`second:${c.req.header('xa')}`) + } + ) + + expect( + await (await first.request('http://localhost/items', { headers: { 'x-a': 'same' } })).text() + ).toBe('first:same') + expect( + await (await second.request('http://localhost/items', { headers: { xa: 'same' } })).text() + ).toBe('second:same') + expect(firstCalls).toBe(1) + expect(secondCalls).toBe(1) + }) + + it.each(['authorization', 'cookie'])( + 'does not cache requests with an implicit %s header', + async (header) => { + const app = new Hono() + let count = 0 + + app.get('/items', cacheMiddleware({ maxAge: 60 }), (c) => { + count += 1 + return c.text(`${c.req.header(header)}:${count}`) + }) + + const request = (value: string) => + app.request('http://localhost/items', { headers: { [header]: value } }) + + expect(await (await request('one')).text()).toBe('one:1') + expect(await (await request('one')).text()).toBe('one:2') + expect(count).toBe(2) + } + ) + + it.each(['authorization', 'cookie'])( + 'caches requests when %s is explicitly included in varies', + async (header) => { + const app = new Hono() + let count = 0 + + app.get('/items', cacheMiddleware({ maxAge: 60, varies: [header] }), (c) => { + count += 1 + return c.text(`${c.req.header(header)}:${count}`) + }) + + const request = (value: string) => + app.request('http://localhost/items', { headers: { [header]: value } }) + + expect(await (await request('one')).text()).toBe('one:1') + expect(await (await request('two')).text()).toBe('two:2') + expect(await (await request('one')).text()).toBe('one:1') + expect(count).toBe(2) + } + ) + + it('refreshes stale responses synchronously', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + + const app = new Hono() + let count = 0 + app.get( + '/items', + cacheMiddleware({ + maxAge: 1, + staleMaxAge: 60, + getKey: () => 'stable-key', + }), + (c) => { + count += 1 + return c.text(String(count)) + } + ) + + const first = await app.request('http://localhost/items') + expect(await first.text()).toBe('1') + + vi.advanceTimersByTime(1100) + + expect(await (await app.request('http://localhost/items')).text()).toBe('2') + expect(await (await app.request('http://localhost/items')).text()).toBe('2') + expect(count).toBe(2) + }) + + it('serves a stale response when synchronous refresh returns 5xx', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + const app = new Hono() + let fail = false + + app.get('/items', cacheMiddleware({ maxAge: 1, staleMaxAge: 60 }), (c) => { + return fail ? c.text('failed', 500) : c.text('cached') + }) + + expect(await (await app.request('http://localhost/items')).text()).toBe('cached') + vi.advanceTimersByTime(1100) + fail = true + + const fallback = await app.request('http://localhost/items') + expect(fallback.status).toBe(200) + expect(await fallback.text()).toBe('cached') + }) + + it('serves a stale response when synchronous refresh throws', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + const app = new Hono() + let fail = false + + app.get('/items', cacheMiddleware({ maxAge: 1, staleMaxAge: 60 }), (c) => { + if (fail) { + throw new Error('failed') + } + return c.text('cached') + }) + + expect(await (await app.request('http://localhost/items')).text()).toBe('cached') + vi.advanceTimersByTime(1100) + fail = true + + const fallback = await app.request('http://localhost/items') + expect(fallback.status).toBe(200) + expect(await fallback.text()).toBe('cached') + }) + + it('does not cache non-cacheable responses with set-cookie', async () => { + const app = new Hono() + let count = 0 + + app.get('/items', cacheMiddleware({ maxAge: 60 }), (c) => { + count += 1 + c.header('set-cookie', `s=${count}; Path=/`) + return c.text(String(count)) + }) + + const res1 = await app.request('http://localhost/items') + const res2 = await app.request('http://localhost/items') + + expect(await res1.text()).toBe('1') + expect(await res2.text()).toBe('2') + expect(count).toBe(2) + }) + + it.each([ + 'text/event-stream; charset=utf-8', + 'application/x-ndjson', + 'application/ndjson', + 'application/json-seq', + 'application/stream+json', + 'multipart/x-mixed-replace; boundary=frame', + ])('does not cache streaming responses with content type %s', async (contentType) => { + const app = new Hono() + let count = 0 + + app.get('/events', cacheMiddleware({ maxAge: 60 }), () => { + count += 1 + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(`data: ${count}\n\n`)) + }, + }) + return new Response(body, { + headers: { 'content-type': contentType }, + }) + }) + + const first = await app.request('http://localhost/events') + const second = await app.request('http://localhost/events') + await first.body?.cancel() + await second.body?.cancel() + + expect(count).toBe(2) + }) + + it('returns unknown streaming responses without waiting for cache serialization', async () => { + vi.useFakeTimers() + const app = new Hono() + let count = 0 + + app.get('/stream', cacheMiddleware({ maxAge: 60 }), () => { + count += 1 + return new Response(new ReadableStream({}), { + headers: { 'content-type': 'application/octet-stream' }, + }) + }) + + const response = await app.request('http://localhost/stream') + expect(response.status).toBe(200) + expect(count).toBe(1) + void response.body?.cancel() + + await vi.advanceTimersByTimeAsync(1100) + const next = await app.request('http://localhost/stream') + expect(count).toBe(2) + void next.body?.cancel() + }) + + it('cancels the private cache branch after an unknown stream exceeds its limit', async () => { + let cancelled = 0 + const app = new Hono() + app.get('/stream', cacheMiddleware({ maxAge: 60 }), () => { + const stream = new ReadableStream({ + async pull(controller) { + await new Promise((resolve) => setTimeout(resolve, 10)) + controller.enqueue(new Uint8Array(1024 * 1024)) + }, + cancel() { + cancelled += 1 + }, + }) + return new Response(stream, { headers: { 'content-type': 'application/octet-stream' } }) + }) + + const response = await app.request('http://localhost/stream') + await response.body?.cancel() + expect(cancelled).toBe(1) + }) + + it('preserves Content-Length on cached HEAD responses', async () => { + const app = new Hono() + let count = 0 + app.get('/items', cacheMiddleware({ maxAge: 60 }), () => { + count += 1 + return new Response('content', { headers: { 'content-length': '7' } }) + }) + + const first = await app.request('http://localhost/items', { method: 'HEAD' }) + const second = await app.request('http://localhost/items', { method: 'HEAD' }) + expect(first.headers.get('content-length')).toBe('7') + expect(second.headers.get('content-length')).toBe('7') + expect(count).toBe(1) + }) + + it('expires coalescing state when response persistence never settles', async () => { + vi.useFakeTimers() + const app = new Hono() + let count = 0 + + app.get( + '/items', + cacheMiddleware({ + maxAge: 60, + serialize: () => new Promise(() => undefined), + }), + (c) => c.text(String(++count)) + ) + + expect(await (await app.request('http://localhost/items')).text()).toBe('1') + await vi.advanceTimersByTimeAsync(5100) + expect(await (await app.request('http://localhost/items')).text()).toBe('2') + }) + + it('adds resident time to Age on cached responses', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + const app = new Hono() + + app.get('/items', cacheMiddleware({ maxAge: 60 }), () => { + return new Response('value', { headers: { age: '10' } }) + }) + + const first = await app.request('http://localhost/items') + expect(first.headers.get('age')).toBe('10') + await flushPromises() + vi.advanceTimersByTime(2500) + const cached = await app.request('http://localhost/items') + expect(cached.headers.get('age')).toBe('12') + }) + + it('includes apparent response age from Date', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:30.000Z')) + const app = new Hono() + + app.get('/items', cacheMiddleware({ maxAge: 60 }), () => { + return new Response('value', { + headers: { date: 'Thu, 01 Jan 2026 00:00:00 GMT' }, + }) + }) + + await app.request('http://localhost/items') + await flushPromises() + expect((await app.request('http://localhost/items')).headers.get('age')).toBe('30') + }) + + it('sanitizes unsafe headers from persisted responses', async () => { + const storage = createCacheStorage() + const storageKey = createTestStorageKey('cache', 'hono/handlers', 'items', 'key') + await storage.setItem(storageKey, { + value: toBase64('cached'), + encoding: 'base64', + status: 200, + headers: { + connection: 'keep-alive, x-hop', + 'set-cookie': 'session=attacker', + 'x-safe': 'yes', + 'x-hop': 'attacker', + }, + mtime: Date.now(), + expires: Date.now() + 60_000, + staleExpires: Date.now() + 60_000, + integrity: 'integrity', + }) + const app = new Hono() + app.get( + '/items', + cacheMiddleware({ + getKey: () => 'key', + integrity: 'integrity', + maxAge: 60, + name: 'items', + storage, + }), + (c) => c.text('origin') + ) + + const response = await app.request('http://localhost/items') + expect(await response.text()).toBe('cached') + expect(response.headers.get('set-cookie')).toBeNull() + expect(response.headers.get('connection')).toBeNull() + expect(response.headers.get('x-hop')).toBeNull() + expect(response.headers.get('x-safe')).toBe('yes') + }) + + it('does not cache responses with unkeyed Vary headers', async () => { + const app = new Hono() + let count = 0 + + app.get('/items', cacheMiddleware({ maxAge: 60 }), (c) => { + count += 1 + c.header('vary', 'Accept-Language, Accept-Encoding') + return c.text(String(count)) + }) + + expect(await (await app.request('http://localhost/items')).text()).toBe('1') + expect(await (await app.request('http://localhost/items')).text()).toBe('2') + }) + + it('caches responses when every Vary header is keyed', async () => { + const app = new Hono() + let count = 0 + + app.get( + '/items', + cacheMiddleware({ maxAge: 60, varies: ['accept-language', 'accept-encoding'] }), + (c) => { + count += 1 + c.header('vary', 'Accept-Language, Accept-Encoding') + return c.text(String(count)) + } + ) + + const headers = { 'accept-encoding': 'gzip', 'accept-language': 'en' } + expect(await (await app.request('http://localhost/items', { headers })).text()).toBe('1') + expect(await (await app.request('http://localhost/items', { headers })).text()).toBe('1') + expect(count).toBe(1) + }) + + it('fails open when response storage reads and writes fail', async () => { + const storage = createCacheStorage() + vi.spyOn(storage, 'getItem').mockRejectedValue(new Error('read unavailable')) + vi.spyOn(storage, 'setItem').mockRejectedValue(new Error('write unavailable')) + const app = new Hono() + let count = 0 + + app.get('/items', cacheMiddleware({ maxAge: 60, storage }), (c) => { + count += 1 + return c.text(String(count)) + }) + + expect(await (await app.request('http://localhost/items')).text()).toBe('1') + expect(await (await app.request('http://localhost/items')).text()).toBe('2') + }) + + it('fails open when response storage removal fails', async () => { + const storage = createCacheStorage() + const app = new Hono() + let invalidate = false + let count = 0 + + app.get( + '/items', + cacheMiddleware({ + keepPreviousOn5xx: false, + maxAge: 60, + shouldInvalidateCache: () => invalidate, + storage, + }), + (c) => { + count += 1 + return c.text(String(count)) + } + ) + + expect(await (await app.request('http://localhost/items')).text()).toBe('1') + vi.spyOn(storage, 'removeItem').mockRejectedValue(new Error('remove unavailable')) + invalidate = true + expect(await (await app.request('http://localhost/items')).text()).toBe('2') + }) + + it.each([204, 205])('replays cached %i responses without a body', async (status) => { + const app = new Hono() + let count = 0 + + app.get('/items', cacheMiddleware({ maxAge: 60 }), () => { + count += 1 + return new Response(null, { headers: { 'x-count': String(count) }, status }) + }) + + const first = await app.request('http://localhost/items') + const cached = await app.request('http://localhost/items') + + expect(first.status).toBe(status) + expect(cached.status).toBe(status) + expect(cached.headers.get('x-count')).toBe('1') + expect(await cached.text()).toBe('') + expect(count).toBe(1) + }) + + it.each([ + ['private responses', { 'cache-control': 'private' }, 200], + ['wildcard vary responses', { vary: '*' }, 200], + ['partial responses', {}, 206], + ])('does not cache %s', async (_name, headers, status) => { + const app = new Hono() + let count = 0 + + app.get('/items', cacheMiddleware({ maxAge: 60 }), () => { + count += 1 + return new Response(String(count), { headers, status }) + }) + + expect(await (await app.request('http://localhost/items')).text()).toBe('1') + expect(await (await app.request('http://localhost/items')).text()).toBe('2') + }) + + it('supports custom serialize/deserialize for responses', async () => { + const app = new Hono() + let count = 0 + + app.get( + '/items', + cacheMiddleware({ + maxAge: 60, + serialize: async (response, context) => ({ + value: await response.text(), + encoding: 'base64', + status: response.status, + headers: { + 'content-type': response.headers.get('content-type') ?? 'text/plain;charset=UTF-8', + }, + mtime: context.now, + expires: context.now + context.maxAge * 1000, + staleExpires: context.now + context.maxAge * 1000, + integrity: context.integrity, + }), + deserialize: (entry) => + new Response(entry.value, { + status: entry.status, + headers: entry.headers, + }), + }), + (c) => { + count += 1 + return c.text(`value-${count}`) + } + ) + + const res1 = await app.request('http://localhost/items') + const res2 = await app.request('http://localhost/items') + + expect(await res1.text()).toBe('value-1') + expect(await res2.text()).toBe('value-1') + expect(count).toBe(1) + }) + + it('removes invalid cached response entries', async () => { + const storage = createCacheStorage() + const app = new Hono() + let count = 0 + + const base = 'cache' + const group = 'hono/handlers' + const name = 'items' + const key = 'manual-key' + const storageKey = createTestStorageKey(base, group, name, key) + + await storage.setItem(storageKey, { value: 1 }) + + app.get( + '/items', + cacheMiddleware({ + storage, + name, + getKey: () => key, + maxAge: 60, + }), + (c) => { + count += 1 + return c.text(`value-${count}`) + } + ) + + const res = await app.request('http://localhost/items') + await vi.waitFor(async () => { + expect(await storage.getItem(storageKey)).not.toBeNull() + }) + const cachedRaw = await (storage.getItem(storageKey) as Promise) + + expect(await res.text()).toBe('value-1') + expect(count).toBe(1) + expect(cachedRaw).toBeTypeOf('object') + expect(cachedRaw).not.toBeNull() + if (!cachedRaw || typeof cachedRaw !== 'object') { + throw new Error('Expected cached response entry object') + } + expect((cachedRaw as { value?: unknown }).value).toBeTypeOf('string') + }) + + it('does not serve persisted responses with missing cache metadata', async () => { + const storage = createCacheStorage() + const storageKey = createTestStorageKey('cache', 'hono/handlers', 'items', 'key') + await storage.setItem(storageKey, { + value: toBase64('poison'), + encoding: 'base64', + status: 200, + headers: {}, + integrity: 'integrity', + }) + const app = new Hono() + let count = 0 + app.get( + '/items', + cacheMiddleware({ + getKey: () => 'key', + integrity: 'integrity', + maxAge: 60, + name: 'items', + storage, + }), + (c) => { + count += 1 + return c.text('origin') + } + ) + + expect(await (await app.request('http://localhost/items')).text()).toBe('origin') + expect(count).toBe(1) + }) + + it('removes persisted responses after their stale window', async () => { + const storage = createCacheStorage() + const storageKey = createTestStorageKey('cache', 'hono/handlers', 'items', 'key') + await storage.setItem(storageKey, { + value: toBase64('expired'), + encoding: 'base64', + status: 200, + headers: {}, + mtime: Date.now() - 3000, + expires: Date.now() - 2000, + staleExpires: Date.now() - 1000, + integrity: 'integrity', + }) + const app = new Hono() + app.onError(() => new Response('failed', { status: 500 })) + app.get( + '/items', + cacheMiddleware({ + getKey: () => 'key', + integrity: 'integrity', + maxAge: 60, + name: 'items', + storage, + }), + () => { + throw new Error('origin failed') + } + ) + + expect((await app.request('http://localhost/items')).status).toBe(500) + expect(await storage.getItem(storageKey)).toBeNull() + }) + + it('falls back to safe key prefix when path decoding fails', async () => { + const app = new Hono() + let count = 0 + + app.get('*', cacheMiddleware({ maxAge: 60 }), (c) => { + count += 1 + return c.text(String(count)) + }) + + const url = 'http://localhost/%E0%A4%A' + const res1 = await app.request(url) + const res2 = await app.request(url) + + expect(await res1.text()).toBe('1') + expect(await res2.text()).toBe('1') + expect(count).toBe(1) + }) + + it('drops cached response entries with integrity mismatch', async () => { + const storage = createCacheStorage() + const app = new Hono() + let count = 0 + + const storageKey = createTestStorageKey('cache', 'hono/handlers', 'items', 'key') + await storage.setItem(storageKey, { + value: toBase64('stale'), + encoding: 'base64', + status: 200, + headers: { 'content-type': 'text/plain' }, + mtime: Date.now(), + expires: Date.now() + 60_000, + staleExpires: Date.now() + 120_000, + integrity: 'stale-integrity', + }) + + app.get( + '/items', + cacheMiddleware({ + storage, + name: 'items', + getKey: () => 'key', + maxAge: 60, + integrity: 'fresh-integrity', + }), + (c) => { + count += 1 + return c.text(`value-${count}`) + } + ) + + const res = await app.request('http://localhost/items') + expect(await res.text()).toBe('value-1') + expect(count).toBe(1) + }) + + it('drops cached response entries rejected by validate()', async () => { + const storage = createCacheStorage() + const app = new Hono() + let count = 0 + + const storageKey = createTestStorageKey('cache', 'hono/handlers', 'items', 'key') + await storage.setItem(storageKey, { + value: toBase64('stale'), + encoding: 'base64', + status: 200, + headers: { 'content-type': 'text/plain' }, + mtime: Date.now(), + expires: Date.now() + 60_000, + staleExpires: Date.now() + 120_000, + integrity: 'integrity', + }) + + app.get( + '/items', + cacheMiddleware({ + storage, + name: 'items', + getKey: () => 'key', + maxAge: 60, + integrity: 'integrity', + validate: () => false, + }), + (c) => { + count += 1 + return c.text(`value-${count}`) + } + ) + + const res = await app.request('http://localhost/items') + expect(await res.text()).toBe('value-1') + expect(count).toBe(1) + }) + + it('evicts old cache when invalidated response is non-cacheable with keepPreviousOn5xx=true', async () => { + const app = new Hono() + let value = 'v1' + let noStore = false + + app.get( + '/items', + cacheMiddleware({ + maxAge: 60, + keepPreviousOn5xx: true, + revalidateHeader: 'x-internal-revalidate', + shouldInvalidateCache: (c) => c.req.header('x-invalidate') === '1', + }), + (c) => { + if (noStore) { + c.header('cache-control', 'no-store') + } + return c.text(value) + } + ) + + expect(await (await app.request('http://localhost/items')).text()).toBe('v1') + + value = 'v2' + noStore = true + const refresh = await app.request('http://localhost/items', { + headers: { 'x-internal-revalidate': '1', 'x-invalidate': '1' }, + }) + expect(await refresh.text()).toBe('v2') + + value = 'v3' + noStore = false + const next = await app.request('http://localhost/items') + expect(await next.text()).toBe('v3') + }) + }) + + describe('cacheFunction', () => { + it('caches function results', async () => { + let count = 0 + + const fn = cacheFunction( + (id: string) => { + count += 1 + return `${id}-${count}` + }, + { + maxAge: 60, + swr: false, + getKey: (id) => id, + } + ) + + const a = await fn('x') + const b = await fn('x') + + expect(a).toBe('x-1') + expect(b).toBe('x-1') + expect(count).toBe(1) + }) + + it('caches null function results', async () => { + let count = 0 + const fn = cacheFunction( + () => { + count += 1 + return null + }, + { maxAge: 60, swr: false } + ) + + expect(await fn()).toBeNull() + expect(await fn()).toBeNull() + expect(count).toBe(1) + }) + + it('invalidates a fresh function result', async () => { + let count = 0 + let invalidate = false + const fn = cacheFunction( + () => { + count += 1 + return count + }, + { + maxAge: 60, + swr: false, + shouldInvalidateCache: () => invalidate, + } + ) + + expect(await fn()).toBe(1) + invalidate = true + expect(await fn()).toBe(2) + }) + + it('deduplicates concurrent calls', async () => { + let count = 0 + + const fn = cacheFunction( + async (id: string) => { + count += 1 + await Promise.resolve() + return `${id}-${count}` + }, + { + maxAge: 60, + swr: false, + getKey: (id) => id, + } + ) + + const [a, b, c] = await Promise.all([fn('x'), fn('x'), fn('x')]) + + expect(a).toBe('x-1') + expect(b).toBe('x-1') + expect(c).toBe('x-1') + expect(count).toBe(1) + }) + + it('deduplicates concurrent calls across wrappers sharing an explicit identity', async () => { + const storage = createCacheStorage() + let count = 0 + let release: (() => void) | undefined + const gate = new Promise((resolve) => { + release = resolve + }) + const fetcher = async () => { + count += 1 + await gate + return count + } + const first = cacheFunction(fetcher, { maxAge: 60, name: 'shared', storage }) + const second = cacheFunction(fetcher, { maxAge: 60, name: 'shared', storage }) + + const pending = [first(), second()] + await vi.waitFor(() => { + expect(count).toBe(1) + }) + release?.() + + expect(await Promise.all(pending)).toEqual([1, 1]) + expect(count).toBe(1) + }) + + it('does not deduplicate calls across storage instances', async () => { + let count = 0 + let resolveFirst!: () => void + const firstCall = new Promise((resolve) => { + resolveFirst = resolve + }) + const fetcher = async () => { + count += 1 + const current = count + if (current === 1) { + await firstCall + } + return current + } + const options = { + getKey: () => 'key', + maxAge: 60, + swr: false, + } + const first = cacheFunction(fetcher, { ...options, storage: createCacheStorage() }) + const second = cacheFunction(fetcher, { ...options, storage: createCacheStorage() }) + + const firstResult = first() + const secondResult = second() + expect(await secondResult).toBe(2) + resolveFirst() + expect(await firstResult).toBe(1) + }) + + it('respects shouldBypassCache for functions', async () => { + let count = 0 + let bypass = false + + const fn = cacheFunction( + (id: string) => { + count += 1 + return `${id}-${count}` + }, + { + maxAge: 60, + swr: false, + getKey: (id) => id, + shouldBypassCache: () => bypass, + } + ) + + const a = await fn('x') + bypass = true + const b = await fn('x') + bypass = false + const c = await fn('x') + + expect(a).toBe('x-1') + expect(b).toBe('x-2') + expect(c).toBe('x-1') + expect(count).toBe(2) + }) + + it('removes cache before invalidation refresh when keepPreviousOn5xx is false', async () => { + const storage = createCacheStorage() + let count = 0 + let shouldInvalidate = false + let shouldThrow = false + + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + + const fn = cacheFunction( + () => { + count += 1 + if (shouldThrow) { + throw new Error('boom') + } + return `v${count}` + }, + { + storage, + base: 'base', + group: 'group', + name: 'name', + getKey: () => 'key', + maxAge: 1, + staleMaxAge: 0, + swr: false, + keepPreviousOn5xx: false, + shouldInvalidateCache: () => shouldInvalidate, + } + ) + + await fn() + + vi.advanceTimersByTime(1100) + + shouldInvalidate = true + shouldThrow = true + await expect(fn()).rejects.toThrow('boom') + + const cached = await storage.getItem('base:group:name:key.json') + expect(cached).toBeNull() + }) + + it('keeps previous cache before invalidation refresh when keepPreviousOn5xx is true', async () => { + const storage = createCacheStorage() + let count = 0 + let shouldInvalidate = false + let shouldThrow = false + + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + + const fn = cacheFunction( + () => { + count += 1 + if (shouldThrow) { + throw new Error('boom') + } + return `v${count}` + }, + { + storage, + base: 'base', + group: 'group', + name: 'name', + getKey: () => 'key', + maxAge: 1, + staleMaxAge: 0, + swr: false, + keepPreviousOn5xx: true, + shouldInvalidateCache: () => shouldInvalidate, + } + ) + + await fn() + + vi.advanceTimersByTime(1100) + + shouldInvalidate = true + shouldThrow = true + await expect(fn()).rejects.toThrow('boom') + + const cached = await storage.getItem('base:group:name:key.json') + expect(cached).not.toBeNull() + }) + + it('supports custom serialize/deserialize for functions', async () => { + let count = 0 + + const fn = cacheFunction( + () => { + count += 1 + return { value: count } + }, + { + maxAge: 60, + swr: false, + getKey: () => 'key', + serialize: (result, context) => ({ + value: JSON.stringify(result), + mtime: context.now, + expires: context.now + context.maxAge * 1000, + staleExpires: context.now + context.maxAge * 1000, + integrity: context.integrity, + }), + deserialize: (entry) => { + if (typeof entry.value !== 'string') { + throw new TypeError('Expected serialized string value') + } + return JSON.parse(entry.value) as { value: number } + }, + } + ) + + const a = await fn() + const b = await fn() + + expect(a).toEqual({ value: 1 }) + expect(b).toEqual({ value: 1 }) + expect(count).toBe(1) + }) + + it('supports validate hook for function entries', async () => { + let count = 0 + let valid = true + + const fn = cacheFunction( + () => { + count += 1 + return `v${count}` + }, + { + maxAge: 60, + swr: false, + getKey: () => 'key', + validate: () => valid, + } + ) + + await fn() + valid = false + const second = await fn() + + expect(second).toBe('v2') + expect(count).toBe(2) + }) + + it('serves stale and refreshes function cache in background', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + + let count = 0 + + const fn = cacheFunction( + () => { + count += 1 + return `v${count}` + }, + { + maxAge: 1, + staleMaxAge: 60, + swr: true, + getKey: () => 'key', + } + ) + + const first = await fn() + vi.advanceTimersByTime(1100) + + const stale = await fn() + await flushPromises() + const refreshed = await fn() + + expect(first).toBe('v1') + expect(stale).toBe('v1') + expect(refreshed).toBe('v2') + }) + + it('handles rejected background function refreshes while serving stale', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + + let shouldThrow = false + const fn = cacheFunction( + () => { + if (shouldThrow) { + throw new Error('refresh failed') + } + return 'cached' + }, + { maxAge: 1, staleMaxAge: 60, swr: true } + ) + + expect(await fn()).toBe('cached') + shouldThrow = true + vi.advanceTimersByTime(1100) + expect(await fn()).toBe('cached') + await flushPromises() + }) + + it('coalesces concurrent synchronous function failures', async () => { + let count = 0 + const fn = cacheFunction( + () => { + count += 1 + throw new Error('failed') + }, + { maxAge: 60, swr: false } + ) + + const results = await Promise.allSettled(Array.from({ length: 500 }, () => fn())) + expect(results.every((result) => result.status === 'rejected')).toBe(true) + expect(count).toBe(1) + await flushPromises() + await expect(fn()).rejects.toThrow('failed') + expect(count).toBe(2) + }) + + it('prevents a slow expired function call from overwriting a newer result', async () => { + vi.useFakeTimers() + let count = 0 + let release: (() => void) | undefined + const gate = new Promise((resolve) => { + release = resolve + }) + const fn = cacheFunction( + async () => { + count += 1 + if (count === 1) { + await gate + return 'old' + } + return 'new' + }, + { maxAge: 60, name: 'ordered-function', swr: false } + ) + + const oldCall = fn() + await vi.waitFor(() => { + expect(count).toBe(1) + }) + await vi.advanceTimersByTimeAsync(5100) + expect(await fn()).toBe('new') + release?.() + expect(await oldCall).toBe('old') + await flushPromises() + expect(await fn()).toBe('new') + }) + + it('keeps empty function cache-key segments isolated', async () => { + const storage = createCacheStorage() + let secondCalls = 0 + const first = cacheFunction(() => 'first', { + base: 'base', + getKey: () => 'key', + group: 'group', + maxAge: 60, + name: '', + storage, + }) + const second = cacheFunction( + () => { + secondCalls += 1 + return 'second' + }, + { + base: 'base', + getKey: () => '', + group: 'group', + maxAge: 60, + name: 'key', + storage, + } + ) + + expect(await first()).toBe('first') + expect(await second()).toBe('second') + expect(secondCalls).toBe(1) + expect((await storage.getKeys()).sort()).toEqual([ + 'base:group:%00:key.json', + 'base:group:key:%00.json', + ]) + }) + + it.each([ + ['NaN', () => Number.NaN], + ['Infinity', () => Number.POSITIVE_INFINITY], + ['negative zero', () => -0], + ['Date', () => new Date('2026-01-01T00:00:00.000Z')], + ['Map', () => new Map([['key', 'value']])], + ])('does not persist JSON-unsafe %s function results', async (_name, createValue) => { + let count = 0 + const fn = cacheFunction( + () => { + count += 1 + return createValue() + }, + { maxAge: 60, swr: false } + ) + + const first = await fn() + await flushPromises() + const second = await fn() + expect(Object.prototype.toString.call(second)).toBe(Object.prototype.toString.call(first)) + if (typeof first === 'number') { + expect(Object.is(second, first)).toBe(true) + } + expect(count).toBe(2) + }) + + it('fails open when persisted function metadata getters throw', async () => { + const storage = createCacheStorage() + vi.spyOn(storage, 'getItem').mockResolvedValue( + new Proxy( + {}, + { + get() { + throw new Error('hostile record') + }, + } + ) + ) + let count = 0 + const fn = cacheFunction(() => `origin-${++count}`, { + getKey: () => 'key', + maxAge: 60, + storage, + swr: false, + }) + + expect(await fn()).toBe('origin-1') + }) + + it('fails open after a storage read timeout', async () => { + vi.useFakeTimers() + const storage = createCacheStorage() + vi.spyOn(storage, 'getItem').mockReturnValue(new Promise(() => undefined)) + let count = 0 + const fn = cacheFunction(() => `origin-${++count}`, { + getKey: () => 'key', + maxAge: 60, + storage, + swr: false, + }) + + const result = fn() + await vi.advanceTimersByTimeAsync(5100) + expect(await result).toBe('origin-1') + }) + + it('does not block fresh function results on a hung cache write', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + const storage = createCacheStorage() + let count = 0 + const fn = cacheFunction(() => `v${++count}`, { + getKey: () => 'key', + maxAge: 1, + staleMaxAge: 1, + storage, + swr: true, + }) + + expect(await fn()).toBe('v1') + await vi.waitFor(async () => { + expect((await storage.getKeys()).length).toBe(1) + }) + vi.spyOn(storage, 'setItem').mockReturnValue(new Promise(() => undefined)) + vi.advanceTimersByTime(1100) + expect(await fn()).toBe('v1') + await flushPromises() + expect(count).toBe(2) + vi.advanceTimersByTime(1100) + expect(await fn()).toBe('v2') + }) + + it('bypasses cache when maxAge is zero', async () => { + let count = 0 + + const fn = cacheFunction( + () => { + count += 1 + return count + }, + { maxAge: 0 } + ) + + const a = await fn() + const b = await fn() + + expect(a).toBe(1) + expect(b).toBe(2) + expect(count).toBe(2) + }) + + it('uses default argument hashing when getKey is omitted', async () => { + let count = 0 + const fn = cacheFunction( + (input: { id: string }) => { + count += 1 + return `${input.id}-${count}` + }, + { maxAge: 60, swr: false } + ) + + const a = await fn({ id: 'x' }) + const b = await fn({ id: 'x' }) + + expect(a).toBe('x-1') + expect(b).toBe('x-1') + expect(count).toBe(1) + }) + + it('isolates wrappers created from the same closure source', async () => { + const storage = createCacheStorage() + const makeCached = (value: string) => cacheFunction(() => value, { maxAge: 60, storage }) + const first = makeCached('tenant-a') + const second = makeCached('tenant-b') + + expect(await first()).toBe('tenant-a') + expect(await second()).toBe('tenant-b') + expect(await first()).toBe('tenant-a') + expect(await second()).toBe('tenant-b') + }) + + it('keeps zero and negative zero argument keys isolated', async () => { + let count = 0 + const fn = cacheFunction( + (value: number) => { + count += 1 + return Object.is(value, -0) ? 'negative' : 'positive' + }, + { maxAge: 60 } + ) + + expect(await fn(0)).toBe('positive') + expect(await fn(-0)).toBe('negative') + expect(count).toBe(2) + }) + + it.each([ + [new Date('2026-01-01T00:00:00.000Z'), '2026-01-01T00:00:00.000Z'], + [Number.NaN, null], + ])('keeps type-distinct arguments isolated', async (firstArg, secondArg) => { + let count = 0 + const fn = cacheFunction( + (value: unknown) => { + count += 1 + return `${String(value)}:${count}` + }, + { maxAge: 60, swr: false } + ) + + expect(await fn(firstArg)).toBe(`${String(firstArg)}:1`) + expect(await fn(secondArg)).toBe(`${String(secondArg)}:2`) + expect(await fn(firstArg)).toBe(`${String(firstArg)}:1`) + expect(count).toBe(2) + }) + + it('removes malformed function cache entries before computing fresh value', async () => { + const storage = createCacheStorage() + let count = 0 + + await storage.setItem( + createTestStorageKey('cache', 'hono/functions', 'fn', 'key'), + 123 as unknown as object + ) + + const fn = cacheFunction( + () => { + count += 1 + return `v${count}` + }, + { + storage, + base: 'cache', + group: 'hono/functions', + name: 'fn', + getKey: () => 'key', + maxAge: 60, + swr: false, + } + ) + + const value = await fn() + expect(value).toBe('v1') + expect(count).toBe(1) + }) + + it('does not serve function entries with missing cache metadata', async () => { + const storage = createCacheStorage() + const storageKey = createTestStorageKey('cache', 'hono/functions', 'fn', 'key') + await storage.setItem(storageKey, { value: 'poison', integrity: 'integrity' }) + let count = 0 + const fn = cacheFunction( + () => { + count += 1 + return 'origin' + }, + { + getKey: () => 'key', + integrity: 'integrity', + maxAge: 60, + name: 'fn', + storage, + } + ) + + expect(await fn()).toBe('origin') + expect(count).toBe(1) + }) + + it('removes function entries after their stale window', async () => { + const storage = createCacheStorage() + const storageKey = createTestStorageKey('cache', 'hono/functions', 'fn', 'key') + await storage.setItem(storageKey, { + value: 'expired', + mtime: Date.now() - 3000, + expires: Date.now() - 2000, + staleExpires: Date.now() - 1000, + integrity: 'integrity', + }) + const fn = cacheFunction( + () => { + throw new Error('origin failed') + }, + { + getKey: () => 'key', + integrity: 'integrity', + maxAge: 60, + name: 'fn', + storage, + } + ) + + await expect(fn()).rejects.toThrow('origin failed') + expect(await storage.getItem(storageKey)).toBeNull() + }) + + it('fails open when function storage reads and writes fail', async () => { + const storage = createCacheStorage() + vi.spyOn(storage, 'getItem').mockRejectedValue(new Error('read unavailable')) + vi.spyOn(storage, 'setItem').mockRejectedValue(new Error('write unavailable')) + let count = 0 + + const fn = cacheFunction(() => ++count, { maxAge: 60, storage }) + + expect(await fn()).toBe(1) + expect(await fn()).toBe(2) + }) + + it('resolves global defaults when the cached function is called', async () => { + let count = 0 + const fn = cacheFunction(() => ++count) + + setCacheDefaults({ maxAge: 0 }) + expect(await fn()).toBe(1) + expect(await fn()).toBe(2) + + setCacheDefaults({ maxAge: 60 }) + expect(await fn()).toBe(3) + expect(await fn()).toBe(3) + }) + }) + + describe('global cache accessors', () => { + it('exports stableStringify for deterministic custom keys', () => { + expect(stableStringify({ b: 2, a: 1 })).toBe(stableStringify({ a: 1, b: 2 })) + }) + + it('sets and gets global cache defaults and storage', () => { + const storage = createCacheStorage() + + setCacheStorage(storage) + setCacheDefaults({ maxAge: 120 }) + const defaults = getCacheDefaults() + defaults.maxAge = 1 + + expect(getCacheStorage()).toBe(storage) + expect(getCacheDefaults()).toEqual({ maxAge: 120 }) + + setCacheDefaults({ staleMaxAge: 30 }) + expect(getCacheDefaults()).toEqual({ staleMaxAge: 30 }) + setCacheDefaults({}) + expect(getCacheDefaults()).toEqual({}) + }) + + it('bounds and expires the default memory storage', async () => { + const storage = createCacheStorage() + + for (let index = 0; index <= 1000; index += 1) { + await storage.setItem(`key-${index}`, index) + } + + expect(await storage.getItem('key-0')).toBeNull() + expect(await storage.getItem('key-1000')).toBe(1000) + + await storage.setItem('expiring', 'value', { ttl: 0.001 }) + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(await storage.getItem('expiring')).toBeNull() + + const smallStorage = createCacheStorage({ + maxEntries: 10, + maxEntrySize: 10, + maxSize: 100, + }) + await smallStorage.setItem('oversized', 'a value larger than ten bytes') + expect(await smallStorage.getItem('oversized')).toBeNull() + }) + }) +}) diff --git a/packages/universal-cache/src/index.ts b/packages/universal-cache/src/index.ts new file mode 100644 index 000000000..6408ec952 --- /dev/null +++ b/packages/universal-cache/src/index.ts @@ -0,0 +1,20 @@ +export { + cacheDefaults, + cacheFunction, + cacheMiddleware, + createCacheStorage, + getCacheDefaults, + getCacheStorage, + setCacheDefaults, + setCacheStorage, +} from './cache' +export type { + CacheBaseOptions, + CacheDefaults, + CacheStorageOptions, + CachedFunctionEntry, + CachedResponseEntry, + CacheFunctionOptions, + CacheMiddlewareOptions, +} from './types' +export { stableStringify } from './utils' diff --git a/packages/universal-cache/src/index.workerd.test.ts b/packages/universal-cache/src/index.workerd.test.ts new file mode 100644 index 000000000..986891116 --- /dev/null +++ b/packages/universal-cache/src/index.workerd.test.ts @@ -0,0 +1,167 @@ +import { createExecutionContext, waitOnExecutionContext } from 'cloudflare:test' +import { Hono } from 'hono' +import { getRuntimeKey } from 'hono/adapter' +import { cacheMiddleware, createCacheStorage, setCacheStorage } from '.' + +describe('@hono/universal-cache workerd', () => { + beforeEach(() => { + setCacheStorage(createCacheStorage()) + }) + + it('runs in the Cloudflare Workers runtime', () => { + expect(getRuntimeKey()).toBe('workerd') + }) + + it('does not manually revalidate unless revalidateHeader is configured', async () => { + const app = new Hono() + let value = 'v1' + let count = 0 + + app.get('/items', cacheMiddleware({ maxAge: 60 }), (c) => { + count += 1 + return c.text(value) + }) + + const ctx1 = createExecutionContext() + const first = await app.request('http://localhost/items', {}, {}, ctx1) + await waitOnExecutionContext(ctx1) + expect(await first.text()).toBe('v1') + + value = 'v2' + const ctx2 = createExecutionContext() + const attempted = await app.request( + 'http://localhost/items', + { headers: { 'x-cache-revalidate': '1' } }, + {}, + ctx2 + ) + await waitOnExecutionContext(ctx2) + + const ctx3 = createExecutionContext() + const cached = await app.request('http://localhost/items', {}, {}, ctx3) + await waitOnExecutionContext(ctx3) + + expect(await attempted.text()).toBe('v1') + expect(await cached.text()).toBe('v1') + expect(count).toBe(1) + }) + + it('respects shouldRevalidate on workerd', async () => { + const app = new Hono() + let value = 'v1' + let allowRevalidate = false + + app.get( + '/items', + cacheMiddleware({ + maxAge: 60, + revalidateHeader: 'x-custom-revalidate', + shouldRevalidate: () => allowRevalidate, + }), + (c) => c.text(value) + ) + + const ctx1 = createExecutionContext() + const first = await app.request('http://localhost/items', {}, {}, ctx1) + await waitOnExecutionContext(ctx1) + expect(await first.text()).toBe('v1') + + value = 'v2' + const ctx2 = createExecutionContext() + const blocked = await app.request( + 'http://localhost/items', + { headers: { 'x-custom-revalidate': '1' } }, + {}, + ctx2 + ) + await waitOnExecutionContext(ctx2) + expect(await blocked.text()).toBe('v1') + + allowRevalidate = true + const ctx3 = createExecutionContext() + const revalidated = await app.request( + 'http://localhost/items', + { headers: { 'x-custom-revalidate': '1' } }, + {}, + ctx3 + ) + await waitOnExecutionContext(ctx3) + expect(await revalidated.text()).toBe('v2') + }) + + it('denies custom manual revalidation without shouldRevalidate on workerd', async () => { + const app = new Hono() + let value = 'v1' + let count = 0 + + app.get( + '/items', + cacheMiddleware({ + maxAge: 60, + revalidateHeader: 'x-custom-revalidate', + }), + (c) => { + count += 1 + return c.text(value) + } + ) + + const ctx1 = createExecutionContext() + const first = await app.request('http://localhost/items', {}, {}, ctx1) + await waitOnExecutionContext(ctx1) + expect(await first.text()).toBe('v1') + + value = 'v2' + const ctx2 = createExecutionContext() + const attempted = await app.request( + 'http://localhost/items', + { headers: { 'x-custom-revalidate': '1' } }, + {}, + ctx2 + ) + await waitOnExecutionContext(ctx2) + expect(await attempted.text()).toBe('v1') + + const ctx3 = createExecutionContext() + const cached = await app.request('http://localhost/items', {}, {}, ctx3) + await waitOnExecutionContext(ctx3) + + expect(await cached.text()).toBe('v1') + expect(count).toBe(1) + }) + + it('refreshes stale entries synchronously on workerd', async () => { + const app = new Hono() + let count = 0 + + app.get( + '/items', + cacheMiddleware({ + maxAge: 1, + staleMaxAge: 60, + }), + (c) => { + count += 1 + return c.text(`value-${count}`) + } + ) + + const ctx1 = createExecutionContext() + const first = await app.request('http://localhost/items', {}, {}, ctx1) + await waitOnExecutionContext(ctx1) + expect(await first.text()).toBe('value-1') + + await new Promise((resolve) => setTimeout(resolve, 1100)) + + const ctx2 = createExecutionContext() + const refreshed = await app.request('http://localhost/items', {}, {}, ctx2) + await waitOnExecutionContext(ctx2) + expect(await refreshed.text()).toBe('value-2') + + const ctx3 = createExecutionContext() + const cached = await app.request('http://localhost/items', {}, {}, ctx3) + await waitOnExecutionContext(ctx3) + expect(await cached.text()).toBe('value-2') + expect(count).toBe(2) + }) +}) diff --git a/packages/universal-cache/src/types.ts b/packages/universal-cache/src/types.ts new file mode 100644 index 000000000..e17027d24 --- /dev/null +++ b/packages/universal-cache/src/types.ts @@ -0,0 +1,128 @@ +import type { Context } from 'hono' +import type { Storage } from 'unstorage' + +export type CacheKeyFn = (...args: TArgs) => string | Promise + +export interface CacheStorageOptions { + /** Maximum number of in-memory entries. */ + maxEntries?: number + /** Maximum total in-memory size in bytes. */ + maxSize?: number + /** Maximum size of one in-memory entry in bytes. */ + maxEntrySize?: number +} + +/** + * Shared cache options for middleware and function caching. + */ +export interface CacheBaseOptions { + /** Storage namespace prefix. */ + base?: string + /** Cache group segment (handlers/functions). */ + group?: string + /** Optional hash function for default keys. */ + hash?: (value: string) => string | Promise + /** Manual integrity value to invalidate cache. */ + integrity?: string + /** + * Keep the previous cache entry when refresh fails with a 5xx-style error. + * - middleware: preserve previous entry for response status >= 500 + * - function cache: preserve previous entry when wrapped function throws + * Only applies when `shouldInvalidateCache` is used. + */ + keepPreviousOn5xx?: boolean + /** Max age in seconds. */ + maxAge?: number + /** Cache entry name (used as part of the storage key). */ + name?: string + /** Stale max age in seconds. Use -1 for unlimited stale. */ + staleMaxAge?: number + /** Custom storage instance to use for caching. */ + storage?: Storage +} + +/** + * Global cache defaults applied to middleware and cached functions. + */ +export interface CacheDefaults extends CacheBaseOptions {} + +export interface CacheMiddlewareOptions extends CacheBaseOptions { + /** Deserialize a cached entry back into a response. */ + deserialize?: (entry: CachedResponseEntry) => Response | Promise + /** Provide a custom cache key. */ + getKey?: (ctx: Context) => string | Promise + /** Allowed HTTP methods (default: GET, HEAD). */ + methods?: string[] + /** Custom header name to allow manual cache revalidation. Disabled by default. */ + revalidateHeader?: string | false + /** Serialize the response into a cached entry. */ + serialize?: ( + response: Response, + context: { integrity: string; maxAge: number; staleMaxAge: number; now: number } + ) => CachedResponseEntry | Promise + /** Return true to bypass cache entirely for this request. */ + shouldBypassCache?: (ctx: Context) => boolean | Promise + /** Return true to invalidate the cache before re-fetch. */ + shouldInvalidateCache?: (ctx: Context) => boolean | Promise + /** Return true to allow a manual revalidation request. */ + shouldRevalidate?: (ctx: Context) => boolean | Promise + /** Optional validation for cached response entries. */ + validate?: (entry: CachedResponseEntry) => boolean + /** Request headers to include in the cache key. */ + varies?: string[] +} + +export interface CacheFunctionOptions< + TArgs extends unknown[], + TResult = unknown, + TStored = TResult, +> extends CacheBaseOptions { + /** Deserialize a cached entry back into the function result. */ + deserialize?: (entry: CachedFunctionEntry) => TResult | Promise + /** Provide a custom cache key. */ + getKey?: CacheKeyFn + /** Serialize the function result into a cached entry. */ + serialize?: ( + value: TResult, + context: { integrity: string; maxAge: number; staleMaxAge: number; now: number } + ) => CachedFunctionEntry | Promise> + /** Return true to bypass cache entirely for this call. */ + shouldBypassCache?: (...args: TArgs) => boolean | Promise + /** Return true to invalidate the cache before re-fetch. */ + shouldInvalidateCache?: (...args: TArgs) => boolean | Promise + /** Enable stale-while-revalidate behavior. */ + swr?: boolean + /** Optional validation for cached function entries. */ + validate?: (entry: CachedFunctionEntry, ...args: TArgs) => boolean +} + +export interface CachedResponseEntry { + encoding: 'base64' + /** Expiry timestamp (ms). */ + expires: number + /** Response headers. */ + headers: Record + /** Integrity value. */ + integrity: string + /** Last updated timestamp (ms). */ + mtime: number + /** Stale expiry timestamp (ms) or null for unlimited stale. */ + staleExpires: number | null + /** Response status code. */ + status: number + /** Base64 encoded response body. */ + value: string +} + +export interface CachedFunctionEntry { + /** Expiry timestamp (ms). */ + expires: number + /** Integrity value. */ + integrity: string + /** Last updated timestamp (ms). */ + mtime: number + /** Stale expiry timestamp (ms) or null for unlimited stale. */ + staleExpires: number | null + /** Cached value. */ + value: TResult +} diff --git a/packages/universal-cache/src/utils.test.ts b/packages/universal-cache/src/utils.test.ts new file mode 100644 index 000000000..01f3dd712 --- /dev/null +++ b/packages/universal-cache/src/utils.test.ts @@ -0,0 +1,88 @@ +import { + computeTtlSeconds, + isExpired, + isStaleValid, + normalizePathToName, + stableStringify, + toLower, +} from './utils' + +describe('utils', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('normalizes strings and paths', () => { + expect(toLower('X-CACHE-KEY')).toBe('x-cache-key') + expect(normalizePathToName('/')).toBe('root') + expect(normalizePathToName('/api/items/')).toBe('api:items') + }) + + it('stableStringify handles nullish and primitives', () => { + expect(stableStringify(null)).toBe('null') + expect(stableStringify(undefined)).toBe('undefined') + expect(stableStringify(123)).toBe('123') + expect(stableStringify('abc')).toBe("'abc'") + expect(stableStringify(true)).toBe('true') + expect(stableStringify(Number.NaN)).not.toBe(stableStringify(null)) + expect(stableStringify(-0)).not.toBe(stableStringify(0)) + }) + + it('stableStringify handles Date, arrays, and sorted object keys', () => { + const date = new Date('2026-01-01T00:00:00.000Z') + expect(stableStringify(date)).not.toBe(stableStringify(date.toISOString())) + expect(stableStringify([{ b: 2, a: 1 }, 'x'])).toBe(stableStringify([{ a: 1, b: 2 }, 'x'])) + expect(stableStringify({ z: 1, a: { y: 2, x: 1 } })).toBe( + stableStringify({ a: { x: 1, y: 2 }, z: 1 }) + ) + expect(stableStringify(["a','b"])).not.toBe(stableStringify(['a', 'b'])) + }) + + it('stableStringify handles cyclic values', () => { + const first: { self?: unknown; value: number } = { value: 1 } + const second: { self?: unknown; value: number } = { value: 1 } + first.self = first + second.self = second + + expect(stableStringify(first)).toBe(stableStringify(second)) + }) + + it('stableStringify preserves negative zero in maps and floating arrays', () => { + expect(stableStringify(new Map([['value', 0]]))).not.toBe( + stableStringify(new Map([['value', -0]])) + ) + expect(stableStringify(new Map([['value', { nested: 0 }]]))).not.toBe( + stableStringify(new Map([['value', { nested: -0 }]])) + ) + expect(stableStringify(new Float64Array([0]))).not.toBe(stableStringify(new Float64Array([-0]))) + }) + + it('stableStringify supports cyclic maps', () => { + const first = new Map() + const second = new Map() + first.set('self', first) + second.set('self', second) + expect(stableStringify(first)).toBe(stableStringify(second)) + }) + + it('computes TTL for all branches', () => { + expect(computeTtlSeconds(0, 30)).toBe(0) + expect(computeTtlSeconds(-1, 30)).toBe(0) + expect(computeTtlSeconds(60, -1)).toBeUndefined() + expect(computeTtlSeconds(60, 0)).toBe(60) + expect(computeTtlSeconds(60, 30)).toBe(90) + }) + + it('checks expiration and stale validity', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + const now = Date.now() + + expect(isExpired(now - 1)).toBe(true) + expect(isExpired(now + 1)).toBe(false) + + expect(isStaleValid(null)).toBe(true) + expect(isStaleValid(now - 1)).toBe(false) + expect(isStaleValid(now + 1)).toBe(true) + }) +}) diff --git a/packages/universal-cache/src/utils.ts b/packages/universal-cache/src/utils.ts new file mode 100644 index 000000000..aaea060ed --- /dev/null +++ b/packages/universal-cache/src/utils.ts @@ -0,0 +1,109 @@ +import { serialize as serializeHashValue } from 'ohash' + +/** Default storage base prefix. */ +export const DEFAULT_CACHE_BASE = 'cache' +/** Default storage group for cached handlers. */ +export const DEFAULT_HANDLER_GROUP = 'hono/handlers' +/** Default storage group for cached functions. */ +export const DEFAULT_FUNCTION_GROUP = 'hono/functions' +/** Default cache max age in seconds. */ +export const DEFAULT_MAX_AGE = 60 +/** Default stale max age in seconds. */ +export const DEFAULT_STALE_MAX_AGE = 0 + +/** Normalize a string to lower-case. */ +export const toLower = (value: string): string => value.toLowerCase() + +/** Normalize a URL path into a cache-friendly name. */ +export const normalizePathToName = (path: string): string => { + const trimmed = path.replace(/(^\/|\/$)/g, '') + if (!trimmed) { + return 'root' + } + return trimmed.replace(/\/+?/g, ':') +} + +/** Stable, type-aware serialization for cache keys. */ +export const stableStringify = (value: unknown): string => { + const references = new WeakMap() + let referenceIndex = 0 + + const serialize = (current: unknown): string => { + if (Object.is(current, -0)) { + return 'number:-0' + } + + const isPlainObject = + typeof current === 'object' && + current !== null && + (Object.getPrototypeOf(current) === Object.prototype || + Object.getPrototypeOf(current) === null) + const isMap = current instanceof Map + const isSet = current instanceof Set + const isFloatArray = current instanceof Float32Array || current instanceof Float64Array + if (!Array.isArray(current) && !isPlainObject && !isMap && !isSet && !isFloatArray) { + return serializeHashValue(current) + } + + const object = current as object + const reference = references.get(object) + if (reference !== undefined) { + return `reference:${reference}` + } + const nextReference = referenceIndex++ + references.set(object, nextReference) + + if (Array.isArray(current)) { + const items = Array.from({ length: current.length }, (_, index) => + index in current ? ['value', serialize(current[index])] : ['hole'] + ) + return `array:${nextReference}:${JSON.stringify(items)}` + } + + if (isMap) { + const entries = [...current].map(([key, entryValue]) => [ + serialize(key), + serialize(entryValue), + ]) + return `map:${nextReference}:${JSON.stringify(entries)}` + } + + if (isSet) { + return `set:${nextReference}:${JSON.stringify([...current].map(serialize))}` + } + + if (isFloatArray) { + return `${current.constructor.name}:${nextReference}:${JSON.stringify([...current].map(serialize))}` + } + + const record = current as Record + const entries = Object.keys(record) + .sort() + .map((key) => [key, serialize(record[key])]) + return `object:${nextReference}:${JSON.stringify(entries)}` + } + + return serialize(value) +} + +/** Compute storage TTL in seconds from cache options. */ +export const computeTtlSeconds = (maxAge: number, staleMaxAge: number): number | undefined => { + if (maxAge <= 0) { + return 0 + } + if (staleMaxAge < 0) { + return undefined + } + return Math.max(0, maxAge + Math.max(0, staleMaxAge)) +} + +/** Check if a timestamp (ms) is expired. */ +export const isExpired = (expires: number): boolean => Date.now() > expires + +/** Check if stale cache is still valid. */ +export const isStaleValid = (staleExpires: number | null): boolean => { + if (staleExpires === null) { + return true + } + return Date.now() <= staleExpires +} diff --git a/packages/universal-cache/tsconfig.build.json b/packages/universal-cache/tsconfig.build.json new file mode 100644 index 000000000..4a1f19acc --- /dev/null +++ b/packages/universal-cache/tsconfig.build.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": {}, + "references": [] +} diff --git a/packages/universal-cache/tsconfig.json b/packages/universal-cache/tsconfig.json new file mode 100644 index 000000000..d4ad6cfa3 --- /dev/null +++ b/packages/universal-cache/tsconfig.json @@ -0,0 +1,12 @@ +{ + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.build.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/universal-cache/tsconfig.spec.json b/packages/universal-cache/tsconfig.spec.json new file mode 100644 index 000000000..380781a09 --- /dev/null +++ b/packages/universal-cache/tsconfig.spec.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "../../dist/packages/universal-cache", + "types": [ + "@cloudflare/workers-types", + "@cloudflare/vitest-pool-workers/types", + "vitest/globals" + ] + }, + "include": ["src", "tsdown.config.ts", "vitest.config.ts", "vitest.workerd.config.ts"], + "references": [] +} diff --git a/packages/universal-cache/tsdown.config.ts b/packages/universal-cache/tsdown.config.ts new file mode 100644 index 000000000..d1b94d2e0 --- /dev/null +++ b/packages/universal-cache/tsdown.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + deps: { + alwaysBundle: [/^ohash(?:\/|$)/], + }, + entry: 'src/index.ts', + fixedExtension: true, + platform: 'neutral', +}) diff --git a/packages/universal-cache/vitest.config.ts b/packages/universal-cache/vitest.config.ts new file mode 100644 index 000000000..5c533b392 --- /dev/null +++ b/packages/universal-cache/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineProject } from 'vitest/config' + +export default defineProject({ + test: { + globals: true, + include: ['src/**/*.test.ts'], + exclude: ['src/**/*.workerd.test.ts'], + }, +}) diff --git a/packages/universal-cache/vitest.workerd.config.ts b/packages/universal-cache/vitest.workerd.config.ts new file mode 100644 index 000000000..ac728b790 --- /dev/null +++ b/packages/universal-cache/vitest.workerd.config.ts @@ -0,0 +1,17 @@ +import { cloudflareTest } from '@cloudflare/vitest-pool-workers' +import { defineProject } from 'vitest/config' + +const workerdPlugin = cloudflareTest({ + miniflare: { + compatibilityDate: '2025-03-10', + compatibilityFlags: ['nodejs_compat'], + }, +}) as never + +export default defineProject({ + plugins: [workerdPlugin], + test: { + globals: true, + include: ['src/**/*.workerd.test.ts'], + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 663bdade5..0e8d6fa2a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1011,6 +1011,37 @@ importers: specifier: ^4.1.7 version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@25.9.5)(@vitest/coverage-istanbul@4.1.10)(msw@2.15.0(@types/node@25.9.5)(typescript@6.0.3))(vite@8.1.3(@types/node@25.9.5)(esbuild@0.28.1)(yaml@2.9.0)) + packages/universal-cache: + dependencies: + lru-cache: + specifier: ^10.4.3 + version: 10.4.3 + unstorage: + specifier: ^1.17.3 + version: 1.17.3 + devDependencies: + '@cloudflare/vitest-pool-workers': + specifier: ^0.16.10 + version: 0.16.20(@cloudflare/workers-types@4.20260702.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.10) + '@cloudflare/workers-types': + specifier: ^4.20250612.0 + version: 4.20260702.1 + hono: + specifier: ^4.11.5 + version: 4.12.28 + ohash: + specifier: ^2.0.11 + version: 2.0.11 + tsdown: + specifier: ^0.22.3 + version: 0.22.4(@arethetypeswrong/core@0.18.4)(publint@0.3.21)(typescript@6.0.3) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.7 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@25.9.5)(@vitest/coverage-istanbul@4.1.10)(msw@2.15.0(@types/node@25.9.5)(typescript@6.0.3))(vite@8.1.3(@types/node@25.9.5)(esbuild@0.28.1)(yaml@2.9.0)) + packages/valibot-validator: devDependencies: hono: @@ -3476,6 +3507,10 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} @@ -5060,6 +5095,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.2.6: + resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} + engines: {node: 20 || >=22} + lru-cache@11.5.2: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} @@ -5483,6 +5522,9 @@ packages: ofetch@1.5.1: resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + on-finished@2.3.0: resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} engines: {node: '>= 0.8'} @@ -5924,6 +5966,10 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + readdirp@5.0.0: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} @@ -6666,6 +6712,68 @@ packages: unrs-resolver@1.12.2: resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} + unstorage@1.17.3: + resolution: {integrity: sha512-i+JYyy0DoKmQ3FximTHbGadmIYb8JEpq7lxUjnjeB702bCPum0vzo6oy5Mfu0lpqISw7hCyMW2yj4nWC8bqJ3Q==} + peerDependencies: + '@azure/app-configuration': ^1.8.0 + '@azure/cosmos': ^4.2.0 + '@azure/data-tables': ^13.3.0 + '@azure/identity': ^4.6.0 + '@azure/keyvault-secrets': ^4.9.0 + '@azure/storage-blob': ^12.26.0 + '@capacitor/preferences': ^6.0.3 || ^7.0.0 + '@deno/kv': '>=0.9.0' + '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 + '@planetscale/database': ^1.19.0 + '@upstash/redis': ^1.34.3 + '@vercel/blob': '>=0.27.1' + '@vercel/functions': ^2.2.12 || ^3.0.0 + '@vercel/kv': ^1.0.1 + aws4fetch: ^1.0.20 + db0: '>=0.2.1' + idb-keyval: ^6.2.1 + ioredis: ^5.4.2 + uploadthing: ^7.4.4 + peerDependenciesMeta: + '@azure/app-configuration': + optional: true + '@azure/cosmos': + optional: true + '@azure/data-tables': + optional: true + '@azure/identity': + optional: true + '@azure/keyvault-secrets': + optional: true + '@azure/storage-blob': + optional: true + '@capacitor/preferences': + optional: true + '@deno/kv': + optional: true + '@netlify/blobs': + optional: true + '@planetscale/database': + optional: true + '@upstash/redis': + optional: true + '@vercel/blob': + optional: true + '@vercel/functions': + optional: true + '@vercel/kv': + optional: true + aws4fetch: + optional: true + db0: + optional: true + idb-keyval: + optional: true + ioredis: + optional: true + uploadthing: + optional: true + unstorage@1.17.5: resolution: {integrity: sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==} peerDependencies: @@ -7185,7 +7293,7 @@ snapshots: '@loaderkit/resolve': 1.0.6 cjs-module-lexer: 1.4.3 fflate: 0.8.3 - lru-cache: 11.5.2 + lru-cache: 11.2.6 semver: 7.8.5 typescript: 5.6.1-rc validate-npm-package-name: 5.0.1 @@ -9479,6 +9587,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + chokidar@5.0.0: dependencies: readdirp: 5.0.0 @@ -11281,6 +11393,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.2.6: {} + lru-cache@11.5.2: {} lru-cache@5.1.1: @@ -11870,6 +11984,8 @@ snapshots: node-fetch-native: 1.6.7 ufo: 1.6.4 + ohash@2.0.11: {} + on-finished@2.3.0: dependencies: ee-first: 1.1.1 @@ -12349,6 +12465,8 @@ snapshots: dependencies: picomatch: 2.3.2 + readdirp@4.1.2: {} + readdirp@5.0.0: {} recma-build-jsx@1.0.0: @@ -13257,6 +13375,17 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 + unstorage@1.17.3: + dependencies: + anymatch: 3.1.3 + chokidar: 4.0.3 + destr: 2.0.5 + h3: 1.15.11 + lru-cache: 10.4.3 + node-fetch-native: 1.6.7 + ofetch: 1.5.1 + ufo: 1.6.4 + unstorage@1.17.5: dependencies: anymatch: 3.1.3 diff --git a/tsconfig.json b/tsconfig.json index 6c1819434..3466ec242 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -44,6 +44,7 @@ { "path": "packages/tsyringe" }, { "path": "packages/typebox-validator" }, { "path": "packages/typia-validator" }, + { "path": "packages/universal-cache" }, { "path": "packages/ua-blocker" }, { "path": "packages/valibot-validator" }, { "path": "packages/zod-openapi" },