import type { Context, Env, MiddlewareHandler } from 'hono' import { cache as hono_cache } from 'hono/cache' import { etag as hono_etag, RETAINED_304_HEADERS } from 'hono/etag' import type * as z from 'zod/mini' import * as EdgeCache from './EdgeCache.js' /** * Named `Cache-Control` policies, tiered by how volatile each resource is. The * `max-age` bounds how stale a shared (edge) hit can be; `stale-while-revalidate` * lets clients/CDNs serve a slightly older copy while refreshing in the * background. Handlers may override per response via `Cache.setPolicy` (e.g. a * mined transaction is `immutable`, a pending one is `noStore`). */ export const policies = { /** * Publicly shareable binary assets (token logos). `public` because the bytes * carry no per-principal data and the edge layer strips per-request headers. * Deliberately NOT `immutable`: these live at a stable URL * (`/tokens/:token/logo`) whose underlying R2 icon can be replaced in place * (admin upload), so a long `max-age` is paired with `stale-while-revalidate` * to revalidate rather than pin a replaced logo for a year. Use a versioned/ * content-addressed URL if true `immutable` forever-caching is needed. */ asset: 'public, max-age=86400, stale-while-revalidate=604800', /** Fallback for routes that do not specify a policy. */ default: 'private, max-age=30, stale-while-revalidate=300', /** Newest-first feeds (transactions, receipts, transfers): refresh quickly. */ feed: 'private, max-age=10, stale-while-revalidate=30', /** Immutable resources (mined transactions and receipts): cache aggressively. */ immutable: 'private, max-age=86400, stale-while-revalidate=604800', /** Token metadata and indexed listings: change slowly. */ metadata: 'private, max-age=60, stale-while-revalidate=300', /** Transient or uncacheable responses (pending transactions). */ noStore: 'no-store', /** Slow-changing curated data (verified tokens). */ stable: 'private, max-age=300, stale-while-revalidate=3600', /** Current-state pages (holders, balances): moderately volatile. */ state: 'private, max-age=30, stale-while-revalidate=120', } as const /** * Overrides the cache policy for the current response, taking precedence over * the route's default `Cache.response` policy. Lets a handler vary caching by * outcome — e.g. cache a mined transaction as `immutable` but a pending one as * `noStore`. Must be called before the handler returns. */ export function setPolicy( c: Context, cacheControl: string, ): void { c.set('cacheControl' as never, cacheControl as never) } /** Hono response-cache helpers. */ export function response( options: response.Options, ): MiddlewareHandler { const defaultCacheControl = options.cacheControl ?? policies.default const vary = options.vary ?? ['Accept-Encoding', 'Authorization', 'Tempo-API-Key', 'X-API-Key'] // Mirror hono/cache's vary normalization so the `no-cache` purge below // deletes exactly the entry the wrapped middleware reads. const varyKeyDirectives = [ ...new Set(vary.map((name) => name.trim().toLowerCase()).filter(Boolean)), ] const cache = hono_cache({ cacheName: options.name, keyGenerator: options.key, onCacheNotAvailable: false, vary: [...vary], wait: true, }) const etag = hono_etag() const middleware: MiddlewareHandler = async (c, next) => { const values = response.preservedHeaders.map((name) => [name, c.res.headers.get(name)] as const) // Honor the standard request `Cache-Control` directives (plus the HTTP/1.0 // `Pragma: no-cache` fallback, used only when `Cache-Control` is absent), // mirroring `EdgeCache.middleware`: a refresh that bypasses the edge layer // must also bypass this inner response cache, or the caller is served the // inner stored copy and the "refresh" never reaches the handler. // `no-cache` purges the stored entry so the wrapped hono/cache misses and // re-stores the fresh response; `no-store` skips reads and writes entirely // while leaving the stored entry untouched. const directives = (c.req.header('Cache-Control') ?? c.req.header('Pragma') ?? '').toLowerCase() const noStore = directives.includes('no-store') const noCache = noStore || directives.includes('no-cache') // The Cache API global is platform-provided (Cloudflare Workers, browsers) // and absent from Node's type surface, so probe it structurally — on // runtimes without it the wrapped hono/cache is a no-op and there is // nothing to purge. type Caches = { open(name: string): Promise<{ delete(key: string): Promise }> } const caches = (globalThis as { caches?: Caches }).caches if (noCache && !noStore && caches) { // hono/cache 4.13 wraps generated keys in a canonical URL containing the // request method and vary values. Keep this in sync with its // `createCacheKey` so refreshes remove the stored GET response. const key = await options.key(c) const url = new URL('/.hono/cache', c.req.url) url.searchParams.append('__hono_cache_key', key.split('#', 1)[0] ?? key) url.searchParams.append('__hono_cache_method', c.req.method) for (const name of varyKeyDirectives) url.searchParams.append( '__hono_cache_vary', JSON.stringify([name, c.req.raw.headers.get(name) ?? '']), ) await (await caches.open(options.name)).delete(url.href) } // Run ETag inside Hono's cache miss path so fresh responses are hashed // once before storage. Cached hits bypass Hono's ETag middleware, so this // wrapper handles conditional requests from the stored ETag below. const handler = async () => { await etag(c, async () => { await next() // Rate-limit headers are per request and would be stale if stored with // the shared cached body. for (const name of response.preservedHeaders) c.res.headers.delete(name) }) // Stamp the resolved policy's shared form onto 2xx responses BEFORE // hono/cache stores them: the explicit `s-maxage` bounds the stored // entry's lifetime to the policy (instead of the platform cache's // default TTL), and a policy resolved to `noStore` (e.g. a pending // transaction via `setPolicy`) becomes `no-store`, which hono/cache // refuses to store. Client-facing headers are applied after storage // below; non-2xx responses keep their own headers (`Response.error` // marks envelopes `no-store`). if (c.res.status >= 200 && c.res.status < 300) { const policy = edgeCachePolicy( (c.get('cacheControl' as never) as string | undefined) ?? defaultCacheControl, ) c.res.headers.set('Cache-Control', policy ? EdgeCache.cacheControl(policy) : 'no-store') } } const result = noStore ? await handler() : await cache(c, handler) // A handler may override the policy per response (see `setPolicy`); fall back // to the route default. Publishing the derived policy is what marks the // response as eligible for the shared edge cache (see `EdgeCache.middleware`), // so `noStore` responses are never persisted there. The edge layer reads this // after the whole chain runs, so setting it here (post-handler) is in time. const cacheControl = (c.get('cacheControl' as never) as string | undefined) ?? defaultCacheControl c.set('edgeCache' as never, edgeCachePolicy(cacheControl) as never) let res = result instanceof Response ? new Response(result.body, result) : c.res if ( result instanceof Response && Etag.matches(res.headers.get('ETag'), c.req.header('If-None-Match')) ) res = notModified(res) // Apply private client cache headers after Hono cache storage; the stored // copy carries the shared `public, s-maxage` form stamped above, while the // client receives the policy's private form. Error responses keep their // own `Cache-Control` (`no-store` from `Response.error` or the app-level // safety net) instead of inheriting the route policy. if ((res.status >= 200 && res.status < 300) || res.status === 304) setCacheHeaders(res.headers, { cacheControl, vary }) for (const [name, value] of values) if (value) res.headers.set(name, value) if (result instanceof Response) return res return undefined } // `markCacheable` lets the edge layer pay its store lookup only on routes // that mount this middleware; everything else skips the read. return EdgeCache.markCacheable(middleware, { key: options.key as (c: Context) => Promise | string, }) } export declare namespace response { /** Options for response caching. */ type Options = { /** Cache-control value returned to clients. */ cacheControl?: string | undefined /** Cache name passed to Hono's cache middleware. */ name: string /** Cache key generator. */ key: (c: Context) => Promise | string /** Vary headers used for the response and cache key. */ vary?: readonly string[] | undefined } } export namespace response { /** Per-request headers that must not be stored in shared response caches. */ export const preservedHeaders = ['RateLimit-Limit', 'RateLimit-Remaining', 'RateLimit-Reset'] } /** * Builds a cache key from the request URL after parsing query params through * the given Zod schema. Defaults from the schema are baked into the key so * cached entries collide regardless of param ordering, casing, or omitted * defaults. Resolves an optional `chainId` query parameter against the app's * default chain id when the caller did not provide one. */ export function urlKey< environment extends { Variables: { chainId: number } }, schema extends z.ZodMiniType, >(c: Context, schema: schema): string { const url = new URL(c.req.url) const query = schema.parse( Object.fromEntries( Object.entries(c.req.queries()).map(([key, values]) => [ key, values.length === 1 ? values[0] : values, ]), ), ) as Record url.search = '' if (query['chainId'] === undefined) url.searchParams.set('chainId', String(c.get('chainId'))) for (const [key, value] of Object.entries(query)) { if (value === undefined) continue if (Array.isArray(value)) { for (const item of value) url.searchParams.append(key, String(item as number | string | boolean)) continue } url.searchParams.set(key, String(value as number | string | boolean)) } return url.toString() } namespace Etag { export function matches(etag: string | null, ifNoneMatch: string | undefined) { if (!etag || !ifNoneMatch) return false return ifNoneMatch.split(/,\s*/).some((tag) => stripWeak(tag) === stripWeak(etag)) } function stripWeak(etag: string) { return etag.replace(/^W\//, '') } } function notModified(response: Response) { const headers = new Headers() for (const name of RETAINED_304_HEADERS) { const value = response.headers.get(name) if (value) headers.set(name, value) } return new Response(null, { headers, status: 304, statusText: 'Not Modified', }) } function setCacheHeaders( headers: Headers, options: { cacheControl: string; vary: readonly string[] }, ) { headers.set('Cache-Control', options.cacheControl) const vary = new Map( (headers.get('Vary') ?? '') .split(',') .map((value) => value.trim()) .filter(Boolean) .map((value) => [value.toLowerCase(), value] as const), ) for (const value of options.vary) vary.set(value.toLowerCase(), value) headers.set('Vary', Array.from(vary.values()).join(', ')) } // Derive the shared-cache policy from a route's client `Cache-Control`. A // positive `s-maxage`/`max-age` (and no `no-store`/`no-cache`) is what makes a // route eligible for the anonymous edge cache; the `private` directive only // scopes the client copy and is intentionally ignored here. function edgeCachePolicy(cacheControl: string): EdgeCache.Policy | undefined { const directives = new Map( cacheControl .split(',') .map((part) => part.trim().toLowerCase()) .filter(Boolean) .map((part) => part.split('=') as [string, string | undefined]), ) if (directives.has('no-store') || directives.has('no-cache')) return undefined const maxAge = Number(directives.get('s-maxage') ?? directives.get('max-age')) if (!Number.isFinite(maxAge) || maxAge <= 0) return undefined const staleWhileRevalidate = Number(directives.get('stale-while-revalidate')) return { maxAge, staleWhileRevalidate: Number.isFinite(staleWhileRevalidate) && staleWhileRevalidate > 0 ? staleWhileRevalidate : undefined, } }