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 a9ec15b22..eb76c86c0 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 49c282f45..a195f24f5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -43,6 +43,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 5ce8f9d81..cdea94257 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2469,6 +2469,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" @@ -12329,6 +12344,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" @@ -16081,7 +16103,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: