From 6d0ef1e2031dda747816fa38051569c00405fbab Mon Sep 17 00:00:00 2001 From: raed bahri Date: Thu, 12 Mar 2026 11:47:29 +0100 Subject: [PATCH 01/12] feat(universal-cache): address review feedback --- .changeset/fuzzy-garlic-clean.md | 12 + deno.jsonc | 1 + packages/universal-cache/CHANGELOG.md | 1 + packages/universal-cache/README.md | 81 ++ packages/universal-cache/deno.json | 15 + packages/universal-cache/package.json | 60 + packages/universal-cache/src/cache.ts | 773 +++++++++++++ packages/universal-cache/src/index.test.ts | 1056 ++++++++++++++++++ packages/universal-cache/src/index.ts | 19 + packages/universal-cache/src/types.ts | 128 +++ packages/universal-cache/src/utils.test.ts | 57 + packages/universal-cache/src/utils.ts | 64 ++ packages/universal-cache/tsconfig.build.json | 5 + packages/universal-cache/tsconfig.json | 12 + packages/universal-cache/tsconfig.spec.json | 13 + packages/universal-cache/tsdown.config.ts | 11 + packages/universal-cache/vitest.config.ts | 9 + tsconfig.json | 1 + yarn.lock | 24 +- 19 files changed, 2341 insertions(+), 1 deletion(-) create mode 100644 .changeset/fuzzy-garlic-clean.md create mode 100644 packages/universal-cache/CHANGELOG.md create mode 100644 packages/universal-cache/README.md create mode 100644 packages/universal-cache/deno.json create mode 100644 packages/universal-cache/package.json create mode 100644 packages/universal-cache/src/cache.ts create mode 100644 packages/universal-cache/src/index.test.ts create mode 100644 packages/universal-cache/src/index.ts create mode 100644 packages/universal-cache/src/types.ts create mode 100644 packages/universal-cache/src/utils.test.ts create mode 100644 packages/universal-cache/src/utils.ts create mode 100644 packages/universal-cache/tsconfig.build.json create mode 100644 packages/universal-cache/tsconfig.json create mode 100644 packages/universal-cache/tsconfig.spec.json create mode 100644 packages/universal-cache/tsdown.config.ts create mode 100644 packages/universal-cache/vitest.config.ts diff --git a/.changeset/fuzzy-garlic-clean.md b/.changeset/fuzzy-garlic-clean.md new file mode 100644 index 000000000..00744211d --- /dev/null +++ b/.changeset/fuzzy-garlic-clean.md @@ -0,0 +1,12 @@ +--- +'@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-while-revalidate support +- storage/default accessors (`set/getCacheStorage`, `set/getCacheDefaults`) +- custom keying, serialization, validation, and invalidation hooks diff --git a/deno.jsonc b/deno.jsonc index f62aa3621..a89f252fb 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..6d9df41e6 --- /dev/null +++ b/packages/universal-cache/README.md @@ -0,0 +1,81 @@ +# @hono/universal-cache + +[![codecov](https://codecov.io/github/honojs/middleware/graph/badge.svg?flag=universal-cache)](https://codecov.io/github/honojs/middleware) + +Universal cache utilities for Hono. + +## Features + +- Response caching with `cacheMiddleware()` +- Function result caching with `cacheFunction()` +- Stale-while-revalidate support +- Cache defaults via middleware `cacheDefaults()` +- Custom keying, storage, serialization, and validation + +## Usage + +```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 })) +``` + +## Configure defaults + +```ts +import { Hono } from 'hono' +import { cacheDefaults } from '@hono/universal-cache' +import { createStorage } from 'unstorage' +import memoryDriver from 'unstorage/drivers/memory' + +const app = new Hono() + +app.use( + cacheDefaults({ + storage: createStorage({ driver: memoryDriver() }), + maxAge: 60, + staleMaxAge: 30, + swr: true, + }) +) +``` + +## Cached function + +```ts +import { cacheFunction } from '@hono/universal-cache' + +const getStats = cacheFunction(async (id: string) => ({ id, ts: Date.now() }), { + maxAge: 60, + getKey: (id) => id, +}) +``` + +## API + +- `cacheMiddleware(options | maxAge)` +- `cacheDefaults(options)` +- `cacheFunction(fn, options | maxAge)` +- `setCacheStorage(storage)` / `getCacheStorage()` +- `setCacheDefaults(options)` / `getCacheDefaults()` +- `createCacheStorage()` + +## Notes + +- Cached responses drop `set-cookie` and hop-by-hop headers. +- Manual cache revalidation is disabled by default. Set `revalidateHeader` to opt in. +- Use `shouldRevalidate` to gate manual revalidation requests. +- Middleware cache defaults to `GET` and `HEAD`. +- Default `maxAge` is `60` seconds. +- On `workerd`, stale middleware entries are refreshed synchronously instead of using background self-fetch. + +## 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..2a724d84d --- /dev/null +++ b/packages/universal-cache/deno.json @@ -0,0 +1,15 @@ +{ + "name": "@hono/universal-cache", + "version": "0.0.0", + "license": "MIT", + "exports": { + ".": "./src/index.ts" + }, + "imports": { + "hono": "jsr:@hono/hono@^4.8.3" + }, + "publish": { + "include": ["deno.json", "README.md", "src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] + } +} diff --git a/packages/universal-cache/package.json b/packages/universal-cache/package.json new file mode 100644 index 000000000..f375db848 --- /dev/null +++ b/packages/universal-cache/package.json @@ -0,0 +1,60 @@ +{ + "name": "@hono/universal-cache", + "version": "0.0.0", + "description": "Universal cache middleware and helpers for Hono", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown", + "format": "prettier --check . --ignore-path ../../.gitignore", + "lint": "eslint", + "typecheck": "tsc -b tsconfig.json", + "test": "vitest", + "test:workerd": "vitest --config vitest.workerd.config.ts", + "version:jsr": "yarn version:set $npm_package_version" + }, + "license": "MIT", + "publishConfig": { + "registry": "https://registry.npmjs.org", + "access": "public", + "provenance": true + }, + "repository": { + "type": "git", + "url": "git+https://github.com/honojs/middleware.git", + "directory": "packages/universal-cache" + }, + "homepage": "https://github.com/honojs/middleware", + "peerDependencies": { + "hono": ">=4.0.0" + }, + "dependencies": { + "ohash": "^2.0.11", + "unstorage": "^1.17.0" + }, + "devDependencies": { + "@cloudflare/vitest-pool-workers": "https://pkg.pr.new/@cloudflare/vitest-pool-workers@7143d5d", + "@cloudflare/workers-types": "^4.20250612.0", + "hono": "^4.11.5", + "tsdown": "^0.15.9", + "typescript": "^5.9.3", + "vitest": "^4.1.0-beta.1" + } +} diff --git a/packages/universal-cache/src/cache.ts b/packages/universal-cache/src/cache.ts new file mode 100644 index 000000000..c9db02494 --- /dev/null +++ b/packages/universal-cache/src/cache.ts @@ -0,0 +1,773 @@ +import type { Context, MiddlewareHandler, Next } from 'hono' +import { getRuntimeKey } from 'hono/adapter' +import { decodeBase64, encodeBase64 } from 'hono/utils/encode' +import { hash as ohash } from 'ohash' +import { createStorage } from 'unstorage' +import type { Storage } from 'unstorage' +import memoryDriver from 'unstorage/drivers/memory' +import type { + CacheConfigOptions, + CacheDefaults, + CachedFunctionEntry, + CachedResponseEntry, + CacheFunctionOptions, + CacheMiddlewareOptions, +} 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', + 'content-length', +]) + +const INTERNAL_REVALIDATE_HEADER = 'x-hono-universal-cache-revalidate' + +let defaultStorage: Storage = createStorage({ + driver: memoryDriver(), +}) + +let defaultCacheOptions: CacheDefaults = {} +const requestCacheDefaults = new WeakMap() + +const pendingFunctionRequests = new Map>() +const pendingRevalidations = new Map>() + +const setRequestCacheDefaults = (ctx: Context, options: CacheConfigOptions = {}) => { + 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 + +/** + * Set global cache defaults applied to middleware and cached functions. + */ +export const setCacheDefaults = (options: CacheDefaults): void => { + defaultCacheOptions = { + ...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: CacheConfigOptions = {}): MiddlewareHandler => { + return async (ctx, next) => { + setRequestCacheDefaults(ctx, options) + await next() + } +} + +/** + * Create a new in-memory storage instance. + */ +export const createCacheStorage = (): Storage => + createStorage({ + driver: memoryDriver(), + }) + +const createStorageKey = (base: string, group: string, name: string, key: string) => { + const segments = [base, group, name, key].filter(Boolean) + return `${segments.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 fullPath = `${url.pathname}${url.search}` + + let pathPrefix = '-' + try { + pathPrefix = escapeKey(decodeURI(url.pathname)).slice(0, 16) || 'index' + } catch { + pathPrefix = '-' + } + + const hashedPath = `${pathPrefix}.${await hashFn(fullPath)}` + if (!varies?.length) { + return hashedPath + } + + const varyParts = await Promise.all( + varies.map(async (header) => { + const value = ctx.req.header(header) ?? '' + return `${escapeKey(toLower(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 createCachedResponse = (entry: CachedResponseEntry) => { + const headers = new Headers(entry.headers) + return new Response(decodeBase64(entry.value), { + status: entry.status, + headers, + }) +} + +const getCacheHeaders = (response: Response): Record => { + const headers = new Headers(response.headers) + for (const header of HOP_BY_HOP_HEADERS) { + headers.delete(header) + } + headers.delete('set-cookie') + const entries: Record = {} + headers.forEach((value, key) => { + entries[key] = value + }) + return entries +} + +const isCacheableResponse = (response: Response) => { + if (response.status < 200 || response.status >= 300) { + return false + } + if (response.headers.has('set-cookie')) { + return false + } + const etag = response.headers.get('etag') + if (etag === 'undefined') { + return false + } + const lastModified = response.headers.get('last-modified') + if (lastModified === 'undefined') { + 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')) +} + +const defaultSerializeResponse = async ( + response: Response, + context: { integrity: string; maxAge: number; staleMaxAge: number; now: number } +) => { + const { integrity, maxAge, staleMaxAge, now } = context + const buffer = await response.clone().arrayBuffer() + const value = encodeBase64(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 isValidCachedResponseEntry = (entry: unknown): entry is CachedResponseEntry => { + if (!isRecord(entry)) { + return false + } + if (typeof entry['value'] !== 'string') { + return false + } + if (typeof entry['status'] !== 'number') { + return false + } + if (!isRecord(entry['headers'])) { + return false + } + const headers = entry['headers'] + if (headers['etag'] === 'undefined') { + return false + } + if (headers['last-modified'] === 'undefined') { + return false + } + return true +} + +const isValidCachedFunctionEntry = ( + entry: unknown +): entry is CachedFunctionEntry => { + if (!isRecord(entry)) { + return false + } + return '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(`${group}:${name}`)) + return { name, key, storageKey, integrity } +} + +const maybeServeCachedResponse = async ( + ctx: Context, + storage: Storage, + storageKey: string, + integrity: string, + swr: boolean, + cachedRaw: unknown, + deserialize: NonNullable, + revalidateHeader: string | false, + validate?: CacheMiddlewareOptions['validate'] +) => { + const cached = isValidCachedResponseEntry(cachedRaw) ? cachedRaw : null + if (!cached) { + if (cachedRaw !== null) { + await storage.removeItem(storageKey) + } + return null + } + if (cached.integrity !== integrity) { + await storage.removeItem(storageKey) + return null + } + if (validate && validate(cached) === false) { + await storage.removeItem(storageKey) + return null + } + + if (!isExpired(cached.expires)) { + return await deserialize(cached) + } + + if (swr && isStaleValid(cached.staleExpires)) { + if (getRuntimeKey() === 'workerd') { + return null + } + + if (!pendingRevalidations.has(storageKey)) { + const revalidatePromise = (async () => { + try { + const refreshHeaders = new Headers(ctx.req.raw.headers) + if (revalidateHeader) { + refreshHeaders.delete(revalidateHeader) + } + refreshHeaders.set(INTERNAL_REVALIDATE_HEADER, '1') + const request = new Request(ctx.req.url, { + method: ctx.req.method, + headers: refreshHeaders, + }) + await fetch(request) + } finally { + pendingRevalidations.delete(storageKey) + } + })() + pendingRevalidations.set(storageKey, revalidatePromise) + } + return await deserialize(cached) + } + + return null +} + +const shouldBypassMiddlewareCache = async (ctx: Context, options: CacheMiddlewareOptions) => { + 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 true + } + return await options.shouldRevalidate(ctx) +} + +const cacheResponseEntry = async ( + response: Response, + storage: Storage, + storageKey: string, + integrity: string, + maxAge: number, + staleMaxAge: number, + now: number, + serialize: NonNullable +) => { + const rawEntry = await serialize(response, { integrity, maxAge, staleMaxAge, now }) + const ttl = computeTtlSeconds(maxAge, staleMaxAge) + if (ttl === 0) { + return + } + await storage.setItem(storageKey, rawEntry, ttl ? { ttl } : undefined) +} + +const readCachedResponse = async ( + ctx: Context, + storage: Storage, + storageKey: string, + integrity: string, + options: CacheMiddlewareOptions, + swr: boolean, + deserialize: NonNullable, + revalidateHeader: string | false +) => { + const cachedRaw = await storage.getItem(storageKey) + return await maybeServeCachedResponse( + ctx, + storage, + storageKey, + integrity, + swr, + cachedRaw, + deserialize, + revalidateHeader, + options.validate + ) +} + +const writeCachedResponse = async ( + ctx: Context, + storage: Storage, + storageKey: string, + integrity: string, + response: Response, + maxAge: number, + staleMaxAge: number, + now: number, + serialize: NonNullable +) => { + const cachePromise = cacheResponseEntry( + response, + storage, + storageKey, + integrity, + maxAge, + staleMaxAge, + now, + serialize + ) + + if (getRuntimeKey() === 'workerd') { + ctx.executionCtx?.waitUntil?.(cachePromise) + return + } + + await 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 + const { config: middlewareConfig, ...routeOptions } = normalized + const isConfigOnly = middlewareConfig !== undefined && Object.keys(routeOptions).length === 0 + + const handler: MiddlewareHandler = async (ctx: Context, next: Next) => { + if (middlewareConfig) { + setRequestCacheDefaults(ctx, middlewareConfig) + } + + if (isConfigOnly) { + return next() + } + + const merged: CacheMiddlewareOptions = { + ...defaultCacheOptions, + ...getRequestCacheDefaults(ctx), + ...routeOptions, + } + + 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 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 bypass = await shouldBypassMiddlewareCache(ctx, merged) + if (bypass) { + return next() + } + + const isInternalRevalidateRequest = ctx.req.header(INTERNAL_REVALIDATE_HEADER) === '1' + const isManualRevalidateRequest = + revalidateHeader !== false && ctx.req.header(revalidateHeader) === '1' + const isRevalidateRequest = + isInternalRevalidateRequest || + (isManualRevalidateRequest && (await shouldManualRevalidateMiddlewareCache(ctx, merged))) + const { storageKey, integrity } = await resolveHandlerCacheKey(ctx, merged, base, group, hashFn) + + if (!isRevalidateRequest) { + const cachedResponse = await readCachedResponse( + ctx, + storage, + storageKey, + integrity, + merged, + swr, + deserialize, + revalidateHeader + ) + if (cachedResponse) { + ctx.res = cachedResponse + return cachedResponse + } + } + + const shouldInvalidate = await shouldInvalidateMiddlewareCache(ctx, merged) + if (shouldInvalidate && !keepPreviousOn5xx) { + await storage.removeItem(storageKey) + } + + await next() + const response = ctx.res + + if (!response) { + return response + } + + if (!isCacheableResponse(response)) { + if (shouldInvalidate && keepPreviousOn5xx && response.status < 500) { + await storage.removeItem(storageKey) + } + return response + } + + await writeCachedResponse( + ctx, + storage, + storageKey, + integrity, + response, + maxAge, + staleMaxAge, + Date.now(), + serialize + ) + 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 } +) => + createFunctionEntry(result, context.integrity, context.maxAge, context.staleMaxAge, context.now) + +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']> +) => { + const rawEntry = await serialize(result, { integrity, maxAge, staleMaxAge, now }) + const ttl = computeTtlSeconds(maxAge, staleMaxAge) + await storage.setItem(storageKey, rawEntry, ttl ? { ttl } : undefined) + 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']>, + validate?: CacheFunctionOptions['validate'], + validateArgs?: TArgs +): Promise => { + if (!cached || cached.integrity !== integrity) { + return null + } + if (validate) { + const args = validateArgs ?? ([] as unknown as TArgs) + if (validate(cached, ...args) === false) { + return null + } + } + if (!isExpired(cached.expires)) { + return (await deserialize(cached)) as TResult + } + if (swr && isStaleValid(cached.staleExpires)) { + if (!pendingFunctionRequests.has(storageKey)) { + const refreshPromise = Promise.resolve(fetcher()) + .then((fresh) => + refreshFunctionCache( + storage, + storageKey, + fresh, + integrity, + maxAge, + staleMaxAge, + Date.now(), + serialize + ) + ) + .finally(() => { + pendingFunctionRequests.delete(storageKey) + }) + pendingFunctionRequests.set(storageKey, refreshPromise) + } + return (await deserialize(cached)) as TResult + } + return null +} + +/** + * 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 = typeof options === 'number' ? { maxAge: options } : options + const merged = { ...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 ?? fn.name) || '_' + const group = merged.group ?? DEFAULT_FUNCTION_GROUP + const hashFn = merged.hash ?? ((value: string) => ohash(value)) + const serialize = merged.serialize ?? defaultSerializeFunctionEntry + const deserialize = merged.deserialize ?? defaultDeserializeFunctionEntry + const integrityValue = merged.integrity + let integrityCache: string | null = null + let integrityPromise: Promise | null = null + + const getFunctionIntegrity = async () => { + if (integrityCache) { + return integrityCache + } + integrityPromise ??= (async () => { + const integrity = integrityValue ?? (await hashFn(fn.toString())) + integrityCache = integrity + return integrity + })() + return await integrityPromise + } + + return async (...args: TArgs): Promise => { + // Resolve storage at call time, not function creation time + 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 = await getFunctionIntegrity() + const storageKey = await getFunctionStorageKey(merged, base, group, name, args, hashFn) + + const cachedRaw = await storage.getItem(storageKey) + const cached = isValidCachedFunctionEntry(cachedRaw) ? cachedRaw : null + if (!cached && cachedRaw !== null) { + await storage.removeItem(storageKey) + } + const cachedValue = await maybeServeCachedFunctionValue( + cached, + storageKey, + integrity, + swr, + () => fn(...args), + storage, + maxAge, + staleMaxAge, + serialize, + deserialize, + merged.validate, + args + ) + if (cachedValue !== null) { + return cachedValue + } + + const shouldInvalidate = await shouldInvalidateFunctionCache(merged, args) + if (shouldInvalidate && !keepPreviousOn5xx) { + await storage.removeItem(storageKey) + } + + if (pendingFunctionRequests.has(storageKey)) { + return (await pendingFunctionRequests.get(storageKey)) as TResult + } + + const resultPromise = Promise.resolve(fn(...args)) + .then((result) => + refreshFunctionCache( + storage, + storageKey, + result, + integrity, + maxAge, + staleMaxAge, + Date.now(), + serialize + ) + ) + .finally(() => { + pendingFunctionRequests.delete(storageKey) + }) + + pendingFunctionRequests.set(storageKey, resultPromise) + 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..eae50777e --- /dev/null +++ b/packages/universal-cache/src/index.test.ts @@ -0,0 +1,1056 @@ +import { Hono } from 'hono' +import type { CacheDefaults } from './types' +import { + cacheDefaults, + cacheFunction, + cacheMiddleware, + createCacheStorage, + getCacheDefaults, + getCacheStorage, + setCacheDefaults, + setCacheStorage, +} from '.' + +const resetDefaultOptions = () => { + const defaults = { + base: undefined, + group: undefined, + hash: undefined, + integrity: undefined, + keepPreviousOn5xx: undefined, + maxAge: undefined, + name: undefined, + revalidateHeader: undefined, + staleMaxAge: undefined, + storage: undefined, + swr: undefined, + } as unknown as CacheDefaults + setCacheDefaults(defaults) +} + +const flushPromises = async () => { + await Promise.resolve() + await Promise.resolve() +} + +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, + swr: false, + }), + (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('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'], + swr: false, + }), + (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('respects shouldBypassCache', async () => { + const app = new Hono() + let count = 0 + + app.get( + '/items', + cacheMiddleware({ + maxAge: 60, + swr: false, + 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('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, + swr: false, + 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, + swr: false, + 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, swr: false }), (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('supports custom revalidate header', async () => { + const app = new Hono() + let value = 'v1' + + app.get( + '/items', + cacheMiddleware({ + maxAge: 60, + swr: false, + 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('v2') + }) + + it('respects shouldRevalidate for manual revalidation', async () => { + const app = new Hono() + let value = 'v1' + let allowRevalidate = false + + app.get( + '/items', + cacheMiddleware({ + maxAge: 60, + swr: false, + 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('applies defaults from cacheDefaults()', async () => { + const app = new Hono() + let count = 0 + + app.use('*', cacheDefaults({ maxAge: 60, swr: false })) + 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('supports route-level config overrides via cacheMiddleware({ config })', async () => { + const app = new Hono() + let count = 0 + + app.use('*', cacheDefaults({ maxAge: 60, swr: false })) + app.get( + '/items', + cacheMiddleware({ + config: { maxAge: 0 }, + swr: false, + }), + (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('2') + expect(count).toBe(2) + }) + + it('keys by varies headers', async () => { + const app = new Hono() + let count = 0 + + app.get( + '/items', + cacheMiddleware({ + maxAge: 60, + swr: false, + 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('serves stale and revalidates in background once per key', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + + const app = new Hono() + let count = 0 + let fetchCalls = 0 + + let resolveRefresh!: () => void + const waitForRefresh = new Promise((resolve) => { + resolveRefresh = resolve + }) + + app.get( + '/items', + cacheMiddleware({ + maxAge: 1, + staleMaxAge: 60, + swr: true, + getKey: () => 'stable-key', + }), + async (c) => { + count += 1 + if (count > 1) { + await waitForRefresh + } + return c.text(String(count)) + } + ) + + const nativeFetch = globalThis.fetch + vi.stubGlobal('fetch', (async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init) + const url = new URL(request.url) + if (url.hostname === 'localhost') { + fetchCalls += 1 + return app.request(request) + } + return nativeFetch(input, init) + }) as typeof fetch) + + const first = await app.request('http://localhost/items') + expect(await first.text()).toBe('1') + + vi.advanceTimersByTime(1100) + + const stale1 = await app.request('http://localhost/items') + const stale2 = await app.request('http://localhost/items') + + expect(await stale1.text()).toBe('1') + expect(await stale2.text()).toBe('1') + expect(fetchCalls).toBe(1) + + resolveRefresh() + await flushPromises() + await flushPromises() + + expect(count).toBe(2) + }) + + 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('supports custom serialize/deserialize for responses', async () => { + const app = new Hono() + let count = 0 + + app.get( + '/items', + cacheMiddleware({ + maxAge: 60, + swr: false, + serialize: async (response, context) => ({ + value: await response.clone().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 = `${base}:${group}:${name}:${key}.json` + + await storage.setItem(storageKey, { value: 1 }) + + app.get( + '/items', + cacheMiddleware({ + storage, + name, + getKey: () => key, + maxAge: 60, + swr: false, + }), + (c) => { + count += 1 + return c.text(`value-${count}`) + } + ) + + const res = await app.request('http://localhost/items') + 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('falls back to safe key prefix when path decoding fails', async () => { + const app = new Hono() + let count = 0 + + app.get('*', cacheMiddleware({ maxAge: 60, swr: false }), (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 = 'cache:hono/handlers:items:key.json' + 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, + swr: false, + 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 = 'cache:hono/handlers:items:key.json' + 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, + swr: false, + 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('treats etag/last-modified header value "undefined" as invalid cache entry', async () => { + const storage = createCacheStorage() + const app = new Hono() + let count = 0 + + await storage.setItem('cache:hono/handlers:etag:key.json', { + value: toBase64('stale'), + encoding: 'base64', + status: 200, + headers: { etag: 'undefined' }, + mtime: Date.now(), + expires: Date.now() + 60_000, + staleExpires: Date.now() + 120_000, + integrity: 'integrity', + }) + + await storage.setItem('cache:hono/handlers:last-mod:key.json', { + value: toBase64('stale'), + encoding: 'base64', + status: 200, + headers: { 'last-modified': 'undefined' }, + mtime: Date.now(), + expires: Date.now() + 60_000, + staleExpires: Date.now() + 120_000, + integrity: 'integrity', + }) + + app.get( + '/etag', + cacheMiddleware({ + storage, + name: 'etag', + getKey: () => 'key', + maxAge: 60, + swr: false, + integrity: 'integrity', + }), + (c) => { + count += 1 + return c.text(`value-${count}`) + } + ) + + app.get( + '/last-mod', + cacheMiddleware({ + storage, + name: 'last-mod', + getKey: () => 'key', + maxAge: 60, + swr: false, + integrity: 'integrity', + }), + (c) => { + count += 1 + return c.text(`value-${count}`) + } + ) + + const etagRes = await app.request('http://localhost/etag') + const lastModRes = await app.request('http://localhost/last-mod') + + expect(await etagRes.text()).toBe('value-1') + expect(await lastModRes.text()).toBe('value-2') + expect(count).toBe(2) + }) + + 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, + swr: false, + 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('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('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('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('removes malformed function cache entries before computing fresh value', async () => { + const storage = createCacheStorage() + let count = 0 + + await storage.setItem('cache:hono/functions:fn:key.json', 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) + }) + }) + + describe('global cache accessors', () => { + it('sets and gets global cache defaults and storage', () => { + const storage = createCacheStorage() + const defaults = { maxAge: 120, staleMaxAge: 30 } + + setCacheStorage(storage) + setCacheDefaults(defaults) + + expect(getCacheStorage()).toBe(storage) + expect(getCacheDefaults()).toMatchObject(defaults) + }) + }) +}) diff --git a/packages/universal-cache/src/index.ts b/packages/universal-cache/src/index.ts new file mode 100644 index 000000000..2c10e31a4 --- /dev/null +++ b/packages/universal-cache/src/index.ts @@ -0,0 +1,19 @@ +export { + cacheDefaults, + cacheFunction, + cacheMiddleware, + createCacheStorage, + getCacheDefaults, + getCacheStorage, + setCacheDefaults, + setCacheStorage, +} from './cache' +export type { + CacheBaseOptions, + CacheConfigOptions, + CacheDefaults, + CachedFunctionEntry, + CachedResponseEntry, + CacheFunctionOptions, + CacheMiddlewareOptions, +} from './types' diff --git a/packages/universal-cache/src/types.ts b/packages/universal-cache/src/types.ts new file mode 100644 index 000000000..6ecf63168 --- /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 + +/** + * 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 + /** Custom header name to allow manual cache revalidation. Disabled by default. */ + revalidateHeader?: string | false + /** Stale max age in seconds. Use -1 for unlimited stale. */ + staleMaxAge?: number + /** Custom storage instance to use for caching. */ + storage?: Storage + /** Enable stale-while-revalidate behavior. */ + swr?: boolean +} + +/** + * Global cache defaults applied to middleware and cached functions. + */ +export interface CacheDefaults extends CacheBaseOptions {} + +/** + * Options for configuring cache defaults through Hono `app.use(...)`. + */ +export interface CacheConfigOptions extends Omit { + /** Default storage instance used by cache middleware and cached functions. */ + storage?: Storage +} + +export interface CacheMiddlewareOptions extends CacheBaseOptions { + /** + * Optional request-scoped defaults to apply before resolving this middleware options. + * Useful for route-local overrides on top of `app.use(cacheDefaults(...))`. + */ + config?: CacheConfigOptions + /** 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[] + /** 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 extends CacheBaseOptions { + /** Deserialize a cached entry back into the function result. */ + deserialize?: (entry: CachedFunctionEntry) => unknown + /** Provide a custom cache key. */ + getKey?: CacheKeyFn + /** Serialize the function result into a cached entry. */ + serialize?: ( + value: unknown, + 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 + /** 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..53316e3f6 --- /dev/null +++ b/packages/universal-cache/src/utils.test.ts @@ -0,0 +1,57 @@ +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') + }) + + it('stableStringify handles Date, arrays, and sorted object keys', () => { + const date = new Date('2026-01-01T00:00:00.000Z') + expect(stableStringify(date)).toBe('"2026-01-01T00:00:00.000Z"') + + expect(stableStringify([{ b: 2, a: 1 }, 'x'])).toBe('[{"a":1,"b":2},"x"]') + expect(stableStringify({ z: 1, a: { y: 2, x: 1 } })).toBe('{"a":{"x":1,"y":2},"z":1}') + }) + + 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..1abe546e3 --- /dev/null +++ b/packages/universal-cache/src/utils.ts @@ -0,0 +1,64 @@ +/** 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 stringification with sorted object keys. */ +export const stableStringify = (value: unknown): string => { + if (value === null || value === undefined) { + return String(value) + } + if (typeof value !== 'object') { + return JSON.stringify(value) + } + if (value instanceof Date) { + return JSON.stringify(value.toISOString()) + } + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringify(item)).join(',')}]` + } + const record = value as Record + const keys = Object.keys(record).sort() + const entries = keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`) + return `{${entries.join(',')}}` +} + +/** 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..8ecdc0dac --- /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", "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..4baf13a49 --- /dev/null +++ b/packages/universal-cache/tsdown.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + attw: true, + clean: true, + dts: true, + entry: 'src/index.ts', + format: ['cjs', 'esm'], + publint: true, + tsconfig: 'tsconfig.build.json', +}) 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/tsconfig.json b/tsconfig.json index bc485162c..5e3a12dc8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -45,6 +45,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" }, diff --git a/yarn.lock b/yarn.lock index d8e206006..35e56741a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2511,6 +2511,21 @@ __metadata: languageName: unknown linkType: soft +"@hono/universal-cache@workspace:packages/universal-cache": + version: 0.0.0-use.local + resolution: "@hono/universal-cache@workspace:packages/universal-cache" + dependencies: + hono: "npm:^4.11.5" + ohash: "npm:^2.0.11" + tsdown: "npm:^0.15.9" + typescript: "npm:^5.9.3" + unstorage: "npm:^1.17.0" + vitest: "npm:^4.1.0-beta.1" + peerDependencies: + hono: ">=4.0.0" + languageName: unknown + linkType: soft + "@hono/valibot-validator@workspace:packages/valibot-validator": version: 0.0.0-use.local resolution: "@hono/valibot-validator@workspace:packages/valibot-validator" @@ -12619,6 +12634,13 @@ __metadata: languageName: node linkType: hard +"ohash@npm:^2.0.11": + version: 2.0.11 + resolution: "ohash@npm:2.0.11" + checksum: 10c0/d07c8d79cc26da082c1a7c8d5b56c399dd4ed3b2bd069fcae6bae78c99a9bcc3ad813b1e1f49ca2f335292846d689c6141a762cf078727d2302a33d414e69c79 + languageName: node + linkType: hard + "on-finished@npm:2.4.1, on-finished@npm:^2.2.0, on-finished@npm:^2.4.1": version: 2.4.1 resolution: "on-finished@npm:2.4.1" @@ -16392,7 +16414,7 @@ __metadata: languageName: node linkType: hard -"unstorage@npm:^1.17.4": +"unstorage@npm:^1.17.0, unstorage@npm:^1.17.4": version: 1.17.4 resolution: "unstorage@npm:1.17.4" dependencies: From 29d3e05a3bedf12f2e7658106308a0995d0f752e Mon Sep 17 00:00:00 2001 From: raed bahri Date: Tue, 28 Apr 2026 03:59:11 +0100 Subject: [PATCH 02/12] test(universal-cache): validate cloudflare workers runtime --- .../universal-cache/src/index.workerd.test.ts | 170 ++++++++++++++++++ .../universal-cache/vitest.workerd.config.ts | 17 ++ yarn.lock | 2 + 3 files changed, 189 insertions(+) create mode 100644 packages/universal-cache/src/index.workerd.test.ts create mode 100644 packages/universal-cache/vitest.workerd.config.ts 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..78f6ab972 --- /dev/null +++ b/packages/universal-cache/src/index.workerd.test.ts @@ -0,0 +1,170 @@ +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, swr: false }), (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, + swr: false, + 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('supports custom manual revalidation on workerd', async () => { + const app = new Hono() + let value = 'v1' + let count = 0 + + app.get( + '/items', + cacheMiddleware({ + maxAge: 60, + swr: false, + 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 revalidated = await app.request( + 'http://localhost/items', + { headers: { 'x-custom-revalidate': '1' } }, + {}, + ctx2 + ) + await waitOnExecutionContext(ctx2) + expect(await revalidated.text()).toBe('v2') + + const ctx3 = createExecutionContext() + const cached = await app.request('http://localhost/items', {}, {}, ctx3) + await waitOnExecutionContext(ctx3) + + expect(await cached.text()).toBe('v2') + expect(count).toBe(2) + }) + + it('refreshes stale entries synchronously on workerd', async () => { + const app = new Hono() + let count = 0 + + app.get( + '/items', + cacheMiddleware({ + maxAge: 1, + staleMaxAge: 60, + swr: true, + }), + (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/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/yarn.lock b/yarn.lock index 35e56741a..d088039cc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2515,6 +2515,8 @@ __metadata: version: 0.0.0-use.local resolution: "@hono/universal-cache@workspace:packages/universal-cache" dependencies: + "@cloudflare/vitest-pool-workers": "https://pkg.pr.new/@cloudflare/vitest-pool-workers@7143d5d" + "@cloudflare/workers-types": "npm:^4.20250612.0" hono: "npm:^4.11.5" ohash: "npm:^2.0.11" tsdown: "npm:^0.15.9" From 2be0e506dc10a6290a39c5600c6013c4a1bf2d0e Mon Sep 17 00:00:00 2001 From: raed bahri Date: Mon, 13 Jul 2026 02:43:08 +0100 Subject: [PATCH 03/12] fix(universal-cache): harden cache behavior and adopt pnpm --- deno.jsonc | 2 +- packages/universal-cache/README.md | 3 + packages/universal-cache/package.json | 47 ++--- packages/universal-cache/src/cache.ts | 123 +++++++++---- packages/universal-cache/src/index.test.ts | 189 ++++++++++++++++++++ packages/universal-cache/src/index.ts | 1 + packages/universal-cache/tsconfig.spec.json | 2 +- packages/universal-cache/tsdown.config.ts | 6 - pnpm-lock.yaml | 33 ++++ 9 files changed, 345 insertions(+), 61 deletions(-) diff --git a/deno.jsonc b/deno.jsonc index a89f252fb..f654abb81 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -39,7 +39,7 @@ // "packages/tsyringe", "packages/typebox-validator", "packages/typia-validator", - // "packages/universal-cache", + "packages/universal-cache", "packages/valibot-validator", "packages/zod-openapi", "packages/zod-validator", diff --git a/packages/universal-cache/README.md b/packages/universal-cache/README.md index 6d9df41e6..2f6f7aa80 100644 --- a/packages/universal-cache/README.md +++ b/packages/universal-cache/README.md @@ -62,10 +62,13 @@ const getStats = cacheFunction(async (id: string) => ({ id, ts: Date.now() }), { - `setCacheStorage(storage)` / `getCacheStorage()` - `setCacheDefaults(options)` / `getCacheDefaults()` - `createCacheStorage()` +- `stableStringify(value)` for deterministic custom cache keys built from JSON-compatible values ## Notes - Cached responses drop `set-cookie` and hop-by-hop headers. +- Responses marked `private`, `no-store`, `no-cache`, `Vary: *`, or HTTP 206 are not cached. +- Default response keys include the request method, origin, path, query, and configured `varies` headers. - Manual cache revalidation is disabled by default. Set `revalidateHeader` to opt in. - Use `shouldRevalidate` to gate manual revalidation requests. - Middleware cache defaults to `GET` and `HEAD`. diff --git a/packages/universal-cache/package.json b/packages/universal-cache/package.json index f375db848..d245fe6b0 100644 --- a/packages/universal-cache/package.json +++ b/packages/universal-cache/package.json @@ -3,38 +3,36 @@ "version": "0.0.0", "description": "Universal cache middleware and helpers for Hono", "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - "./package.json": "./package.json", - ".": { - "import": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "require": { - "types": "./dist/index.d.cts", - "default": "./dist/index.cjs" - } - } - }, + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.cts", "files": [ "dist" ], "scripts": { - "build": "tsdown", - "format": "prettier --check . --ignore-path ../../.gitignore", + "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": "yarn version:set $npm_package_version" + "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 + "provenance": true, + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" + }, + "./package.json": "./package.json" + } }, "repository": { "type": "git", @@ -50,11 +48,14 @@ "unstorage": "^1.17.0" }, "devDependencies": { - "@cloudflare/vitest-pool-workers": "https://pkg.pr.new/@cloudflare/vitest-pool-workers@7143d5d", + "@cloudflare/vitest-pool-workers": "^0.16.10", "@cloudflare/workers-types": "^4.20250612.0", "hono": "^4.11.5", - "tsdown": "^0.15.9", - "typescript": "^5.9.3", - "vitest": "^4.1.0-beta.1" + "tsdown": "^0.22.3", + "typescript": "^6.0.3", + "vitest": "^4.1.7" + }, + "engines": { + "node": ">=16.0.0" } } diff --git a/packages/universal-cache/src/cache.ts b/packages/universal-cache/src/cache.ts index c9db02494..46e16f593 100644 --- a/packages/universal-cache/src/cache.ts +++ b/packages/universal-cache/src/cache.ts @@ -40,6 +40,25 @@ const HOP_BY_HOP_HEADERS = new Set([ ]) const INTERNAL_REVALIDATE_HEADER = 'x-hono-universal-cache-revalidate' +const CACHE_MISS = Symbol('cache-miss') + +const createInternalRevalidateToken = () => { + if (!globalThis.crypto) { + return null + } + const bytes = new Uint8Array(16) + globalThis.crypto.getRandomValues(bytes) + return encodeBase64(bytes.buffer) +} + +let internalRevalidateToken: string | null | undefined + +const getInternalRevalidateToken = () => { + if (internalRevalidateToken === undefined) { + internalRevalidateToken = createInternalRevalidateToken() + } + return internalRevalidateToken +} let defaultStorage: Storage = createStorage({ driver: memoryDriver(), @@ -48,8 +67,16 @@ let defaultStorage: Storage = createStorage({ let defaultCacheOptions: CacheDefaults = {} const requestCacheDefaults = new WeakMap() -const pendingFunctionRequests = new Map>() -const pendingRevalidations = new Map>() +type PendingRequests = WeakMap>> + +const getPendingRequests = (pendingRequests: PendingRequests, storage: Storage) => { + let requests = pendingRequests.get(storage) + if (!requests) { + requests = new Map() + pendingRequests.set(storage, requests) + } + return requests +} const setRequestCacheDefaults = (ctx: Context, options: CacheConfigOptions = {}) => { const current = requestCacheDefaults.get(ctx) ?? {} @@ -120,7 +147,7 @@ const getDefaultHandlerKey = async ( hashFn: (value: string) => string | Promise ) => { const url = new URL(ctx.req.url) - const fullPath = `${url.pathname}${url.search}` + const fullPath = `${ctx.req.method.toUpperCase()}:${url.origin}${url.pathname}${url.search}` let pathPrefix = '-' try { @@ -172,7 +199,7 @@ const getCacheHeaders = (response: Response): Record => { } const isCacheableResponse = (response: Response) => { - if (response.status < 200 || response.status >= 300) { + if (response.status < 200 || response.status >= 300 || response.status === 206) { return false } if (response.headers.has('set-cookie')) { @@ -186,12 +213,19 @@ const isCacheableResponse = (response: Response) => { if (lastModified === 'undefined') { return false } + if (response.headers.get('vary')?.trim() === '*') { + 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')) + return !( + normalized.includes('no-store') || + normalized.includes('no-cache') || + normalized.includes('private') + ) } const defaultSerializeResponse = async ( @@ -278,6 +312,7 @@ const maybeServeCachedResponse = async ( cachedRaw: unknown, deserialize: NonNullable, revalidateHeader: string | false, + pendingRequests: PendingRequests, validate?: CacheMiddlewareOptions['validate'] ) => { const cached = isValidCachedResponseEntry(cachedRaw) ? cachedRaw : null @@ -301,28 +336,41 @@ const maybeServeCachedResponse = async ( } if (swr && isStaleValid(cached.staleExpires)) { + if (ctx.req.header(INTERNAL_REVALIDATE_HEADER) !== undefined) { + return await deserialize(cached) + } + if (getRuntimeKey() === 'workerd') { return null } - if (!pendingRevalidations.has(storageKey)) { + const revalidateToken = getInternalRevalidateToken() + if (!revalidateToken) { + return null + } + + const requests = getPendingRequests(pendingRequests, storage) + const pendingKey = `${storageKey}:${integrity}` + if (!requests.has(pendingKey)) { const revalidatePromise = (async () => { try { const refreshHeaders = new Headers(ctx.req.raw.headers) if (revalidateHeader) { refreshHeaders.delete(revalidateHeader) } - refreshHeaders.set(INTERNAL_REVALIDATE_HEADER, '1') + refreshHeaders.set(INTERNAL_REVALIDATE_HEADER, revalidateToken) const request = new Request(ctx.req.url, { method: ctx.req.method, headers: refreshHeaders, }) - await fetch(request) + const response = await fetch(request) + await response.body?.cancel() } finally { - pendingRevalidations.delete(storageKey) + requests.delete(pendingKey) } })() - pendingRevalidations.set(storageKey, revalidatePromise) + void revalidatePromise.catch(() => undefined) + requests.set(pendingKey, revalidatePromise) } return await deserialize(cached) } @@ -380,7 +428,8 @@ const readCachedResponse = async ( options: CacheMiddlewareOptions, swr: boolean, deserialize: NonNullable, - revalidateHeader: string | false + revalidateHeader: string | false, + pendingRequests: PendingRequests ) => { const cachedRaw = await storage.getItem(storageKey) return await maybeServeCachedResponse( @@ -392,6 +441,7 @@ const readCachedResponse = async ( cachedRaw, deserialize, revalidateHeader, + pendingRequests, options.validate ) } @@ -437,6 +487,7 @@ export const cacheMiddleware = ( typeof options === 'number' ? { maxAge: options } : options const { config: middlewareConfig, ...routeOptions } = normalized const isConfigOnly = middlewareConfig !== undefined && Object.keys(routeOptions).length === 0 + const pendingRevalidations: PendingRequests = new WeakMap() const handler: MiddlewareHandler = async (ctx: Context, next: Next) => { if (middlewareConfig) { @@ -481,15 +532,18 @@ export const cacheMiddleware = ( return next() } - const isInternalRevalidateRequest = ctx.req.header(INTERNAL_REVALIDATE_HEADER) === '1' + const revalidateToken = getRuntimeKey() === 'workerd' ? null : getInternalRevalidateToken() + const isInternalRevalidateRequest = + revalidateToken !== null && ctx.req.header(INTERNAL_REVALIDATE_HEADER) === revalidateToken const isManualRevalidateRequest = revalidateHeader !== false && ctx.req.header(revalidateHeader) === '1' const isRevalidateRequest = isInternalRevalidateRequest || (isManualRevalidateRequest && (await shouldManualRevalidateMiddlewareCache(ctx, merged))) const { storageKey, integrity } = await resolveHandlerCacheKey(ctx, merged, base, group, hashFn) + const shouldInvalidate = await shouldInvalidateMiddlewareCache(ctx, merged) - if (!isRevalidateRequest) { + if (!isRevalidateRequest && !shouldInvalidate) { const cachedResponse = await readCachedResponse( ctx, storage, @@ -498,7 +552,8 @@ export const cacheMiddleware = ( merged, swr, deserialize, - revalidateHeader + revalidateHeader, + pendingRevalidations ) if (cachedResponse) { ctx.res = cachedResponse @@ -506,7 +561,6 @@ export const cacheMiddleware = ( } } - const shouldInvalidate = await shouldInvalidateMiddlewareCache(ctx, merged) if (shouldInvalidate && !keepPreviousOn5xx) { await storage.removeItem(storageKey) } @@ -626,24 +680,28 @@ const maybeServeCachedFunctionValue = async ( staleMaxAge: number, serialize: NonNullable['serialize']>, deserialize: NonNullable['deserialize']>, + pendingRequests: PendingRequests, validate?: CacheFunctionOptions['validate'], validateArgs?: TArgs -): Promise => { +): Promise => { if (!cached || cached.integrity !== integrity) { - return null + return CACHE_MISS } if (validate) { const args = validateArgs ?? ([] as unknown as TArgs) if (validate(cached, ...args) === false) { - return null + return CACHE_MISS } } if (!isExpired(cached.expires)) { return (await deserialize(cached)) as TResult } if (swr && isStaleValid(cached.staleExpires)) { - if (!pendingFunctionRequests.has(storageKey)) { - const refreshPromise = Promise.resolve(fetcher()) + const requests = getPendingRequests(pendingRequests, storage) + const pendingKey = `${storageKey}:${integrity}` + if (!requests.has(pendingKey)) { + const refreshPromise = Promise.resolve() + .then(fetcher) .then((fresh) => refreshFunctionCache( storage, @@ -657,13 +715,14 @@ const maybeServeCachedFunctionValue = async ( ) ) .finally(() => { - pendingFunctionRequests.delete(storageKey) + requests.delete(pendingKey) }) - pendingFunctionRequests.set(storageKey, refreshPromise) + void refreshPromise.catch(() => undefined) + requests.set(pendingKey, refreshPromise) } return (await deserialize(cached)) as TResult } - return null + return CACHE_MISS } /** @@ -689,6 +748,7 @@ export const cacheFunction = ( const integrityValue = merged.integrity let integrityCache: string | null = null let integrityPromise: Promise | null = null + const pendingFunctionRequests: PendingRequests = new WeakMap() const getFunctionIntegrity = async () => { if (integrityCache) { @@ -717,8 +777,9 @@ export const cacheFunction = ( const integrity = await getFunctionIntegrity() const storageKey = await getFunctionStorageKey(merged, base, group, name, args, hashFn) + const shouldInvalidate = await shouldInvalidateFunctionCache(merged, args) - const cachedRaw = await storage.getItem(storageKey) + const cachedRaw = shouldInvalidate ? null : await storage.getItem(storageKey) const cached = isValidCachedFunctionEntry(cachedRaw) ? cachedRaw : null if (!cached && cachedRaw !== null) { await storage.removeItem(storageKey) @@ -734,20 +795,22 @@ export const cacheFunction = ( staleMaxAge, serialize, deserialize, + pendingFunctionRequests, merged.validate, args ) - if (cachedValue !== null) { + if (cachedValue !== CACHE_MISS) { return cachedValue } - const shouldInvalidate = await shouldInvalidateFunctionCache(merged, args) if (shouldInvalidate && !keepPreviousOn5xx) { await storage.removeItem(storageKey) } - if (pendingFunctionRequests.has(storageKey)) { - return (await pendingFunctionRequests.get(storageKey)) as TResult + const requests = getPendingRequests(pendingFunctionRequests, storage) + const pendingKey = `${storageKey}:${integrity}` + if (requests.has(pendingKey)) { + return (await requests.get(pendingKey)) as TResult } const resultPromise = Promise.resolve(fn(...args)) @@ -764,10 +827,10 @@ export const cacheFunction = ( ) ) .finally(() => { - pendingFunctionRequests.delete(storageKey) + requests.delete(pendingKey) }) - pendingFunctionRequests.set(storageKey, resultPromise) + requests.set(pendingKey, resultPromise) return await resultPromise } } diff --git a/packages/universal-cache/src/index.test.ts b/packages/universal-cache/src/index.test.ts index eae50777e..6f897a7d8 100644 --- a/packages/universal-cache/src/index.test.ts +++ b/packages/universal-cache/src/index.test.ts @@ -9,6 +9,7 @@ import { getCacheStorage, setCacheDefaults, setCacheStorage, + stableStringify, } from '.' const resetDefaultOptions = () => { @@ -228,6 +229,44 @@ describe('@hono/universal-cache', () => { 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, swr: false }), (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, + swr: false, + 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('supports custom revalidate header', async () => { const app = new Hono() let value = 'v1' @@ -361,6 +400,28 @@ describe('@hono/universal-cache', () => { 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'], swr: false }), + (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('serves stale and revalidates in background once per key', async () => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) @@ -421,6 +482,27 @@ describe('@hono/universal-cache', () => { expect(count).toBe(2) }) + it('handles rejected background response refreshes while serving stale', 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, swr: true }), (c) => { + count += 1 + return c.text(String(count)) + }) + + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('refresh failed'))) + + expect(await (await app.request('http://localhost/items')).text()).toBe('1') + vi.advanceTimersByTime(1100) + expect(await (await app.request('http://localhost/items')).text()).toBe('1') + await flushPromises() + expect(count).toBe(1) + }) + it('does not cache non-cacheable responses with set-cookie', async () => { const app = new Hono() let count = 0 @@ -439,6 +521,23 @@ describe('@hono/universal-cache', () => { expect(count).toBe(2) }) + 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 @@ -745,6 +844,41 @@ describe('@hono/universal-cache', () => { 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 @@ -769,6 +903,35 @@ describe('@hono/universal-cache', () => { 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 @@ -976,6 +1139,28 @@ describe('@hono/universal-cache', () => { 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('bypasses cache when maxAge is zero', async () => { let count = 0 @@ -1042,6 +1227,10 @@ describe('@hono/universal-cache', () => { }) describe('global cache accessors', () => { + it('exports stableStringify for deterministic custom keys', () => { + expect(stableStringify({ b: 2, a: 1 })).toBe('{"a":1,"b":2}') + }) + it('sets and gets global cache defaults and storage', () => { const storage = createCacheStorage() const defaults = { maxAge: 120, staleMaxAge: 30 } diff --git a/packages/universal-cache/src/index.ts b/packages/universal-cache/src/index.ts index 2c10e31a4..fa2c5918f 100644 --- a/packages/universal-cache/src/index.ts +++ b/packages/universal-cache/src/index.ts @@ -17,3 +17,4 @@ export type { CacheFunctionOptions, CacheMiddlewareOptions, } from './types' +export { stableStringify } from './utils' diff --git a/packages/universal-cache/tsconfig.spec.json b/packages/universal-cache/tsconfig.spec.json index 8ecdc0dac..380781a09 100644 --- a/packages/universal-cache/tsconfig.spec.json +++ b/packages/universal-cache/tsconfig.spec.json @@ -8,6 +8,6 @@ "vitest/globals" ] }, - "include": ["src", "vitest.config.ts", "vitest.workerd.config.ts"], + "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 index 4baf13a49..d1ab7a428 100644 --- a/packages/universal-cache/tsdown.config.ts +++ b/packages/universal-cache/tsdown.config.ts @@ -1,11 +1,5 @@ import { defineConfig } from 'tsdown' export default defineConfig({ - attw: true, - clean: true, - dts: true, entry: 'src/index.ts', - format: ['cjs', 'esm'], - publint: true, - tsconfig: 'tsconfig.build.json', }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d110e99e..6bc4835f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1011,6 +1011,34 @@ 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: + ohash: + specifier: ^2.0.11 + version: 2.0.11 + unstorage: + specifier: ^1.17.0 + version: 1.17.5 + 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 + 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: @@ -5639,6 +5667,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'} @@ -12136,6 +12167,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 From b3a7f87758b413f98848aea9bdb18a1e9fa506c8 Mon Sep 17 00:00:00 2001 From: raed bahri Date: Mon, 13 Jul 2026 03:16:17 +0100 Subject: [PATCH 04/12] fix(universal-cache): complete release validation --- packages/universal-cache/README.md | 151 +++++++++++++++++---- packages/universal-cache/package.json | 1 + packages/universal-cache/src/cache.ts | 29 +++- packages/universal-cache/src/index.test.ts | 98 +++++++++++++ 4 files changed, 251 insertions(+), 28 deletions(-) diff --git a/packages/universal-cache/README.md b/packages/universal-cache/README.md index 2f6f7aa80..b19e72331 100644 --- a/packages/universal-cache/README.md +++ b/packages/universal-cache/README.md @@ -2,17 +2,25 @@ [![codecov](https://codecov.io/github/honojs/middleware/graph/badge.svg?flag=universal-cache)](https://codecov.io/github/honojs/middleware) -Universal cache utilities for Hono. +Storage-agnostic response and function caching for Hono. ## Features - Response caching with `cacheMiddleware()` - Function result caching with `cacheFunction()` -- Stale-while-revalidate support -- Cache defaults via middleware `cacheDefaults()` -- Custom keying, storage, serialization, and validation +- Request-scoped defaults with `cacheDefaults()` +- Stale-while-revalidate and in-flight request deduplication +- 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 -## Usage +## Installation + +```sh +pnpm add @hono/universal-cache +``` + +## Response caching ```ts import { Hono } from 'hono' @@ -23,27 +31,113 @@ const app = new Hono() app.get('/items', cacheMiddleware(60), (c) => c.json({ ok: true })) ``` -## Configure defaults +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. Configure a persistent or distributed driver for multi-instance deployments. ```ts import { Hono } from 'hono' -import { cacheDefaults } from '@hono/universal-cache' +import { cacheDefaults, cacheMiddleware } from '@hono/universal-cache' import { createStorage } from 'unstorage' -import memoryDriver from 'unstorage/drivers/memory' +import redisDriver from 'unstorage/drivers/redis' const app = new Hono() app.use( + '/api/*', cacheDefaults({ - storage: createStorage({ driver: memoryDriver() }), + storage: createStorage({ driver: redisDriver({ url: process.env.REDIS_URL }) }), maxAge: 60, staleMaxAge: 30, swr: true, }) ) + +app.get('/api/items', cacheMiddleware(), (c) => c.json({ ok: true })) +``` + +`cacheDefaults()` applies defaults to downstream cache middleware for the current request. Use `setCacheDefaults()` and `setCacheStorage()` for process-wide defaults, including `cacheFunction()`. + +Route-local options override request-scoped and process-wide defaults: + +```ts +app.get( + '/api/items', + cacheMiddleware({ + config: { maxAge: 120 }, + staleMaxAge: 60, + }), + handler +) +``` + +## 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 +- `authorization` and `cookie` headers when present + +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, +}) ``` -## Cached function +`getKey` replaces the complete default key. Include every relevant tenant, authorization, cookie, method, body, and variation value when providing one. + +When a response uses `Vary`, list the corresponding request headers in `varies`. Responses with `Vary: *` 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('authorization') === `Bearer ${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. + +## Bypass and invalidation + +```ts +cacheMiddleware({ + shouldBypassCache: (c) => c.req.header('cache-control') === 'no-cache', + 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, + swr: true, +}) +``` + +After `maxAge`, stale entries remain usable for `staleMaxAge` seconds. Use `staleMaxAge: -1` for unlimited stale storage. + +Standard runtimes serve the stale response and perform a deduplicated background self-fetch. Cloudflare Workers refresh stale middleware entries synchronously because background self-fetch behaves differently under `workerd`. Function caches refresh stale values in the background on every runtime. + +## Function caching ```ts import { cacheFunction } from '@hono/universal-cache' @@ -54,26 +148,37 @@ const getStats = cacheFunction(async (id: string) => ({ id, ts: Date.now() }), { }) ``` +Without `getKey`, arguments are deterministically serialized and hashed. Default argument serialization supports JSON-compatible values and `Date`. Provide `getKey` for values such as `Map`, `Set`, `BigInt`, cyclic structures, or class instances. + +Concurrent calls for the same storage, key, and integrity value share one in-flight operation. Different storage instances remain isolated. + +## 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'`. Use `integrity` to invalidate entries when their schema or behavior changes. + ## API - `cacheMiddleware(options | maxAge)` - `cacheDefaults(options)` - `cacheFunction(fn, options | maxAge)` +- `createCacheStorage()` - `setCacheStorage(storage)` / `getCacheStorage()` - `setCacheDefaults(options)` / `getCacheDefaults()` -- `createCacheStorage()` -- `stableStringify(value)` for deterministic custom cache keys built from JSON-compatible values - -## Notes - -- Cached responses drop `set-cookie` and hop-by-hop headers. -- Responses marked `private`, `no-store`, `no-cache`, `Vary: *`, or HTTP 206 are not cached. -- Default response keys include the request method, origin, path, query, and configured `varies` headers. -- Manual cache revalidation is disabled by default. Set `revalidateHeader` to opt in. -- Use `shouldRevalidate` to gate manual revalidation requests. -- Middleware cache defaults to `GET` and `HEAD`. -- Default `maxAge` is `60` seconds. -- On `workerd`, stale middleware entries are refreshed synchronously instead of using background self-fetch. +- `stableStringify(value)` + +Exported types include `CacheBaseOptions`, `CacheConfigOptions`, `CacheDefaults`, `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` +- responses containing `Vary: *` +- malformed persisted entries + +Cached responses exclude `set-cookie`, `content-length`, and other hop-by-hop headers. ## Author diff --git a/packages/universal-cache/package.json b/packages/universal-cache/package.json index d245fe6b0..e8b662618 100644 --- a/packages/universal-cache/package.json +++ b/packages/universal-cache/package.json @@ -2,6 +2,7 @@ "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", diff --git a/packages/universal-cache/src/cache.ts b/packages/universal-cache/src/cache.ts index 46e16f593..22d3c117a 100644 --- a/packages/universal-cache/src/cache.ts +++ b/packages/universal-cache/src/cache.ts @@ -147,7 +147,12 @@ const getDefaultHandlerKey = async ( hashFn: (value: string) => string | Promise ) => { const url = new URL(ctx.req.url) - const fullPath = `${ctx.req.method.toUpperCase()}:${url.origin}${url.pathname}${url.search}` + 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 { @@ -157,12 +162,19 @@ const getDefaultHandlerKey = async ( } const hashedPath = `${pathPrefix}.${await hashFn(fullPath)}` - if (!varies?.length) { + const varyHeaders = new Set(varies?.map(toLower) ?? []) + for (const header of ['authorization', 'cookie']) { + if (ctx.req.header(header) !== undefined) { + varyHeaders.add(header) + } + } + + if (varyHeaders.size === 0) { return hashedPath } const varyParts = await Promise.all( - varies.map(async (header) => { + [...varyHeaders].map(async (header) => { const value = ctx.req.header(header) ?? '' return `${escapeKey(toLower(header))}.${await hashFn(value)}` }) @@ -179,7 +191,8 @@ const getDefaultHandlerName = (ctx: Context) => { const createCachedResponse = (entry: CachedResponseEntry) => { const headers = new Headers(entry.headers) - return new Response(decodeBase64(entry.value), { + const body = entry.status === 204 || entry.status === 205 ? null : decodeBase64(entry.value) + return new Response(body, { status: entry.status, headers, }) @@ -359,8 +372,14 @@ const maybeServeCachedResponse = async ( refreshHeaders.delete(revalidateHeader) } refreshHeaders.set(INTERNAL_REVALIDATE_HEADER, revalidateToken) + const method = ctx.req.method.toUpperCase() + const body = + method === 'GET' || method === 'HEAD' + ? undefined + : await ctx.req.raw.clone().arrayBuffer() const request = new Request(ctx.req.url, { - method: ctx.req.method, + ...(body ? { body } : {}), + method, headers: refreshHeaders, }) const response = await fetch(request) diff --git a/packages/universal-cache/src/index.test.ts b/packages/universal-cache/src/index.test.ts index 6f897a7d8..89d1fcddd 100644 --- a/packages/universal-cache/src/index.test.ts +++ b/packages/universal-cache/src/index.test.ts @@ -115,6 +115,64 @@ describe('@hono/universal-cache', () => { 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'], swr: false }), + 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('preserves request bodies during background revalidation', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + + const app = new Hono() + let count = 0 + let resolveRefresh!: () => void + const refreshed = new Promise((resolve) => { + resolveRefresh = resolve + }) + + app.post( + '/items', + cacheMiddleware({ maxAge: 1, methods: ['POST'], staleMaxAge: 60, swr: true }), + async (c) => { + count += 1 + if (count === 2) { + resolveRefresh() + } + return c.text(`${await c.req.text()}:${count}`) + } + ) + + vi.stubGlobal('fetch', (request: Request) => app.request(request)) + + 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:1') + await refreshed + await flushPromises() + expect(await (await request()).text()).toBe('one:2') + expect(count).toBe(2) + }) + it('respects shouldBypassCache', async () => { const app = new Hono() let count = 0 @@ -422,6 +480,27 @@ describe('@hono/universal-cache', () => { expect(await (await app.request('http://one.example/items')).text()).toBe('GET:one.example:1') }) + it.each(['authorization', 'cookie'])( + 'automatically varies by the %s header', + async (header) => { + const app = new Hono() + let count = 0 + + app.get('/items', cacheMiddleware({ maxAge: 60, swr: false }), (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('serves stale and revalidates in background once per key', async () => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) @@ -521,6 +600,25 @@ describe('@hono/universal-cache', () => { expect(count).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, swr: false }), () => { + 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], From 5b193853fd56cf8a520076c9c7edc62f41480cef Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:17:24 +0000 Subject: [PATCH 05/12] ci: apply automated fixes --- packages/universal-cache/eslint-suppressions.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 packages/universal-cache/eslint-suppressions.json 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 @@ +{} From a9edb131e63f01b9ab7bcc40b802c22207f959b4 Mon Sep 17 00:00:00 2001 From: raed bahri Date: Wed, 15 Jul 2026 15:15:35 +0100 Subject: [PATCH 06/12] fix(universal-cache): address review feedback --- .changeset/fuzzy-garlic-clean.md | 3 +- packages/universal-cache/README.md | 48 +- packages/universal-cache/deno.json | 5 +- packages/universal-cache/package.json | 1 + packages/universal-cache/src/cache.ts | 436 +++++++------- packages/universal-cache/src/index.test.ts | 550 +++++++++++------- packages/universal-cache/src/index.ts | 2 +- .../universal-cache/src/index.workerd.test.ts | 15 +- packages/universal-cache/src/types.ts | 30 +- packages/universal-cache/src/utils.test.ts | 12 +- packages/universal-cache/src/utils.ts | 22 +- pnpm-lock.yaml | 11 +- 12 files changed, 634 insertions(+), 501 deletions(-) diff --git a/.changeset/fuzzy-garlic-clean.md b/.changeset/fuzzy-garlic-clean.md index 00744211d..5893039ad 100644 --- a/.changeset/fuzzy-garlic-clean.md +++ b/.changeset/fuzzy-garlic-clean.md @@ -7,6 +7,7 @@ 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-while-revalidate support +- stale-if-error response fallback and stale-while-revalidate function caching +- bounded TTL-aware in-memory storage by default - storage/default accessors (`set/getCacheStorage`, `set/getCacheDefaults`) - custom keying, serialization, validation, and invalidation hooks diff --git a/packages/universal-cache/README.md b/packages/universal-cache/README.md index b19e72331..154cd6ec9 100644 --- a/packages/universal-cache/README.md +++ b/packages/universal-cache/README.md @@ -9,7 +9,7 @@ Storage-agnostic response and function caching for Hono. - Response caching with `cacheMiddleware()` - Function result caching with `cacheFunction()` - Request-scoped defaults with `cacheDefaults()` -- Stale-while-revalidate and in-flight request deduplication +- Stale-while-revalidate and in-flight deduplication 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 @@ -35,7 +35,7 @@ Passing a number is shorthand for `{ maxAge: number }`. `GET` and `HEAD` are cac ## Storage and defaults -The default storage is an in-memory `unstorage` instance scoped to the current process or isolate. Configure a persistent or distributed driver for multi-instance deployments. +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. ```ts import { Hono } from 'hono' @@ -51,27 +51,15 @@ app.use( storage: createStorage({ driver: redisDriver({ url: process.env.REDIS_URL }) }), maxAge: 60, staleMaxAge: 30, - swr: true, }) ) app.get('/api/items', cacheMiddleware(), (c) => c.json({ ok: true })) ``` -`cacheDefaults()` applies defaults to downstream cache middleware for the current request. Use `setCacheDefaults()` and `setCacheStorage()` for process-wide defaults, including `cacheFunction()`. +`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()`. -Route-local options override request-scoped and process-wide defaults: - -```ts -app.get( - '/api/items', - cacheMiddleware({ - config: { maxAge: 120 }, - staleMaxAge: 60, - }), - handler -) -``` +`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 @@ -81,7 +69,10 @@ Default response keys include: - URL path and query - request body for explicitly enabled non-`GET`/`HEAD` methods - configured `varies` headers -- `authorization` and `cookie` headers when present + +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: @@ -92,9 +83,9 @@ cacheMiddleware({ }) ``` -`getKey` replaces the complete default key. Include every relevant tenant, authorization, cookie, method, body, and variation value when providing one. +`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 the corresponding request headers in `varies`. Responses with `Vary: *` are never cached. +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 @@ -113,7 +104,7 @@ A request with `x-my-cache-revalidate: 1` refreshes the entry only when `shouldR ```ts cacheMiddleware({ - shouldBypassCache: (c) => c.req.header('cache-control') === 'no-cache', + shouldBypassCache: (c) => c.req.header('x-preview') === '1', shouldInvalidateCache: (c) => c.req.query('refresh') === '1', keepPreviousOn5xx: true, }) @@ -129,13 +120,12 @@ cacheMiddleware({ cacheMiddleware({ maxAge: 60, staleMaxAge: 300, - swr: true, }) ``` -After `maxAge`, stale entries remain usable for `staleMaxAge` seconds. Use `staleMaxAge: -1` for unlimited stale storage. +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. -Standard runtimes serve the stale response and perform a deduplicated background self-fetch. Cloudflare Workers refresh stale middleware entries synchronously because background self-fetch behaves differently under `workerd`. Function caches refresh stale values in the background on every runtime. +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 @@ -148,10 +138,12 @@ const getStats = cacheFunction(async (id: string) => ({ id, ts: Date.now() }), { }) ``` -Without `getKey`, arguments are deterministically serialized and hashed. Default argument serialization supports JSON-compatible values and `Date`. Provide `getKey` for values such as `Map`, `Set`, `BigInt`, cyclic structures, or class instances. +Without `getKey`, arguments are deterministically serialized with type information and hashed. This distinguishes values such as a `Date` from the same ISO string and supports common values including `Map`, `Set`, and `BigInt`. Provide `getKey` when application-specific key control is needed. 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 safely preserves JSON-compatible results only. Values such as `Date`, `Map`, `Set`, class instances, and `BigInt` require custom `serialize` and `deserialize` functions when their type or shape must be preserved. + ## 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'`. Use `integrity` to invalidate entries when their schema or behavior changes. @@ -161,12 +153,12 @@ Concurrent calls for the same storage, key, and integrity value share one in-fli - `cacheMiddleware(options | maxAge)` - `cacheDefaults(options)` - `cacheFunction(fn, options | maxAge)` -- `createCacheStorage()` +- `createCacheStorage({ maxEntries?, maxSize?, maxEntrySize? })` - `setCacheStorage(storage)` / `getCacheStorage()` - `setCacheDefaults(options)` / `getCacheDefaults()` - `stableStringify(value)` -Exported types include `CacheBaseOptions`, `CacheConfigOptions`, `CacheDefaults`, `CacheMiddlewareOptions`, `CacheFunctionOptions`, `CachedResponseEntry`, and `CachedFunctionEntry`. +Exported types include `CacheBaseOptions`, `CacheDefaults`, `CacheStorageOptions`, `CacheMiddlewareOptions`, `CacheFunctionOptions`, `CachedResponseEntry`, and `CachedFunctionEntry`. ## Response safety @@ -175,10 +167,12 @@ 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` -- responses containing `Vary: *` +- 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`, `content-length`, and other hop-by-hop headers. +Set `Cache-Control: no-store` on custom streaming response types so they are not buffered for caching. ## Author diff --git a/packages/universal-cache/deno.json b/packages/universal-cache/deno.json index 2a724d84d..9e9d7ccea 100644 --- a/packages/universal-cache/deno.json +++ b/packages/universal-cache/deno.json @@ -6,7 +6,10 @@ ".": "./src/index.ts" }, "imports": { - "hono": "jsr:@hono/hono@^4.8.3" + "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.0" }, "publish": { "include": ["deno.json", "README.md", "src/**/*.ts"], diff --git a/packages/universal-cache/package.json b/packages/universal-cache/package.json index e8b662618..0a3f9dae4 100644 --- a/packages/universal-cache/package.json +++ b/packages/universal-cache/package.json @@ -45,6 +45,7 @@ "hono": ">=4.0.0" }, "dependencies": { + "lru-cache": "^10.4.3", "ohash": "^2.0.11", "unstorage": "^1.17.0" }, diff --git a/packages/universal-cache/src/cache.ts b/packages/universal-cache/src/cache.ts index 22d3c117a..e43e4e790 100644 --- a/packages/universal-cache/src/cache.ts +++ b/packages/universal-cache/src/cache.ts @@ -1,17 +1,17 @@ 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 { Storage } from 'unstorage' -import memoryDriver from 'unstorage/drivers/memory' +import type { Driver, Storage } from 'unstorage' import type { - CacheConfigOptions, CacheDefaults, CachedFunctionEntry, CachedResponseEntry, CacheFunctionOptions, CacheMiddlewareOptions, + CacheStorageOptions, } from './types' import { computeTtlSeconds, @@ -39,29 +39,59 @@ const HOP_BY_HOP_HEADERS = new Set([ 'content-length', ]) -const INTERNAL_REVALIDATE_HEADER = 'x-hono-universal-cache-revalidate' const CACHE_MISS = Symbol('cache-miss') - -const createInternalRevalidateToken = () => { - if (!globalThis.crypto) { - return null - } - const bytes = new Uint8Array(16) - globalThis.crypto.getRandomValues(bytes) - return encodeBase64(bytes.buffer) -} - -let internalRevalidateToken: string | null | undefined - -const getInternalRevalidateToken = () => { - if (internalRevalidateToken === undefined) { - internalRevalidateToken = createInternalRevalidateToken() +const DEFAULT_MEMORY_MAX_ENTRIES = 1000 +const DEFAULT_MEMORY_MAX_SIZE = 50 * 1024 * 1024 +const DEFAULT_MEMORY_MAX_ENTRY_SIZE = 5 * 1024 * 1024 +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() + }, } - return internalRevalidateToken } let defaultStorage: Storage = createStorage({ - driver: memoryDriver(), + driver: createMemoryDriver(), }) let defaultCacheOptions: CacheDefaults = {} @@ -78,7 +108,23 @@ const getPendingRequests = (pendingRequests: PendingRequests, storage: Storage) return requests } -const setRequestCacheDefaults = (ctx: Context, options: CacheConfigOptions = {}) => { +const readCacheEntry = async (storage: Storage, key: string) => { + try { + return await storage.getItem(key) + } catch { + return null + } +} + +const removeCacheEntry = async (storage: Storage, key: string) => { + try { + await storage.removeItem(key) + } catch { + // Cache failures must not fail the request. + } +} + +const setRequestCacheDefaults = (ctx: Context, options: CacheDefaults = {}) => { const current = requestCacheDefaults.get(ctx) ?? {} requestCacheDefaults.set(ctx, { ...current, @@ -101,25 +147,23 @@ export const setCacheStorage = (storage: Storage): void => { export const getCacheStorage = (): Storage => defaultStorage /** - * Set global cache defaults applied to middleware and cached functions. + * Replace the global defaults applied to middleware and cached functions. + * Pass an empty object to reset them. */ export const setCacheDefaults = (options: CacheDefaults): void => { - defaultCacheOptions = { - ...defaultCacheOptions, - ...options, - } + defaultCacheOptions = { ...options } } /** * Get the global cache defaults applied to middleware and cached functions. */ -export const getCacheDefaults = (): CacheDefaults => defaultCacheOptions +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: CacheConfigOptions = {}): MiddlewareHandler => { +export const cacheDefaults = (options: CacheDefaults = {}): MiddlewareHandler => { return async (ctx, next) => { setRequestCacheDefaults(ctx, options) await next() @@ -129,14 +173,14 @@ export const cacheDefaults = (options: CacheConfigOptions = {}): MiddlewareHandl /** * Create a new in-memory storage instance. */ -export const createCacheStorage = (): Storage => +export const createCacheStorage = (options: CacheStorageOptions = {}): Storage => createStorage({ - driver: memoryDriver(), + driver: createMemoryDriver(options), }) const createStorageKey = (base: string, group: string, name: string, key: string) => { const segments = [base, group, name, key].filter(Boolean) - return `${segments.join(':')}.json` + return `${segments.map((segment) => encodeURIComponent(segment)).join(':')}.json` } const escapeKey = (value: string) => value.replace(/\W/g, '') @@ -163,11 +207,6 @@ const getDefaultHandlerKey = async ( const hashedPath = `${pathPrefix}.${await hashFn(fullPath)}` const varyHeaders = new Set(varies?.map(toLower) ?? []) - for (const header of ['authorization', 'cookie']) { - if (ctx.req.header(header) !== undefined) { - varyHeaders.add(header) - } - } if (varyHeaders.size === 0) { return hashedPath @@ -211,23 +250,30 @@ const getCacheHeaders = (response: Response): Record => { return entries } -const isCacheableResponse = (response: Response) => { +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 etag = response.headers.get('etag') - if (etag === 'undefined') { + const contentType = response.headers.get('content-type')?.toLowerCase() + if (contentType && STREAMING_CONTENT_TYPES.some((type) => contentType.startsWith(type))) { return false } - const lastModified = response.headers.get('last-modified') - if (lastModified === 'undefined') { - return false - } - if (response.headers.get('vary')?.trim() === '*') { - 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) { @@ -281,13 +327,6 @@ const isValidCachedResponseEntry = (entry: unknown): entry is CachedResponseEntr if (!isRecord(entry['headers'])) { return false } - const headers = entry['headers'] - if (headers['etag'] === 'undefined') { - return false - } - if (headers['last-modified'] === 'undefined') { - return false - } return true } @@ -312,92 +351,60 @@ const resolveHandlerCacheKey = async ( ? await options.getKey(ctx) : await getDefaultHandlerKey(ctx, options.varies, hashFn) const storageKey = createStorageKey(base, group, name, key) - const integrity = options.integrity ?? (await hashFn(`${group}:${name}`)) + const integrity = options.integrity ?? (await hashFn(stableStringify([group, name]))) return { name, key, storageKey, integrity } } const maybeServeCachedResponse = async ( - ctx: Context, storage: Storage, storageKey: string, integrity: string, - swr: boolean, cachedRaw: unknown, deserialize: NonNullable, - revalidateHeader: string | false, - pendingRequests: PendingRequests, validate?: CacheMiddlewareOptions['validate'] -) => { +): Promise<{ response: Response; stale: boolean } | null> => { const cached = isValidCachedResponseEntry(cachedRaw) ? cachedRaw : null if (!cached) { if (cachedRaw !== null) { - await storage.removeItem(storageKey) + await removeCacheEntry(storage, storageKey) } return null } if (cached.integrity !== integrity) { - await storage.removeItem(storageKey) + await removeCacheEntry(storage, storageKey) return null } if (validate && validate(cached) === false) { - await storage.removeItem(storageKey) + await removeCacheEntry(storage, storageKey) return null } if (!isExpired(cached.expires)) { - return await deserialize(cached) + return { response: await deserialize(cached), stale: false } } - if (swr && isStaleValid(cached.staleExpires)) { - if (ctx.req.header(INTERNAL_REVALIDATE_HEADER) !== undefined) { - return await deserialize(cached) - } - - if (getRuntimeKey() === 'workerd') { - return null - } - - const revalidateToken = getInternalRevalidateToken() - if (!revalidateToken) { - return null - } - - const requests = getPendingRequests(pendingRequests, storage) - const pendingKey = `${storageKey}:${integrity}` - if (!requests.has(pendingKey)) { - const revalidatePromise = (async () => { - try { - const refreshHeaders = new Headers(ctx.req.raw.headers) - if (revalidateHeader) { - refreshHeaders.delete(revalidateHeader) - } - refreshHeaders.set(INTERNAL_REVALIDATE_HEADER, revalidateToken) - const method = ctx.req.method.toUpperCase() - const body = - method === 'GET' || method === 'HEAD' - ? undefined - : await ctx.req.raw.clone().arrayBuffer() - const request = new Request(ctx.req.url, { - ...(body ? { body } : {}), - method, - headers: refreshHeaders, - }) - const response = await fetch(request) - await response.body?.cancel() - } finally { - requests.delete(pendingKey) - } - })() - void revalidatePromise.catch(() => undefined) - requests.set(pendingKey, revalidatePromise) - } - return await deserialize(cached) + if (isStaleValid(cached.staleExpires)) { + return { response: await deserialize(cached), stale: true } } return null } const shouldBypassMiddlewareCache = async (ctx: Context, options: CacheMiddlewareOptions) => { + if (CONDITIONAL_REQUEST_HEADERS.some((header) => ctx.req.header(header) !== undefined)) { + return true + } + + const cacheControl = ctx.req.header('cache-control')?.toLowerCase() + if ( + cacheControl + ?.split(',') + .some((directive) => ['no-cache', 'no-store', 'max-age=0'].includes(directive.trim())) || + ctx.req.header('pragma')?.toLowerCase() === 'no-cache' + ) { + return true + } + if (!options.shouldBypassCache) { return false } @@ -416,7 +423,7 @@ const shouldManualRevalidateMiddlewareCache = async ( options: CacheMiddlewareOptions ) => { if (!options.shouldRevalidate) { - return true + return false } return await options.shouldRevalidate(ctx) } @@ -431,38 +438,39 @@ const cacheResponseEntry = async ( now: number, serialize: NonNullable ) => { - const rawEntry = await serialize(response, { integrity, maxAge, staleMaxAge, now }) - const ttl = computeTtlSeconds(maxAge, staleMaxAge) - if (ttl === 0) { - return + try { + const rawEntry = await serialize(response, { integrity, maxAge, staleMaxAge, now }) + const ttl = computeTtlSeconds(maxAge, staleMaxAge) + if (ttl === 0) { + return + } + await storage.setItem(storageKey, rawEntry, ttl ? { ttl } : undefined) + } catch { + // Cache failures must not fail the response. } - await storage.setItem(storageKey, rawEntry, ttl ? { ttl } : undefined) } const readCachedResponse = async ( - ctx: Context, storage: Storage, storageKey: string, integrity: string, options: CacheMiddlewareOptions, - swr: boolean, - deserialize: NonNullable, - revalidateHeader: string | false, - pendingRequests: PendingRequests + deserialize: NonNullable ) => { - const cachedRaw = await storage.getItem(storageKey) - return await maybeServeCachedResponse( - ctx, - storage, - storageKey, - integrity, - swr, - cachedRaw, - deserialize, - revalidateHeader, - pendingRequests, - options.validate - ) + 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 = async ( @@ -503,29 +511,17 @@ export const cacheMiddleware = ( options: CacheMiddlewareOptions | number = {} ): MiddlewareHandler => { const normalized: CacheMiddlewareOptions = - typeof options === 'number' ? { maxAge: options } : options - const { config: middlewareConfig, ...routeOptions } = normalized - const isConfigOnly = middlewareConfig !== undefined && Object.keys(routeOptions).length === 0 - const pendingRevalidations: PendingRequests = new WeakMap() + typeof options === 'number' ? { maxAge: options } : { ...options } const handler: MiddlewareHandler = async (ctx: Context, next: Next) => { - if (middlewareConfig) { - setRequestCacheDefaults(ctx, middlewareConfig) - } - - if (isConfigOnly) { - return next() - } - const merged: CacheMiddlewareOptions = { ...defaultCacheOptions, ...getRequestCacheDefaults(ctx), - ...routeOptions, + ...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 group = merged.group ?? DEFAULT_HANDLER_GROUP @@ -546,54 +542,71 @@ export const cacheMiddleware = ( return next() } + 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 bypass = await shouldBypassMiddlewareCache(ctx, merged) if (bypass) { return next() } - const revalidateToken = getRuntimeKey() === 'workerd' ? null : getInternalRevalidateToken() - const isInternalRevalidateRequest = - revalidateToken !== null && ctx.req.header(INTERNAL_REVALIDATE_HEADER) === revalidateToken const isManualRevalidateRequest = revalidateHeader !== false && ctx.req.header(revalidateHeader) === '1' const isRevalidateRequest = - isInternalRevalidateRequest || - (isManualRevalidateRequest && (await shouldManualRevalidateMiddlewareCache(ctx, merged))) + isManualRevalidateRequest && (await shouldManualRevalidateMiddlewareCache(ctx, merged)) const { storageKey, integrity } = await resolveHandlerCacheKey(ctx, merged, base, group, hashFn) const shouldInvalidate = await shouldInvalidateMiddlewareCache(ctx, merged) + let staleResponse: Response | undefined if (!isRevalidateRequest && !shouldInvalidate) { - const cachedResponse = await readCachedResponse( - ctx, + const cachedResult = await readCachedResponse( storage, storageKey, integrity, merged, - swr, - deserialize, - revalidateHeader, - pendingRevalidations + deserialize ) - if (cachedResponse) { - ctx.res = cachedResponse - return cachedResponse + if (cachedResult && !cachedResult.stale) { + ctx.res = cachedResult.response + return cachedResult.response } + staleResponse = cachedResult?.response } if (shouldInvalidate && !keepPreviousOn5xx) { - await storage.removeItem(storageKey) + await removeCacheEntry(storage, storageKey) } - await next() + try { + await next() + } catch (error) { + if (staleResponse) { + ctx.res = staleResponse + return staleResponse + } + throw error + } const response = ctx.res if (!response) { return response } - if (!isCacheableResponse(response)) { + if (response.status >= 500 && staleResponse) { + ctx.res = staleResponse + return staleResponse + } + + if (!isCacheableResponse(response, merged.varies)) { if (shouldInvalidate && keepPreviousOn5xx && response.status < 500) { - await storage.removeItem(storageKey) + await removeCacheEntry(storage, storageKey) } return response } @@ -682,9 +695,13 @@ const refreshFunctionCache = async ( now: number, serialize: NonNullable['serialize']> ) => { - const rawEntry = await serialize(result, { integrity, maxAge, staleMaxAge, now }) - const ttl = computeTtlSeconds(maxAge, staleMaxAge) - await storage.setItem(storageKey, rawEntry, ttl ? { ttl } : undefined) + try { + const rawEntry = await serialize(result, { integrity, maxAge, staleMaxAge, now }) + const ttl = computeTtlSeconds(maxAge, staleMaxAge) + await storage.setItem(storageKey, rawEntry, ttl ? { ttl } : undefined) + } catch { + // Cache failures must not fail the function result. + } return result } @@ -752,37 +769,25 @@ export const cacheFunction = ( fn: (...args: TArgs) => Promise | TResult, options: CacheFunctionOptions | number = {} ): ((...args: TArgs) => Promise) => { - const normalized = typeof options === 'number' ? { maxAge: options } : options - const merged = { ...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 ?? fn.name) || '_' - const group = merged.group ?? DEFAULT_FUNCTION_GROUP - const hashFn = merged.hash ?? ((value: string) => ohash(value)) - const serialize = merged.serialize ?? defaultSerializeFunctionEntry - const deserialize = merged.deserialize ?? defaultDeserializeFunctionEntry - const integrityValue = merged.integrity - let integrityCache: string | null = null - let integrityPromise: Promise | null = null + const normalized: CacheFunctionOptions = + typeof options === 'number' ? { maxAge: options } : { ...options } const pendingFunctionRequests: PendingRequests = new WeakMap() - const getFunctionIntegrity = async () => { - if (integrityCache) { - return integrityCache - } - integrityPromise ??= (async () => { - const integrity = integrityValue ?? (await hashFn(fn.toString())) - integrityCache = integrity - return integrity - })() - return await integrityPromise - } - return async (...args: TArgs): Promise => { - // Resolve storage at call time, not function creation time + 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 ?? fn.name) || '_' + const group = merged.group ?? DEFAULT_FUNCTION_GROUP + const hashFn = merged.hash ?? ((value: string) => ohash(value)) + const serialize = merged.serialize ?? defaultSerializeFunctionEntry + const deserialize = merged.deserialize ?? defaultDeserializeFunctionEntry const storage = merged.storage ?? defaultStorage if (maxAge <= 0) { @@ -794,36 +799,41 @@ export const cacheFunction = ( return await fn(...args) } - const integrity = await getFunctionIntegrity() + 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 storage.getItem(storageKey) + const cachedRaw = shouldInvalidate ? null : await readCacheEntry(storage, storageKey) const cached = isValidCachedFunctionEntry(cachedRaw) ? cachedRaw : null if (!cached && cachedRaw !== null) { - await storage.removeItem(storageKey) + 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) } - const cachedValue = await maybeServeCachedFunctionValue( - cached, - storageKey, - integrity, - swr, - () => fn(...args), - storage, - maxAge, - staleMaxAge, - serialize, - deserialize, - pendingFunctionRequests, - merged.validate, - args - ) if (cachedValue !== CACHE_MISS) { return cachedValue } if (shouldInvalidate && !keepPreviousOn5xx) { - await storage.removeItem(storageKey) + await removeCacheEntry(storage, storageKey) } const requests = getPendingRequests(pendingFunctionRequests, storage) diff --git a/packages/universal-cache/src/index.test.ts b/packages/universal-cache/src/index.test.ts index 89d1fcddd..ca371c73c 100644 --- a/packages/universal-cache/src/index.test.ts +++ b/packages/universal-cache/src/index.test.ts @@ -1,5 +1,4 @@ import { Hono } from 'hono' -import type { CacheDefaults } from './types' import { cacheDefaults, cacheFunction, @@ -13,20 +12,7 @@ import { } from '.' const resetDefaultOptions = () => { - const defaults = { - base: undefined, - group: undefined, - hash: undefined, - integrity: undefined, - keepPreviousOn5xx: undefined, - maxAge: undefined, - name: undefined, - revalidateHeader: undefined, - staleMaxAge: undefined, - storage: undefined, - swr: undefined, - } as unknown as CacheDefaults - setCacheDefaults(defaults) + setCacheDefaults({}) } const flushPromises = async () => { @@ -34,6 +20,9 @@ const flushPromises = async () => { 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') @@ -57,7 +46,6 @@ describe('@hono/universal-cache', () => { '/items', cacheMiddleware({ maxAge: 60, - swr: false, }), (c) => { count += 1 @@ -99,7 +87,6 @@ describe('@hono/universal-cache', () => { cacheMiddleware({ maxAge: 60, methods: ['POST'], - swr: false, }), (c) => { count += 1 @@ -119,14 +106,10 @@ describe('@hono/universal-cache', () => { const app = new Hono() let count = 0 - app.post( - '/items', - cacheMiddleware({ maxAge: 60, methods: ['POST'], swr: false }), - async (c) => { - count += 1 - return c.text(`${await c.req.text()}:${count}`) - } - ) + 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' }) @@ -137,38 +120,26 @@ describe('@hono/universal-cache', () => { expect(count).toBe(2) }) - it('preserves request bodies during background revalidation', async () => { + 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 - let resolveRefresh!: () => void - const refreshed = new Promise((resolve) => { - resolveRefresh = resolve - }) - app.post( '/items', - cacheMiddleware({ maxAge: 1, methods: ['POST'], staleMaxAge: 60, swr: true }), + cacheMiddleware({ maxAge: 1, methods: ['POST'], staleMaxAge: 60 }), async (c) => { count += 1 - if (count === 2) { - resolveRefresh() - } return c.text(`${await c.req.text()}:${count}`) } ) - vi.stubGlobal('fetch', (request: Request) => app.request(request)) - 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:1') - await refreshed - await flushPromises() + expect(await (await request()).text()).toBe('one:2') expect(await (await request()).text()).toBe('one:2') expect(count).toBe(2) }) @@ -181,7 +152,6 @@ describe('@hono/universal-cache', () => { '/items', cacheMiddleware({ maxAge: 60, - swr: false, shouldBypassCache: (c) => c.req.header('x-bypass') === '1', }), (c) => { @@ -202,6 +172,53 @@ describe('@hono/universal-cache', () => { 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'], + ['pragma', '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 @@ -211,7 +228,6 @@ describe('@hono/universal-cache', () => { '/items', cacheMiddleware({ maxAge: 60, - swr: false, keepPreviousOn5xx: true, revalidateHeader: 'x-internal-revalidate', shouldInvalidateCache: (c) => c.req.header('x-invalidate') === '1', @@ -244,7 +260,6 @@ describe('@hono/universal-cache', () => { '/items', cacheMiddleware({ maxAge: 60, - swr: false, keepPreviousOn5xx: false, revalidateHeader: 'x-internal-revalidate', shouldInvalidateCache: (c) => c.req.header('x-invalidate') === '1', @@ -272,7 +287,7 @@ describe('@hono/universal-cache', () => { const app = new Hono() let value = 'v1' - app.get('/items', cacheMiddleware({ maxAge: 60, swr: false }), (c) => c.text(value)) + app.get('/items', cacheMiddleware({ maxAge: 60 }), (c) => c.text(value)) const first = await app.request('http://localhost/items') expect(await first.text()).toBe('v1') @@ -291,7 +306,7 @@ describe('@hono/universal-cache', () => { const app = new Hono() let value = 'v1' - app.get('/items', cacheMiddleware({ maxAge: 60, swr: false }), (c) => c.text(value)) + app.get('/items', cacheMiddleware({ maxAge: 60 }), (c) => c.text(value)) await app.request('http://localhost/items') value = 'v2' @@ -313,7 +328,6 @@ describe('@hono/universal-cache', () => { '/items', cacheMiddleware({ maxAge: 60, - swr: false, shouldInvalidateCache: () => invalidate, }), (c) => c.text(value) @@ -325,7 +339,7 @@ describe('@hono/universal-cache', () => { expect(await (await app.request('http://localhost/items')).text()).toBe('v2') }) - it('supports custom revalidate header', async () => { + it('denies a custom revalidate header without shouldRevalidate', async () => { const app = new Hono() let value = 'v1' @@ -333,7 +347,6 @@ describe('@hono/universal-cache', () => { '/items', cacheMiddleware({ maxAge: 60, - swr: false, revalidateHeader: 'x-custom-revalidate', }), (c) => c.text(value) @@ -346,7 +359,7 @@ describe('@hono/universal-cache', () => { }) const cached = await app.request('http://localhost/items') - expect(await cached.text()).toBe('v2') + expect(await cached.text()).toBe('v1') }) it('respects shouldRevalidate for manual revalidation', async () => { @@ -358,7 +371,6 @@ describe('@hono/universal-cache', () => { '/items', cacheMiddleware({ maxAge: 60, - swr: false, revalidateHeader: 'x-custom-revalidate', shouldRevalidate: () => allowRevalidate, }), @@ -386,7 +398,7 @@ describe('@hono/universal-cache', () => { const app = new Hono() let count = 0 - app.use('*', cacheDefaults({ maxAge: 60, swr: false })) + app.use('*', cacheDefaults({ maxAge: 60 })) app.get('/items', cacheMiddleware(), (c) => { count += 1 return c.text(String(count)) @@ -400,31 +412,6 @@ describe('@hono/universal-cache', () => { expect(count).toBe(1) }) - it('supports route-level config overrides via cacheMiddleware({ config })', async () => { - const app = new Hono() - let count = 0 - - app.use('*', cacheDefaults({ maxAge: 60, swr: false })) - app.get( - '/items', - cacheMiddleware({ - config: { maxAge: 0 }, - swr: false, - }), - (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('2') - expect(count).toBe(2) - }) - it('keys by varies headers', async () => { const app = new Hono() let count = 0 @@ -433,7 +420,6 @@ describe('@hono/universal-cache', () => { '/items', cacheMiddleware({ maxAge: 60, - swr: false, varies: ['accept-language'], }), (c) => { @@ -465,7 +451,7 @@ describe('@hono/universal-cache', () => { app.on( ['GET', 'POST'], '/items', - cacheMiddleware({ maxAge: 60, methods: ['GET', 'POST'], swr: false }), + cacheMiddleware({ maxAge: 60, methods: ['GET', 'POST'] }), (c) => { count += 1 return c.text(`${c.req.method}:${new URL(c.req.url).host}:${count}`) @@ -480,13 +466,63 @@ describe('@hono/universal-cache', () => { 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.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'])( - 'automatically varies by the %s header', + 'caches requests when %s is explicitly included in varies', async (header) => { const app = new Hono() let count = 0 - app.get('/items', cacheMiddleware({ maxAge: 60, swr: false }), (c) => { + app.get('/items', cacheMiddleware({ maxAge: 60, varies: [header] }), (c) => { count += 1 return c.text(`${c.req.header(header)}:${count}`) }) @@ -501,85 +537,74 @@ describe('@hono/universal-cache', () => { } ) - it('serves stale and revalidates in background once per key', async () => { + 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 - let fetchCalls = 0 - - let resolveRefresh!: () => void - const waitForRefresh = new Promise((resolve) => { - resolveRefresh = resolve - }) - app.get( '/items', cacheMiddleware({ maxAge: 1, staleMaxAge: 60, - swr: true, getKey: () => 'stable-key', }), - async (c) => { + (c) => { count += 1 - if (count > 1) { - await waitForRefresh - } return c.text(String(count)) } ) - const nativeFetch = globalThis.fetch - vi.stubGlobal('fetch', (async (input: RequestInfo | URL, init?: RequestInit) => { - const request = input instanceof Request ? input : new Request(input, init) - const url = new URL(request.url) - if (url.hostname === 'localhost') { - fetchCalls += 1 - return app.request(request) - } - return nativeFetch(input, init) - }) as typeof fetch) - const first = await app.request('http://localhost/items') expect(await first.text()).toBe('1') vi.advanceTimersByTime(1100) - const stale1 = await app.request('http://localhost/items') - const stale2 = await app.request('http://localhost/items') + 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) + }) - expect(await stale1.text()).toBe('1') - expect(await stale2.text()).toBe('1') - expect(fetchCalls).toBe(1) + 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 - resolveRefresh() - await flushPromises() - await flushPromises() + app.get('/items', cacheMiddleware({ maxAge: 1, staleMaxAge: 60 }), (c) => { + return fail ? c.text('failed', 500) : c.text('cached') + }) - expect(count).toBe(2) + 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('handles rejected background response refreshes while serving stale', async () => { + 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 count = 0 + let fail = false - app.get('/items', cacheMiddleware({ maxAge: 1, staleMaxAge: 60, swr: true }), (c) => { - count += 1 - return c.text(String(count)) + app.get('/items', cacheMiddleware({ maxAge: 1, staleMaxAge: 60 }), (c) => { + if (fail) { + throw new Error('failed') + } + return c.text('cached') }) - vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('refresh failed'))) - - expect(await (await app.request('http://localhost/items')).text()).toBe('1') + expect(await (await app.request('http://localhost/items')).text()).toBe('cached') vi.advanceTimersByTime(1100) - expect(await (await app.request('http://localhost/items')).text()).toBe('1') - await flushPromises() - expect(count).toBe(1) + 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 () => { @@ -600,11 +625,118 @@ describe('@hono/universal-cache', () => { 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('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, swr: false }), () => { + app.get('/items', cacheMiddleware({ maxAge: 60 }), () => { count += 1 return new Response(null, { headers: { 'x-count': String(count) }, status }) }) @@ -644,7 +776,6 @@ describe('@hono/universal-cache', () => { '/items', cacheMiddleware({ maxAge: 60, - swr: false, serialize: async (response, context) => ({ value: await response.clone().text(), encoding: 'base64', @@ -686,7 +817,7 @@ describe('@hono/universal-cache', () => { const group = 'hono/handlers' const name = 'items' const key = 'manual-key' - const storageKey = `${base}:${group}:${name}:${key}.json` + const storageKey = createTestStorageKey(base, group, name, key) await storage.setItem(storageKey, { value: 1 }) @@ -697,7 +828,6 @@ describe('@hono/universal-cache', () => { name, getKey: () => key, maxAge: 60, - swr: false, }), (c) => { count += 1 @@ -722,7 +852,7 @@ describe('@hono/universal-cache', () => { const app = new Hono() let count = 0 - app.get('*', cacheMiddleware({ maxAge: 60, swr: false }), (c) => { + app.get('*', cacheMiddleware({ maxAge: 60 }), (c) => { count += 1 return c.text(String(count)) }) @@ -741,7 +871,7 @@ describe('@hono/universal-cache', () => { const app = new Hono() let count = 0 - const storageKey = 'cache:hono/handlers:items:key.json' + const storageKey = createTestStorageKey('cache', 'hono/handlers', 'items', 'key') await storage.setItem(storageKey, { value: toBase64('stale'), encoding: 'base64', @@ -760,7 +890,6 @@ describe('@hono/universal-cache', () => { name: 'items', getKey: () => 'key', maxAge: 60, - swr: false, integrity: 'fresh-integrity', }), (c) => { @@ -779,7 +908,7 @@ describe('@hono/universal-cache', () => { const app = new Hono() let count = 0 - const storageKey = 'cache:hono/handlers:items:key.json' + const storageKey = createTestStorageKey('cache', 'hono/handlers', 'items', 'key') await storage.setItem(storageKey, { value: toBase64('stale'), encoding: 'base64', @@ -798,7 +927,6 @@ describe('@hono/universal-cache', () => { name: 'items', getKey: () => 'key', maxAge: 60, - swr: false, integrity: 'integrity', validate: () => false, }), @@ -813,73 +941,6 @@ describe('@hono/universal-cache', () => { expect(count).toBe(1) }) - it('treats etag/last-modified header value "undefined" as invalid cache entry', async () => { - const storage = createCacheStorage() - const app = new Hono() - let count = 0 - - await storage.setItem('cache:hono/handlers:etag:key.json', { - value: toBase64('stale'), - encoding: 'base64', - status: 200, - headers: { etag: 'undefined' }, - mtime: Date.now(), - expires: Date.now() + 60_000, - staleExpires: Date.now() + 120_000, - integrity: 'integrity', - }) - - await storage.setItem('cache:hono/handlers:last-mod:key.json', { - value: toBase64('stale'), - encoding: 'base64', - status: 200, - headers: { 'last-modified': 'undefined' }, - mtime: Date.now(), - expires: Date.now() + 60_000, - staleExpires: Date.now() + 120_000, - integrity: 'integrity', - }) - - app.get( - '/etag', - cacheMiddleware({ - storage, - name: 'etag', - getKey: () => 'key', - maxAge: 60, - swr: false, - integrity: 'integrity', - }), - (c) => { - count += 1 - return c.text(`value-${count}`) - } - ) - - app.get( - '/last-mod', - cacheMiddleware({ - storage, - name: 'last-mod', - getKey: () => 'key', - maxAge: 60, - swr: false, - integrity: 'integrity', - }), - (c) => { - count += 1 - return c.text(`value-${count}`) - } - ) - - const etagRes = await app.request('http://localhost/etag') - const lastModRes = await app.request('http://localhost/last-mod') - - expect(await etagRes.text()).toBe('value-1') - expect(await lastModRes.text()).toBe('value-2') - expect(count).toBe(2) - }) - it('evicts old cache when invalidated response is non-cacheable with keepPreviousOn5xx=true', async () => { const app = new Hono() let value = 'v1' @@ -889,7 +950,6 @@ describe('@hono/universal-cache', () => { '/items', cacheMiddleware({ maxAge: 60, - swr: false, keepPreviousOn5xx: true, revalidateHeader: 'x-internal-revalidate', shouldInvalidateCache: (c) => c.req.header('x-invalidate') === '1', @@ -1296,11 +1356,33 @@ describe('@hono/universal-cache', () => { expect(count).toBe(1) }) + 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('cache:hono/functions:fn:key.json', 123 as unknown as object) + await storage.setItem( + createTestStorageKey('cache', 'hono/functions', 'fn', 'key'), + 123 as unknown as object + ) const fn = cacheFunction( () => { @@ -1322,22 +1404,76 @@ describe('@hono/universal-cache', () => { expect(value).toBe('v1') expect(count).toBe(1) }) + + 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('{"a":1,"b":2}') + expect(stableStringify({ b: 2, a: 1 })).toBe(stableStringify({ a: 1, b: 2 })) }) it('sets and gets global cache defaults and storage', () => { const storage = createCacheStorage() - const defaults = { maxAge: 120, staleMaxAge: 30 } setCacheStorage(storage) - setCacheDefaults(defaults) + setCacheDefaults({ maxAge: 120 }) + const defaults = getCacheDefaults() + defaults.maxAge = 1 expect(getCacheStorage()).toBe(storage) - expect(getCacheDefaults()).toMatchObject(defaults) + 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 index fa2c5918f..6408ec952 100644 --- a/packages/universal-cache/src/index.ts +++ b/packages/universal-cache/src/index.ts @@ -10,8 +10,8 @@ export { } from './cache' export type { CacheBaseOptions, - CacheConfigOptions, CacheDefaults, + CacheStorageOptions, CachedFunctionEntry, CachedResponseEntry, CacheFunctionOptions, diff --git a/packages/universal-cache/src/index.workerd.test.ts b/packages/universal-cache/src/index.workerd.test.ts index 78f6ab972..986891116 100644 --- a/packages/universal-cache/src/index.workerd.test.ts +++ b/packages/universal-cache/src/index.workerd.test.ts @@ -17,7 +17,7 @@ describe('@hono/universal-cache workerd', () => { let value = 'v1' let count = 0 - app.get('/items', cacheMiddleware({ maxAge: 60, swr: false }), (c) => { + app.get('/items', cacheMiddleware({ maxAge: 60 }), (c) => { count += 1 return c.text(value) }) @@ -55,7 +55,6 @@ describe('@hono/universal-cache workerd', () => { '/items', cacheMiddleware({ maxAge: 60, - swr: false, revalidateHeader: 'x-custom-revalidate', shouldRevalidate: () => allowRevalidate, }), @@ -90,7 +89,7 @@ describe('@hono/universal-cache workerd', () => { expect(await revalidated.text()).toBe('v2') }) - it('supports custom manual revalidation on workerd', async () => { + it('denies custom manual revalidation without shouldRevalidate on workerd', async () => { const app = new Hono() let value = 'v1' let count = 0 @@ -99,7 +98,6 @@ describe('@hono/universal-cache workerd', () => { '/items', cacheMiddleware({ maxAge: 60, - swr: false, revalidateHeader: 'x-custom-revalidate', }), (c) => { @@ -115,21 +113,21 @@ describe('@hono/universal-cache workerd', () => { value = 'v2' const ctx2 = createExecutionContext() - const revalidated = await app.request( + const attempted = await app.request( 'http://localhost/items', { headers: { 'x-custom-revalidate': '1' } }, {}, ctx2 ) await waitOnExecutionContext(ctx2) - expect(await revalidated.text()).toBe('v2') + 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('v2') - expect(count).toBe(2) + expect(await cached.text()).toBe('v1') + expect(count).toBe(1) }) it('refreshes stale entries synchronously on workerd', async () => { @@ -141,7 +139,6 @@ describe('@hono/universal-cache workerd', () => { cacheMiddleware({ maxAge: 1, staleMaxAge: 60, - swr: true, }), (c) => { count += 1 diff --git a/packages/universal-cache/src/types.ts b/packages/universal-cache/src/types.ts index 6ecf63168..f3b1f296b 100644 --- a/packages/universal-cache/src/types.ts +++ b/packages/universal-cache/src/types.ts @@ -3,6 +3,15 @@ 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. */ @@ -26,14 +35,10 @@ export interface CacheBaseOptions { maxAge?: number /** Cache entry name (used as part of the storage key). */ name?: string - /** Custom header name to allow manual cache revalidation. Disabled by default. */ - revalidateHeader?: string | false /** Stale max age in seconds. Use -1 for unlimited stale. */ staleMaxAge?: number /** Custom storage instance to use for caching. */ storage?: Storage - /** Enable stale-while-revalidate behavior. */ - swr?: boolean } /** @@ -41,26 +46,15 @@ export interface CacheBaseOptions { */ export interface CacheDefaults extends CacheBaseOptions {} -/** - * Options for configuring cache defaults through Hono `app.use(...)`. - */ -export interface CacheConfigOptions extends Omit { - /** Default storage instance used by cache middleware and cached functions. */ - storage?: Storage -} - export interface CacheMiddlewareOptions extends CacheBaseOptions { - /** - * Optional request-scoped defaults to apply before resolving this middleware options. - * Useful for route-local overrides on top of `app.use(cacheDefaults(...))`. - */ - config?: CacheConfigOptions /** 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, @@ -92,6 +86,8 @@ export interface CacheFunctionOptions extends CacheBase 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 } diff --git a/packages/universal-cache/src/utils.test.ts b/packages/universal-cache/src/utils.test.ts index 53316e3f6..03e02d13b 100644 --- a/packages/universal-cache/src/utils.test.ts +++ b/packages/universal-cache/src/utils.test.ts @@ -22,16 +22,18 @@ describe('utils', () => { expect(stableStringify(null)).toBe('null') expect(stableStringify(undefined)).toBe('undefined') expect(stableStringify(123)).toBe('123') - expect(stableStringify('abc')).toBe('"abc"') + expect(stableStringify('abc')).toBe("'abc'") expect(stableStringify(true)).toBe('true') + expect(stableStringify(Number.NaN)).not.toBe(stableStringify(null)) }) it('stableStringify handles Date, arrays, and sorted object keys', () => { const date = new Date('2026-01-01T00:00:00.000Z') - expect(stableStringify(date)).toBe('"2026-01-01T00:00:00.000Z"') - - expect(stableStringify([{ b: 2, a: 1 }, 'x'])).toBe('[{"a":1,"b":2},"x"]') - expect(stableStringify({ z: 1, a: { y: 2, x: 1 } })).toBe('{"a":{"x":1,"y":2},"z":1}') + 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 }) + ) }) it('computes TTL for all branches', () => { diff --git a/packages/universal-cache/src/utils.ts b/packages/universal-cache/src/utils.ts index 1abe546e3..4ac325f80 100644 --- a/packages/universal-cache/src/utils.ts +++ b/packages/universal-cache/src/utils.ts @@ -21,25 +21,8 @@ export const normalizePathToName = (path: string): string => { return trimmed.replace(/\/+?/g, ':') } -/** Stable stringification with sorted object keys. */ -export const stableStringify = (value: unknown): string => { - if (value === null || value === undefined) { - return String(value) - } - if (typeof value !== 'object') { - return JSON.stringify(value) - } - if (value instanceof Date) { - return JSON.stringify(value.toISOString()) - } - if (Array.isArray(value)) { - return `[${value.map((item) => stableStringify(item)).join(',')}]` - } - const record = value as Record - const keys = Object.keys(record).sort() - const entries = keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`) - return `{${entries.join(',')}}` -} +/** Stable, type-aware serialization for cache keys. */ +export const stableStringify = (value: unknown): string => serializeHashValue(value) /** Compute storage TTL in seconds from cache options. */ export const computeTtlSeconds = (maxAge: number, staleMaxAge: number): number | undefined => { @@ -62,3 +45,4 @@ export const isStaleValid = (staleExpires: number | null): boolean => { } return Date.now() <= staleExpires } +import { serialize as serializeHashValue } from 'ohash' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6bc4835f7..5d563bb96 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1013,6 +1013,9 @@ importers: packages/universal-cache: dependencies: + lru-cache: + specifier: ^10.4.3 + version: 10.4.3 ohash: specifier: ^2.0.11 version: 2.0.11 @@ -5249,6 +5252,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} @@ -7368,7 +7375,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 @@ -11580,6 +11587,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.2.6: {} + lru-cache@11.5.2: {} lru-cache@5.1.1: From 7f60b4d1b53bdb430ddc276c0ff36eb8485cb682 Mon Sep 17 00:00:00 2001 From: raed bahri Date: Wed, 15 Jul 2026 16:14:14 +0100 Subject: [PATCH 07/12] fix(universal-cache): harden cache behavior --- .changeset/fuzzy-garlic-clean.md | 2 + packages/universal-cache/README.md | 16 +- packages/universal-cache/deno.json | 2 +- packages/universal-cache/package.json | 7 +- packages/universal-cache/src/cache.ts | 373 ++++++++++++--- packages/universal-cache/src/index.test.ts | 525 ++++++++++++++++++++- packages/universal-cache/src/types.ts | 14 +- packages/universal-cache/src/utils.test.ts | 11 + packages/universal-cache/src/utils.ts | 46 +- packages/universal-cache/tsdown.config.ts | 5 + pnpm-lock.yaml | 91 +++- 11 files changed, 1005 insertions(+), 87 deletions(-) diff --git a/.changeset/fuzzy-garlic-clean.md b/.changeset/fuzzy-garlic-clean.md index 5893039ad..2dc954c1d 100644 --- a/.changeset/fuzzy-garlic-clean.md +++ b/.changeset/fuzzy-garlic-clean.md @@ -8,6 +8,8 @@ Add `@hono/universal-cache`, a universal cache toolkit for Hono with: - `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/packages/universal-cache/README.md b/packages/universal-cache/README.md index 154cd6ec9..e3ff56796 100644 --- a/packages/universal-cache/README.md +++ b/packages/universal-cache/README.md @@ -9,7 +9,8 @@ Storage-agnostic response and function caching for Hono. - Response caching with `cacheMiddleware()` - Function result caching with `cacheFunction()` - Request-scoped defaults with `cacheDefaults()` -- Stale-while-revalidate and in-flight deduplication for cached functions +- 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 @@ -37,6 +38,8 @@ Passing a number is shorthand for `{ maxAge: number }`. `GET` and `HEAD` are cac 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. +Custom storage drivers must bound their own operation latency. A storage promise that never settles also prevents the cache operation from settling. + ```ts import { Hono } from 'hono' import { cacheDefaults, cacheMiddleware } from '@hono/universal-cache' @@ -94,11 +97,12 @@ Manual revalidation is disabled by default. Enable it with a private header name ```ts cacheMiddleware({ revalidateHeader: 'x-my-cache-revalidate', - shouldRevalidate: (c) => c.req.header('authorization') === `Bearer ${process.env.CACHE_TOKEN}`, + 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 @@ -133,12 +137,15 @@ Function caches use `swr: true` by default. They serve stale values and refresh 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 and supports common values including `Map`, `Set`, and `BigInt`. Provide `getKey` when application-specific key control is needed. +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`. 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. @@ -146,7 +153,7 @@ Default function serialization uses JSON through `unstorage`. It safely preserve ## 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'`. Use `integrity` to invalidate entries when their schema or behavior changes. +`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 @@ -172,6 +179,7 @@ The middleware does not cache: - malformed persisted entries Cached responses exclude `set-cookie`, `content-length`, and other hop-by-hop headers. +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 diff --git a/packages/universal-cache/deno.json b/packages/universal-cache/deno.json index 9e9d7ccea..ce9398c6a 100644 --- a/packages/universal-cache/deno.json +++ b/packages/universal-cache/deno.json @@ -9,7 +9,7 @@ "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.0" + "unstorage": "npm:unstorage@1.17.3" }, "publish": { "include": ["deno.json", "README.md", "src/**/*.ts"], diff --git a/packages/universal-cache/package.json b/packages/universal-cache/package.json index 0a3f9dae4..c5e714732 100644 --- a/packages/universal-cache/package.json +++ b/packages/universal-cache/package.json @@ -42,12 +42,12 @@ }, "homepage": "https://github.com/honojs/middleware", "peerDependencies": { - "hono": ">=4.0.0" + "hono": ">=4.8.3" }, "dependencies": { "lru-cache": "^10.4.3", "ohash": "^2.0.11", - "unstorage": "^1.17.0" + "unstorage": "1.17.3" }, "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.16.10", @@ -59,5 +59,8 @@ }, "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 index e43e4e790..07ccfefe0 100644 --- a/packages/universal-cache/src/cache.ts +++ b/packages/universal-cache/src/cache.ts @@ -37,12 +37,16 @@ const HOP_BY_HOP_HEADERS = new Set([ 'transfer-encoding', 'upgrade', 'content-length', + '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 CONDITIONAL_REQUEST_HEADERS = [ 'range', 'if-range', @@ -99,6 +103,11 @@ const requestCacheDefaults = new WeakMap() type PendingRequests = WeakMap>> +const pendingMiddlewareRequests: PendingRequests = new WeakMap() +const pendingFunctionRequests: PendingRequests = new WeakMap() +const functionNamespace = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` +let functionNamespaceIndex = 0 + const getPendingRequests = (pendingRequests: PendingRequests, storage: Storage) => { let requests = pendingRequests.get(storage) if (!requests) { @@ -206,16 +215,16 @@ const getDefaultHandlerKey = async ( } const hashedPath = `${pathPrefix}.${await hashFn(fullPath)}` - const varyHeaders = new Set(varies?.map(toLower) ?? []) + const varyHeaders = [...new Set(varies?.map(toLower) ?? [])].sort() - if (varyHeaders.size === 0) { + if (varyHeaders.length === 0) { return hashedPath } const varyParts = await Promise.all( - [...varyHeaders].map(async (header) => { + varyHeaders.map(async (header) => { const value = ctx.req.header(header) ?? '' - return `${escapeKey(toLower(header))}.${await hashFn(value)}` + return `${encodeURIComponent(header)}.${await hashFn(value)}` }) ) const varyKey = varyParts.join(':') @@ -228,8 +237,37 @@ const getDefaultHandlerName = (ctx: Context) => { 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 = new Headers(entry.headers) + 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, @@ -238,11 +276,7 @@ const createCachedResponse = (entry: CachedResponseEntry) => { } const getCacheHeaders = (response: Response): Record => { - const headers = new Headers(response.headers) - for (const header of HOP_BY_HOP_HEADERS) { - headers.delete(header) - } - headers.delete('set-cookie') + const headers = sanitizeResponseHeaders(response.headers) const entries: Record = {} headers.forEach((value, key) => { entries[key] = value @@ -292,8 +326,8 @@ const defaultSerializeResponse = async ( context: { integrity: string; maxAge: number; staleMaxAge: number; now: number } ) => { const { integrity, maxAge, staleMaxAge, now } = context - const buffer = await response.clone().arrayBuffer() - const value = encodeBase64(buffer) + 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 @@ -314,20 +348,80 @@ const defaultDeserializeResponse = (entry: CachedResponseEntry) => createCachedR const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null -const isValidCachedResponseEntry = (entry: unknown): entry is CachedResponseEntry => { - if (!isRecord(entry)) { - return false +const readResponseBody = async (response: Response) => { + const body = response.clone().body + if (!body) { + return new Uint8Array() } - if (typeof entry['value'] !== 'string') { - return false + + 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) + } + reader.releaseLock() } - if (typeof entry['status'] !== 'number') { - return false + + const buffer = new Uint8Array(size) + let offset = 0 + for (const chunk of chunks) { + buffer.set(chunk, offset) + offset += chunk.byteLength } - if (!isRecord(entry['headers'])) { + 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 true + 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 = ( @@ -336,7 +430,7 @@ const isValidCachedFunctionEntry = ( if (!isRecord(entry)) { return false } - return 'value' in entry + return hasValidCacheMetadata(entry) && 'value' in entry } const resolveHandlerCacheKey = async ( @@ -387,6 +481,7 @@ const maybeServeCachedResponse = async ( return { response: await deserialize(cached), stale: true } } + await removeCacheEntry(storage, storageKey) return null } @@ -400,7 +495,11 @@ const shouldBypassMiddlewareCache = async (ctx: Context, options: CacheMiddlewar cacheControl ?.split(',') .some((directive) => ['no-cache', 'no-store', 'max-age=0'].includes(directive.trim())) || - ctx.req.header('pragma')?.toLowerCase() === 'no-cache' + ctx.req + .header('pragma') + ?.toLowerCase() + .split(',') + .some((directive) => directive.trim() === 'no-cache') ) { return true } @@ -473,7 +572,7 @@ const readCachedResponse = async ( } } -const writeCachedResponse = async ( +const writeCachedResponse = ( ctx: Context, storage: Storage, storageKey: string, @@ -484,8 +583,14 @@ const writeCachedResponse = async ( now: number, serialize: NonNullable ) => { + let cacheResponse: Response + try { + cacheResponse = response.clone() + } catch { + return Promise.resolve() + } const cachePromise = cacheResponseEntry( - response, + cacheResponse, storage, storageKey, integrity, @@ -497,10 +602,8 @@ const writeCachedResponse = async ( if (getRuntimeKey() === 'workerd') { ctx.executionCtx?.waitUntil?.(cachePromise) - return } - - await cachePromise + return cachePromise } /** @@ -511,7 +614,13 @@ export const cacheMiddleware = ( options: CacheMiddlewareOptions | number = {} ): MiddlewareHandler => { const normalized: CacheMiddlewareOptions = - typeof options === 'number' ? { maxAge: options } : { ...options } + 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 = { @@ -542,6 +651,9 @@ export const cacheMiddleware = ( return next() } + const isManualRevalidateRequest = + revalidateHeader !== false && ctx.req.header(revalidateHeader) === '1' + if (!merged.getKey) { const keyedHeaders = new Set(merged.varies?.map(toLower) ?? []) if ( @@ -552,20 +664,41 @@ export const cacheMiddleware = ( } } + const isRevalidateRequest = + isManualRevalidateRequest && (await shouldManualRevalidateMiddlewareCache(ctx, merged)) + const bypass = await shouldBypassMiddlewareCache(ctx, merged) if (bypass) { return next() } - const isManualRevalidateRequest = - revalidateHeader !== false && ctx.req.header(revalidateHeader) === '1' - const isRevalidateRequest = - isManualRevalidateRequest && (await shouldManualRevalidateMiddlewareCache(ctx, merged)) 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 - if (!isRevalidateRequest && !shouldInvalidate) { + 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, @@ -580,6 +713,73 @@ export const cacheMiddleware = ( 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 rejectPending: ((error: unknown) => void) | undefined + let pendingPromise: Promise | undefined + let pendingTimeout: ReturnType | undefined + let sharedPendingResponse: Response | null = null + + const clearPending = () => { + if (pendingTimeout) { + clearTimeout(pendingTimeout) + pendingTimeout = undefined + } + if (requests.get(pendingKey) === pendingPromise) { + requests.delete(pendingKey) + } + if (sharedPendingResponse?.body) { + setTimeout(() => { + void sharedPendingResponse?.body?.cancel().catch(() => undefined) + }, 0) + } + sharedPendingResponse = null + } + + if (shouldCoalesce) { + pendingPromise = new Promise((resolve, reject) => { + resolvePending = resolve + rejectPending = reject + }) + requests.set(pendingKey, pendingPromise) + pendingTimeout = setTimeout(() => { + resolvePending?.(null) + clearPending() + }, DEFAULT_PENDING_REQUEST_TIME) + void pendingPromise.catch(() => undefined) + } + + const settlePending = (response: Response | null, completion?: Promise) => { + if (!pendingPromise || !resolvePending || requests.get(pendingKey) !== pendingPromise) { + return + } + try { + sharedPendingResponse = response?.clone() ?? null + } catch { + sharedPendingResponse = null + } + resolvePending(sharedPendingResponse) + if (completion) { + void completion.finally(clearPending) + } else { + void Promise.resolve().then(clearPending) + } + } + + const failPending = (error: unknown) => { + if (pendingPromise && rejectPending) { + rejectPending(error) + clearPending() + } + } + if (shouldInvalidate && !keepPreviousOn5xx) { await removeCacheEntry(storage, storageKey) } @@ -588,18 +788,22 @@ export const cacheMiddleware = ( await next() } catch (error) { if (staleResponse) { + settlePending(staleResponse) ctx.res = staleResponse return staleResponse } + failPending(error) throw error } const response = ctx.res if (!response) { + settlePending(null) return response } if (response.status >= 500 && staleResponse) { + settlePending(staleResponse) ctx.res = staleResponse return staleResponse } @@ -608,10 +812,11 @@ export const cacheMiddleware = ( if (shouldInvalidate && keepPreviousOn5xx && response.status < 500) { await removeCacheEntry(storage, storageKey) } + settlePending(response.status >= 500 ? response : null) return response } - await writeCachedResponse( + const cacheWrite = writeCachedResponse( ctx, storage, storageKey, @@ -622,6 +827,7 @@ export const cacheMiddleware = ( Date.now(), serialize ) + settlePending(response, cacheWrite) return response } @@ -653,8 +859,8 @@ const defaultSerializeFunctionEntry = ( const defaultDeserializeFunctionEntry = (entry: CachedFunctionEntry) => entry.value -const shouldBypassFunctionCache = async ( - options: CacheFunctionOptions, +const shouldBypassFunctionCache = async ( + options: CacheFunctionOptions, args: TArgs ) => { if (!options.shouldBypassCache) { @@ -663,8 +869,8 @@ const shouldBypassFunctionCache = async ( return await options.shouldBypassCache(...args) } -const shouldInvalidateFunctionCache = async ( - options: CacheFunctionOptions, +const shouldInvalidateFunctionCache = async ( + options: CacheFunctionOptions, args: TArgs ) => { if (!options.shouldInvalidateCache) { @@ -673,8 +879,8 @@ const shouldInvalidateFunctionCache = async ( return await options.shouldInvalidateCache(...args) } -const getFunctionStorageKey = async ( - options: CacheFunctionOptions, +const getFunctionStorageKey = async ( + options: CacheFunctionOptions, base: string, group: string, name: string, @@ -685,7 +891,7 @@ const getFunctionStorageKey = async ( return createStorageKey(base, group, name, key) } -const refreshFunctionCache = async ( +const refreshFunctionCache = async ( storage: Storage, storageKey: string, result: TResult, @@ -693,7 +899,7 @@ const refreshFunctionCache = async ( maxAge: number, staleMaxAge: number, now: number, - serialize: NonNullable['serialize']> + serialize: NonNullable['serialize']> ) => { try { const rawEntry = await serialize(result, { integrity, maxAge, staleMaxAge, now }) @@ -705,8 +911,8 @@ const refreshFunctionCache = async ( return result } -const maybeServeCachedFunctionValue = async ( - cached: CachedFunctionEntry | null, +const maybeServeCachedFunctionValue = async ( + cached: CachedFunctionEntry | null, storageKey: string, integrity: string, swr: boolean, @@ -714,18 +920,23 @@ const maybeServeCachedFunctionValue = async ( storage: Storage, maxAge: number, staleMaxAge: number, - serialize: NonNullable['serialize']>, - deserialize: NonNullable['deserialize']>, + serialize: NonNullable['serialize']>, + deserialize: NonNullable['deserialize']>, pendingRequests: PendingRequests, - validate?: CacheFunctionOptions['validate'], + validate?: CacheFunctionOptions['validate'], validateArgs?: TArgs ): Promise => { - if (!cached || cached.integrity !== integrity) { + 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 } } @@ -734,10 +945,16 @@ const maybeServeCachedFunctionValue = async ( } if (swr && isStaleValid(cached.staleExpires)) { const requests = getPendingRequests(pendingRequests, storage) - const pendingKey = `${storageKey}:${integrity}` + const pendingKey = stableStringify([storageKey, integrity]) if (!requests.has(pendingKey)) { - const refreshPromise = Promise.resolve() - .then(fetcher) + 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, @@ -751,13 +968,16 @@ const maybeServeCachedFunctionValue = async ( ) ) .finally(() => { - requests.delete(pendingKey) + clearTimeout(timeout) + if (requests.get(pendingKey) === refreshPromise) { + requests.delete(pendingKey) + } }) - void refreshPromise.catch(() => undefined) - requests.set(pendingKey, refreshPromise) + .catch(() => undefined) } return (await deserialize(cached)) as TResult } + await removeCacheEntry(storage, storageKey) return CACHE_MISS } @@ -765,16 +985,16 @@ const maybeServeCachedFunctionValue = async ( * Wrap a function with cache behavior. * Provide `hash` in options to use WebCrypto or node:crypto for key hashing. */ -export const cacheFunction = ( +export const cacheFunction = ( fn: (...args: TArgs) => Promise | TResult, - options: CacheFunctionOptions | number = {} + options: CacheFunctionOptions | number = {} ): ((...args: TArgs) => Promise) => { - const normalized: CacheFunctionOptions = + const normalized: CacheFunctionOptions = typeof options === 'number' ? { maxAge: options } : { ...options } - const pendingFunctionRequests: PendingRequests = new WeakMap() + const implicitName = `${fn.name || '_'}:${functionNamespace}:${functionNamespaceIndex++}` return async (...args: TArgs): Promise => { - const merged: CacheFunctionOptions = { + const merged: CacheFunctionOptions = { ...defaultCacheOptions, ...normalized, } @@ -783,11 +1003,15 @@ export const cacheFunction = ( const swr = merged.swr ?? true const keepPreviousOn5xx = merged.keepPreviousOn5xx ?? true const base = merged.base ?? DEFAULT_CACHE_BASE - const name = (merged.name ?? fn.name) || '_' + 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 - const deserialize = merged.deserialize ?? defaultDeserializeFunctionEntry + 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) { @@ -804,13 +1028,13 @@ export const cacheFunction = ( const shouldInvalidate = await shouldInvalidateFunctionCache(merged, args) const cachedRaw = shouldInvalidate ? null : await readCacheEntry(storage, storageKey) - const cached = isValidCachedFunctionEntry(cachedRaw) ? cachedRaw : null + const cached = isValidCachedFunctionEntry(cachedRaw) ? cachedRaw : null if (!cached && cachedRaw !== null) { await removeCacheEntry(storage, storageKey) } let cachedValue: TResult | typeof CACHE_MISS = CACHE_MISS try { - cachedValue = await maybeServeCachedFunctionValue( + cachedValue = await maybeServeCachedFunctionValue( cached, storageKey, integrity, @@ -837,12 +1061,19 @@ export const cacheFunction = ( } const requests = getPendingRequests(pendingFunctionRequests, storage) - const pendingKey = `${storageKey}:${integrity}` + const pendingKey = stableStringify([storageKey, integrity]) if (requests.has(pendingKey)) { return (await requests.get(pendingKey)) as TResult } - const resultPromise = Promise.resolve(fn(...args)) + 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, @@ -856,10 +1087,12 @@ export const cacheFunction = ( ) ) .finally(() => { - requests.delete(pendingKey) + clearTimeout(timeout) + if (requests.get(pendingKey) === resultPromise) { + requests.delete(pendingKey) + } }) - - requests.set(pendingKey, resultPromise) + .catch(() => undefined) return await resultPromise } } diff --git a/packages/universal-cache/src/index.test.ts b/packages/universal-cache/src/index.test.ts index ca371c73c..ecb6c6947 100644 --- a/packages/universal-cache/src/index.test.ts +++ b/packages/universal-cache/src/index.test.ts @@ -61,6 +61,99 @@ describe('@hono/universal-cache', () => { 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('does not cache methods outside GET/HEAD by default', async () => { const app = new Hono() let count = 0 @@ -202,6 +295,7 @@ describe('@hono/universal-cache', () => { ['cache-control', 'public, max-age=0'], ['cache-control', 'no-store'], ['pragma', 'no-cache'], + ['pragma', 'foo, no-cache'], ])('bypasses cached responses for %s: %s', async (header, value) => { const app = new Hono() let count = 0 @@ -394,6 +488,72 @@ describe('@hono/universal-cache', () => { 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 @@ -496,6 +656,42 @@ describe('@hono/universal-cache', () => { 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) => { @@ -656,6 +852,120 @@ describe('@hono/universal-cache', () => { 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('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 @@ -777,7 +1087,7 @@ describe('@hono/universal-cache', () => { cacheMiddleware({ maxAge: 60, serialize: async (response, context) => ({ - value: await response.clone().text(), + value: await response.text(), encoding: 'base64', status: response.status, headers: { @@ -836,6 +1146,9 @@ describe('@hono/universal-cache', () => { ) 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') @@ -848,6 +1161,70 @@ describe('@hono/universal-cache', () => { 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 @@ -1061,6 +1438,31 @@ describe('@hono/universal-cache', () => { 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 @@ -1319,6 +1721,50 @@ describe('@hono/universal-cache', () => { 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('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 @@ -1356,6 +1802,33 @@ describe('@hono/universal-cache', () => { 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], @@ -1405,6 +1878,56 @@ describe('@hono/universal-cache', () => { 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')) diff --git a/packages/universal-cache/src/types.ts b/packages/universal-cache/src/types.ts index f3b1f296b..e17027d24 100644 --- a/packages/universal-cache/src/types.ts +++ b/packages/universal-cache/src/types.ts @@ -72,16 +72,20 @@ export interface CacheMiddlewareOptions extends CacheBaseOptions { varies?: string[] } -export interface CacheFunctionOptions extends CacheBaseOptions { +export interface CacheFunctionOptions< + TArgs extends unknown[], + TResult = unknown, + TStored = TResult, +> extends CacheBaseOptions { /** Deserialize a cached entry back into the function result. */ - deserialize?: (entry: CachedFunctionEntry) => unknown + deserialize?: (entry: CachedFunctionEntry) => TResult | Promise /** Provide a custom cache key. */ getKey?: CacheKeyFn /** Serialize the function result into a cached entry. */ serialize?: ( - value: unknown, + value: TResult, context: { integrity: string; maxAge: number; staleMaxAge: number; now: number } - ) => CachedFunctionEntry | Promise> + ) => 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. */ @@ -89,7 +93,7 @@ export interface CacheFunctionOptions extends CacheBase /** Enable stale-while-revalidate behavior. */ swr?: boolean /** Optional validation for cached function entries. */ - validate?: (entry: CachedFunctionEntry, ...args: TArgs) => boolean + validate?: (entry: CachedFunctionEntry, ...args: TArgs) => boolean } export interface CachedResponseEntry { diff --git a/packages/universal-cache/src/utils.test.ts b/packages/universal-cache/src/utils.test.ts index 03e02d13b..f103485b2 100644 --- a/packages/universal-cache/src/utils.test.ts +++ b/packages/universal-cache/src/utils.test.ts @@ -25,6 +25,7 @@ describe('utils', () => { 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', () => { @@ -34,6 +35,16 @@ describe('utils', () => { 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('computes TTL for all branches', () => { diff --git a/packages/universal-cache/src/utils.ts b/packages/universal-cache/src/utils.ts index 4ac325f80..459cc3cb1 100644 --- a/packages/universal-cache/src/utils.ts +++ b/packages/universal-cache/src/utils.ts @@ -1,3 +1,5 @@ +import { serialize as serializeHashValue } from 'ohash' + /** Default storage base prefix. */ export const DEFAULT_CACHE_BASE = 'cache' /** Default storage group for cached handlers. */ @@ -22,7 +24,48 @@ export const normalizePathToName = (path: string): string => { } /** Stable, type-aware serialization for cache keys. */ -export const stableStringify = (value: unknown): string => serializeHashValue(value) +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) + if (!Array.isArray(current) && !isPlainObject) { + 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)}` + } + + 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 => { @@ -45,4 +88,3 @@ export const isStaleValid = (staleExpires: number | null): boolean => { } return Date.now() <= staleExpires } -import { serialize as serializeHashValue } from 'ohash' diff --git a/packages/universal-cache/tsdown.config.ts b/packages/universal-cache/tsdown.config.ts index d1ab7a428..d1b94d2e0 100644 --- a/packages/universal-cache/tsdown.config.ts +++ b/packages/universal-cache/tsdown.config.ts @@ -1,5 +1,10 @@ import { defineConfig } from 'tsdown' export default defineConfig({ + deps: { + alwaysBundle: [/^ohash(?:\/|$)/], + }, entry: 'src/index.ts', + fixedExtension: true, + platform: 'neutral', }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4abf3ee20..85e8d92ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1020,8 +1020,8 @@ importers: specifier: ^2.0.11 version: 2.0.11 unstorage: - specifier: ^1.17.0 - version: 1.17.5 + specifier: 1.17.3 + version: 1.17.3 devDependencies: '@cloudflare/vitest-pool-workers': specifier: ^0.16.10 @@ -3507,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'} @@ -5962,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'} @@ -6704,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: @@ -9517,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 @@ -12391,6 +12465,8 @@ snapshots: dependencies: picomatch: 2.3.2 + readdirp@4.1.2: {} + readdirp@5.0.0: {} recma-build-jsx@1.0.0: @@ -13299,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 From ce27706e1cf5215d27cd989f87b31dd56088a4f9 Mon Sep 17 00:00:00 2001 From: raed bahri Date: Wed, 15 Jul 2026 18:29:53 +0100 Subject: [PATCH 08/12] fix(universal-cache): harden concurrent cache operations --- packages/universal-cache/README.md | 8 +- packages/universal-cache/src/cache.ts | 336 +++++++++++++++++---- packages/universal-cache/src/index.test.ts | 307 +++++++++++++++++++ packages/universal-cache/src/utils.test.ts | 18 ++ packages/universal-cache/src/utils.ts | 21 +- 5 files changed, 633 insertions(+), 57 deletions(-) diff --git a/packages/universal-cache/README.md b/packages/universal-cache/README.md index e3ff56796..7b9abe01f 100644 --- a/packages/universal-cache/README.md +++ b/packages/universal-cache/README.md @@ -38,7 +38,7 @@ Passing a number is shorthand for `{ maxAge: number }`. `GET` and `HEAD` are cac 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. -Custom storage drivers must bound their own operation latency. A storage promise that never settles also prevents the cache operation from settling. +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' @@ -143,13 +143,13 @@ const getStats = cacheFunction(async (id: string) => ({ id, ts: Date.now() }), { }) ``` -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`. Provide `getKey` for identity-sensitive values such as symbols, functions, or sparse arrays. +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 safely preserves JSON-compatible results only. Values such as `Date`, `Map`, `Set`, class instances, and `BigInt` require custom `serialize` and `deserialize` functions when their type or shape must be preserved. +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 @@ -178,7 +178,7 @@ The middleware does not cache: - responses containing `Vary: *` or a `Vary` header not covered by `varies` - malformed persisted entries -Cached responses exclude `set-cookie`, `content-length`, and other hop-by-hop headers. +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. diff --git a/packages/universal-cache/src/cache.ts b/packages/universal-cache/src/cache.ts index 07ccfefe0..c9ba50575 100644 --- a/packages/universal-cache/src/cache.ts +++ b/packages/universal-cache/src/cache.ts @@ -36,7 +36,6 @@ const HOP_BY_HOP_HEADERS = new Set([ 'trailer', 'transfer-encoding', 'upgrade', - 'content-length', 'proxy-connection', ]) @@ -47,6 +46,7 @@ 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', @@ -102,11 +102,18 @@ 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) @@ -117,19 +124,125 @@ const getPendingRequests = (pendingRequests: PendingRequests, storage: Storage) 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 storage.getItem(key) + 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 storage.removeItem(key) + 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) + } } } @@ -188,8 +301,10 @@ export const createCacheStorage = (options: CacheStorageOptions = {}): Storage = }) const createStorageKey = (base: string, group: string, name: string, key: string) => { - const segments = [base, group, name, key].filter(Boolean) - return `${segments.map((segment) => encodeURIComponent(segment)).join(':')}.json` + const segments = [base, group, name, key] + return `${segments + .map((segment) => (segment === '' ? '%00' : encodeURIComponent(segment))) + .join(':')}.json` } const escapeKey = (value: string) => value.replace(/\W/g, '') @@ -321,6 +436,21 @@ const isCacheableResponse = (response: Response, varies: string[] | undefined) = ) } +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 } @@ -349,7 +479,7 @@ const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null const readResponseBody = async (response: Response) => { - const body = response.clone().body + const body = response.body if (!body) { return new Uint8Array() } @@ -383,9 +513,15 @@ const readResponseBody = async (response: Response) => { clearTimeout(timeout) } if (!complete) { - void reader.cancel().catch(() => undefined) + void reader + .cancel() + .catch(() => undefined) + .finally(() => { + reader.releaseLock() + }) + } else { + reader.releaseLock() } - reader.releaseLock() } const buffer = new Uint8Array(size) @@ -490,16 +626,9 @@ const shouldBypassMiddlewareCache = async (ctx: Context, options: CacheMiddlewar return true } - const cacheControl = ctx.req.header('cache-control')?.toLowerCase() if ( - cacheControl - ?.split(',') - .some((directive) => ['no-cache', 'no-store', 'max-age=0'].includes(directive.trim())) || - ctx.req - .header('pragma') - ?.toLowerCase() - .split(',') - .some((directive) => directive.trim() === 'no-cache') + hasRequestNoCacheDirective(ctx.req.header('cache-control')) || + hasRequestNoCacheDirective(ctx.req.header('pragma')) ) { return true } @@ -535,7 +664,8 @@ const cacheResponseEntry = async ( maxAge: number, staleMaxAge: number, now: number, - serialize: NonNullable + serialize: NonNullable, + generation: number ) => { try { const rawEntry = await serialize(response, { integrity, maxAge, staleMaxAge, now }) @@ -543,9 +673,16 @@ const cacheResponseEntry = async ( if (ttl === 0) { return } - await storage.setItem(storageKey, rawEntry, ttl ? { ttl } : undefined) + 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) } } @@ -581,7 +718,8 @@ const writeCachedResponse = ( maxAge: number, staleMaxAge: number, now: number, - serialize: NonNullable + serialize: NonNullable, + generation: number ) => { let cacheResponse: Response try { @@ -597,7 +735,8 @@ const writeCachedResponse = ( maxAge, staleMaxAge, now, - serialize + serialize, + generation ) if (getRuntimeKey() === 'workerd') { @@ -722,19 +861,23 @@ export const cacheMiddleware = ( } let resolvePending: ((response: Response | null) => void) | undefined - let rejectPending: ((error: unknown) => 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 } - if (requests.get(pendingKey) === pendingPromise) { - requests.delete(pendingKey) - } + detachPending() if (sharedPendingResponse?.body) { setTimeout(() => { void sharedPendingResponse?.body?.cancel().catch(() => undefined) @@ -744,22 +887,25 @@ export const cacheMiddleware = ( } if (shouldCoalesce) { - pendingPromise = new Promise((resolve, reject) => { + pendingPromise = new Promise((resolve) => { resolvePending = resolve - rejectPending = reject }) 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 || requests.get(pendingKey) !== pendingPromise) { + if (!pendingPromise || !resolvePending || pendingSettled) { return } + pendingSettled = true try { sharedPendingResponse = response?.clone() ?? null } catch { @@ -773,46 +919,56 @@ export const cacheMiddleware = ( } } - const failPending = (error: unknown) => { - if (pendingPromise && rejectPending) { - rejectPending(error) + const releasePending = () => { + if (pendingPromise && resolvePending && !pendingSettled) { + pendingSettled = true + resolvePending(null) clearPending() } } if (shouldInvalidate && !keepPreviousOn5xx) { - await removeCacheEntry(storage, storageKey) + 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 } - failPending(error) + 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 (shouldInvalidate && keepPreviousOn5xx && response.status < 500) { - await removeCacheEntry(storage, storageKey) + if (response.status < 500) { + await waitForCacheMutation( + queueCacheMutation(storage, storageKey, generation, () => storage.removeItem(storageKey)) + ) } - settlePending(response.status >= 500 ? response : null) + finishCacheMutation(storage, storageKey, generation) + settlePending(response.status >= 500 && !ctx.req.raw.signal.aborted ? response : null) return response } @@ -825,9 +981,20 @@ export const cacheMiddleware = ( maxAge, staleMaxAge, Date.now(), - serialize + serialize, + generation ) - settlePending(response, cacheWrite) + detachPending() + void cacheWrite.then(async () => { + const cachedResult = await readCachedResponse( + storage, + storageKey, + integrity, + merged, + deserialize + ) + settlePending(cachedResult?.response ?? null) + }) return response } @@ -853,8 +1020,55 @@ const createFunctionEntry = ( const defaultSerializeFunctionEntry = ( result: TResult, context: { integrity: string; maxAge: number; staleMaxAge: number; now: number } -) => - createFunctionEntry(result, context.integrity, context.maxAge, context.staleMaxAge, context.now) +) => { + 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 @@ -899,15 +1113,19 @@ const refreshFunctionCache = async ( maxAge: number, staleMaxAge: number, now: number, - serialize: NonNullable['serialize']> + serialize: NonNullable['serialize']>, + generation: number ) => { try { const rawEntry = await serialize(result, { integrity, maxAge, staleMaxAge, now }) const ttl = computeTtlSeconds(maxAge, staleMaxAge) - await storage.setItem(storageKey, rawEntry, ttl ? { ttl } : undefined) + 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 } @@ -947,6 +1165,7 @@ const maybeServeCachedFunctionValue = async { @@ -964,7 +1183,8 @@ const maybeServeCachedFunctionValue = async { @@ -973,7 +1193,9 @@ const maybeServeCachedFunctionValue = async undefined) + .catch(() => { + finishCacheMutation(storage, storageKey, generation) + }) } return (await deserialize(cached)) as TResult } @@ -1028,7 +1250,12 @@ export const cacheFunction = (cachedRaw) ? cachedRaw : null + let cached: CachedFunctionEntry | null = null + try { + cached = isValidCachedFunctionEntry(cachedRaw) ? cachedRaw : null + } catch { + cached = null + } if (!cached && cachedRaw !== null) { await removeCacheEntry(storage, storageKey) } @@ -1056,16 +1283,18 @@ export const cacheFunction = storage.removeItem(storageKey)) + ) + } const resultPromise = Promise.resolve().then(() => fn(...args)) requests.set(pendingKey, resultPromise) const timeout = setTimeout(() => { @@ -1083,7 +1312,8 @@ export const cacheFunction = { @@ -1092,7 +1322,9 @@ export const cacheFunction = undefined) + .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 index ecb6c6947..252151b8a 100644 --- a/packages/universal-cache/src/index.test.ts +++ b/packages/universal-cache/src/index.test.ts @@ -154,6 +154,143 @@ describe('@hono/universal-cache', () => { 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 @@ -294,6 +431,8 @@ describe('@hono/universal-cache', () => { ['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) => { @@ -875,6 +1014,42 @@ describe('@hono/universal-cache', () => { 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() @@ -1739,6 +1914,138 @@ describe('@hono/universal-cache', () => { 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')) diff --git a/packages/universal-cache/src/utils.test.ts b/packages/universal-cache/src/utils.test.ts index f103485b2..01f3dd712 100644 --- a/packages/universal-cache/src/utils.test.ts +++ b/packages/universal-cache/src/utils.test.ts @@ -47,6 +47,24 @@ describe('utils', () => { 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) diff --git a/packages/universal-cache/src/utils.ts b/packages/universal-cache/src/utils.ts index 459cc3cb1..aaea060ed 100644 --- a/packages/universal-cache/src/utils.ts +++ b/packages/universal-cache/src/utils.ts @@ -38,7 +38,10 @@ export const stableStringify = (value: unknown): string => { current !== null && (Object.getPrototypeOf(current) === Object.prototype || Object.getPrototypeOf(current) === null) - if (!Array.isArray(current) && !isPlainObject) { + 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) } @@ -57,6 +60,22 @@ export const stableStringify = (value: unknown): string => { 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() From a4ebbfbec0201c9c193328e1a3524cc907e24afa Mon Sep 17 00:00:00 2001 From: raed bahri Date: Fri, 17 Jul 2026 04:53:13 +0100 Subject: [PATCH 09/12] fix(universal-cache): align bundled dependencies --- packages/universal-cache/package.json | 4 ++-- pnpm-lock.yaml | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/universal-cache/package.json b/packages/universal-cache/package.json index c5e714732..e005ae332 100644 --- a/packages/universal-cache/package.json +++ b/packages/universal-cache/package.json @@ -46,13 +46,13 @@ }, "dependencies": { "lru-cache": "^10.4.3", - "ohash": "^2.0.11", - "unstorage": "1.17.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" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85e8d92ed..0e8d6fa2a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1016,11 +1016,8 @@ importers: lru-cache: specifier: ^10.4.3 version: 10.4.3 - ohash: - specifier: ^2.0.11 - version: 2.0.11 unstorage: - specifier: 1.17.3 + specifier: ^1.17.3 version: 1.17.3 devDependencies: '@cloudflare/vitest-pool-workers': @@ -1032,6 +1029,9 @@ importers: 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) From 1a1f529dd686c4fb223914a86d6e7108ee798f19 Mon Sep 17 00:00:00 2001 From: raed bahri Date: Tue, 28 Jul 2026 10:43:24 +0100 Subject: [PATCH 10/12] fix(universal-cache): keep cache fills coalesced --- packages/universal-cache/src/cache.ts | 22 +++++++++++-- packages/universal-cache/src/index.test.ts | 38 ++++++++++++++-------- 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/packages/universal-cache/src/cache.ts b/packages/universal-cache/src/cache.ts index c9ba50575..e1f5230b5 100644 --- a/packages/universal-cache/src/cache.ts +++ b/packages/universal-cache/src/cache.ts @@ -110,6 +110,7 @@ interface CacheMutationState { const pendingMiddlewareRequests: PendingRequests = new WeakMap() const pendingFunctionRequests: PendingRequests = new WeakMap() +const pendingMiddlewareFreshUntil = new WeakMap, number>() const cacheMutations = new WeakMap>() const functionNamespace = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` let functionNamespaceIndex = 0 @@ -818,6 +819,21 @@ export const cacheMiddleware = ( const shouldCoalesce = !isRevalidateRequest && !shouldInvalidate let staleResponse: Response | undefined + const getPendingResponse = () => { + const pending = requests.get(pendingKey) as Promise | undefined + if (!pending) { + return undefined + } + const freshUntil = pendingMiddlewareFreshUntil.get(pending) + if (freshUntil !== undefined && Date.now() > freshUntil) { + if (requests.get(pendingKey) === pending) { + requests.delete(pendingKey) + } + return undefined + } + return pending + } + const servePendingResponse = async (pending: Promise) => { const shared = await pending if (!shared) { @@ -830,7 +846,7 @@ export const cacheMiddleware = ( } if (shouldCoalesce) { - const pending = requests.get(pendingKey) as Promise | undefined + const pending = getPendingResponse() if (pending) { const pendingResponse = await servePendingResponse(pending) return pendingResponse === true ? ctx.res : pendingResponse @@ -853,7 +869,7 @@ export const cacheMiddleware = ( } if (shouldCoalesce) { - const pending = requests.get(pendingKey) as Promise | undefined + const pending = getPendingResponse() if (pending) { const pendingResponse = await servePendingResponse(pending) return pendingResponse === true ? ctx.res : pendingResponse @@ -890,6 +906,7 @@ export const cacheMiddleware = ( pendingPromise = new Promise((resolve) => { resolvePending = resolve }) + pendingMiddlewareFreshUntil.set(pendingPromise, Date.now() + maxAge * 1000) requests.set(pendingKey, pendingPromise) pendingTimeout = setTimeout(() => { pendingSettled = true @@ -984,7 +1001,6 @@ export const cacheMiddleware = ( serialize, generation ) - detachPending() void cacheWrite.then(async () => { const cachedResult = await readCachedResponse( storage, diff --git a/packages/universal-cache/src/index.test.ts b/packages/universal-cache/src/index.test.ts index 252151b8a..af178a5a5 100644 --- a/packages/universal-cache/src/index.test.ts +++ b/packages/universal-cache/src/index.test.ts @@ -1256,23 +1256,30 @@ describe('@hono/universal-cache', () => { it('supports custom serialize/deserialize for responses', async () => { const app = new Hono() let count = 0 + let releaseSerialize: (() => void) | undefined + const serializeGate = new Promise((resolve) => { + releaseSerialize = resolve + }) 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, - }), + serialize: async (response, context) => { + await serializeGate + return { + 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, @@ -1286,8 +1293,13 @@ describe('@hono/universal-cache', () => { ) const res1 = await app.request('http://localhost/items') - const res2 = await app.request('http://localhost/items') + const res2Promise = app.request('http://localhost/items') + await flushPromises() + const countBeforeRelease = count + releaseSerialize?.() + const res2 = await res2Promise + expect(countBeforeRelease).toBe(1) expect(await res1.text()).toBe('value-1') expect(await res2.text()).toBe('value-1') expect(count).toBe(1) From 610f4740470d96568c7bc6be7836b31e3fcf4135 Mon Sep 17 00:00:00 2001 From: raed bahri Date: Tue, 28 Jul 2026 11:15:00 +0100 Subject: [PATCH 11/12] fix(universal-cache): coordinate cache cleanup --- packages/universal-cache/src/cache.ts | 96 +++++++++----- packages/universal-cache/src/index.test.ts | 146 +++++++++++++++++++++ 2 files changed, 210 insertions(+), 32 deletions(-) diff --git a/packages/universal-cache/src/cache.ts b/packages/universal-cache/src/cache.ts index e1f5230b5..84feb1ec7 100644 --- a/packages/universal-cache/src/cache.ts +++ b/packages/universal-cache/src/cache.ts @@ -107,6 +107,10 @@ interface CacheMutationState { pending: boolean tail: Promise } +interface CacheMutationSnapshot { + generation: number + pending: boolean +} const pendingMiddlewareRequests: PendingRequests = new WeakMap() const pendingFunctionRequests: PendingRequests = new WeakMap() @@ -134,6 +138,14 @@ const getCacheMutations = (storage: Storage) => { return mutations } +const getCacheMutationSnapshot = (storage: Storage, key: string): CacheMutationSnapshot => { + const state = cacheMutations.get(storage)?.get(key) + return { + generation: state?.generation ?? 0, + pending: state?.pending ?? false, + } +} + const beginCacheMutation = (storage: Storage, key: string) => { const mutations = getCacheMutations(storage) const current = mutations.get(key) @@ -227,24 +239,27 @@ const readCacheEntry = async (storage: Storage, key: string) => { } } -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 removeCacheEntry = async ( + storage: Storage, + key: string, + expectedMutation: CacheMutationSnapshot +) => { + const currentMutation = getCacheMutationSnapshot(storage, key) + if ( + expectedMutation.pending || + currentMutation.pending || + currentMutation.generation !== expectedMutation.generation + ) { + return } + const generation = beginCacheMutation(storage, key) + const mutation = queueCacheMutation(storage, key, generation, () => storage.removeItem(key)) + await waitForCacheMutation(mutation) + const latestMutation = cacheMutations.get(storage)?.get(key)?.tail + if (latestMutation && latestMutation !== mutation) { + await waitForCacheMutation(latestMutation) + } + finishCacheMutation(storage, key, generation) } const setRequestCacheDefaults = (ctx: Context, options: CacheDefaults = {}) => { @@ -317,10 +332,14 @@ const getDefaultHandlerKey = async ( ) => { 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())}` + let body = '' + if (method !== 'GET' && method !== 'HEAD') { + try { + body = `:${encodeBase64(await ctx.req.arrayBuffer())}` + } catch { + return + } + } const fullPath = `${method}:${url.origin}${url.pathname}${url.search}${body}` let pathPrefix = '-' @@ -581,6 +600,9 @@ const resolveHandlerCacheKey = async ( const key = options.getKey ? await options.getKey(ctx) : await getDefaultHandlerKey(ctx, options.varies, hashFn) + if (key === undefined) { + return + } const storageKey = createStorageKey(base, group, name, key) const integrity = options.integrity ?? (await hashFn(stableStringify([group, name]))) return { name, key, storageKey, integrity } @@ -592,21 +614,22 @@ const maybeServeCachedResponse = async ( integrity: string, cachedRaw: unknown, deserialize: NonNullable, + expectedMutation: CacheMutationSnapshot, validate?: CacheMiddlewareOptions['validate'] ): Promise<{ response: Response; stale: boolean } | null> => { const cached = isValidCachedResponseEntry(cachedRaw) ? cachedRaw : null if (!cached) { if (cachedRaw !== null) { - await removeCacheEntry(storage, storageKey) + await removeCacheEntry(storage, storageKey, expectedMutation) } return null } if (cached.integrity !== integrity) { - await removeCacheEntry(storage, storageKey) + await removeCacheEntry(storage, storageKey, expectedMutation) return null } if (validate && validate(cached) === false) { - await removeCacheEntry(storage, storageKey) + await removeCacheEntry(storage, storageKey, expectedMutation) return null } @@ -618,7 +641,7 @@ const maybeServeCachedResponse = async ( return { response: await deserialize(cached), stale: true } } - await removeCacheEntry(storage, storageKey) + await removeCacheEntry(storage, storageKey, expectedMutation) return null } @@ -694,6 +717,7 @@ const readCachedResponse = async ( options: CacheMiddlewareOptions, deserialize: NonNullable ) => { + const expectedMutation = getCacheMutationSnapshot(storage, storageKey) const cachedRaw = await readCacheEntry(storage, storageKey) try { return await maybeServeCachedResponse( @@ -702,10 +726,11 @@ const readCachedResponse = async ( integrity, cachedRaw, deserialize, + expectedMutation, options.validate ) } catch { - await removeCacheEntry(storage, storageKey) + await removeCacheEntry(storage, storageKey, expectedMutation) return null } } @@ -812,7 +837,11 @@ export const cacheMiddleware = ( return next() } - const { storageKey, integrity } = await resolveHandlerCacheKey(ctx, merged, base, group, hashFn) + const resolvedKey = await resolveHandlerCacheKey(ctx, merged, base, group, hashFn) + if (!resolvedKey) { + return next() + } + const { storageKey, integrity } = resolvedKey const shouldInvalidate = await shouldInvalidateMiddlewareCache(ctx, merged) const requests = getPendingRequests(pendingMiddlewareRequests, storage) const pendingKey = stableStringify([storageKey, integrity]) @@ -1157,6 +1186,7 @@ const maybeServeCachedFunctionValue = async ['serialize']>, deserialize: NonNullable['deserialize']>, pendingRequests: PendingRequests, + expectedMutation: CacheMutationSnapshot, validate?: CacheFunctionOptions['validate'], validateArgs?: TArgs ): Promise => { @@ -1164,13 +1194,13 @@ const maybeServeCachedFunctionValue = async | null = null try { @@ -1273,7 +1304,7 @@ export const cacheFunction = { expect(await (await app.request('http://localhost/items')).text()).toBe('new') }) + it('does not let delayed invalid-entry removal erase a newer response', async () => { + const storage = createCacheStorage() + const storageKey = createTestStorageKey('cache', 'hono/handlers', 'items', 'key') + await storage.setItem(storageKey, { value: 'malformed' }) + const originalRemoveItem = storage.removeItem.bind(storage) + let markRemoveStarted: (() => void) | undefined + let releaseRemove: (() => void) | undefined + const removeStarted = new Promise((resolve) => { + markRemoveStarted = resolve + }) + const removeGate = new Promise((resolve) => { + releaseRemove = resolve + }) + vi.spyOn(storage, 'removeItem').mockImplementation(async (key) => { + markRemoveStarted?.() + await removeGate + await originalRemoveItem(key) + }) + + const app = new Hono() + let freshCount = 0 + app.onError(() => new Response('failed', { status: 500 })) + app.get( + '/items', + cacheMiddleware({ + getKey: () => 'key', + integrity: 'integrity', + maxAge: 60, + name: 'items', + revalidateHeader: 'x-revalidate', + shouldRevalidate: () => true, + storage, + }), + (c) => { + if (c.req.header('x-fail') === '1') { + throw new Error('origin failed') + } + return c.text(`fresh-${++freshCount}`) + } + ) + + const invalidRead = app.request('http://localhost/items', { + headers: { 'x-fail': '1' }, + }) + await removeStarted + const refreshed = await app.request('http://localhost/items', { + headers: { 'x-revalidate': '1' }, + }) + expect(await refreshed.text()).toBe('fresh-1') + await flushPromises() + releaseRemove?.() + + expect((await invalidRead).status).toBe(500) + expect(await (await app.request('http://localhost/items')).text()).toBe('fresh-1') + expect(freshCount).toBe(1) + }) + it('prevents manual revalidation from being overwritten by an older fill', async () => { const app = new Hono() let count = 0 @@ -350,6 +407,46 @@ describe('@hono/universal-cache', () => { expect(count).toBe(2) }) + it('reuses request bodies read by earlier Hono middleware', async () => { + const app = new Hono() + let count = 0 + + app.use('/items', async (c, next) => { + await c.req.text() + await next() + }) + app.post('/items', cacheMiddleware({ maxAge: 60, methods: ['POST'] }), 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') + expect(await (await request()).text()).toBe('one:1') + expect(count).toBe(1) + }) + + it('bypasses caching when a custom method body cannot be replayed', async () => { + const app = new Hono() + let count = 0 + + app.use('/items', async (c, next) => { + await c.req.raw.text() + await next() + }) + app.post('/items', cacheMiddleware({ maxAge: 60, methods: ['POST'] }), (c) => { + count += 1 + return c.text(String(count)) + }) + + const request = () => app.request('http://localhost/items', { body: 'one', method: 'POST' }) + + expect(await (await request()).text()).toBe('1') + expect(await (await request()).text()).toBe('2') + expect(count).toBe(2) + }) + it('refreshes expired custom methods synchronously', async () => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) @@ -1957,6 +2054,55 @@ describe('@hono/universal-cache', () => { expect(await fn()).toBe('new') }) + it('does not let delayed invalid-entry removal erase a newer function result', async () => { + const storage = createCacheStorage() + const storageKey = createTestStorageKey('cache', 'hono/functions', 'shared', 'key') + await storage.setItem(storageKey, { value: 'malformed' }) + const originalRemoveItem = storage.removeItem.bind(storage) + let markRemoveStarted: (() => void) | undefined + let releaseRemove: (() => void) | undefined + const removeStarted = new Promise((resolve) => { + markRemoveStarted = resolve + }) + const removeGate = new Promise((resolve) => { + releaseRemove = resolve + }) + vi.spyOn(storage, 'removeItem').mockImplementation(async (key) => { + markRemoveStarted?.() + await removeGate + await originalRemoveItem(key) + }) + + const common = { + getKey: () => 'key', + integrity: 'integrity', + maxAge: 60, + name: 'shared', + storage, + swr: false, + } + const failingReader = cacheFunction(() => { + throw new Error('origin failed') + }, common) + const invalidatingWriter = cacheFunction(() => 'fresh', { + ...common, + keepPreviousOn5xx: true, + shouldInvalidateCache: () => true, + }) + + const invalidRead = failingReader().catch(() => undefined) + await removeStarted + expect(await invalidatingWriter()).toBe('fresh') + await flushPromises() + releaseRemove?.() + await invalidRead + + await vi.waitFor(async () => { + const cached = await storage.getItem<{ value?: unknown }>(storageKey) + expect(cached?.value).toBe('fresh') + }) + }) + it('keeps empty function cache-key segments isolated', async () => { const storage = createCacheStorage() let secondCalls = 0 From 336c344f5a1370d3970d4833274a9aff8bce551c Mon Sep 17 00:00:00 2001 From: raed bahri Date: Tue, 28 Jul 2026 11:18:29 +0100 Subject: [PATCH 12/12] chore(universal-cache): align typescript toolchain --- packages/universal-cache/package.json | 2 +- pnpm-lock.yaml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/universal-cache/package.json b/packages/universal-cache/package.json index e005ae332..240c1e96e 100644 --- a/packages/universal-cache/package.json +++ b/packages/universal-cache/package.json @@ -54,7 +54,7 @@ "hono": "^4.11.5", "ohash": "^2.0.11", "tsdown": "^0.22.3", - "typescript": "^6.0.3", + "typescript": "npm:@typescript/typescript6@^6.0.2", "vitest": "^4.1.7" }, "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 621cdc6dc..4c8c29cd5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1037,13 +1037,13 @@ importers: 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) + version: 0.22.4(@arethetypeswrong/core@0.18.4)(@typescript/typescript6@6.0.2)(publint@0.3.21) typescript: - specifier: ^6.0.3 - version: 6.0.3 + specifier: npm:@typescript/typescript6@^6.0.2 + version: '@typescript/typescript6@6.0.2' 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)) + 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/typescript6@6.0.2))(vite@8.1.3(@types/node@25.9.5)(esbuild@0.28.1)(yaml@2.9.0)) packages/valibot-validator: devDependencies: