import { Hono } from 'hono' import * as z from 'zod/mini' import * as Cache from './Cache.js' describe('urlKey', () => { test('serializes parsed arrays as repeated query parameters', async () => { const schema = z.object({ include: z.array(z.string()) }) const app = new Hono<{ Variables: { chainId: number } }>() app.use('*', async (c, next) => { c.set('chainId', 4217) await next() }) app.get('/', (c) => c.text(Cache.urlKey(c, schema))) const left = await (await app.request('/?include=a%2Cb&include=c')).text() const right = await (await app.request('/?include=a&include=b%2Cc')).text() expect(left).toMatchInlineSnapshot(`"http://localhost/?chainId=4217&include=a%2Cb&include=c"`) expect(right).toMatchInlineSnapshot(`"http://localhost/?chainId=4217&include=a&include=b%2Cc"`) }) }) describe('response', () => { test('preserves rate-limit headers without caching them', async () => { await withCaches(async () => { let calls = 0 const app = new Hono() app.use('*', async (c, next) => { c.header('RateLimit-Remaining', calls === 0 ? '9' : '8') await next() }) app.get('/', Cache.response({ key: (c) => c.req.url, name: 'test' }), (c) => c.json({ calls: ++calls }), ) const first = await app.request('/') const second = await app.request('/') const third = await app.request('/', { headers: { 'If-None-Match': first.headers.get('etag') ?? '' }, }) expect(calls).toMatchInlineSnapshot(`1`) expect(await first.json()).toMatchInlineSnapshot(` { "calls": 1, } `) expect(first.headers.get('cache-control')).toMatchInlineSnapshot( `"private, max-age=30, stale-while-revalidate=300"`, ) expect(etag(first)).toMatchInlineSnapshot(`""`) expect(first.headers.get('ratelimit-remaining')).toMatchInlineSnapshot(`"9"`) expect(first.headers.get('vary')).toMatchInlineSnapshot( `"Accept-Encoding, Authorization, Tempo-API-Key, X-API-Key"`, ) expect(await second.json()).toMatchInlineSnapshot(` { "calls": 1, } `) expect(second.headers.get('cache-control')).toMatchInlineSnapshot( `"private, max-age=30, stale-while-revalidate=300"`, ) expect(second.headers.get('etag') === first.headers.get('etag')).toMatchInlineSnapshot(`true`) expect(second.headers.get('ratelimit-remaining')).toMatchInlineSnapshot(`"8"`) expect(second.headers.get('vary')).toMatchInlineSnapshot( `"Accept-Encoding, Authorization, Tempo-API-Key, X-API-Key"`, ) expect(third.status).toMatchInlineSnapshot(`304`) expect(await third.text()).toMatchInlineSnapshot(`""`) expect(third.headers.get('cache-control')).toMatchInlineSnapshot( `"private, max-age=30, stale-while-revalidate=300"`, ) expect(third.headers.get('etag') === first.headers.get('etag')).toMatchInlineSnapshot(`true`) expect(third.headers.get('ratelimit-remaining')).toMatchInlineSnapshot(`"8"`) expect(third.headers.get('vary')).toMatchInlineSnapshot( `"Accept-Encoding, Authorization, Tempo-API-Key, X-API-Key"`, ) }) }) test('varies cached responses by the legacy API-key header', async () => { await withCaches(async () => { let calls = 0 const app = new Hono() app.get('/', Cache.response({ key: (c) => c.req.url, name: 'test' }), (c) => c.json({ calls: ++calls, token: c.req.header('x-api-key') }), ) const first = await app.request('/', { headers: { 'x-api-key': 'one' } }) const second = await app.request('/', { headers: { 'x-api-key': 'two' } }) const hit = await app.request('/', { headers: { 'x-api-key': 'one' } }) expect(calls).toMatchInlineSnapshot(`2`) expect(await first.json()).toMatchInlineSnapshot(` { "calls": 1, "token": "one", } `) expect(await second.json()).toMatchInlineSnapshot(` { "calls": 2, "token": "two", } `) expect(await hit.json()).toMatchInlineSnapshot(` { "calls": 1, "token": "one", } `) }) }) test('stores entries with the shared policy form, not the client form', async () => { await withCaches(async (stores) => { const app = new Hono() app.get('/', Cache.response({ key: (c) => c.req.url, name: 'test' }), (c) => c.json({ ok: true }), ) await app.request('/') const [stored] = [...(stores.get('test')?.values() ?? [])] // The stored copy carries `public, s-maxage` so platform caches bound // the entry's lifetime to the policy; the client copy stays private. expect(stored?.headers.get('cache-control')).toMatchInlineSnapshot( `"public, max-age=30, s-maxage=30, stale-while-revalidate=300"`, ) }) }) test('refreshes the stored entry when the request sends `Cache-Control: no-cache`', async () => { await withCaches(async () => { let calls = 0 const app = new Hono() app.get('/', Cache.response({ key: (c) => c.req.url, name: 'test' }), (c) => c.json({ calls: ++calls }), ) await app.request('/') // `no-cache` purges the stored entry, re-runs the handler, and stores // the fresh response, so the following plain request is served the // refreshed copy. const refreshed = await app.request('/', { headers: { 'cache-control': 'no-cache' } }) const after = await app.request('/') expect(calls).toMatchInlineSnapshot(`2`) expect(await refreshed.json()).toMatchInlineSnapshot(` { "calls": 2, } `) expect(await after.json()).toMatchInlineSnapshot(` { "calls": 2, } `) }) }) test('bypasses without purging when the request sends `Cache-Control: no-store`', async () => { await withCaches(async () => { let calls = 0 const app = new Hono() app.get('/', Cache.response({ key: (c) => c.req.url, name: 'test' }), (c) => c.json({ calls: ++calls }), ) await app.request('/') // `no-store` skips reads and writes but leaves the stored entry alone: // the bypassing caller gets a fresh response while the next plain // request is still served the original copy. const bypassed = await app.request('/', { headers: { 'cache-control': 'no-store' } }) const after = await app.request('/') expect(calls).toMatchInlineSnapshot(`2`) expect(await bypassed.json()).toMatchInlineSnapshot(` { "calls": 2, } `) expect(await after.json()).toMatchInlineSnapshot(` { "calls": 1, } `) }) }) test('honors `Pragma: no-cache` when `Cache-Control` is absent', async () => { await withCaches(async () => { let calls = 0 const app = new Hono() app.get('/', Cache.response({ key: (c) => c.req.url, name: 'test' }), (c) => c.json({ calls: ++calls }), ) await app.request('/') const refreshed = await app.request('/', { headers: { pragma: 'no-cache' } }) expect(calls).toMatchInlineSnapshot(`2`) expect(await refreshed.json()).toMatchInlineSnapshot(` { "calls": 2, } `) }) }) test('does not store responses a handler overrides to `noStore`', async () => { await withCaches(async (stores) => { let calls = 0 const app = new Hono() app.get('/', Cache.response({ key: (c) => c.req.url, name: 'test' }), (c) => { Cache.setPolicy(c, Cache.policies.noStore) return c.json({ calls: ++calls }) }) const first = await app.request('/') const second = await app.request('/') // The `noStore` override must keep the response out of the inner cache // (e.g. a pending transaction), so every request re-runs the handler. expect(calls).toMatchInlineSnapshot(`2`) expect(stores.get('test')?.size ?? 0).toMatchInlineSnapshot(`0`) expect(first.headers.get('cache-control')).toMatchInlineSnapshot(`"no-store"`) expect(second.headers.get('cache-control')).toMatchInlineSnapshot(`"no-store"`) }) }) test('leaves error response `Cache-Control` alone', async () => { await withCaches(async (stores) => { const app = new Hono() app.get('/', Cache.response({ key: (c) => c.req.url, name: 'test' }), (c) => { // Mirrors `Response.error`, which marks envelopes `no-store`. c.header('Cache-Control', 'no-store') return c.json({ error: true }, 404) }) const first = await app.request('/') const second = await app.request('/') expect(first.status).toMatchInlineSnapshot(`404`) expect(first.headers.get('cache-control')).toMatchInlineSnapshot(`"no-store"`) expect(second.headers.get('cache-control')).toMatchInlineSnapshot(`"no-store"`) expect(stores.get('test')?.size ?? 0).toMatchInlineSnapshot(`0`) }) }) }) function etag(response: Response) { return response.headers.has('etag') ? '' : null } async function withCaches(fn: (stores: Map>) => Promise) { const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'caches') const stores = new Map>() Object.defineProperty(globalThis, 'caches', { configurable: true, value: { async open(name: string) { let store = stores.get(name) if (!store) { store = new Map() stores.set(name, store) } return { async delete(key: string) { return store.delete(key) }, async match(key: string) { return store.get(key)?.clone() }, async put(key: string, response: Response) { store.set(key, response.clone()) }, } }, }, }) try { await fn(stores) } finally { if (descriptor) Object.defineProperty(globalThis, 'caches', descriptor) else delete (globalThis as { caches?: unknown }).caches } }