SyncValsverifier → artifact → classifier → verdict
SyncVals · Trial · trial_0ef53d1d0b3a424f

claude-code / claude-opus-5

✗ failed task: hono-http-cache-rfc9111 GOOD_FAILURE genuine
steps84
cost$10.15
duration30m 43s
Solved from the instruction alone, tests/ and solution/ were withheld from the agent's workspace and restored only for grading.
Reward = tests/test.sh exit code (0 → resolved); the classification below is post-hoc and cannot change it.
Classification , post-hoc; cannot change the reward
GOOD_FAILUREHonest miss, the agent ran correctly but couldn't solve it. Expected for a hard task; the task is sound.
SubtypeWrong Approach
EvidenceOffline static classifier runner used; no model call was made.
Root causeLocal verifier result was used only to choose a safe default classification.
Trajectory
Tool-by-tool agent trajectory
77 tool calls · 4 tool types · 84 steps
Hono's built-in `cache` middleware only delegates to the platform Web Cache API. Implement a self-contained RFC 9111 shared cache as a new middleware at `src/middleware/http-cache/index.ts` exporting `httpCache(options?: HttpCacheOptions)`, `HttpCacheOptions = { store?: HttpCacheStore; now?: () => number }`, and `HttpCacheStore = { get(key: string): unknown | undefined; set(key: string, value: unknown): unknown; delete(key: string): boolean }` (a plain `Map` qualifies). `store` defaults to a fresh `Map` per instance; `now` returns epoch milliseconds, defaults to `Date.now`, and is the only clock the middleware may ever read. Wire `hono/http-cache` into `package.json` (`exports`, `typesVersions`) and `jsr.json` exactly like the sibling `hono/etag` entries. The downstream handler is the origin: a request satisfied from the store must not invoke it, and every other request invokes it exactly once. Only GET responses are stored , one store key per target URI, exactly `c.req.url`, holding all its Vary-selected variants; HEAD may be answered from a stored GET response but never stores; unsafe methods pass through and invalidate per RFC 9111 §4.4, including same-origin Location and Content-Location targets. Everything else follows RFC 9111 for a shared cache: storage eligibility (§3, with the Authorization exceptions), freshness and the full Age algorithm (§4.2), Vary selection (§4.1), validation using conditional requests the cache constructs from stored validators (visible to the handler on `c.req`), 304 freshening and header update (§4.3, §3.2), and both directive sets (§5) , plus RFC 9110 §13.2 precondition evaluation, in its normative order, whenever a stored response is used (a generated 304 carries the §15.4.5 header set and Age), and RFC 5861 `stale-if-error`, where an error is a 500/502/503/504 from the handler. Excluded: `stale-while-revalidate`, Range and 206 responses, `Pragma`, `Warning`, trailers, and `immutable`. Where the RFCs leave latitude, pin: request cache directives are always honored; qualified `private`/`no-cache` store or serve with the named fields stripped; heuristic freshness is 10% of Date minus Last-Modified, zero without Last-Modified; duplicate freshness directives , duplicate Expires header lines included , take the most restrictive value and invalid ones mean stale; Vary matching compares values after trimming and collapsing internal optional whitespace, absent matching only absent; among several matching variants the most recent Date wins. IMPORTANT: Please work on this in a new branch from main and commit everything when you are done.
I'll start by exploring the repo structure to understand conventions.

List repo root and read package.json

shell
ls && cat package.json | head -80
output
LICENSE
README.md
benchmarks
build
bun.lock
bunfig.toml
codecov.yml
docs
eslint.config.mjs
jsr.json
node_modules
package.cjs.json
package.json
perf-measures
runtime-tests
src
tsconfig.base.json
tsconfig.build.json
tsconfig.json
tsconfig.spec.json
vitest.config.ts
{
  "name": "hono",
  "version": "4.13.2",
  "description": "Web framework built on Web Standards",
  "main": "dist/cjs/index.js",
  "type": "module",
  "module": "dist/index.js",
  "types": "dist/types/index.d.ts",
  "files": [
    "dist",
    "!dist/**/*.tsbuildinfo"
  ],
  "scripts": {
    "test": "tsc -p tsconfig.spec.json && vitest --run",
    "test:watch": "vitest --watch",
    "test:deno": "deno test --allow-read --allow-env --allow-write --allow-net -c runtime-tests/deno/deno.json runtime-tests/deno && deno test --no-lock -c runtime-tests/deno-jsx/deno.precompile.json runtime-tests/deno-jsx && deno test --no-lock -c runtime-tests/deno-jsx/deno.react-jsx.json runtime-tests/deno-jsx",
    "test:bun": "bun test --jsx-import-source ../../src/jsx runtime-tests/bun/*",
    "test:fastly": "vitest --run --project fastly",
    "test:node": "vitest --run --project node",
    "test:workerd": "vitest --run --project workerd",
    "test:lambda": "vitest --run --project lambda",
    "test:lambda-edge": "vitest --run --project lambda-edge",
    "test:all": "bun run test && bun test:deno && bun test:bun",
    "lint": "eslint src runtime-tests build perf-measures benchmarks",
    "lint:fix": "eslint src runtime-tests build perf-measures benchmarks --fix",
    "format": "prettier --check --cache \"src/**/*.{js,ts,tsx}\" \"runtime-tests/**/*.{js,ts,tsx}\" \"build/**/*.{js,ts,tsx}\" \"perf-measures/**/*.{js,ts,tsx}\" \"benchmarks/**/*.{js,ts,tsx}\"",
    "format:fix": "prettier --write --cache --cache-strategy metadata \"src/**/*.{js,ts,tsx}\" \"runtime-tests/**/*.{js,ts,tsx}\" \"build/**/*.{js,ts,tsx}\" \"perf-measures/**/*.{js,ts,tsx}\" \"benchmarks/**/*.{js,ts,tsx}\"",
    "editorconfig-checker": "editorconfig-checker",
    "copy:package.cjs.json": "cp ./package.cjs.json ./dist/cjs/package.json && cp ./package.cjs.json ./dist/types/package.json",
    "build": "bun run --shell bun remove-dist && bun ./build/build.ts && bun run copy:package.cjs.json",
    "postbuild": "publint",
    "watch": "bun run --shell bun remove-dist && bun ./build/build.ts --watch && bun run copy:package.cjs.json",
    "coverage": "vitest --run --coverage",
    "prerelease": "bun test:deno && bun run build",
    "release": "np --no-publish",
    "remove-dist": "rm -rf dist"
  },
  "exports": {
    ".": {
      "types": "./dist/types/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/cjs/index.js"
    },
    "./request": {
      "types": "./dist/types/request.d.ts",
      "import": "./dist/request.js",
      "require": "./dist/cjs/request.js"
    },
    "./types": {
      "types": "./dist/types/types.d.ts",
      "import": "./dist/types.js",
      "require": "./dist/cjs/types.js"
    },
    "./hono-base": {
      "types": "./dist/types/hono-base.d.ts",
      "import": "./dist/hono-base.js",
      "require": "./dist/cjs/hono-base.js"
    },
    "./tiny": {
      "types": "./dist/types/preset/tiny.d.ts",
      "import": "./dist/preset/tiny.js",
      "require": "./dist/cjs/preset/tiny.js"
    },
    "./quick": {
      "types": "./dist/types/preset/quick.d.ts",
      "import": "./dist/preset/quick.js",
      "require": "./dist/cjs/preset/quick.js"
    },
    "./http-exception": {
      "types": "./dist/types/http-exception.d.ts",
      "import": "./dist/http-exception.js",
      "require": "./dist/cjs/http-exception.js"
    },
    "./basic-auth": {
      "types": "./dist/types/middleware/basic-auth/index.d.ts",
      "import": "./dist/middleware/basic-auth/index.js",
      "require": "./dist/cjs/middleware/basic-auth/index.js"
    },
    "./bearer-auth": {
      "types": "./dist/types/middleware/bearer-auth/index.d.ts",

[stdout]
LICENSE
README.md
benchmarks
build
bun.lock
bunfig.toml
codecov.yml
docs
eslint.config.mjs
jsr.json
node_modules
package.cjs.json
package.json
perf-measures
runtime-tests
src
tsconfig.base.json
tsconfig.build.json
tsconfig.json
tsconfig.spec.json
vitest.config.ts
{
  "name": "hono",
  "version": "4.13.2",
  "description": "Web framework built on Web Standards",
  "main": "dist/cjs/index.js",
  "type": "module",
  "module": "dist/index.js",
  "types": "dist/types/index.d.ts",
  "files": [
    "dist",
    "!dist/**/*.tsbuildinfo"
  ],
  "scripts": {
    "test": "tsc -p tsconfig.spec.json && vitest --run",
    "test:watch": "vitest --watch",
    "test:deno": "deno test --allow-read --allow-env --allow-write --allow-net -c runtime-tests/deno/deno.json runtime-tests/deno && deno test --no-lock -c runtime-tests/deno-jsx/deno.precompile.json runtime-tests/deno-jsx && deno test --no-lock -c runtime-tests/deno-jsx/deno.react-jsx.json runtime-tests/deno-jsx",
    "test:bun": "bun test --jsx-import-source ../../src/jsx runtime-tests/bun/*",
    "test:fastly": "vitest --run --project fastly",
    "test:node": "vitest --run --project node",
    "test:workerd": "vitest --run --project workerd",
    "test:lambda": "vitest --run --project lambda",
    "test:lambda-edge": "vitest --run --project lambda-edge",
    "test:all": "bun run test && bun test:deno && bun test:bun",
    "lint": "eslint src runtime-tests build perf-measures benchmarks",
    "lint:fix": "eslint src runtime-tests build perf-measures benchmarks --fix",
    "format": "prettier --check --cache \"src/**/*.{js,ts,tsx}\" \"runtime-tests/**/*.{js,ts,tsx}\" \"build/**/*.{js,ts,tsx}\" \"perf-measures/**/*.{js,ts,tsx}\" \"benchmarks/**/*.{js,ts,tsx}\"",
    "format:fix": "prettier --write --cache --cache-strategy metadata \"src/**/*.{js,ts,tsx}\" \"runtime-tests/**/*.{js,ts,tsx}\" \"build/**/*.{js,ts,tsx}\" \"perf-measures/**/*.{js,ts,tsx}\" \"benchmarks/**/*.{js,ts,tsx}\"",
    "editorconfig-checker": "editorconfig-checker",
    "copy:package.cjs.json": "cp ./package.cjs.json ./dist/cjs/package.json && cp ./package.cjs.json ./dist/types/package.json",
    "build": "bun run --shell bun remove-dist && bun ./build/build.ts && bun run copy:package.cjs.json",
    "postbuild": "publint",
    "watch": "bun run --shell bun remove-dist && bun ./build/build.ts --watch && bun run copy:package.cjs.json",
    "coverage": "vitest --run --coverage",
    "prerelease": "bun test:deno && bun run build",
    "release": "np --no-publish",
    "remove-dist": "rm -rf dist"
  },
  "exports": {
    ".": {
      "types": "./dist/types/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/cjs/index.js"
    },
    "./request": {
      "types": "./dist/types/request.d.ts",
      "import": "./dist/request.js",
      "require": "./dist/cjs/request.js"
    },
    "./types": {
      "types": "./dist/types/types.d.ts",
      "import": "./dist/types.js",
      "require": "./dist/cjs/types.js"
    },
    "./hono-base": {
      "types": "./dist/types/hono-base.d.ts",
      "import": "./dist/hono-base.js",
      "require": "./dist/cjs/hono-base.js"
    },
    "./tiny": {
      "types": "./dist/types/preset/tiny.d.ts",
      "import": "./dist/preset/tiny.js",
      "require": "./dist/cjs/preset/tiny.js"
    },
    "./quick": {
      "types": "./dist/types/preset/quick.d.ts",
      "import": "./dist/preset/quick.js",
      "require": "./dist/cjs/preset/quick.js"
    },
    "./http-exception": {
      "types": "./dist/types/http-exception.d.ts",
      "import": "./dist/http-exception.js",
      "require": "./dist/cjs/http-exception.js"
    },
    "./basic-auth": {
      "types": "./dist/types/middleware/basic-auth/index.d.ts",
      "import": "./dist/middleware/basic-auth/index.js",
      "require": "./dist/cjs/middleware/basic-auth/index.js"
    },
    "./bearer-auth": {
      "types": "./dist/types/middleware/bearer-auth/index.d.ts",

List middleware dirs

shell
ls src/middleware/ && ls src/middleware/etag/ && ls src/middleware/cache/
output
basic-auth
bearer-auth
body-limit
cache
combine
compress
context-storage
cors
csrf
etag
ip-restriction
jsx-renderer
jwk
jwt
language
logger
method-not-allowed
method-override
powered-by
pretty-json
request-id
secure-headers
serve-static
timeout
timing
trailing-slash
digest.ts
index.test.ts
index.ts
index.test.ts
index.ts

[stdout]
basic-auth
bearer-auth
body-limit
cache
combine
compress
context-storage
cors
csrf
etag
ip-restriction
jsx-renderer
jwk
jwt
language
logger
method-not-allowed
method-override
powered-by
pretty-json
request-id
secure-headers
serve-static
timeout
timing
trailing-slash
digest.ts
index.test.ts
index.ts
index.test.ts
index.ts

/app/src/middleware/cache/index.ts

contents
1	/**
2	 * @module
3	 * Cache Middleware for Hono.
4	 */
5	
6	import type { Context } from '../../context'
7	import { cloneRawRequest } from '../../request'
8	import type { MiddlewareHandler } from '../../types'
9	import { sha256 } from '../../utils/crypto'
10	import type { StatusCode } from '../../utils/http-status'
11	
12	/**
13	 * status codes that can be cached by default.
14	 */
15	const defaultCacheableStatusCodes: ReadonlyArray<StatusCode> = [200]
16	
17	const defaultMaxQueryBodySize = 64 * 1024
18	
19	const cacheKeyPath = '/.hono/cache'
20	const cacheKeyParameter = '__hono_cache_key'
21	const cacheMethodKeyParameter = '__hono_cache_method'
22	const queryDigestKeyParameter = '__hono_query_digest'
23	const cacheVaryKeyParameter = '__hono_cache_vary'
24	
25	type CacheKeyRequest =
26	  | { method: 'GET' }
27	  | {
28	      method: 'QUERY'
29	      digest: string
30	    }
31	
32	const queryRepresentationMetadataHeaders = [
33	  'content-type',
34	  'content-encoding',
35	  'content-language',
36	  'content-location',
37	] as const
38	
39	const shouldSkipCacheControl = (cacheControl: string | null): boolean =>
40	  !!cacheControl && /(?:^|,\s*)(?:private|no-(?:store|cache))(?:\s*(?:=|,|$))/i.test(cacheControl)
41	
42	const parseVaryDirectives = (vary: string | string[] | null | undefined): string[] => {
43	  if (vary == null) {
44	    return []
45	  }
46	  return (Array.isArray(vary) ? vary : vary.split(','))
47	    .map((directive) => directive.trim().toLowerCase())
48	    .filter(Boolean)
49	}
50	
51	const createCacheKey = (
52	  key: string,
53	  requestUrl: string,
54	  request: CacheKeyRequest,
55	  varyHeaders: [string, string][]
56	): string => {
57	  const url = new URL(cacheKeyPath, requestUrl)
58	  url.searchParams.append(cacheKeyParameter, key.split('#', 1)[0])
59	  url.searchParams.append(cacheMethodKeyParameter, request.method)
60	
61	  if (request.method === 'QUERY') {
62	    url.searchParams.append(queryDigestKeyParameter, request.digest)
63	  }
64	
65	  for (const header of varyHeaders) {
66	    url.searchParams.append(cacheVaryKeyParameter, JSON.stringify(header))
67	  }
68	
69	  return url.href
70	}
71	
72	const shouldSkipCache = (
73	  res: Response,
74	  optionsVaryDirectives: Set<string> | undefined,
75	  responseVary: string[]
76	): boolean =>
77	  (responseVary.length &&
78	    (!optionsVaryDirectives || responseVary.some((name) => !optionsVaryDirectives.has(name)))) ||
79	  shouldSkipCacheControl(res.headers.get('Cache-Control')) ||
80	  res.headers.has('Set-Cookie')
81	
82	const reportCacheNotAvailable = (
83	  onCacheNotAvailable: ((reason: string) => void) | false | undefined,
84	  reason: string
85	): void => {
86	  if (onCacheNotAvailable === false) {
87	    // suppress log
88	  } else if (onCacheNotAvailable) {
89	    onCacheNotAvailable(reason)
90	  } else {
91	    console.log(reason)
92	  }
93	}
94	
95	const createQueryDigest = async (
96	  c: Context,
97	  maxQueryBodySize: number
98	): Promise<string | undefined> => {
99	  if (!globalThis.crypto?.subtle) {
100	    return undefined
101	  }
102	
103	  if (c.req.raw.bodyUsed && Object.keys(c.req.bodyCache)[0] === 'formData') {
104	    // FormData cannot be reserialized with a stable multipart boundary after
105	    // the original request body has been consumed.
106	    return undefined
107	  }
108	
109	  try {
110	    // RFC 10008 Section 2.7 requires QUERY cache keys to incorporate the
111	    // request content and its related representation metadata.
112	    const requestHeaders = c.req.raw.headers
113	    const metadata = new TextEncoder().encode(
114	      JSON.stringify(
115	        queryRepresentationMetadataHeaders.map((header) => [header, requestHeaders.get(header)])
116	      )
117	    )
118	    const body = (await cloneRawRequest(c.req)).body
119	    const chunks: Uint8Array[] = []
120	    let bodySize = 0
121	
122	    if (body) {
123	      const reader = body.getReader()
124	      for (;;) {
125	        const { done, value } = await reader.read()
126	        if (done) {
127	          break
128	        }
129	        bodySize += value.byteLength
130	        if (bodySize > maxQueryBodySize) {
131	          // Do not await cancellation because a cloned stream's cancellation
132	          // can wait for the original request stream to finish.
133	          void reader.cancel().catch(() => {})
134	          return undefined
135	        }
136	        chunks.push(value)
137	      }
138	    }
139	
140	    const data = new Uint8Array(metadata.byteLength + bodySize)
141	    data.set(metadata)
142	    let offset = metadata.byteLength
143	    for (const chunk of chunks) {
144	      data.set(chunk, offset)
145	      offset += chunk.byteLength
146	    }
147	
148	    return (await sha256(data)) ?? undefined
149	  } catch {
150	    // A QUERY response cannot be cached safely if its content cannot be read.
151	    return undefined
152	  }
153	}
154	
155	/**
156	 * Cache Middleware for Hono.
157	 *
158	 * @see {@link https://hono.dev/docs/middleware/builtin/cache}
159	 *
160	 * @param {Object} options - The options for the cache middleware.
161	 * @param {string | Function} options.cacheName - The name of the cache. Can be used to store multiple caches with different identifiers.
162	 * @param {boolean} [options.wait=false] - A boolean indicating if Hono should wait for the Promise of the `cache.put` function to resolve before continuing with the request. Required to be true for the Deno environment.
163	 * @param {string} [options.cacheControl] - A string of directives for the `Cache-Control` header.
164	 * @param {string | string[]} [options.vary] - Adds the configured request headers to the cache key variants and sets the `Vary` header in the response. If the original response header already contains a `Vary` header, the values are merged, removing any duplicates.
165	 * @param {Function} [options.keyGenerator] - Generates keys for every request in the `cacheName` store. This can be used to cache data based on request parameters or context parameters. QUERY keys additionally include a digest of the request content and its representation metadata.
166	 * @param {number} [options.maxQueryBodySize=65536] - The maximum QUERY request body size in bytes that can be cached. Larger QUERY requests bypass the cache.
167	 * @param {number[]} [options.cacheableStatusCodes=[200]] - An array of status codes that can be cached.
168	 * @param {Function | false} [options.onCacheNotAvailable] - A callback invoked with the reason when `globalThis.caches` is not available or QUERY caching cannot use Web Crypto. By default, the reason is logged to the console. Set to `false` to suppress the log, or provide a custom function.
169	 * @returns {MiddlewareHandler} The middleware handler function.
170	 * @throws {Error} If the `vary` option includes "*".
171	 *
172	 * @example
173	 * ```ts
174	 * app.use(
175	 *   '*',
176	 *   cache({
177	 *     cacheName: 'my-app',
178	 *     cacheControl: 'max-age=3600',
179	 *   })
180	 * )
181	 * ```
182	 */
183	export const cache = (options: {
184	  cacheName: string | ((c: Context) => Promise<string> | string)
185	  wait?: boolean
186	  cacheControl?: string
187	  vary?: string | string[]
188	  keyGenerator?: (c: Context) => Promise<string> | string
189	  maxQueryBodySize?: number
190	  cacheableStatusCodes?: StatusCode[]
191	  onCacheNotAvailable?: ((reason: string) => void) | false
192	}): MiddlewareHandler => {
193	  if (!globalThis.caches) {
194	    reportCacheNotAvailable(
195	      options.onCacheNotAvailable,
196	      'Cache Middleware is not enabled because caches is not defined.'
197	    )
198	    return async (_c, next) => await next()
199	  }
200	
201	  if (!globalThis.crypto?.subtle) {
202	    reportCacheNotAvailable(
203	      options.onCacheNotAvailable,
204	      'Cache Middleware cannot cache QUERY requests because Web Crypto is not available.'
205	    )
206	  }
207	
208	  if (options.wait === undefined) {
209	    options.wait = false
210	  }
211	
212	  const cacheControlDirectives = options.cacheControl
213	    ?.split(',')
214	    .map((directive) => directive.toLowerCase())
215	  const optionsVaryList = parseVaryDirectives(options.vary)
216	  const varyDirectives = optionsVaryList.length ? new Set(optionsVaryList) : undefined
217	  // RFC 7231 Section 7.1.4 specifies that "*" is not allowed in Vary header.
218	  // See: https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.4
219	  if (varyDirectives?.has('*')) {
220	    throw new Error(
221	      'Middleware vary configuration cannot include "*", as it disallows effective caching.'
222	    )
223	  }
224	
225	  const cacheableStatusCodes = new Set<number>(
226	    options.cacheableStatusCodes ?? defaultCacheableStatusCodes
227	  )
228	  const maxQueryBodySize = options.maxQueryBodySize ?? defaultMaxQueryBodySize
229	
230	  const addHeader = (c: Context, responseVary: string[]) => {
231	    if (cacheControlDirectives) {
232	      const existingDirectives =
233	        c.res.headers
234	          .get('Cache-Control')
235	          ?.split(',')
236	          // Directive names are case-insensitive (RFC 7234 §5.2); lower-case so
237	          // the case-insensitive de-dup check below matches handler-set names
238	          // like `Max-Age`.
239	          .map((d) => d.trim().split('=', 1)[0].toLowerCase()) ?? []
240	      for (const directive of cacheControlDirectives) {
241	        let [name, value] = directive.trim().split('=', 2)
242	        name = name.toLowerCase()
243	        if (!existingDirectives.includes(name)) {
244	          c.header('Cache-Control', `${name}${value ? `=${value}` : ''}`, { append: true })
245	        }
246	      }
247	    }
248	
249	    if (varyDirectives) {
250	      if (responseVary.length === 0) {
251	        c.header('Vary', Array.from(varyDirectives).join(', '))
252	      } else {
253	        const merged = new Set(varyDirectives)
254	        for (const directive of responseVary) {
255	          merged.add(directive)
256	        }
257	        if (merged.has('*')) {
258	          c.header('Vary', '*')
259	        } else {
260	          c.header('Vary', Array.from(merged).join(', '))
261	        }
262	      }
263	    }
264	  }
265	
266	  return async function cache(c, next) {
267	    if (
268	      (c.req.method !== 'GET' && c.req.method !== 'QUERY') ||
269	      c.req.raw.headers.has('Authorization')
270	    ) {
271	      await next()
272	      return
273	    }
274	
275	    let cacheKeyRequest: CacheKeyRequest = { method: 'GET' }
276	    if (c.req.method === 'QUERY') {
277	      const digest = await createQueryDigest(c, maxQueryBodySize)
278	      if (digest === undefined) {
279	        await next()
280	        return
281	      }
282	      cacheKeyRequest = { method: 'QUERY', digest }
283	    }
284	
285	    let key = c.req.url
286	    if (options.keyGenerator) {
287	      key = await options.keyGenerator(c)
288	    }
289	    const varyHeaders: [string, string][] = []
290	    if (varyDirectives) {
291	      for (const directive of varyDirectives) {
292	        const value = c.req.raw.headers.get(directive) ?? ''
293	        varyHeaders.push([directive, value])
294	      }
295	    }
296	    key = createCacheKey(key, c.req.url, cacheKeyRequest, varyHeaders)
297	
298	    const cacheName =
299	      typeof options.cacheName === 'function' ? await options.cacheName(c) : options.cacheName
300	    const cache = await caches.open(cacheName)
301	    const response = await cache.match(key)
302	    if (response) {
303	      return new Response(response.body, response)
304	    }
305	
306	    await next()
307	    if (!cacheableStatusCodes.has(c.res.status)) {
308	      return
309	    }
310	    const responseVary = parseVaryDirectives(c.res.headers.get('Vary'))
311	    addHeader(c, responseVary)
312	
313	    if (shouldSkipCache(c.res, varyDirectives, responseVary)) {
314	      return
315	    }
316	
317	    const res = c.res.clone()
318	    if (options.wait) {
319	      await cache.put(key, res)
320	    } else {
321	      c.executionCtx.waitUntil(cache.put(key, res))
322	    }
323	  }
324	}
325

Find etag entries in package.json and jsr.json

shell
grep -n "etag" /app/package.json /app/jsr.json
output
/app/jsr.json:28:    "./etag": "./src/middleware/etag/index.ts",
/app/package.json:134:    "./etag": {
/app/package.json:135:      "types": "./dist/types/middleware/etag/index.d.ts",
/app/package.json:136:      "import": "./dist/middleware/etag/index.js",
/app/package.json:137:      "require": "./dist/cjs/middleware/etag/index.js"
/app/package.json:476:      "etag": [
/app/package.json:477:        "./dist/types/middleware/etag"

[stdout]
/app/jsr.json:28:    "./etag": "./src/middleware/etag/index.ts",
/app/package.json:134:    "./etag": {
/app/package.json:135:      "types": "./dist/types/middleware/etag/index.d.ts",
/app/package.json:136:      "import": "./dist/middleware/etag/index.js",
/app/package.json:137:      "require": "./dist/cjs/middleware/etag/index.js"
/app/package.json:476:      "etag": [
/app/package.json:477:        "./dist/types/middleware/etag"

/app/src/middleware/etag/index.ts

contents
1	/**
2	 * @module
3	 * ETag Middleware for Hono.
4	 */
5	
6	import type { MiddlewareHandler } from '../../types'
7	import { generateDigest } from './digest'
8	
9	type ETagOptions = {
10	  retainedHeaders?: string[]
11	  weak?: boolean
12	  generateDigest?: (body: Uint8Array<ArrayBuffer>) => ArrayBuffer | Promise<ArrayBuffer>
13	}
14	
15	/**
16	 * Default headers to pass through on 304 responses. From the spec:
17	 * > The response must not contain a body and must include the headers that
18	 * > would have been sent in an equivalent 200 OK response: Cache-Control,
19	 * > Content-Location, Date, ETag, Expires, and Vary.
20	 */
21	export const RETAINED_304_HEADERS = [
22	  'cache-control',
23	  'content-location',
24	  'date',
25	  'etag',
26	  'expires',
27	  'vary',
28	]
29	
30	const stripWeak = (tag: string) => tag.replace(/^W\//, '')
31	
32	function etagMatches(etag: string, ifNoneMatch: string | null) {
33	  return (
34	    ifNoneMatch != null && ifNoneMatch.split(/,\s*/).some((t) => stripWeak(t) === stripWeak(etag))
35	  )
36	}
37	
38	function initializeGenerator(
39	  generator?: ETagOptions['generateDigest']
40	): ETagOptions['generateDigest'] | undefined {
41	  if (!generator) {
42	    if (crypto && crypto.subtle) {
43	      generator = (body: Uint8Array<ArrayBuffer>) =>
44	        crypto.subtle.digest(
45	          {
46	            name: 'SHA-1',
47	          },
48	          body
49	        )
50	    }
51	  }
52	
53	  return generator
54	}
55	
56	/**
57	 * ETag Middleware for Hono.
58	 *
59	 * @see {@link https://hono.dev/docs/middleware/builtin/etag}
60	 *
61	 * @param {ETagOptions} [options] - The options for the ETag middleware.
62	 * @param {boolean} [options.weak=false] - Define using or not using a weak validation. If true is set, then `W/` is added to the prefix of the value.
63	 * @param {string[]} [options.retainedHeaders=RETAINED_304_HEADERS] - The headers that you want to retain in the 304 Response.
64	 * @param {function(Uint8Array): ArrayBuffer | Promise<ArrayBuffer>} [options.generateDigest] -
65	 * A custom digest generation function. By default, it uses 'SHA-1'
66	 * This function is called with the response body as a `Uint8Array` and should return a hash as an `ArrayBuffer` or a Promise of one.
67	 * @returns {MiddlewareHandler} The middleware handler function.
68	 *
69	 * @example
70	 * ```ts
71	 * const app = new Hono()
72	 *
73	 * app.use('/etag/*', etag())
74	 * app.get('/etag/abc', (c) => {
75	 *   return c.text('Hono is hot')
76	 * })
77	 * ```
78	 */
79	export const etag = (options?: ETagOptions): MiddlewareHandler => {
80	  const retainedHeaders = options?.retainedHeaders ?? RETAINED_304_HEADERS
81	  const weak = options?.weak ?? false
82	  const generator = initializeGenerator(options?.generateDigest)
83	
84	  return async function etag(c, next) {
85	    const ifNoneMatch = c.req.header('If-None-Match') ?? null
86	
87	    await next()
88	
89	    if (
90	      !(c.req.method === 'GET' || c.req.method === 'HEAD' || c.req.method === 'QUERY') ||
91	      !c.res.ok
92	    ) {
93	      return
94	    }
95	
96	    const res = c.res as Response
97	    let etag = res.headers.get('ETag')
98	
99	    if (!etag) {
100	      if (!generator) {
101	        return
102	      }
103	      const hash = await generateDigest(
104	        // This type casing avoids the type error for `deno publish`
105	        res.clone().body as ReadableStream<Uint8Array<ArrayBuffer>>,
106	        generator
107	      )
108	      if (hash === null) {
109	        return
110	      }
111	      etag = weak ? `W/"${hash}"` : `"${hash}"`
112	    }
113	
114	    const matched = ifNoneMatch === '*' || etagMatches(etag, ifNoneMatch)
115	
116	    if (matched) {
117	      c.res = new Response(null, {
118	        status: 304,
119	        statusText: 'Not Modified',
120	        headers: {
121	          ETag: etag,
122	        },
123	      })
124	      c.res.headers.forEach((_, key) => {
125	        if (retainedHeaders.indexOf(key.toLowerCase()) === -1) {
126	          c.res.headers.delete(key)
127	        }
128	      })
129	    } else {
130	      c.res.headers.set('ETag', etag)
131	    }
132	  }
133	}
134

/app/jsr.json

contents
1	{
2	  "name": "@hono/hono",
3	  "version": "0.0.0",
4	  "compilerOptions": {
5	    "lib": ["dom", "dom.iterable", "deno.ns"]
6	  },
7	  "unstable": ["sloppy-imports"],
8	  "exports": {
9	    ".": "./src/index.ts",
10	    "./request": "./src/request.ts",
11	    "./types": "./src/types.ts",
12	    "./hono-base": "./src/hono-base.ts",
13	    "./tiny": "./src/preset/tiny.ts",
14	    "./quick": "./src/preset/quick.ts",
15	    "./http-exception": "./src/http-exception.ts",
16	    "./basic-auth": "./src/middleware/basic-auth/index.ts",
17	    "./bearer-auth": "./src/middleware/bearer-auth/index.ts",
18	    "./body-limit": "./src/middleware/body-limit/index.ts",
19	    "./ip-restriction": "./src/middleware/ip-restriction/index.ts",
20	    "./cache": "./src/middleware/cache/index.ts",
21	    "./route": "./src/helper/route/index.ts",
22	    "./cookie": "./src/helper/cookie/index.ts",
23	    "./accepts": "./src/helper/accepts/index.ts",
24	    "./compress": "./src/middleware/compress/index.ts",
25	    "./context-storage": "./src/middleware/context-storage/index.ts",
26	    "./cors": "./src/middleware/cors/index.ts",
27	    "./csrf": "./src/middleware/csrf/index.ts",
28	    "./etag": "./src/middleware/etag/index.ts",
29	    "./trailing-slash": "./src/middleware/trailing-slash/index.ts",
30	    "./html": "./src/helper/html/index.ts",
31	    "./css": "./src/helper/css/index.ts",
32	    "./jsx": "./src/jsx/index.ts",
33	    "./jsx/jsx-dev-runtime": "./src/jsx/jsx-dev-runtime.ts",
34	    "./jsx/jsx-runtime": "./src/jsx/jsx-runtime.ts",
35	    "./jsx/streaming": "./src/jsx/streaming.ts",
36	    "./jsx-renderer": "./src/middleware/jsx-renderer/index.ts",
37	    "./jsx/dom": "./src/jsx/dom/index.ts",
38	    "./jsx/dom/jsx-dev-runtime": "./src/jsx/dom/jsx-dev-runtime.ts",
39	    "./jsx/dom/jsx-runtime": "./src/jsx/dom/jsx-runtime.ts",
40	    "./jsx/dom/client": "./src/jsx/dom/client.ts",
41	    "./jsx/dom/css": "./src/jsx/dom/css.ts",
42	    "./jsx/dom/server": "./src/jsx/dom/server.ts",
43	    "./jwt": "./src/middleware/jwt/jwt.ts",
44	    "./jwk": "./src/middleware/jwk/jwk.ts",
45	    "./timeout": "./src/middleware/timeout/index.ts",
46	    "./timing": "./src/middleware/timing/timing.ts",
47	    "./logger": "./src/middleware/logger/index.ts",
48	    "./method-not-allowed": "./src/middleware/method-not-allowed/index.ts",
49	    "./method-override": "./src/middleware/method-override/index.ts",
50	    "./powered-by": "./src/middleware/powered-by/index.ts",
51	    "./pretty-json": "./src/middleware/pretty-json/index.ts",
52	    "./request-id": "./src/middleware/request-id/request-id.ts",
53	    "./language": "./src/middleware/language/language.ts",
54	    "./secure-headers": "./src/middleware/secure-headers/secure-headers.ts",
55	    "./combine": "./src/middleware/combine/index.ts",
56	    "./ssg": "./src/helper/ssg/index.ts",
57	    "./streaming": "./src/helper/streaming/index.ts",
58	    "./validator": "./src/validator/index.ts",
59	    "./router": "./src/router.ts",
60	    "./router/reg-exp-router": "./src/router/reg-exp-router/index.ts",
61	    "./router/smart-router": "./src/router/smart-router/index.ts",
62	    "./router/trie-router": "./src/router/trie-router/index.ts",
63	    "./router/pattern-router": "./src/router/pattern-router/index.ts",
64	    "./router/linear-router": "./src/router/linear-router/index.ts",
65	    "./client": "./src/client/index.ts",
66	    "./adapter": "./src/helper/adapter/index.ts",
67	    "./factory": "./src/helper/factory/index.ts",
68	    "./serve-static": "./src/middleware/serve-static/index.ts",
69	    "./cloudflare-workers": "./src/adapter/cloudflare-workers/index.ts",
70	    "./cloudflare-pages": "./src/adapter/cloudflare-pages/index.ts",
71	    "./deno": "./src/adapter/deno/index.ts",
72	    "./bun": "./src/adapter/bun/index.ts",
73	    "./aws-lambda": "./src/adapter/aws-lambda/index.ts",
74	    "./vercel": "./src/adapter/vercel/index.ts",
75	    "./netlify": "./src/adapter/netlify/index.ts",
76	    "./lambda-edge": "./src/adapter/lambda-edge/index.ts",
77	    "./service-worker": "./src/adapter/service-worker/index.ts",
78	    "./testing": "./src/helper/testing/index.ts",
79	    "./dev": "./src/helper/dev/index.ts",
80	    "./ws": "./src/helper/websocket/index.ts",
81	    "./conninfo": "./src/helper/conninfo/index.ts",
82	    "./proxy": "./src/helper/proxy/index.ts",
83	    "./utils/body": "./src/utils/body.ts",
84	    "./utils/buffer": "./src/utils/buffer.ts",
85	    "./utils/color": "./src/utils/color.ts",
86	    "./utils/concurrent": "./src/utils/concurrent.ts",
87	    "./utils/cookie": "./src/utils/cookie.ts",
88	    "./utils/crypto": "./src/utils/crypto.ts",
89	    "./utils/encode": "./src/utils/encode.ts",
90	    "./utils/filepath": "./src/utils/filepath.ts",
91	    "./utils/handler": "./src/utils/handler.ts",
92	    "./utils/headers": "./src/utils/headers.ts",
93	    "./utils/html": "./src/utils/html.ts",
94	    "./utils/http-status": "./src/utils/http-status.ts",
95	    "./utils/accept": "./src/utils/accept.ts",
96	    "./utils/jwt": "./src/utils/jwt/index.ts",
97	    "./utils/jwt/jwa": "./src/utils/jwt/jwa.ts",
98	    "./utils/jwt/jws": "./src/utils/jwt/jws.ts",
99	    "./utils/jwt/jwt": "./src/utils/jwt/jwt.ts",
100	    "./utils/jwt/types": "./src/utils/jwt/types.ts",
101	    "./utils/jwt/utf8": "./src/utils/jwt/utf8.ts",
102	    "./utils/mime": "./src/utils/mime.ts",
103	    "./utils/stream": "./src/utils/stream.ts",
104	    "./utils/types": "./src/utils/types.ts",
105	    "./utils/url": "./src/utils/url.ts",
106	    "./utils/ipaddr": "./src/utils/ipaddr.ts"
107	  },
108	  "publish": {
109	    "include": ["jsr.json", "LICENSE", "README.md", "src/**/*.ts"],
110	    "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
111	  }
112	}
113

/app/package.json

contents
120	      "types": "./dist/types/middleware/context-storage/index.d.ts",
121	      "import": "./dist/middleware/context-storage/index.js",
122	      "require": "./dist/cjs/middleware/context-storage/index.js"
123	    },
124	    "./cors": {
125	      "types": "./dist/types/middleware/cors/index.d.ts",
126	      "import": "./dist/middleware/cors/index.js",
127	      "require": "./dist/cjs/middleware/cors/index.js"
128	    },
129	    "./csrf": {
130	      "types": "./dist/types/middleware/csrf/index.d.ts",
131	      "import": "./dist/middleware/csrf/index.js",
132	      "require": "./dist/cjs/middleware/csrf/index.js"
133	    },
134	    "./etag": {
135	      "types": "./dist/types/middleware/etag/index.d.ts",
136	      "import": "./dist/middleware/etag/index.js",
137	      "require": "./dist/cjs/middleware/etag/index.js"
138	    },
139	    "./trailing-slash": {
140	      "types": "./dist/types/middleware/trailing-slash/index.d.ts",
141	      "import": "./dist/middleware/trailing-slash/index.js",
142	      "require": "./dist/cjs/middleware/trailing-slash/index.js"
143	    },
144	    "./html": {
145	      "types": "./dist/types/helper/html/index.d.ts",
146	      "import": "./dist/helper/html/index.js",
147	      "require": "./dist/cjs/helper/html/index.js"
148	    },
149	    "./css": {
150	      "types": "./dist/types/helper/css/index.d.ts",
151	      "import": "./dist/helper/css/index.js",
152	      "require": "./dist/cjs/helper/css/index.js"
153	    },
154	    "./jsx": {
155	      "types": "./dist/types/jsx/index.d.ts",
156	      "import": "./dist/jsx/index.js",
157	      "require": "./dist/cjs/jsx/index.js"
158	    },
159	    "./jsx/jsx-dev-runtime": {

/app/package.json

contents
455	      "route": [
456	        "./dist/types/helper/route"
457	      ],
458	      "cookie": [
459	        "./dist/types/helper/cookie"
460	      ],
461	      "accepts": [
462	        "./dist/types/helper/accepts"
463	      ],
464	      "compress": [
465	        "./dist/types/middleware/compress"
466	      ],
467	      "context-storage": [
468	        "./dist/types/middleware/context-storage"
469	      ],
470	      "cors": [
471	        "./dist/types/middleware/cors"
472	      ],
473	      "csrf": [
474	        "./dist/types/middleware/csrf"
475	      ],
476	      "etag": [
477	        "./dist/types/middleware/etag"
478	      ],
479	      "trailing-slash": [
480	        "./dist/types/middleware/trailing-slash"
481	      ],
482	      "html": [
483	        "./dist/types/helper/html"
484	      ],
485	      "css": [
486	        "./dist/types/helper/css"
487	      ],
488	      "jsx": [
489	        "./dist/types/jsx"
490	      ],
491	      "jsx/jsx-runtime": [
492	        "./dist/types/jsx/jsx-runtime.d.ts"
493	      ],
494	      "jsx/jsx-dev-runtime": [
495	        "./dist/types/jsx/jsx-dev-runtime.d.ts"
496	      ],
497	      "jsx/streaming": [
498	        "./dist/types/jsx/streaming.d.ts"
499	      ],
500	      "jsx-renderer": [
501	        "./dist/types/middleware/jsx-renderer"
502	      ],
503	      "jsx/dom": [
504	        "./dist/types/jsx/dom"

Read build script

shell
cat /app/build/build.ts
output
/*
  This script is heavily inspired by `built.ts` used in @kaze-style/react.
  https://[code-host]/taishinaritomi/kaze-style/blob/main/scripts/build.ts
  MIT License
  Copyright (c) 2022 Taishi Naritomi
*/

/// <reference types="bun-types" />

import { $, Glob } from 'bun'
import { build, context } from 'esbuild'
import type { Plugin, PluginBuild, BuildOptions } from 'esbuild'
import fs from 'fs'
import path from 'path'
import { removePrivateFields } from './remove-private-fields'
import { validateExports } from './validate-exports'

const isWatch = process.argv.includes('--watch')

const readJsonExports = (path: string) => JSON.parse(fs.readFileSync(path, 'utf-8')).exports

const [packageJsonExports, jsrJsonExports] = ['./package.json', './jsr.json'].map(readJsonExports)

// Validate exports of package.json and jsr.json
validateExports(packageJsonExports, jsrJsonExports, 'jsr.json')
validateExports(jsrJsonExports, packageJsonExports, 'package.json')

const ignorePatterns = [
  'src/**/*.test.ts',
  'src/mod.ts',
  'src/middleware.ts',
  'src/deno/**/*.ts',
].map((pattern) => new Glob(pattern))
const entryPoints: string[] = []
for await (const file of new Glob('src/**/*.ts').scan('.')) {
  if (!ignorePatterns.some((ignore) => ignore.match(file))) {
    entryPoints.push(file)
  }
}

/*
  This plugin is inspired by the following.
  https://[code-host]/evanw/esbuild/issues/622#issuecomment-769462611
*/
const addExtension = (extension: string = '.js', fileExtension: string = '.ts'): Plugin => ({
  name: 'add-extension',
  setup(build: PluginBuild) {
    build.onResolve({ filter: /.*/ }, (args) => {
      if (args.importer) {
        const p = path.join(args.resolveDir, args.path)
        let tsPath = `${p}${fileExtension}`

        let importPath = ''
        if (fs.existsSync(tsPath)) {
          importPath = args.path + extension
        } else {
          tsPath = path.join(args.resolveDir, args.path, `index${fileExtension}`)
          if (fs.existsSync(tsPath)) {
            if (args.path.endsWith('/')) {
              importPath = `${args.path}index${extension}`
            } else {
              importPath = `${args.path}/index${extension}`
            }
          }
        }
        return { path: importPath, external: true }
      }
    })
  },
})

const commonOptions: BuildOptions = {
  entryPoints,
  logLevel: 'info',
  platform: 'node',
}

const cjsConfig: BuildOptions = {
  ...commonOptions,
  outbase: './src',
  outdir: './dist/cjs',
  format: 'cjs',
}

const esmConfig: BuildOptions = {
  ...commonOptions,
  bundle: true,
  outbase: './src',
  outdir: './dist',
  format: 'esm',
  plugins: [addExtension('.js')],
}

const runBuild = async (config: BuildOptions) => {
  if (isWatch) {
    const ctx = await context(config)
    await ctx.watch()
  } else {
    await build(config)
  }
}

await Promise.all([
  runBuild(esmConfig),
  runBuild(cjsConfig),
  $`tsc ${isWatch ? ['-w'] : []} --emitDeclarationOnly --declaration --project tsconfig.build.json`,
])

// Remove #private fields
const dtsEntries: string[] = []
for await (const file of new Glob('dist/types/**/*.d.ts').scan('.')) {
  dtsEntries.push(file)
}
await removePrivateFields(dtsEntries)

[stdout]
/*
  This script is heavily inspired by `built.ts` used in @kaze-style/react.
  https://[code-host]/taishinaritomi/kaze-style/blob/main/scripts/build.ts
  MIT License
  Copyright (c) 2022 Taishi Naritomi
*/

/// <reference types="bun-types" />

import { $, Glob } from 'bun'
import { build, context } from 'esbuild'
import type { Plugin, PluginBuild, BuildOptions } from 'esbuild'
import fs from 'fs'
import path from 'path'
import { removePrivateFields } from './remove-private-fields'
import { validateExports } from './validate-exports'

const isWatch = process.argv.includes('--watch')

const readJsonExports = (path: string) => JSON.parse(fs.readFileSync(path, 'utf-8')).exports

const [packageJsonExports, jsrJsonExports] = ['./package.json', './jsr.json'].map(readJsonExports)

// Validate exports of package.json and jsr.json
validateExports(packageJsonExports, jsrJsonExports, 'jsr.json')
validateExports(jsrJsonExports, packageJsonExports, 'package.json')

const ignorePatterns = [
  'src/**/*.test.ts',
  'src/mod.ts',
  'src/middleware.ts',
  'src/deno/**/*.ts',
].map((pattern) => new Glob(pattern))
const entryPoints: string[] = []
for await (const file of new Glob('src/**/*.ts').scan('.')) {
  if (!ignorePatterns.some((ignore) => ignore.match(file))) {
    entryPoints.push(file)
  }
}

/*
  This plugin is inspired by the following.
  https://[code-host]/evanw/esbuild/issues/622#issuecomment-769462611
*/
const addExtension = (extension: string = '.js', fileExtension: string = '.ts'): Plugin => ({
  name: 'add-extension',
  setup(build: PluginBuild) {
    build.onResolve({ filter: /.*/ }, (args) => {
      if (args.importer) {
        const p = path.join(args.resolveDir, args.path)
        let tsPath = `${p}${fileExtension}`

        let importPath = ''
        if (fs.existsSync(tsPath)) {
          importPath = args.path + extension
        } else {
          tsPath = path.join(args.resolveDir, args.path, `index${fileExtension}`)
          if (fs.existsSync(tsPath)) {
            if (args.path.endsWith('/')) {
              importPath = `${args.path}index${extension}`
            } else {
              importPath = `${args.path}/index${extension}`
            }
          }
        }
        return { path: importPath, external: true }
      }
    })
  },
})

const commonOptions: BuildOptions = {
  entryPoints,
  logLevel: 'info',
  platform: 'node',
}

const cjsConfig: BuildOptions = {
  ...commonOptions,
  outbase: './src',
  outdir: './dist/cjs',
  format: 'cjs',
}

const esmConfig: BuildOptions = {
  ...commonOptions,
  bundle: true,
  outbase: './src',
  outdir: './dist',
  format: 'esm',
  plugins: [addExtension('.js')],
}

const runBuild = async (config: BuildOptions) => {
  if (isWatch) {
    const ctx = await context(config)
    await ctx.watch()
  } else {
    await build(config)
  }
}

await Promise.all([
  runBuild(esmConfig),
  runBuild(cjsConfig),
  $`tsc ${isWatch ? ['-w'] : []} --emitDeclarationOnly --declaration --project tsconfig.build.json`,
])

// Remove #private fields
const dtsEntries: string[] = []
for await (const file of new Glob('dist/types/**/*.d.ts').scan('.')) {
  dtsEntries.push(file)
}
await removePrivateFields(dtsEntries)

Inspect request.ts

shell
grep -n "cloneRawRequest\|class HonoRequest\|constructor" /app/src/request.ts | head -40
output
34:export class HonoRequest<P extends string = '/', I extends Input['out'] = {}> {
69:  constructor(
465: *     // Body has been consumed, but cloneRawRequest still works
466: *     const clonedReq = await cloneRawRequest(c.req)
472:export const cloneRawRequest = async (req: HonoRequest): Promise<Request> => {
489:    // Content-Type header no longer matches. Let the Request constructor generate it.

[stdout]
34:export class HonoRequest<P extends string = '/', I extends Input['out'] = {}> {
69:  constructor(
465: *     // Body has been consumed, but cloneRawRequest still works
466: *     const clonedReq = await cloneRawRequest(c.req)
472:export const cloneRawRequest = async (req: HonoRequest): Promise<Request> => {
489:    // Content-Type header no longer matches. Let the Request constructor generate it.

/app/src/request.ts

contents
30	type RequiredRequestInit = Required<Omit<RequestInit, OptionalRequestInitProperties>> & {
31	  [Key in OptionalRequestInitProperties]?: RequestInit[Key]
32	}
33	
34	export class HonoRequest<P extends string = '/', I extends Input['out'] = {}> {
35	  /**
36	   * `.raw` can get the raw Request object.
37	   *
38	   * @see {@link https://hono.dev/docs/api/request#raw}
39	   *
40	   * @example
41	   * ```ts
42	   * // For Cloudflare Workers
43	   * app.post('/', async (c) => {
44	   *   const metadata = c.req.raw.cf?.hostMetadata?
45	   *   ...
46	   * })
47	   * ```
48	   */
49	  raw: Request
50	
51	  #validatedData: { [K in keyof ValidationTargets]?: {} } | undefined // Short name of validatedData
52	  #matchResult: Result<[unknown, RouterRoute]>
53	  routeIndex: number = 0
54	  /**
55	   * `.path` can get the pathname of the request.
56	   *
57	   * @see {@link https://hono.dev/docs/api/request#path}
58	   *
59	   * @example
60	   * ```ts
61	   * app.get('/about/me', (c) => {
62	   *   const pathname = c.req.path // `/about/me`
63	   * })
64	   * ```
65	   */
66	  path: string
67	  bodyCache: BodyCache = {}
68	
69	  constructor(
70	    request: Request,
71	    path: string = '/',
72	    matchResult: Result<[unknown, RouterRoute]> = [[]]
73	  ) {
74	    this.raw = request
75	    this.path = path
76	    this.#matchResult = matchResult
77	  }
78	
79	  /**
80	   * `.req.param()` gets the path parameters.
81	   *
82	   * @see {@link https://hono.dev/docs/api/routing#path-parameter}
83	   *
84	   * @example
85	   * ```ts
86	   * const name = c.req.param('name')
87	   * // or all parameters at once
88	   * const { id, comment_id } = c.req.param()
89	   * ```
90	   */
91	  param<P2 extends ParamKeys<P> = ParamKeys<P>>(
92	    key: string extends P ? never : P2 extends `${infer _}?` ? never : P2
93	  ): string
94	  param<P2 extends RemoveQuestion<ParamKeys<P>> = RemoveQuestion<ParamKeys<P>>>(
95	    key: P2
96	  ): string | undefined
97	  param(key: string): string | undefined
98	  param<P2 extends string = P>(): Simplify<UnionToIntersection<ParamKeyToRecord<ParamKeys<P2>>>>
99	  param(key?: string): unknown {
100	    return key ? this.#getDecodedParam(key) : this.#getAllDecodedParams()
101	  }
102	
103	  #getDecodedParam(key: string): string | undefined {
104	    const paramKey = this.#matchResult[0][this.routeIndex][1][key]
105	    const param = this.#getParamValue(paramKey)
106	    return param && tryDecodeURIComponent(param)
107	  }
108	
109	  #getAllDecodedParams(): Record<string, string> {
110	    const decoded: Record<string, string> = {}
111	
112	    const keys = Object.keys(this.#matchResult[0][this.routeIndex][1])
113	    for (const key of keys) {
114	      const value = this.#getParamValue(this.#matchResult[0][this.routeIndex][1][key])
115	      if (value !== undefined) {
116	        decoded[key] = tryDecodeURIComponent(value)
117	      }
118	    }
119	
120	    return decoded
121	  }
122	
123	  #getParamValue(paramKey: any): string | undefined {
124	    return this.#matchResult[1] ? this.#matchResult[1][paramKey as any] : paramKey
125	  }
126	
127	  /**
128	   * `.query()` can get querystring parameters.
129	   *
130	   * @see {@link https://hono.dev/docs/api/request#query}
131	   *
132	   * @example
133	   * ```ts
134	   * // Query params
135	   * app.get('/search', (c) => {
136	   *   const query = c.req.query('q')
137	   * })
138	   *
139	   * // Get all params at once
140	   * app.get('/search', (c) => {
141	   *   const { q, limit, offset } = c.req.query()
142	   * })
143	   * ```
144	   */
145	  query(key: string): string | undefined
146	  query(): Record<string, string>
147	  query(key?: string) {
148	    return getQueryParam(this.url, key)
149	  }

Check header/url/method accessors

shell
grep -n "header(\|get url\|get method" -A 12 /app/src/request.ts | sed -n '1,80p'
output
171:   * `.header()` can get the request header value.
172-   *
173-   * @see {@link https://hono.dev/docs/api/request#header}
174-   *
175-   * @example
176-   * ```ts
177-   * app.get('/', (c) => {
178:   *   const userAgent = c.req.header('User-Agent')
179-   * })
180-   * ```
181-   */
182:  header(name: RequestHeader): string | undefined
183:  header(name: string): string | undefined
184:  header(): Record<RequestHeader | (string & CustomHeader), string>
185:  header(name?: string) {
186-    if (name) {
187-      return this.raw.headers.get(name) ?? undefined
188-    }
189-
190-    const headerData: Record<string, string | undefined> = Object.create(null)
191-    this.raw.headers.forEach((value, key) => {
192-      headerData[key] = value
193-    })
194-    return headerData
195-  }
196-
197-  /**
--
365:  get url(): string {
366-    return this.raw.url
367-  }
368-
369-  /**
370-   * `.method()` can get the method name of the request.
371-   *
372-   * @see {@link https://hono.dev/docs/api/request#method}
373-   *
374-   * @example
375-   * ```ts
376-   * app.get('/about/me', (c) => {
377-   *   const method = c.req.method // `GET`
--
381:  get method(): string {
382-    return this.raw.method
383-  }
384-
385-  get [GET_MATCH_RESULT](): Result<[unknown, RouterRoute]> {
386-    return this.#matchResult
387-  }
388-
389-  /**
390-   * `.matchedRoutes()` can return a matched route in the handler
391-   *
392-   * @deprecated
393-   *
--
486:  const headers = req.header()
487-  if (body instanceof FormData) {
488-    // The FormData is re-serialized with a fresh multipart boundary, so the original
489-    // Content-Type header no longer matches. Let the Request constructor generate it.
490-    delete headers['content-type']
491-  }
492-
493-  const requestInit: RequiredRequestInit = {
494-    body,
495-    cache: req.raw.cache,
496-    credentials: req.raw.credentials,
497-    headers,
498-    integrity: req.raw.integrity,

[stdout]
171:   * `.header()` can get the request header value.
172-   *
173-   * @see {@link https://hono.dev/docs/api/request#header}
174-   *
175-   * @example
176-   * ```ts
177-   * app.get('/', (c) => {
178:   *   const userAgent = c.req.header('User-Agent')
179-   * })
180-   * ```
181-   */
182:  header(name: RequestHeader): string | undefined
183:  header(name: string): string | undefined
184:  header(): Record<RequestHeader | (string & CustomHeader), string>
185:  header(name?: string) {
186-    if (name) {
187-      return this.raw.headers.get(name) ?? undefined
188-    }
189-
190-    const headerData: Record<string, string | undefined> = Object.create(null)
191-    this.raw.headers.forEach((value, key) => {
192-      headerData[key] = value
193-    })
194-    return headerData
195-  }
196-
197-  /**
--
365:  get url(): string {
366-    return this.raw.url
367-  }
368-
369-  /**
370-   * `.method()` can get the method name of the request.
371-   *
372-   * @see {@link https://hono.dev/docs/api/request#method}
373-   *
374-   * @example
375-   * ```ts
376-   * app.get('/about/me', (c) => {
377-   *   const method = c.req.method // `GET`
--
381:  get method(): string {
382-    return this.raw.method
383-  }
384-
385-  get [GET_MATCH_RESULT](): Result<[unknown, RouterRoute]> {
386-    return this.#matchResult
387-  }
388-
389-  /**
390-   * `.matchedRoutes()` can return a matched route in the handler
391-   *
392-   * @deprecated
393-   *
--
486:  const headers = req.header()
487-  if (body instanceof FormData) {
488-    // The FormData is re-serialized with a fresh multipart boundary, so the original
489-    // Content-Type header no longer matches. Let the Request constructor generate it.
490-    delete headers['content-type']
491-  }
492-
493-  const requestInit: RequiredRequestInit = {
494-    body,
495-    cache: req.raw.cache,
496-    credentials: req.raw.credentials,
497-    headers,
498-    integrity: req.raw.integrity,

Read eslint and vitest config

shell
sed -n 1,60p /app/eslint.config.mjs; echo ---; cat /app/vitest.config.ts
output
import baseConfig from '@hono/eslint-config'
import { defineConfig, globalIgnores } from 'eslint/config'

// Disable all TypeScript rules that require type information
const typeCheckedRules = {
  '@typescript-eslint/await-thenable': 'off',
  '@typescript-eslint/no-base-to-string': 'off',
  '@typescript-eslint/no-confusing-void-expression': 'off',
  '@typescript-eslint/no-duplicate-type-constituents': 'off',
  '@typescript-eslint/no-floating-promises': 'off',
  '@typescript-eslint/no-for-in-array': 'off',
  '@typescript-eslint/no-implied-eval': 'off',
  '@typescript-eslint/no-meaningless-void-operator': 'off',
  '@typescript-eslint/no-misused-promises': 'off',
  '@typescript-eslint/no-mixed-enums': 'off',
  '@typescript-eslint/no-redundant-type-constituents': 'off',
  '@typescript-eslint/no-unnecessary-boolean-literal-compare': 'off',
  '@typescript-eslint/no-unnecessary-condition': 'off',
  '@typescript-eslint/no-unnecessary-template-expression': 'off',
  '@typescript-eslint/no-unnecessary-type-arguments': 'off',
  '@typescript-eslint/no-unnecessary-type-assertion': 'off',
  "@typescript-eslint/no-unnecessary-type-conversion": 'off',
  '@typescript-eslint/no-unsafe-argument': 'off',
  '@typescript-eslint/no-unsafe-assignment': 'off',
  '@typescript-eslint/no-unsafe-call': 'off',
  '@typescript-eslint/no-unsafe-enum-comparison': 'off',
  '@typescript-eslint/no-unsafe-member-access': 'off',
  '@typescript-eslint/no-unsafe-return': 'off',
  '@typescript-eslint/no-unsafe-unary-minus': 'off',
  '@typescript-eslint/only-throw-error': 'off',
  '@typescript-eslint/prefer-includes': 'off',
  '@typescript-eslint/prefer-nullish-coalescing': 'off',
  '@typescript-eslint/prefer-optional-chain': 'off',
  '@typescript-eslint/prefer-promise-reject-errors': 'off',
  '@typescript-eslint/prefer-reduce-type-parameter': 'off',
  '@typescript-eslint/prefer-regexp-exec': 'off',
  '@typescript-eslint/prefer-return-this-type': 'off',
  '@typescript-eslint/prefer-string-starts-ends-with': 'off',
  '@typescript-eslint/require-await': 'off',
  '@typescript-eslint/restrict-plus-operands': 'off',
  '@typescript-eslint/restrict-template-expressions': 'off',
  '@typescript-eslint/return-await': 'off',
  '@typescript-eslint/strict-boolean-expressions': 'off',
  '@typescript-eslint/unbound-method': 'off',
  '@typescript-eslint/use-unknown-in-catch-callback-variable': 'off',
  '@typescript-eslint/prefer-find': 'off',
  '@typescript-eslint/no-misused-spread': 'off',
  '@typescript-eslint/related-getter-setter-pairs': 'off',
  '@typescript-eslint/prefer-literal-enum-member': 'off',

  // Stylistic rules
  '@typescript-eslint/consistent-indexed-object-style': 'off',
  '@typescript-eslint/consistent-type-definitions': 'off',
  '@typescript-eslint/dot-notation': 'off',
  '@typescript-eslint/no-array-delete': 'off',
  '@typescript-eslint/no-confusing-non-null-assertion': 'off',
  '@typescript-eslint/no-deprecated': 'off',
  '@typescript-eslint/no-dynamic-delete': 'off',
  '@typescript-eslint/no-invalid-void-type': 'off',
  '@typescript-eslint/no-non-null-assertion': 'off',
---
import { configDefaults, defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    globals: true,
    coverage: {
      enabled: true,
      provider: 'v8',
      reportsDirectory: './coverage/raw/default',
      reporter: ['json', 'text', 'html'],
      exclude: [
        ...(configDefaults.coverage.exclude ?? []),
        'benchmarks',
        'runtime-tests',
        'build/build.ts',
        'src/test-utils',
        'perf-measures',

        // types are compile-time only, so their coverage cannot be measured
        'src/**/types.ts',
        'src/jsx/intrinsic-elements.ts',
        'src/utils/http-status.ts',
      ],
    },
    projects: [
      './runtime-tests/*/vitest.config.ts',
      {
        oxc: {
          jsx: {
            runtime: 'automatic',
            importSource: './src/jsx',
          },
        },
        extends: true,
        test: {
          exclude: [...configDefaults.exclude, '**/sandbox/**', '**/*.case.test.*'],
          include: [
            'src/**/(*.)+(spec|test).+(ts|tsx|js)',
            'scripts/**/(*.)+(spec|test).+(ts|tsx|js)',
            'build/**/(*.)+(spec|test).+(ts|tsx|js)',
          ],
          name: 'main',
        },
      },
      {
        oxc: {
          jsx: {
            runtime: 'automatic',
            importSource: './src/jsx',
          },
        },
        extends: true,
        test: {
          include: ['src/jsx/dom/**/(*.)+(spec|test).+(ts|tsx|js)', 'src/jsx/hooks/dom.test.tsx'],
          name: 'jsx-runtime-default',
        },
      },
      {
        oxc: {
          jsx: {
            runtime: 'automatic',
            importSource: './src/jsx/dom',
          },
        },
        extends: true,
        test: {
          include: ['src/jsx/dom/**/(*.)+(spec|test).+(ts|tsx|js)', 'src/jsx/hooks/dom.test.tsx'],
          name: 'jsx-runtime-dom',
        },
      },
    ],
  },
})

[stdout]
import baseConfig from '@hono/eslint-config'
import { defineConfig, globalIgnores } from 'eslint/config'

// Disable all TypeScript rules that require type information
const typeCheckedRules = {
  '@typescript-eslint/await-thenable': 'off',
  '@typescript-eslint/no-base-to-string': 'off',
  '@typescript-eslint/no-confusing-void-expression': 'off',
  '@typescript-eslint/no-duplicate-type-constituents': 'off',
  '@typescript-eslint/no-floating-promises': 'off',
  '@typescript-eslint/no-for-in-array': 'off',
  '@typescript-eslint/no-implied-eval': 'off',
  '@typescript-eslint/no-meaningless-void-operator': 'off',
  '@typescript-eslint/no-misused-promises': 'off',
  '@typescript-eslint/no-mixed-enums': 'off',
  '@typescript-eslint/no-redundant-type-constituents': 'off',
  '@typescript-eslint/no-unnecessary-boolean-literal-compare': 'off',
  '@typescript-eslint/no-unnecessary-condition': 'off',
  '@typescript-eslint/no-unnecessary-template-expression': 'off',
  '@typescript-eslint/no-unnecessary-type-arguments': 'off',
  '@typescript-eslint/no-unnecessary-type-assertion': 'off',
  "@typescript-eslint/no-unnecessary-type-conversion": 'off',
  '@typescript-eslint/no-unsafe-argument': 'off',
  '@typescript-eslint/no-unsafe-assignment': 'off',
  '@typescript-eslint/no-unsafe-call': 'off',
  '@typescript-eslint/no-unsafe-enum-comparison': 'off',
  '@typescript-eslint/no-unsafe-member-access': 'off',
  '@typescript-eslint/no-unsafe-return': 'off',
  '@typescript-eslint/no-unsafe-unary-minus': 'off',
  '@typescript-eslint/only-throw-error': 'off',
  '@typescript-eslint/prefer-includes': 'off',
  '@typescript-eslint/prefer-nullish-coalescing': 'off',
  '@typescript-eslint/prefer-optional-chain': 'off',
  '@typescript-eslint/prefer-promise-reject-errors': 'off',
  '@typescript-eslint/prefer-reduce-type-parameter': 'off',
  '@typescript-eslint/prefer-regexp-exec': 'off',
  '@typescript-eslint/prefer-return-this-type': 'off',
  '@typescript-eslint/prefer-string-starts-ends-with': 'off',
  '@typescript-eslint/require-await': 'off',
  '@typescript-eslint/restrict-plus-operands': 'off',
  '@typescript-eslint/restrict-template-expressions': 'off',
  '@typescript-eslint/return-await': 'off',
  '@typescript-eslint/strict-boolean-expressions': 'off',
  '@typescript-eslint/unbound-method': 'off',
  '@typescript-eslint/use-unknown-in-catch-callback-variable': 'off',
  '@typescript-eslint/prefer-find': 'off',
  '@typescript-eslint/no-misused-spread': 'off',
  '@typescript-eslint/related-getter-setter-pairs': 'off',
  '@typescript-eslint/prefer-literal-enum-member': 'off',

  // Stylistic rules
  '@typescript-eslint/consistent-indexed-object-style': 'off',
  '@typescript-eslint/consistent-type-definitions': 'off',
  '@typescript-eslint/dot-notation': 'off',
  '@typescript-eslint/no-array-delete': 'off',
  '@typescript-eslint/no-confusing-non-null-assertion': 'off',
  '@typescript-eslint/no-deprecated': 'off',
  '@typescript-eslint/no-dynamic-delete': 'off',
  '@typescript-eslint/no-invalid-void-type': 'off',
  '@typescript-eslint/no-non-null-assertion': 'off',
---
import { configDefaults, defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    globals: true,
    coverage: {
      enabled: true,
      provider: 'v8',
      reportsDirectory: './coverage/raw/default',
      reporter: ['json', 'text', 'html'],
      exclude: [
        ...(configDefaults.coverage.exclude ?? []),
        'benchmarks',
        'runtime-tests',
        'build/build.ts',
        'src/test-utils',
        'perf-measures',

        // types are compile-time only, so their coverage cannot be measured
        'src/**/types.ts',
        'src/jsx/intrinsic-elements.ts',
        'src/utils/http-status.ts',
      ],
    },
    projects: [
      './runtime-tests/*/vitest.config.ts',
      {
        oxc: {
          jsx: {
            runtime: 'automatic',
            importSource: './src/jsx',
          },
        },
        extends: true,
        test: {
          exclude: [...configDefaults.exclude, '**/sandbox/**', '**/*.case.test.*'],
          include: [
            'src/**/(*.)+(spec|test).+(ts|tsx|js)',
            'scripts/**/(*.)+(spec|test).+(ts|tsx|js)',
            'build/**/(*.)+(spec|test).+(ts|tsx|js)',
          ],
          name: 'main',
        },
      },
      {
        oxc: {
          jsx: {
            runtime: 'automatic',
            importSource: './src/jsx',
          },
        },
        extends: true,
        test: {
          include: ['src/jsx/dom/**/(*.)+(spec|test).+(ts|tsx|js)', 'src/jsx/hooks/dom.test.tsx'],
          name: 'jsx-runtime-default',
        },
      },
      {
        oxc: {
          jsx: {
            runtime: 'automatic',
            importSource: './src/jsx/dom',
          },
        },
        extends: true,
        test: {
          include: ['src/jsx/dom/**/(*.)+(spec|test).+(ts|tsx|js)', 'src/jsx/hooks/dom.test.tsx'],
          name: 'jsx-runtime-dom',
        },
      },
    ],
  },
})

Inspect Context res handling

shell
grep -n "set res\|get res\|#res\|newResponse\|setHeaders" /app/src/context.ts | head -40
output
337:  #res: Response | undefined
400:   * @see {@link https://hono.dev/docs/api/context#res}
403:  get res(): Response {
404:    return (this.#res ||= createResponseInstance(null, {
414:  set res(_res: Response | undefined) {
415:    if (this.#res && _res) {
417:      for (const [k, v] of this.#res.headers.entries()) {
422:          const cookies = this.#res.headers.getSetCookie()
432:    this.#res = _res
517:      this.#res = createResponseInstance((this.#res as Response).body, this.#res)
519:    const headers = this.#res ? this.#res.headers : (this.#preparedHeaders ??= new Headers())
604:  #newResponse(
609:    let responseHeaders = this.#res ? new Headers(this.#res.headers) : this.#preparedHeaders
654:  newResponse: NewResponse = (...args) => this.#newResponse(...(args as Parameters<NewResponse>))
681:  ): ReturnType<BodyRespond> => this.#newResponse(data, arg, headers) as ReturnType<BodyRespond>
702:      : (this.#newResponse(
729:    return this.#newResponse(
742:      this.#newResponse(html, arg, setDefaultContentType('text/html; charset=UTF-8', headers))
774:    return this.newResponse(null, status ?? 302) as any

[stdout]
337:  #res: Response | undefined
400:   * @see {@link https://hono.dev/docs/api/context#res}
403:  get res(): Response {
404:    return (this.#res ||= createResponseInstance(null, {
414:  set res(_res: Response | undefined) {
415:    if (this.#res && _res) {
417:      for (const [k, v] of this.#res.headers.entries()) {
422:          const cookies = this.#res.headers.getSetCookie()
432:    this.#res = _res
517:      this.#res = createResponseInstance((this.#res as Response).body, this.#res)
519:    const headers = this.#res ? this.#res.headers : (this.#preparedHeaders ??= new Headers())
604:  #newResponse(
609:    let responseHeaders = this.#res ? new Headers(this.#res.headers) : this.#preparedHeaders
654:  newResponse: NewResponse = (...args) => this.#newResponse(...(args as Parameters<NewResponse>))
681:  ): ReturnType<BodyRespond> => this.#newResponse(data, arg, headers) as ReturnType<BodyRespond>
702:      : (this.#newResponse(
729:    return this.#newResponse(
742:      this.#newResponse(html, arg, setDefaultContentType('text/html; charset=UTF-8', headers))
774:    return this.newResponse(null, status ?? 302) as any

/app/src/context.ts

contents
395	      throw Error('This context has no ExecutionContext')
396	    }
397	  }
398	
399	  /**
400	   * @see {@link https://hono.dev/docs/api/context#res}
401	   * The Response object for the current request.
402	   */
403	  get res(): Response {
404	    return (this.#res ||= createResponseInstance(null, {
405	      headers: (this.#preparedHeaders ??= new Headers()),
406	    }))
407	  }
408	
409	  /**
410	   * Sets the Response object for the current request.
411	   *
412	   * @param _res - The Response object to set.
413	   */
414	  set res(_res: Response | undefined) {
415	    if (this.#res && _res) {
416	      _res = createResponseInstance(_res.body, _res)
417	      for (const [k, v] of this.#res.headers.entries()) {
418	        if (k === 'content-type') {
419	          continue
420	        }
421	        if (k === 'set-cookie') {
422	          const cookies = this.#res.headers.getSetCookie()
423	          _res.headers.delete('set-cookie')
424	          for (const cookie of cookies) {
425	            _res.headers.append('set-cookie', cookie)
426	          }
427	        } else {
428	          _res.headers.set(k, v)
429	        }
430	      }
431	    }
432	    this.#res = _res
433	    this.finalized = true
434	  }
435	
436	  /**
437	   * `.render()` can create a response within a layout.
438	   *
439	   * @see {@link https://hono.dev/docs/api/context#render-setrenderer}
440	   *
441	   * @example
442	   * ```ts
443	   * app.get('/', (c) => {
444	   *   return c.render('Hello!')
445	   * })
446	   * ```
447	   */
448	  render: Renderer = (...args) => {
449	    this.#renderer ??= (content: string | Promise<string>) => this.html(content)
450	    return this.#renderer(...args)
451	  }
452	
453	  /**
454	   * Sets the layout for the response.

Read compose.ts

shell
cat /app/src/compose.ts
output
import type { Context } from './context'
import type { Env, ErrorHandler, Next, NotFoundHandler } from './types'

/**
 * Compose middleware functions into a single function based on `koa-compose` package.
 *
 * @template E - The environment type.
 *
 * @param {[[Function, unknown], unknown][] | [[Function]][]} middleware - An array of middleware functions and their corresponding parameters.
 * @param {ErrorHandler<E>} [onError] - An optional error handler function.
 * @param {NotFoundHandler<E>} [onNotFound] - An optional not-found handler function.
 *
 * @returns {(context: Context, next?: Next) => Promise<Context>} - A composed middleware function.
 */
export const compose = <E extends Env = Env>(
  middleware: [[Function, unknown], unknown][] | [[Function]][],
  onError?: ErrorHandler<E>,
  onNotFound?: NotFoundHandler<E>
): ((context: Context, next?: Next) => Promise<Context>) => {
  return (context, next) => {
    let index = -1

    return dispatch(0)

    /**
     * Dispatch the middleware functions.
     *
     * @param {number} i - The current index in the middleware array.
     *
     * @returns {Promise<Context>} - A promise that resolves to the context.
     */
    async function dispatch(i: number): Promise<Context> {
      if (i <= index) {
        throw new Error('next() called multiple times')
      }
      index = i

      let res
      let isError = false
      let handler

      if (middleware[i]) {
        handler = middleware[i][0][0]
        context.req.routeIndex = i
      } else {
        handler = (i === middleware.length && next) || undefined
      }

      if (handler) {
        try {
          res = await handler(context, () => dispatch(i + 1))
        } catch (err) {
          if (err instanceof Error && onError) {
            context.error = err
            res = await onError(err, context)
            isError = true
          } else {
            throw err
          }
        }
      } else {
        if (context.finalized === false && onNotFound) {
          res = await onNotFound(context)
        }
      }

      if (res && (context.finalized === false || isError)) {
        context.res = res
      }
      return context
    }
  }
}

[stdout]
import type { Context } from './context'
import type { Env, ErrorHandler, Next, NotFoundHandler } from './types'

/**
 * Compose middleware functions into a single function based on `koa-compose` package.
 *
 * @template E - The environment type.
 *
 * @param {[[Function, unknown], unknown][] | [[Function]][]} middleware - An array of middleware functions and their corresponding parameters.
 * @param {ErrorHandler<E>} [onError] - An optional error handler function.
 * @param {NotFoundHandler<E>} [onNotFound] - An optional not-found handler function.
 *
 * @returns {(context: Context, next?: Next) => Promise<Context>} - A composed middleware function.
 */
export const compose = <E extends Env = Env>(
  middleware: [[Function, unknown], unknown][] | [[Function]][],
  onError?: ErrorHandler<E>,
  onNotFound?: NotFoundHandler<E>
): ((context: Context, next?: Next) => Promise<Context>) => {
  return (context, next) => {
    let index = -1

    return dispatch(0)

    /**
     * Dispatch the middleware functions.
     *
     * @param {number} i - The current index in the middleware array.
     *
     * @returns {Promise<Context>} - A promise that resolves to the context.
     */
    async function dispatch(i: number): Promise<Context> {
      if (i <= index) {
        throw new Error('next() called multiple times')
      }
      index = i

      let res
      let isError = false
      let handler

      if (middleware[i]) {
        handler = middleware[i][0][0]
        context.req.routeIndex = i
      } else {
        handler = (i === middleware.length && next) || undefined
      }

      if (handler) {
        try {
          res = await handler(context, () => dispatch(i + 1))
        } catch (err) {
          if (err instanceof Error && onError) {
            context.error = err
            res = await onError(err, context)
            isError = true
          } else {
            throw err
          }
        }
      } else {
        if (context.finalized === false && onNotFound) {
          res = await onNotFound(context)
        }
      }

      if (res && (context.finalized === false || isError)) {
        context.res = res
      }
      return context
    }
  }
}

Find c.res = undefined idiom

shell
grep -rn "c.res = undefined" /app/src --include=*.ts | head -20
output
/app/src/hono.test.ts:1191:      c.res = undefined

[stdout]
/app/src/hono.test.ts:1191:      c.res = undefined

Read res=undefined test

shell
sed -n 1180,1210p /app/src/hono.test.ts
output
describe('Overwrite the response from middleware after next()', () => {
    const app = new Hono()

    app.use('/normal', async (c, next) => {
      await next()
      c.res = new Response('Middleware')
    })

    app.use('/overwrite', async (c, next) => {
      await next()
      c.res = undefined
      c.res = new Response('Middleware')
    })

    app.get('*', (c) => {
      c.header('x-custom', 'foo')
      return c.text('Handler')
    })

    it('Should have the custom header', async () => {
      const res = await app.request('/normal')
      expect(res.headers.get('x-custom')).toBe('foo')
    })

    it('Should not have the custom header', async () => {
      const res = await app.request('/overwrite')
      expect(res.headers.get('x-custom')).toBe(null)
    })
  })
})

[stdout]
  describe('Overwrite the response from middleware after next()', () => {
    const app = new Hono()

    app.use('/normal', async (c, next) => {
      await next()
      c.res = new Response('Middleware')
    })

    app.use('/overwrite', async (c, next) => {
      await next()
      c.res = undefined
      c.res = new Response('Middleware')
    })

    app.get('*', (c) => {
      c.header('x-custom', 'foo')
      return c.text('Handler')
    })

    it('Should have the custom header', async () => {
      const res = await app.request('/normal')
      expect(res.headers.get('x-custom')).toBe('foo')
    })

    it('Should not have the custom header', async () => {
      const res = await app.request('/overwrite')
      expect(res.headers.get('x-custom')).toBe(null)
    })
  })
})

/app/src/context.ts

contents
595	    ContextVariableMap & (IsAny<E['Variables']> extends true ? Record<string, any> : E['Variables'])
596	  > {
597	    if (!this.#var) {
598	      // eslint-disable-next-line @typescript-eslint/no-explicit-any
599	      return {} as any
600	    }
601	    return Object.fromEntries(this.#var)
602	  }
603	
604	  #newResponse(
605	    data: Data | null,
606	    arg?: StatusCode | ResponseOrInit,
607	    headers?: HeaderRecord
608	  ): Response {
609	    let responseHeaders = this.#res ? new Headers(this.#res.headers) : this.#preparedHeaders
610	
611	    if (typeof arg === 'object' && arg.headers) {
612	      responseHeaders ??= new Headers()
613	      for (const [key, value] of new Headers(arg.headers)) {
614	        if (key === 'set-cookie') {
615	          responseHeaders.append(key, value)
616	        } else {
617	          responseHeaders.set(key, value)
618	        }
619	      }
620	    }
621	
622	    if (headers) {
623	      if (!responseHeaders) {
624	        let count = 0
625	        for (const k in headers) {
626	          if (++count > 1 || typeof headers[k as keyof HeaderRecord] !== 'string') {
627	            responseHeaders = new Headers()
628	            break
629	          }
630	        }
631	      }
632	      if (responseHeaders) {
633	        for (const k in headers) {
634	          const v = headers[k as keyof HeaderRecord]
635	          if (typeof v === 'string') {
636	            responseHeaders.set(k, v)
637	          } else {
638	            responseHeaders.delete(k)
639	            for (const v2 of v) {
640	              responseHeaders.append(k, v2)
641	            }
642	          }
643	        }
644	      }
645	    }
646	
647	    const status = typeof arg === 'number' ? arg : (arg?.status ?? this.#status)
648	    return createResponseInstance(data, {
649	      status,
650	      headers: responseHeaders ?? (headers as Record<string, string> | undefined),
651	    })
652	  }
653	
654	  newResponse: NewResponse = (...args) => this.#newResponse(...(args as Parameters<NewResponse>))
655	
656	  /**
657	   * `.body()` can return the HTTP response.
658	   * You can set headers with `.header()` and set HTTP status code with `.status`.
659	   * This can also be set in `.text()`, `.json()` and so on.
660	   *
661	   * @see {@link https://hono.dev/docs/api/context#body}
662	   *
663	   * @example
664	   * ```ts

Check Data and ResponseOrInit types

shell
grep -n "export type Data\|type ResponseOrInit\|interface NewResponse\|type NewResponse" -A 12 /app/src/context.ts | head -60
output
26:export type Data = string | ArrayBuffer | ReadableStream | Uint8Array<ArrayBuffer>
27-
28-/**
29- * Interface for the execution context in a web worker or similar environment.
30- */
31-export interface ExecutionContext {
32-  /**
33-   * Extends the lifetime of the event callback until the promise is settled.
34-   *
35-   * @param promise - A promise to wait for.
36-   */
37-  waitUntil(promise: Promise<unknown>): void
38-  /**
--
113:interface NewResponse {
114-  (data: Data | null, status?: StatusCode, headers?: HeaderRecord): Response
115-  (data: Data | null, init?: ResponseOrInit): Response
116-}
117-
118-/**
119- * Interface for responding with a body.
120- */
121-interface BodyRespond {
122-  // if we return content, only allow the status codes that allow for returning the body
123-  <T extends Data, U extends ContentfulStatusCode>(
124-    data: T,
125-    status?: U,
--
277:type ResponseOrInit<T extends StatusCode = StatusCode> = ResponseInit<T> | Response
278-
279-export const TEXT_PLAIN = 'text/plain; charset=UTF-8'
280-
281-const setDefaultContentType = (contentType: string, headers?: HeaderRecord): HeaderRecord => {
282-  return {
283-    'Content-Type': contentType,
284-    ...headers,
285-  }
286-}
287-
288-const createResponseInstance = (
289-  body?: BodyInit | null | undefined,

[stdout]
26:export type Data = string | ArrayBuffer | ReadableStream | Uint8Array<ArrayBuffer>
27-
28-/**
29- * Interface for the execution context in a web worker or similar environment.
30- */
31-export interface ExecutionContext {
32-  /**
33-   * Extends the lifetime of the event callback until the promise is settled.
34-   *
35-   * @param promise - A promise to wait for.
36-   */
37-  waitUntil(promise: Promise<unknown>): void
38-  /**
--
113:interface NewResponse {
114-  (data: Data | null, status?: StatusCode, headers?: HeaderRecord): Response
115-  (data: Data | null, init?: ResponseOrInit): Response
116-}
117-
118-/**
119- * Interface for responding with a body.
120- */
121-interface BodyRespond {
122-  // if we return content, only allow the status codes that allow for returning the body
123-  <T extends Data, U extends ContentfulStatusCode>(
124-    data: T,
125-    status?: U,
--
277:type ResponseOrInit<T extends StatusCode = StatusCode> = ResponseInit<T> | Response
278-
279-export const TEXT_PLAIN = 'text/plain; charset=UTF-8'
280-
281-const setDefaultContentType = (contentType: string, headers?: HeaderRecord): HeaderRecord => {
282-  return {
283-    'Content-Type': contentType,
284-    ...headers,
285-  }
286-}
287-
288-const createResponseInstance = (
289-  body?: BodyInit | null | undefined,

Locate ResponseInit type

shell
grep -rn "ResponseInit" /app/src/types.ts /app/src/utils/http-status.ts /app/src/context.ts | head -20
output
/app/src/context.ts:271:interface ResponseInit<T extends StatusCode = StatusCode> {
/app/src/context.ts:277:type ResponseOrInit<T extends StatusCode = StatusCode> = ResponseInit<T> | Response
/app/src/context.ts:290:  init?: globalThis.ResponseInit

[stdout]
/app/src/context.ts:271:interface ResponseInit<T extends StatusCode = StatusCode> {
/app/src/context.ts:277:type ResponseOrInit<T extends StatusCode = StatusCode> = ResponseInit<T> | Response
/app/src/context.ts:290:  init?: globalThis.ResponseInit

/app/src/context.ts

contents
265	  | [string, string][]
266	  | Record<'Content-Type', BaseMime>
267	  | Record<ResponseHeader, string>
268	  | Record<string, string>
269	  | Headers
270	
271	interface ResponseInit<T extends StatusCode = StatusCode> {
272	  headers?: ResponseHeadersInit
273	  status?: T
274	  statusText?: string
275	}
276	
277	type ResponseOrInit<T extends StatusCode = StatusCode> = ResponseInit<T> | Response
278	
279	export const TEXT_PLAIN = 'text/plain; charset=UTF-8'
280	
281	const setDefaultContentType = (contentType: string, headers?: HeaderRecord): HeaderRecord => {
282	  return {
283	    'Content-Type': contentType,
284	    ...headers,
285	  }
286	}
287	
288	const createResponseInstance = (
289	  body?: BodyInit | null | undefined,
290	  init?: globalThis.ResponseInit
291	): Response => new Response(body, init)
292	
293	export class Context<
294	  // eslint-disable-next-line @typescript-eslint/no-explicit-any
295	  E extends Env = any,
296	  // eslint-disable-next-line @typescript-eslint/no-explicit-any
297	  P extends string = any,
298	  I extends Input = {},
299	> {
300	  #rawRequest: Request
301	  #req: HonoRequest<P, I['out']> | undefined
302	  /**
303	   * `.env` can get bindings (environment variables, secrets, KV namespaces, D1 database, R2 bucket etc.) in Cloudflare Workers.
304	   *
Now I have a clear picture. Let me create the branch and write the middleware.

Create feature branch

shell
git checkout -b feat/http-cache-middleware && git status --short
output
Switched to a new branch 'feat/http-cache-middleware'

[stdout]
Switched to a new branch 'feat/http-cache-middleware'
write · /app/src/middleware/http-cache/index.ts
/**
 * @module
 * HTTP Cache Middleware for Hono.
 *
 * A self-contained shared cache implementing RFC 9111 (HTTP Caching), the
 * precondition evaluation of RFC 9110 Section 13.2, and the `stale-if-error`
 * extension of RFC 5861. It does not depend on the platform Web Cache API:
 * responses are kept in a user-supplied store, and the downstream handler
 * plays the role of the origin server.
 *
 * Out of scope: `stale-while-revalidate`, Range requests and 206 responses,
 * `Pragma`, `Warning`, trailers, and `immutable`.
 */

import type { Context } from '../../context'
import type { MiddlewareHandler } from '../../types'
import type { StatusCode } from '../../utils/http-status'

/**
 * The backing store of the cache. One entry per target URI, so a plain `Map`
 * qualifies. Values are opaque: only this middleware creates and reads them.
 */
export type HttpCacheStore = {
  get(key: string): unknown | undefined
  set(key: string, value: unknown): unknown
  delete(key: string): boolean
}

/**
 * Options for the HTTP cache middleware.
 */
export type HttpCacheOptions = {
  /**
   * The store holding cached responses. Defaults to a fresh `Map` per middleware instance.
   */
  store?: HttpCacheStore
  /**
   * Returns the current time in epoch milliseconds. Defaults to `Date.now`.
   * This is the only clock the middleware reads.
   */
  now?: () => number
}

/**
 * A single stored response, i.e. one of the Vary-selected variants of a target URI.
 */
type StoredResponse = {
  status: number
  /** Response header fields as `[lower-cased name, value]` pairs. */
  headers: [string, string][]
  body: Uint8Array<ArrayBuffer> | null
  /** The local clock when the request that produced this response was sent (RFC 9111 §4.2.3). */
  requestTime: number
  /** The local clock when this response was received (RFC 9111 §4.2.3). */
  responseTime: number
  /**
   * The selecting header fields (RFC 9111 §4.1) as `[lower-cased name,
   * normalized value or `null` when the field was absent]` pairs, sorted by name.
   */
  vary: [string, string | null][]
}

/** Everything stored under one target URI. */
type CacheEntry = {
  variants: StoredResponse[]
}

/** Parsed `Cache-Control`: directive name to the argument of each occurrence. */
type CacheControl = Map<string, (string | null)[]>

/** The values derived from a stored response that drive reuse decisions. */
type StoredInfo = {
  headers: Headers
  directives: CacheControl
  /** The Date of the response, falling back to the time it was received. */
  date: number
  /** `current_age` in milliseconds (RFC 9111 §4.2.3). */
  age: number
  /** `freshness_lifetime` in milliseconds (RFC 9111 §4.2.1). */
  lifetime: number
}

const SECOND = 1000

/** RFC 9111 §1.2.2 recommends clamping oversized delta-seconds values. */
const MAX_DELTA_SECONDS = 2147483648

/** RFC 9110 §9.2.1. Requests with any other method invalidate stored responses. */
const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS', 'TRACE']

/**
 * Hop-by-hop fields (RFC 9110 §7.6.1). RFC 9111 §3.1 allows removing them
 * before storage, and RFC 9111 §3.2 keeps them out of header updates.
 */
const HOP_BY_HOP_HEADERS = [
  'connection',
  'proxy-connection',
  'keep-alive',
  'te',
  'transfer-encoding',
  'upgrade',
]

/** RFC 9110 §13.1. The cache evaluates these itself, so they are never forwarded. */
const PRECONDITION_HEADERS = [
  'if-match',
  'if-none-match',
  'if-modified-since',
  'if-unmodified-since',
  'if-range',
]

/** The header fields a generated 304 has to carry (RFC 9110 §15.4.5). */
const RETAINED_304_HEADERS = [
  'cache-control',
  'content-location',
  'date',
  'etag',
  'expires',
  'vary',
]

/**
 * Status codes that are heuristically cacheable (RFC 9111 §4.2.2). 206 is
 * excluded because Range requests are out of scope for this cache.
 */
const HEURISTICALLY_CACHEABLE_STATUSES = [
  200, 203, 204, 300, 301, 308, 404, 405, 410, 414, 451,
]

/**
 * The status codes whose caching semantics this cache understands, used to
 * evaluate the `must-understand` response directive (RFC 9111 §5.2.2.3).
 * 206 and 304 are absent: this cache never stores them.
 */
const UNDERSTOOD_STATUSES = [
  200, 201, 202, 203, 204, 205, 300, 301, 302, 303, 305, 307, 308, 400, 401, 402, 403, 404, 405,
  406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 417, 421, 422, 426, 500, 501, 502, 503, 504,
  505,
]

/** The status codes treated as an error by `stale-if-error` (RFC 5861 §4). */
const ERROR_STATUSES = [500, 502, 503, 504]

/** Statuses whose responses must not carry a body. */
const NULL_BODY_STATUSES = [101, 204, 205, 304]

//
// Generic HTTP parsing helpers
//

const MONTHS = 'Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec'
const IMF_FIXDATE = /^[A-Za-z]{3}, (\d{2}) ([A-Za-z]{3}) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/
const RFC850_DATE = /^[A-Za-z]+, (\d{2})-([A-Za-z]{3})-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/
const ASCTIME_DATE = /^[A-Za-z]{3} ([A-Za-z]{3}) ([ \d]\d) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/

const toEpoch = (
  year: number,
  month: string,
  day: number,
  hour: number,
  minute: number,
  second: number
): number | null => {
  const monthIndex = MONTHS.indexOf(month)
  if (monthIndex < 0 || day < 1 || day > 31 || hour > 23 || minute > 59 || second > 60) {
    return null
  }
  return Date.UTC(year, monthIndex / 4, day, hour, minute, second)
}

/**
 * Parses the three HTTP-date formats of RFC 9110 §5.6.7. `Date.parse` is not
 * used because it accepts values an HTTP-date is not allowed to have, such as
 * the `Expires: 0` that RFC 9111 §5.3 requires to be read as "already expired".
 *
 * `nowMs` is only used for the two-digit years of the obsolete rfc850 format;
 * it is converted, never read from the clock.
 */
const parseHttpDate = (value: string, nowMs: number): number | null => {
  let match = IMF_FIXDATE.exec(value)
  if (match) {
    return toEpoch(+match[3], match[2], +match[1], +match[4], +match[5], +match[6])
  }

  match = RFC850_DATE.exec(value)
  if (match) {
    // RFC 9110 §5.6.7: a timestamp that appears to be more than 50 years in
    // the future is the most recent year in the past with the same last two digits.
    const currentYear = new Date(nowMs).getUTCFullYear()
    let year = Math.floor(currentYear / 100) * 100 + +match[3]
    if (year > currentYear + 50) {
      year -= 100
    }
    return toEpoch(year, match[2], +match[1], +match[4], +match[5], +match[6])
  }

  match = ASCTIME_DATE.exec(value)
  if (match) {
    return toEpoch(+match[6], match[1], +match[2], +match[3], +match[4], +match[5])
  }

  return null
}

/**
 * Splits the value of a header field that carries HTTP-dates. Duplicate field
 * lines arrive comma-combined, and both date formats that contain a comma put
 * it right after the day name, which is the only all-alphabetic fragment.
 */
const splitDateList = (value: string): string[] => {
  const fragments = value.split(',')
  const dates: string[] = []
  for (let i = 0; i < fragments.length; i++) {
    const fragment = fragments[i].trim()
    if (/^[A-Za-z]+$/.test(fragment) && i + 1 < fragments.length) {
      dates.push(`${fragment}, ${fragments[++i].trim()}`)
    } else {
      dates.push(fragment)
    }
  }
  return dates
}

/**
 * Reads a header field carrying an HTTP-date. Returns `undefined` when the
 * field is absent and `null` when it is invalid; duplicate field lines take
 * the earliest (most restrictive) value.
 */
const readDateHeader = (
  headers: Headers,
  name: string,
  nowMs: number
): number | null | undefined => {
  const value = headers.get(name)
  if (value === null) {
    return undefined
  }
  let earliest: number | undefined
  for (const date of splitDateList(value)) {
    const parsed = parseHttpDate(date, nowMs)
    if (parsed === null) {
      return null
    }
    earliest = earliest === undefined ? parsed : Math.min(earliest, parsed)
  }
  return earliest ?? null
}

/**
 * Reads a precondition header field carrying a single HTTP-date. RFC 9110
 * §13.1.3 and §13.1.4 require ignoring values that are not a valid HTTP-date,
 * including values that appear to be a list of dates.
 */
const readSingleDate = (headers: Headers, name: string, nowMs: number): number | null => {
  const value = headers.get(name)
  if (value === null) {
    return null
  }
  const dates = splitDateList(value)
  return dates.length === 1 ? parseHttpDate(dates[0], nowMs) : null
}

/**
 * Parses a `Cache-Control` field value into its directives, keeping every
 * occurrence of a directive and unquoting quoted arguments.
 */
const parseCacheControl = (value: string | null): CacheControl => {
  const directives: CacheControl = new Map()
  if (!value) {
    return directives
  }

  let index = 0
  while (index < value.length) {
    let start = index
    while (index < value.length && value[index] !== ',' && value[index] !== '=') {
      index++
    }
    const name = value.slice(start, index).trim().toLowerCase()

    let argument: string | null = null
    if (value[index] === '=') {
      index++
      while (value[index] === ' ' || value[index] === '\t') {
        index++
      }
      if (value[index] === '"') {
        index++
        let quoted = ''
        while (index < value.length && value[index] !== '"') {
          if (value[index] === '\\' && index + 1 < value.length) {
            index++
          }
          quoted += value[index++]
        }
        argument = quoted
        while (index < value.length && value[index] !== ',') {
          index++
        }
      } else {
        start = index
        while (index < value.length && value[index] !== ',') {
          index++
        }
        argument = value.slice(start, index).trim()
      }
    }

    if (name) {
      const occurrences = directives.get(name)
      if (occurrences) {
        occurrences.push(argument)
      } else {
        directives.set(name, [argument])
      }
    }
    index++
  }

  return directives
}

/** RFC 9111 §1.2.2: delta-seconds is a non-negative decimal integer. */
const parseDeltaSeconds = (argument: string | null): number | null =>
  argument !== null && /^\d+$/.test(argument)
    ? Math.min(Number(argument) * SECOND, MAX_DELTA_SECONDS * SECOND)
    : null

/**
 * Reads a delta-seconds directive as milliseconds. Returns `undefined` when
 * the directive is absent; duplicate occurrences are reduced with `restrict`
 * (which always picks the most restrictive value) and an occurrence that is
 * not a valid delta-seconds contributes `invalid` instead.
 */
const readDeltaSeconds = (
  directives: CacheControl,
  name: string,
  restrict: (a: number, b: number) => number,
  invalid: number
): number | undefined => {
  const occurrences = directives.get(name)
  if (!occurrences) {
    return undefined
  }
  let result: number | undefined
  for (const occurrence of occurrences) {
    const milliseconds = parseDeltaSeconds(occurrence) ?? invalid
    result = result === undefined ? milliseconds : restrict(result, milliseconds)
  }
  return result
}

/**
 * Reads the `max-stale` request directive as milliseconds. Unlike the other
 * delta-seconds directives it is valid without an argument, which means that
 * any amount of staleness is acceptable (RFC 9111 §5.2.1.2).
 */
const readMaxStale = (directives: CacheControl): number | undefined => {
  const occurrences = directives.get('max-stale')
  if (!occurrences) {
    return undefined
  }
  let result = Infinity
  for (const occurrence of occurrences) {
    if (occurrence !== null) {
      result = Math.min(result, parseDeltaSeconds(occurrence) ?? 0)
    }
  }
  return result
}

/** Whether a directive is present without an argument, i.e. in its unqualified form. */
const hasUnqualified = (directives: CacheControl, name: string): boolean =>
  !!directives.get(name)?.some((argument) => argument === null)

/** The field names listed by the qualified form of `no-cache` or `private`. */
const qualifiedFields = (directives: CacheControl, name: string): string[] =>
  (directives.get(name) ?? []).flatMap(
    (argument) =>
      argument
        ?.split(',')
        .map((field) => field.trim().toLowerCase())
        .filter(Boolean) ?? []
  )

/** Entity tags cannot contain a double quote, so they can be matched directly. */
const ENTITY_TAG = /\*|(?:W\/)?"[^"]*"/g

const stripWeak = (tag: string): string => tag.replace(/^W\//, '')

/** RFC 9110 §8.8.3.2. */
const matchesEntityTag = (list: string, etag: string | null, strong: boolean): boolean => {
  const tags = list.match(ENTITY_TAG) ?? []
  if (tags.includes('*')) {
    // A stored response is a current representation of the target resource.
    return true
  }
  if (etag === null) {
    return false
  }
  return tags.some((tag) =>
    strong ? tag === etag && !tag.startsWith('W/') : stripWeak(tag) === stripWeak(etag)
  )
}

/**
 * Normalizes a field value for Vary comparison: leading and trailing
 * whitespace is removed and internal optional whitespace is collapsed.
 */
const normalizeFieldValue = (value: string | null): string | null =>
  value === null ? null : value.trim().replace(/[ \t]+/g, ' ')

const headerPairs = (headers: Headers): [string, string][] => {
  const pairs: [string, string][] = []
  headers.forEach((value, name) => pairs.push([name, value]))
  return pairs
}

/**
 * Copies the header fields of `source` onto `target`, replacing the fields
 * that are already present (RFC 9111 §3.2).
 */
const updateHeaders = (target: Headers, source: Headers): void => {
  if (source.has('set-cookie')) {
    target.delete('set-cookie')
    for (const cookie of source.getSetCookie()) {
      target.append('set-cookie', cookie)
    }
  }
  source.forEach((value, name) => {
    // Content-Length is excepted by RFC 9111 §3.2, hop-by-hop fields by §3.1.
    if (name !== 'set-cookie' && name !== 'content-length' && !HOP_BY_HOP_HEADERS.includes(name)) {
      target.set(name, value)
    }
  })
}

//
// Stored responses
//

const readEntry = (store: HttpCacheStore, key: string): CacheEntry | undefined => {
  const value = store.get(key)
  return value && Array.isArray((value as CacheEntry).variants) ? (value as CacheEntry) : undefined
}

/**
 * The selecting header fields of a response (RFC 9111 §4.1). A `Vary` field
 * value of `*` is kept as-is: it never matches a subsequent request.
 */
const selectingFields = (
  vary: string | null,
  requestHeaders: Headers
): [string, string | null][] => {
  if (!vary) {
    return []
  }
  const names = vary
    .split(',')
    .map((name) => name.trim().toLowerCase())
    .filter(Boolean)
  return [...new Set(names)]
    .sort()
    .map((name) =>
      name === '*' ? ['*', null] : [name, normalizeFieldValue(requestHeaders.get(name))]
    )
}

/** RFC 9111 §4.1: every selecting header field has to match. */
const variantMatches = (variant: StoredResponse, requestHeaders: Headers): boolean =>
  variant.vary.every(
    ([name, value]) => name !== '*' && normalizeFieldValue(requestHeaders.get(name)) === value
  )

/**
 * Selects the stored response to use for a request. When several variants
 * match, the one with the most recent Date wins (RFC 9111 §4).
 */
const selectVariant = (
  entry: CacheEntry,
  requestHeaders: Headers,
  nowMs: number
): StoredResponse | undefined => {
  let selected: StoredResponse | undefined
  let selectedDate = -Infinity
  for (const variant of entry.variants) {
    if (!variantMatches(variant, requestHeaders)) {
      continue
    }
    const date = responseDate(variant, nowMs)
    if (date >= selectedDate) {
      selected = variant
      selectedDate = date
    }
  }
  return selected
}

const responseDate = (variant: StoredResponse, nowMs: number): number => {
  const date = readDateHeader(new Headers(variant.headers), 'date', nowMs)
  // RFC 9111 §4.2.3: without a usable Date, the time of reception is used.
  return typeof date === 'number' ? date : variant.responseTime
}

/**
 * The freshness lifetime of a stored response in milliseconds (RFC 9111 §4.2.1).
 * Duplicated directives take the most restrictive value, and an invalid value
 * makes the response stale.
 */
const freshnessLifetime = (
  headers: Headers,
  directives: CacheControl,
  date: number,
  nowMs: number
): number => {
  // A shared cache prefers s-maxage over max-age and Expires (RFC 9111 §5.2.2.10).
  const sharedMaxAge = readDeltaSeconds(directives, 's-maxage', Math.min, 0)
  if (sharedMaxAge !== undefined) {
    return sharedMaxAge
  }
  const maxAge = readDeltaSeconds(directives, 'max-age', Math.min, 0)
  if (maxAge !== undefined) {
    return maxAge
  }
  const expires = readDateHeader(headers, 'expires', nowMs)
  if (expires !== undefined) {
    // RFC 9111 §5.3: an invalid Expires means the response is already expired.
    return expires === null ? 0 : Math.max(0, expires - date)
  }

  // Heuristic freshness: 10% of the interval since the last modification.
  const lastModified = readDateHeader(headers, 'last-modified', nowMs)
  if (typeof lastModified !== 'number') {
    return 0
  }
  return Math.max(0, (date - lastModified) * 0.1)
}

/**
 * The current age of a stored response in milliseconds, following the
 * algorithm of RFC 9111 §4.2.3. An invalid Age makes the response stale.
 */
const currentAge = (variant: StoredResponse, headers: Headers, date: number, nowMs: number) => {
  let ageValue = 0
  const age = headers.get('age')
  if (age !== null) {
    for (const value of age.split(',')) {
      const seconds = parseDeltaSeconds(value.trim())
      if (seconds === null) {
        return Infinity
      }
      ageValue = Math.max(ageValue, seconds)
    }
  }

  const apparentAge = Math.max(0, variant.responseTime - date)
  const responseDelay = variant.responseTime - variant.requestTime
  const correctedAgeValue = ageValue + responseDelay
  const correctedInitialAge = Math.max(apparentAge, correctedAgeValue)
  const residentTime = nowMs - variant.responseTime
  return correctedInitialAge + residentTime
}

const describe = (variant: StoredResponse, nowMs: number): StoredInfo => {
  const headers = new Headers(variant.headers)
  const directives = parseCacheControl(headers.get('cache-control'))
  const date = responseDate(variant, nowMs)
  return {
    headers,
    directives,
    date,
    age: currentAge(variant, headers, date, nowMs),
    lifetime: freshnessLifetime(headers, directives, date, nowMs),
  }
}

/**
 * Whether serving this stored response while stale is forbidden by an
 * explicit in-protocol directive (RFC 9111 §4.2.4). For a shared cache,
 * s-maxage carries the semantics of proxy-revalidate (RFC 9111 §5.2.2.10).
 */
const staleForbidden = (info: StoredInfo): boolean =>
  info.directives.has('no-cache') ||
  info.directives.has('must-revalidate') ||
  info.directives.has('proxy-revalidate') ||
  info.directives.has('s-maxage')

/**
 * Decides whether a stored response can be reused as-is, honoring the request
 * cache directives of RFC 9111 §5.2.1 as well as the response directives.
 */
const canReuse = (info: StoredInfo, requestDirectives: CacheControl): boolean => {
  // The unqualified form of no-cache requires successful validation, both when
  // sent by the client (§5.2.1.4) and when stored with the response (§5.2.2.4).
  if (hasUnqualified(requestDirectives, 'no-cache') || hasUnqualified(info.directives, 'no-cache')) {
    return false
  }

  const maxAge = readDeltaSeconds(requestDirectives, 'max-age', Math.min, 0)
  if (maxAge !== undefined && info.age > maxAge) {
    return false
  }
  const minFresh = readDeltaSeconds(requestDirectives, 'min-fresh', Math.max, Infinity)
  if (minFresh !== undefined && info.lifetime - info.age < minFresh) {
    return false
  }

  const staleness = info.age - info.lifetime
  if (staleness < 0) {
    return true
  }
  // A stale response may only be served when the client explicitly accepts
  // staleness and no directive of the stored response forbids it (§4.2.4).
  const maxStale = readMaxStale(requestDirectives)
  return maxStale !== undefined && staleness <= maxStale && !staleForbidden(info)
}

/** RFC 5861 §4, with the staleness limits of RFC 9111 §4.2.4 applied. */
const canServeOnError = (info: StoredInfo, requestDirectives: CacheControl): boolean => {
  const staleIfError =
    readDeltaSeconds(info.directives, 'stale-if-error', Math.min, 0) ??
    readDeltaSeconds(requestDirectives, 'stale-if-error', Math.min, 0)
  return (
    staleIfError !== undefined && info.age - info.lifetime <= staleIfError && !staleForbidden(info)
  )
}

/** RFC 9111 §3, for a shared cache. */
const isStorable = (
  status: number,
  requestDirectives: CacheControl,
  responseDirectives: CacheControl,
  responseHeaders: Headers,
  authorized: boolean
): boolean => {
  // 206 and 304 are the status codes a cache has to understand to store them.
  if (status < 200 || status === 206 || status === 304) {
    return false
  }

  // must-understand forbids storage of a status code the cache does not know,
  // and overrides the no-store directive of the response (§5.2.2.3).
  const mustUnderstand = responseDirectives.has('must-understand')
  if (mustUnderstand && !UNDERSTOOD_STATUSES.includes(status)) {
    return false
  }
  if (!mustUnderstand && responseDirectives.has('no-store')) {
    return false
  }
  if (requestDirectives.has('no-store')) {
    return false
  }
  // The qualified form of private only keeps the listed fields out of a shared cache.
  if (hasUnqualified(responseDirectives, 'private')) {
    return false
  }

  const sharedMaxAge = responseDirectives.has('s-maxage')
  if (
    authorized &&
    !responseDirectives.has('public') &&
    !responseDirectives.has('must-revalidate') &&
    !sharedMaxAge
  ) {
    // RFC 9111 §3.5: a response to an authenticated request needs an explicit
    // directive to be stored by a shared cache.
    return false
  }

  return (
    responseDirectives.has('public') ||
    responseDirectives.has('max-age') ||
    sharedMaxAge ||
    responseHeaders.has('expires') ||
    HEURISTICALLY_CACHEABLE_STATUSES.includes(status)
  )
}

//
// Preconditions (RFC 9110 §13.2)
//

/**
 * Evaluates the preconditions of a request against a stored response, in the
 * order of RFC 9110 §13.2.2. Returns the status code to respond with, or
 * `undefined` when every precondition passes. If-Range is not evaluated
 * because Range requests are out of scope.
 */
const evaluatePreconditions = (
  requestHeaders: Headers,
  storedHeaders: Headers,
  variant: StoredResponse,
  nowMs: number
): 304 | 412 | undefined => {
  const etag = storedHeaders.get('etag')
  // RFC 9111 §4.3.2: without a Last-Modified, the Date of the stored response
  // is used, and failing that the time it was received.
  const lastModified =
    readDateHeader(storedHeaders, 'last-modified', nowMs) ??
    readDateHeader(storedHeaders, 'date', nowMs) ??
    Math.floor(variant.responseTime / SECOND) * SECOND

  const ifMatch = requestHeaders.get('if-match')
  if (ifMatch !== null) {
    if (!matchesEntityTag(ifMatch, etag, true)) {
      return 412
    }
  } else {
    const ifUnmodifiedSince = readSingleDate(requestHeaders, 'if-unmodified-since', nowMs)
    if (ifUnmodifiedSince !== null && (lastModified === null || lastModified > ifUnmodifiedSince)) {
      return 412
    }
  }

  const ifNoneMatch = requestHeaders.get('if-none-match')
  if (ifNoneMatch !== null) {
    if (matchesEntityTag(ifNoneMatch, etag, false)) {
      return 304
    }
  } else {
    const ifModifiedSince = readSingleDate(requestHeaders, 'if-modified-since', nowMs)
    if (ifModifiedSince !== null && lastModified !== null && lastModified <= ifModifiedSince) {
      return 304
    }
  }

  return undefined
}

//
// The middleware
//

/**
 * HTTP Cache Middleware for Hono.
 *
 * Implements a shared cache as specified by RFC 9111, using the downstream
 * handler as the origin server. A request that can be satisfied from the
 * store never reaches the handler; every other request reaches it once,
 * carrying the conditional header fields the cache derived from the stored
 * validators when it needs to revalidate.
 *
 * @param {HttpCacheOptions} [options] - The options for the HTTP cache middleware.
 * @param {HttpCacheStore} [options.store] - The store holding cached responses. Defaults to a fresh `Map`.
 * @param {function(): number} [options.now] - Returns the current time in epoch milliseconds. Defaults to `Date.now`.
 * @returns {MiddlewareHandler} The middleware handler function.
 *
 * @example
 * ```ts
 * const app = new Hono()
 *
 * app.use(httpCache())
 * app.get('/', (c) => {
 *   c.header('Cache-Control', 'max-age=60')
 *   return c.text('Hono is hot')
 * })
 * ```
 */
export const httpCache = (options?: HttpCacheOptions): MiddlewareHandler => {
  const store: HttpCacheStore = options?.store ?? new Map<string, unknown>()
  const now = options?.now ?? Date.now

  /** RFC 9111 §4.4. */
  const invalidate = (target: string | null, base: string): void => {
    if (target === null) {
      return
    }
    try {
      const url = new URL(target, base)
      url.hash = ''
      // Only a reference with the same origin as the target URI is invalidated.
      if (url.origin === new URL(base).origin) {
        store.delete(url.href)
      }
    } catch {
      // A reference that cannot be resolved is not a cache key.
    }
  }

  /**
   * Builds the response to send from a stored response, evaluating the
   * preconditions of the request against it.
   */
  const useStored = (
    c: Context,
    variant: StoredResponse,
    info: StoredInfo,
    requestHeaders: Headers,
    nowMs: number,
    validated: boolean
  ): Response => {
    const headers = new Headers(variant.headers)
    if (!validated) {
      // The qualified form of no-cache forbids reusing the listed fields
      // without successful validation (RFC 9111 §5.2.2.4).
      for (const field of qualifiedFields(info.directives, 'no-cache')) {
        headers.delete(field)
      }
    }

    const age = String(
      Number.isFinite(info.age) ? Math.max(0, Math.floor(info.age / SECOND)) : MAX_DELTA_SECONDS
    )
    headers.set('Age', age)

    const precondition = evaluatePreconditions(requestHeaders, headers, variant, nowMs)
    if (precondition === 412) {
      return c.newResponse(null, { status: 412 })
    }
    if (precondition === 304) {
      const notModified = new Headers()
      for (const name of RETAINED_304_HEADERS) {
        const value = headers.get(name)
        if (value !== null) {
          notModified.set(name, value)
        }
      }
      notModified.set('Age', age)
      return c.newResponse(null, { status: 304, headers: notModified })
    }

    const body =
      c.req.method === 'HEAD' || NULL_BODY_STATUSES.includes(variant.status) ? null : variant.body
    return c.newResponse(body, { status: variant.status as StatusCode, headers })
  }

  return async function httpCache(c, next) {
    const method = c.req.method
    const key = c.req.url
    // The request may be replaced by a conditional one, so the header fields
    // the client actually sent are kept for Vary selection and preconditions.
    const requestHeaders = c.req.raw.headers
    const requestDirectives = parseCacheControl(requestHeaders.get('cache-control'))

    if (method !== 'GET' && method !== 'HEAD') {
      await next()
      // RFC 9111 §4.4: a non-error response to an unsafe request invalidates
      // the target URI, and the URIs of Location and Content-Location.
      const status = c.res.status
      if (!SAFE_METHODS.includes(method) && status >= 200 && status < 400) {
        store.delete(key)
        invalidate(c.res.headers.get('location'), key)
        invalidate(c.res.headers.get('content-location'), key)
      }
      return
    }

    const entry = readEntry(store, key)
    let nowMs = now()
    const variant = entry && selectVariant(entry, requestHeaders, nowMs)
    let info = variant && describe(variant, nowMs)

    if (variant && info && canReuse(info, requestDirectives)) {
      return useStored(c, variant, info, requestHeaders, nowMs, false)
    }

    if (requestDirectives.has('only-if-cached')) {
      // RFC 9111 §5.2.1.7: the request must not be forwarded, so the handler
      // is not reached. This is the only request the cache answers by itself
      // without a stored response.
      return c.newResponse(null, { status: 504 })
    }

    // RFC 9111 §4.3.1: validate with the validators of the stored response.
    // A HEAD response is never stored, so it is never used for validation.
    let validators: Headers | undefined
    if (variant && method === 'GET') {
      const etag = info!.headers.get('etag')
      const lastModified = info!.headers.get('last-modified')
      if (etag !== null || lastModified !== null) {
        validators = new Headers(requestHeaders)
        // The cache evaluates the preconditions of the client itself.
        for (const name of PRECONDITION_HEADERS) {
          validators.delete(name)
        }
        if (etag !== null) {
          validators.set('If-None-Match', etag)
        }
        if (lastModified !== null) {
          validators.set('If-Modified-Since', lastModified)
        }
        c.req.raw = new Request(c.req.raw, { headers: validators })
      }
    }

    const requestTime = now()
    await next()
    const responseTime = now()
    const res = c.res
    const status = res.status
    nowMs = responseTime

    if (variant && entry) {
      if (validators && status === 304) {
        // RFC 9111 §4.3.4: freshen the stored response with the 304, unless
        // the origin validated a representation the cache does not hold.
        const etag = res.headers.get('etag')
        const storedETag = info!.headers.get('etag')
        if (etag === null || storedETag === null || stripWeak(etag) === stripWeak(storedETag)) {
          const headers = new Headers(variant.headers)
          updateHeaders(headers, res.headers)
          variant.headers = headerPairs(headers)
          variant.vary = selectingFields(headers.get('vary'), requestHeaders)
          variant.requestTime = requestTime
          variant.responseTime = responseTime
          store.set(key, entry)

          info = describe(variant, nowMs)
          c.res = undefined
          c.res = useStored(c, variant, info, requestHeaders, nowMs, true)
          return
        }
      }

      if (ERROR_STATUSES.includes(status)) {
        info = describe(variant, nowMs)
        if (canServeOnError(info, requestDirectives)) {
          c.res = undefined
          c.res = useStored(c, variant, info, requestHeaders, nowMs, false)
          return
        }
      }
    }

    // Only GET responses are stored, and a 304 never replaces what it freshens.
    if (method !== 'GET' || status === 304) {
      return
    }

    const responseDirectives = parseCacheControl(res.headers.get('cache-control'))
    const storable = isStorable(
      status,
      requestDirectives,
      responseDirectives,
      res.headers,
      requestHeaders.has('authorization')
    )
    // A full response supersedes what the cache holds for the same variant,
    // while an error response leaves the stored responses untouched.
    if (!storable && !(entry && status < 400)) {
      return
    }

    const vary = selectingFields(res.headers.get('vary'), requestHeaders)
    const varyKey = JSON.stringify(vary)
    const variants = (entry?.variants ?? []).filter(
      (stored) =>
        JSON.stringify(stored.vary) !== varyKey && !variantMatches(stored, requestHeaders)
    )

    if (storable) {
      const headers = new Headers(res.headers)
      // RFC 9111 §3.1: hop-by-hop fields and the fields listed by a qualified
      // private directive are not stored.
      for (const name of [
        ...HOP_BY_HOP_HEADERS,
        ...(res.headers.get('connection')?.split(',') ?? []),
        ...qualifiedFields(responseDirectives, 'private'),
      ]) {
        headers.delete(name.trim().toLowerCase())
      }

      variants.push({
        status,
        headers: headerPairs(headers),
        body: res.body === null ? null : new Uint8Array(await res.clone().arrayBuffer()),
        requestTime,
        responseTime,
        vary,
      })
    }

    if (variants.length) {
      store.set(key, { variants })
    } else {
      store.delete(key)
    }
  }
}
Now let me refine it with the type and structural fixes I spotted:
write · /app/src/middleware/http-cache/index.ts
/**
 * @module
 * HTTP Cache Middleware for Hono.
 *
 * A self-contained shared cache implementing RFC 9111 (HTTP Caching), the
 * precondition evaluation of RFC 9110 Section 13.2, and the `stale-if-error`
 * extension of RFC 5861. It does not depend on the platform Web Cache API:
 * responses are kept in a user-supplied store, and the downstream handler
 * plays the role of the origin server.
 *
 * Out of scope: `stale-while-revalidate`, Range requests and 206 responses,
 * `Pragma`, `Warning`, trailers, and `immutable`.
 */

import type { Context } from '../../context'
import type { MiddlewareHandler } from '../../types'
import type { StatusCode } from '../../utils/http-status'

/**
 * The backing store of the cache. It holds one entry per target URI, so a
 * plain `Map` qualifies. Values are opaque: only this middleware creates them.
 */
export type HttpCacheStore = {
  get(key: string): unknown | undefined
  set(key: string, value: unknown): unknown
  delete(key: string): boolean
}

/**
 * Options for the HTTP cache middleware.
 */
export type HttpCacheOptions = {
  /**
   * The store holding cached responses. Defaults to a fresh `Map` per middleware instance.
   */
  store?: HttpCacheStore
  /**
   * Returns the current time in epoch milliseconds. Defaults to `Date.now`.
   * This is the only clock the middleware reads.
   */
  now?: () => number
}

/**
 * A single stored response, i.e. one of the Vary-selected variants of a target URI.
 */
type StoredResponse = {
  status: number
  /** Response header fields as `[lower-cased name, value]` pairs. */
  headers: [string, string][]
  body: Uint8Array<ArrayBuffer> | null
  /** The local clock when the request that produced this response was sent (RFC 9111 §4.2.3). */
  requestTime: number
  /** The local clock when this response was received (RFC 9111 §4.2.3). */
  responseTime: number
  /**
   * The selecting header fields (RFC 9111 §4.1) as `[lower-cased name,
   * normalized value or `null` when the field was absent]` pairs, sorted by name.
   */
  vary: [string, string | null][]
}

/** Everything stored under one target URI. */
type CacheEntry = {
  variants: StoredResponse[]
}

/** Parsed `Cache-Control`: directive name to the argument of each occurrence. */
type CacheControl = Map<string, (string | null)[]>

/** The values derived from a stored response that drive reuse decisions. */
type StoredInfo = {
  headers: Headers
  directives: CacheControl
  /** The Date of the response, falling back to the time it was received. */
  date: number
  /** `current_age` in milliseconds (RFC 9111 §4.2.3). */
  age: number
  /** `freshness_lifetime` in milliseconds (RFC 9111 §4.2.1). */
  lifetime: number
}

const SECOND = 1000

/** RFC 9111 §1.2.2 recommends clamping oversized delta-seconds values. */
const MAX_DELTA_SECONDS = 2147483648

/** RFC 9110 §9.2.1. A request with any other method invalidates stored responses. */
const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS', 'TRACE']

/**
 * Hop-by-hop fields (RFC 9110 §7.6.1). RFC 9111 §3.1 allows removing them
 * before storage, and RFC 9111 §3.2 keeps them out of header updates.
 */
const HOP_BY_HOP_HEADERS = [
  'connection',
  'proxy-connection',
  'keep-alive',
  'te',
  'transfer-encoding',
  'upgrade',
]

/** RFC 9110 §13.1. The cache evaluates these itself, so they are never forwarded. */
const PRECONDITION_HEADERS = [
  'if-match',
  'if-none-match',
  'if-modified-since',
  'if-unmodified-since',
  'if-range',
]

/** The header fields a generated 304 has to carry (RFC 9110 §15.4.5). */
const RETAINED_304_HEADERS = [
  'cache-control',
  'content-location',
  'date',
  'etag',
  'expires',
  'vary',
]

/**
 * Status codes that are heuristically cacheable (RFC 9111 §4.2.2). 206 is
 * excluded because Range requests are out of scope for this cache.
 */
const HEURISTICALLY_CACHEABLE_STATUSES = [200, 203, 204, 300, 301, 308, 404, 405, 410, 414, 451]

/**
 * The status codes whose caching semantics this cache understands, used to
 * evaluate the `must-understand` response directive (RFC 9111 §5.2.2.3).
 * 206 and 304 are absent: this cache never stores them.
 */
const UNDERSTOOD_STATUSES = [
  200, 201, 202, 203, 204, 205, 300, 301, 302, 303, 305, 307, 308, 400, 401, 402, 403, 404, 405,
  406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 417, 421, 422, 426, 500, 501, 502, 503, 504,
  505,
]

/** The status codes treated as an error by `stale-if-error` (RFC 5861 §4). */
const ERROR_STATUSES = [500, 502, 503, 504]

/** Statuses whose responses must not carry a body. */
const NULL_BODY_STATUSES = [101, 204, 205, 304]

//
// Generic HTTP parsing helpers
//

const MONTHS = 'Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec'
const IMF_FIXDATE = /^[A-Za-z]{3}, (\d{2}) ([A-Za-z]{3}) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/
const RFC850_DATE = /^[A-Za-z]+, (\d{2})-([A-Za-z]{3})-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/
const ASCTIME_DATE = /^[A-Za-z]{3} ([A-Za-z]{3}) ([ \d]\d) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/

const toEpoch = (
  year: number,
  month: string,
  day: number,
  hour: number,
  minute: number,
  second: number
): number | null => {
  const monthIndex = MONTHS.indexOf(month)
  if (monthIndex < 0 || day < 1 || day > 31 || hour > 23 || minute > 59 || second > 60) {
    return null
  }
  return Date.UTC(year, monthIndex / 4, day, hour, minute, second)
}

/**
 * Parses the three HTTP-date formats of RFC 9110 §5.6.7. `Date.parse` is not
 * used because it accepts values that an HTTP-date is not allowed to have,
 * such as the `Expires: 0` that RFC 9111 §5.3 requires to be read as a time
 * in the past.
 *
 * `nowMs` is only needed for the two-digit years of the obsolete rfc850
 * format; it is converted, never read from the clock.
 */
const parseHttpDate = (value: string, nowMs: number): number | null => {
  let match = IMF_FIXDATE.exec(value)
  if (match) {
    return toEpoch(+match[3], match[2], +match[1], +match[4], +match[5], +match[6])
  }

  match = RFC850_DATE.exec(value)
  if (match) {
    // RFC 9110 §5.6.7: a timestamp that appears to be more than 50 years in
    // the future is the most recent year in the past ending with the same two digits.
    const currentYear = new Date(nowMs).getUTCFullYear()
    let year = Math.floor(currentYear / 100) * 100 + +match[3]
    if (year > currentYear + 50) {
      year -= 100
    }
    return toEpoch(year, match[2], +match[1], +match[4], +match[5], +match[6])
  }

  match = ASCTIME_DATE.exec(value)
  if (match) {
    return toEpoch(+match[6], match[1], +match[2], +match[3], +match[4], +match[5])
  }

  return null
}

/**
 * Splits the value of a header field carrying HTTP-dates. Duplicate field
 * lines arrive comma-combined, and both date formats that contain a comma put
 * it right after the day name, which is their only all-alphabetic fragment.
 */
const splitDateList = (value: string): string[] => {
  const fragments = value.split(',')
  const dates: string[] = []
  for (let i = 0; i < fragments.length; i++) {
    const fragment = fragments[i].trim()
    if (/^[A-Za-z]+$/.test(fragment) && i + 1 < fragments.length) {
      dates.push(`${fragment}, ${fragments[++i].trim()}`)
    } else {
      dates.push(fragment)
    }
  }
  return dates
}

/**
 * Reads a header field carrying an HTTP-date. Returns `undefined` when the
 * field is absent and `null` when it is invalid; duplicate field lines take
 * the earliest, i.e. the most restrictive, value.
 */
const readDateHeader = (
  headers: Headers,
  name: string,
  nowMs: number
): number | null | undefined => {
  const value = headers.get(name)
  if (value === null) {
    return undefined
  }
  let earliest: number | undefined
  for (const date of splitDateList(value)) {
    const parsed = parseHttpDate(date, nowMs)
    if (parsed === null) {
      return null
    }
    earliest = earliest === undefined ? parsed : Math.min(earliest, parsed)
  }
  return earliest ?? null
}

/**
 * Reads a precondition header field carrying a single HTTP-date. RFC 9110
 * §13.1.3 and §13.1.4 require ignoring a value that is not a valid HTTP-date,
 * including a value that appears to be a list of dates.
 */
const readSingleDate = (headers: Headers, name: string, nowMs: number): number | null => {
  const value = headers.get(name)
  if (value === null) {
    return null
  }
  const dates = splitDateList(value)
  return dates.length === 1 ? parseHttpDate(dates[0], nowMs) : null
}

/**
 * Parses a `Cache-Control` field value into its directives, keeping every
 * occurrence of a directive and unquoting quoted arguments.
 */
const parseCacheControl = (value: string | null): CacheControl => {
  const directives: CacheControl = new Map()
  if (!value) {
    return directives
  }

  let index = 0
  while (index < value.length) {
    let start = index
    while (index < value.length && value[index] !== ',' && value[index] !== '=') {
      index++
    }
    const name = value.slice(start, index).trim().toLowerCase()

    let argument: string | null = null
    if (value[index] === '=') {
      index++
      while (value[index] === ' ' || value[index] === '\t') {
        index++
      }
      if (value[index] === '"') {
        index++
        let quoted = ''
        while (index < value.length && value[index] !== '"') {
          if (value[index] === '\\' && index + 1 < value.length) {
            index++
          }
          quoted += value[index++]
        }
        argument = quoted
        while (index < value.length && value[index] !== ',') {
          index++
        }
      } else {
        start = index
        while (index < value.length && value[index] !== ',') {
          index++
        }
        argument = value.slice(start, index).trim()
      }
    }

    if (name) {
      const occurrences = directives.get(name)
      if (occurrences) {
        occurrences.push(argument)
      } else {
        directives.set(name, [argument])
      }
    }
    index++
  }

  return directives
}

/**
 * Parses a delta-seconds argument (RFC 9111 §1.2.2, a non-negative decimal
 * integer) into milliseconds. Returns `null` when the argument is invalid.
 */
const parseDelta = (argument: string | null): number | null =>
  argument !== null && /^\d+$/.test(argument)
    ? Math.min(Number(argument), MAX_DELTA_SECONDS) * SECOND
    : null

/**
 * Reads a delta-seconds directive as milliseconds. Returns `undefined` when
 * the directive is absent; duplicate occurrences are reduced with `restrict`,
 * which always picks the most restrictive value, and an occurrence that is not
 * a valid delta-seconds contributes `invalid` instead.
 */
const readDelta = (
  directives: CacheControl,
  name: string,
  restrict: (a: number, b: number) => number,
  invalid: number
): number | undefined => {
  const occurrences = directives.get(name)
  if (!occurrences) {
    return undefined
  }
  let result: number | undefined
  for (const occurrence of occurrences) {
    const milliseconds = parseDelta(occurrence) ?? invalid
    result = result === undefined ? milliseconds : restrict(result, milliseconds)
  }
  return result
}

/**
 * Reads the `max-stale` request directive as milliseconds. Unlike the other
 * delta-seconds directives it is valid without an argument, which means that
 * any amount of staleness is acceptable (RFC 9111 §5.2.1.2).
 */
const readMaxStale = (directives: CacheControl): number | undefined => {
  const occurrences = directives.get('max-stale')
  if (!occurrences) {
    return undefined
  }
  let result = Infinity
  for (const occurrence of occurrences) {
    if (occurrence !== null) {
      result = Math.min(result, parseDelta(occurrence) ?? 0)
    }
  }
  return result
}

/** Whether a directive is present without an argument, i.e. in its unqualified form. */
const hasUnqualified = (directives: CacheControl, name: string): boolean =>
  !!directives.get(name)?.some((argument) => argument === null)

/** The field names listed by the qualified form of `no-cache` or `private`. */
const qualifiedFields = (directives: CacheControl, name: string): string[] =>
  (directives.get(name) ?? []).flatMap(
    (argument) =>
      argument
        ?.split(',')
        .map((field) => field.trim().toLowerCase())
        .filter(Boolean) ?? []
  )

/** An entity tag cannot contain a double quote, so it can be matched directly. */
const ENTITY_TAG = /\*|(?:W\/)?"[^"]*"/g

const stripWeak = (tag: string): string => tag.replace(/^W\//, '')

/** Entity tag comparison as defined by RFC 9110 §8.8.3.2. */
const matchesEntityTag = (list: string, etag: string | null, strong: boolean): boolean => {
  const tags = list.match(ENTITY_TAG) ?? []
  if (tags.includes('*')) {
    // A stored response is a current representation of the target resource.
    return true
  }
  if (etag === null) {
    return false
  }
  return tags.some((tag) =>
    strong ? tag === etag && !tag.startsWith('W/') : stripWeak(tag) === stripWeak(etag)
  )
}

/**
 * Normalizes a field value for Vary comparison: leading and trailing
 * whitespace is removed and internal optional whitespace is collapsed.
 */
const normalizeFieldValue = (value: string | null): string | null =>
  value === null ? null : value.trim().replace(/[ \t]+/g, ' ')

const headerPairs = (headers: Headers): [string, string][] => {
  const pairs: [string, string][] = []
  headers.forEach((value, name) => pairs.push([name, value]))
  return pairs
}

/**
 * Copies the header fields of `source` onto `target`, replacing the fields
 * that are already present (RFC 9111 §3.2).
 */
const updateHeaders = (target: Headers, source: Headers): void => {
  if (source.has('set-cookie')) {
    target.delete('set-cookie')
    for (const cookie of source.getSetCookie()) {
      target.append('set-cookie', cookie)
    }
  }
  source.forEach((value, name) => {
    // Content-Length is excepted by RFC 9111 §3.2, hop-by-hop fields by §3.1.
    if (name !== 'set-cookie' && name !== 'content-length' && !HOP_BY_HOP_HEADERS.includes(name)) {
      target.set(name, value)
    }
  })
}

//
// Stored responses
//

const readEntry = (store: HttpCacheStore, key: string): CacheEntry | undefined => {
  const value = store.get(key)
  return typeof value === 'object' && value !== null && Array.isArray((value as CacheEntry).variants)
    ? (value as CacheEntry)
    : undefined
}

/**
 * The selecting header fields of a response (RFC 9111 §4.1). A `Vary` field
 * value of `*` is kept as-is: it never matches a subsequent request.
 */
const selectingFields = (
  vary: string | null,
  requestHeaders: Headers
): [string, string | null][] => {
  if (!vary) {
    return []
  }
  const names = vary
    .split(',')
    .map((name) => name.trim().toLowerCase())
    .filter(Boolean)
  return [...new Set(names)]
    .sort()
    .map((name): [string, string | null] =>
      name === '*' ? ['*', null] : [name, normalizeFieldValue(requestHeaders.get(name))]
    )
}

/** RFC 9111 §4.1: every selecting header field has to match. */
const variantMatches = (variant: StoredResponse, requestHeaders: Headers): boolean =>
  variant.vary.every(
    ([name, value]) => name !== '*' && normalizeFieldValue(requestHeaders.get(name)) === value
  )

const responseDate = (variant: StoredResponse, nowMs: number): number => {
  const date = readDateHeader(new Headers(variant.headers), 'date', nowMs)
  // RFC 9111 §4.2.3: without a usable Date, the time of reception is used.
  return typeof date === 'number' ? date : variant.responseTime
}

/**
 * Selects the stored response to use for a request. When several variants
 * match, the one with the most recent Date wins (RFC 9111 §4).
 */
const selectVariant = (
  entry: CacheEntry,
  requestHeaders: Headers,
  nowMs: number
): StoredResponse | undefined => {
  let selected: StoredResponse | undefined
  let selectedDate = -Infinity
  for (const variant of entry.variants) {
    if (!variantMatches(variant, requestHeaders)) {
      continue
    }
    const date = responseDate(variant, nowMs)
    if (date >= selectedDate) {
      selected = variant
      selectedDate = date
    }
  }
  return selected
}

/**
 * The freshness lifetime of a stored response in milliseconds (RFC 9111
 * §4.2.1). Duplicated directives take the most restrictive value, and an
 * invalid value makes the response stale.
 */
const freshnessLifetime = (
  headers: Headers,
  directives: CacheControl,
  date: number,
  nowMs: number
): number => {
  // A shared cache prefers s-maxage over max-age and Expires (RFC 9111 §5.2.2.10).
  const sharedMaxAge = readDelta(directives, 's-maxage', Math.min, 0)
  if (sharedMaxAge !== undefined) {
    return sharedMaxAge
  }
  const maxAge = readDelta(directives, 'max-age', Math.min, 0)
  if (maxAge !== undefined) {
    return maxAge
  }
  const expires = readDateHeader(headers, 'expires', nowMs)
  if (expires !== undefined) {
    // RFC 9111 §5.3: an invalid Expires means the response is already expired.
    return expires === null ? 0 : Math.max(0, expires - date)
  }

  // Heuristic freshness: 10% of the interval since the last modification.
  const lastModified = readDateHeader(headers, 'last-modified', nowMs)
  return typeof lastModified === 'number' ? Math.max(0, (date - lastModified) * 0.1) : 0
}

/**
 * The current age of a stored response in milliseconds, following the
 * algorithm of RFC 9111 §4.2.3. An invalid Age makes the response stale.
 */
const currentAge = (
  variant: StoredResponse,
  headers: Headers,
  date: number,
  nowMs: number
): number => {
  let ageValue = 0
  const age = headers.get('age')
  if (age !== null) {
    for (const value of age.split(',')) {
      const milliseconds = parseDelta(value.trim())
      if (milliseconds === null) {
        return Infinity
      }
      ageValue = Math.max(ageValue, milliseconds)
    }
  }

  const apparentAge = Math.max(0, variant.responseTime - date)
  const responseDelay = variant.responseTime - variant.requestTime
  const correctedAgeValue = ageValue + responseDelay
  const correctedInitialAge = Math.max(apparentAge, correctedAgeValue)
  const residentTime = nowMs - variant.responseTime
  return correctedInitialAge + residentTime
}

const describe = (variant: StoredResponse, nowMs: number): StoredInfo => {
  const headers = new Headers(variant.headers)
  const directives = parseCacheControl(headers.get('cache-control'))
  const date = responseDate(variant, nowMs)
  return {
    headers,
    directives,
    date,
    age: currentAge(variant, headers, date, nowMs),
    lifetime: freshnessLifetime(headers, directives, date, nowMs),
  }
}

/**
 * Whether serving a stored response while it is stale is forbidden by an
 * explicit in-protocol directive (RFC 9111 §4.2.4). For a shared cache,
 * s-maxage carries the semantics of proxy-revalidate (RFC 9111 §5.2.2.10).
 */
const staleForbidden = (info: StoredInfo): boolean =>
  info.directives.has('no-cache') ||
  info.directives.has('must-revalidate') ||
  info.directives.has('proxy-revalidate') ||
  info.directives.has('s-maxage')

/**
 * Decides whether a stored response can be reused as-is, honoring the request
 * cache directives of RFC 9111 §5.2.1 as well as the response directives.
 */
const canReuse = (info: StoredInfo, requestDirectives: CacheControl): boolean => {
  // The unqualified form of no-cache requires successful validation, both when
  // sent by the client (§5.2.1.4) and when stored with the response (§5.2.2.4).
  if (hasUnqualified(requestDirectives, 'no-cache') || hasUnqualified(info.directives, 'no-cache')) {
    return false
  }

  const maxAge = readDelta(requestDirectives, 'max-age', Math.min, 0)
  if (maxAge !== undefined && info.age > maxAge) {
    return false
  }
  const minFresh = readDelta(requestDirectives, 'min-fresh', Math.max, Infinity)
  if (minFresh !== undefined && info.lifetime - info.age < minFresh) {
    return false
  }

  const staleness = info.age - info.lifetime
  if (staleness < 0) {
    return true
  }
  // A stale response may only be served when the client explicitly accepts
  // staleness and no directive of the stored response forbids it (§4.2.4).
  const maxStale = readMaxStale(requestDirectives)
  return maxStale !== undefined && staleness <= maxStale && !staleForbidden(info)
}

/** RFC 5861 §4, bounded by the staleness prohibitions of RFC 9111 §4.2.4. */
const canServeOnError = (info: StoredInfo, requestDirectives: CacheControl): boolean => {
  const staleIfError =
    readDelta(info.directives, 'stale-if-error', Math.min, 0) ??
    readDelta(requestDirectives, 'stale-if-error', Math.min, 0)
  return (
    staleIfError !== undefined && info.age - info.lifetime <= staleIfError && !staleForbidden(info)
  )
}

/** Whether a response may be stored by a shared cache (RFC 9111 §3). */
const isStorable = (
  status: number,
  requestDirectives: CacheControl,
  responseDirectives: CacheControl,
  responseHeaders: Headers,
  authorized: boolean
): boolean => {
  // A 206 or a 304 may only be stored by a cache that understands it.
  if (status < 200 || status === 206 || status === 304) {
    return false
  }

  // must-understand forbids storing a status code the cache does not know, and
  // overrides the no-store directive of the response (§5.2.2.3).
  const mustUnderstand = responseDirectives.has('must-understand')
  if (mustUnderstand && !UNDERSTOOD_STATUSES.includes(status)) {
    return false
  }
  if (!mustUnderstand && responseDirectives.has('no-store')) {
    return false
  }
  if (requestDirectives.has('no-store')) {
    return false
  }
  // The qualified form of private only keeps the listed fields out of a shared cache.
  if (hasUnqualified(responseDirectives, 'private')) {
    return false
  }

  const sharedMaxAge = responseDirectives.has('s-maxage')
  if (
    authorized &&
    !responseDirectives.has('public') &&
    !responseDirectives.has('must-revalidate') &&
    !sharedMaxAge
  ) {
    // RFC 9111 §3.5: a response to a request with an Authorization header
    // field needs an explicit directive to be stored by a shared cache.
    return false
  }

  return (
    responseDirectives.has('public') ||
    responseDirectives.has('max-age') ||
    sharedMaxAge ||
    responseHeaders.has('expires') ||
    HEURISTICALLY_CACHEABLE_STATUSES.includes(status)
  )
}

/**
 * Evaluates the preconditions of a request against a stored response, in the
 * order of RFC 9110 §13.2.2. Returns the status code to respond with, or
 * `undefined` when every precondition passes. If-Range is not evaluated
 * because Range requests are out of scope.
 */
const evaluatePreconditions = (
  requestHeaders: Headers,
  storedHeaders: Headers,
  variant: StoredResponse,
  nowMs: number
): 304 | 412 | undefined => {
  const etag = storedHeaders.get('etag')
  // RFC 9111 §4.3.2: without a Last-Modified, the Date of the stored response
  // is used, and failing that the time the response was received.
  const lastModified =
    readDateHeader(storedHeaders, 'last-modified', nowMs) ??
    readDateHeader(storedHeaders, 'date', nowMs) ??
    Math.floor(variant.responseTime / SECOND) * SECOND

  const ifMatch = requestHeaders.get('if-match')
  if (ifMatch !== null) {
    if (!matchesEntityTag(ifMatch, etag, true)) {
      return 412
    }
  } else {
    const ifUnmodifiedSince = readSingleDate(requestHeaders, 'if-unmodified-since', nowMs)
    if (ifUnmodifiedSince !== null && lastModified > ifUnmodifiedSince) {
      return 412
    }
  }

  const ifNoneMatch = requestHeaders.get('if-none-match')
  if (ifNoneMatch !== null) {
    if (matchesEntityTag(ifNoneMatch, etag, false)) {
      return 304
    }
  } else {
    const ifModifiedSince = readSingleDate(requestHeaders, 'if-modified-since', nowMs)
    if (ifModifiedSince !== null && lastModified <= ifModifiedSince) {
      return 304
    }
  }

  return undefined
}

/**
 * HTTP Cache Middleware for Hono.
 *
 * Implements a shared cache as specified by RFC 9111, using the downstream
 * handler as the origin server. A request that can be satisfied from the
 * store never reaches the handler; every other request reaches it once,
 * carrying the conditional header fields that the cache derived from the
 * stored validators whenever it has to revalidate.
 *
 * @param {HttpCacheOptions} [options] - The options for the HTTP cache middleware.
 * @param {HttpCacheStore} [options.store] - The store holding cached responses. Defaults to a fresh `Map`.
 * @param {function(): number} [options.now] - Returns the current time in epoch milliseconds. Defaults to `Date.now`.
 * @returns {MiddlewareHandler} The middleware handler function.
 *
 * @example
 * ```ts
 * const app = new Hono()
 *
 * app.use(httpCache())
 * app.get('/', (c) => {
 *   c.header('Cache-Control', 'max-age=60')
 *   return c.text('Hono is hot')
 * })
 * ```
 */
export const httpCache = (options?: HttpCacheOptions): MiddlewareHandler => {
  const store: HttpCacheStore = options?.store ?? new Map<string, unknown>()
  const now = options?.now ?? Date.now

  /** Invalidates a URI reference of a response, if it shares the target origin (RFC 9111 §4.4). */
  const invalidate = (reference: string | null, base: string): void => {
    if (reference === null) {
      return
    }
    try {
      const url = new URL(reference, base)
      url.hash = ''
      if (url.origin === new URL(base).origin) {
        store.delete(url.href)
      }
    } catch {
      // A reference that cannot be resolved cannot be a cache key either.
    }
  }

  /**
   * Builds the response to send from a stored response, after evaluating the
   * preconditions of the request against it.
   */
  const useStored = (
    c: Context,
    variant: StoredResponse,
    info: StoredInfo,
    requestHeaders: Headers,
    nowMs: number,
    validated: boolean
  ): Response => {
    const headers = new Headers(variant.headers)
    if (!validated) {
      // The qualified form of no-cache forbids reusing the listed fields
      // without successful validation (RFC 9111 §5.2.2.4).
      for (const field of qualifiedFields(info.directives, 'no-cache')) {
        headers.delete(field)
      }
    }

    const age = String(
      Number.isFinite(info.age) ? Math.max(0, Math.floor(info.age / SECOND)) : MAX_DELTA_SECONDS
    )
    headers.set('Age', age)

    const precondition = evaluatePreconditions(requestHeaders, headers, variant, nowMs)
    if (precondition === 412) {
      return c.newResponse(null, { status: 412 })
    }
    if (precondition === 304) {
      const notModified = new Headers()
      for (const name of RETAINED_304_HEADERS) {
        const value = headers.get(name)
        if (value !== null) {
          notModified.set(name, value)
        }
      }
      notModified.set('Age', age)
      return c.newResponse(null, { status: 304, headers: notModified })
    }

    const body =
      c.req.method === 'HEAD' || NULL_BODY_STATUSES.includes(variant.status) ? null : variant.body
    return c.newResponse(body, { status: variant.status as StatusCode, headers })
  }

  return async function httpCache(c, next) {
    const method = c.req.method
    const key = c.req.url
    // The request may be replaced by a conditional one, so the header fields
    // the client sent are kept for Vary selection and precondition evaluation.
    const requestHeaders = c.req.raw.headers
    const requestDirectives = parseCacheControl(requestHeaders.get('cache-control'))

    if (method !== 'GET' && method !== 'HEAD') {
      await next()
      // RFC 9111 §4.4: a non-error response to an unsafe request invalidates
      // the target URI, as well as the URIs of Location and Content-Location.
      const status = c.res.status
      if (!SAFE_METHODS.includes(method) && status >= 200 && status < 400) {
        store.delete(key)
        invalidate(c.res.headers.get('location'), key)
        invalidate(c.res.headers.get('content-location'), key)
      }
      return
    }

    const entry = readEntry(store, key)
    let nowMs = now()
    const variant = entry && selectVariant(entry, requestHeaders, nowMs)
    let info = variant && describe(variant, nowMs)

    if (variant && info) {
      if (canReuse(info, requestDirectives)) {
        return useStored(c, variant, info, requestHeaders, nowMs, false)
      }
    }

    if (requestDirectives.has('only-if-cached')) {
      // RFC 9111 §5.2.1.7: the request must not be forwarded, so this is the
      // one case where the handler is not reached on a cache miss.
      return c.newResponse(null, { status: 504 })
    }

    // RFC 9111 §4.3.1: revalidate with the validators of the stored response.
    // A HEAD response is never stored, so it never validates a stored response.
    let validating = false
    if (variant && info && method === 'GET') {
      const etag = info.headers.get('etag')
      const lastModified = info.headers.get('last-modified')
      if (etag !== null || lastModified !== null) {
        const conditional = new Headers(requestHeaders)
        // The cache evaluates the preconditions of the client itself.
        for (const name of PRECONDITION_HEADERS) {
          conditional.delete(name)
        }
        if (etag !== null) {
          conditional.set('If-None-Match', etag)
        }
        if (lastModified !== null) {
          conditional.set('If-Modified-Since', lastModified)
        }
        c.req.raw = new Request(c.req.raw, { headers: conditional })
        validating = true
      }
    }

    const requestTime = now()
    await next()
    const responseTime = now()
    nowMs = responseTime
    const res = c.res
    const status = res.status

    if (entry && variant && info) {
      const etag = res.headers.get('etag')
      const storedETag = info.headers.get('etag')
      if (
        validating &&
        status === 304 &&
        // RFC 9111 §4.3.4: a 304 only freshens the stored response it selects.
        (etag === null || storedETag === null || stripWeak(etag) === stripWeak(storedETag))
      ) {
        const headers = new Headers(variant.headers)
        updateHeaders(headers, res.headers)
        variant.headers = headerPairs(headers)
        variant.vary = selectingFields(headers.get('vary'), requestHeaders)
        variant.requestTime = requestTime
        variant.responseTime = responseTime
        store.set(key, entry)

        info = describe(variant, nowMs)
        c.res = undefined
        c.res = useStored(c, variant, info, requestHeaders, nowMs, true)
        return
      }

      if (ERROR_STATUSES.includes(status)) {
        info = describe(variant, nowMs)
        if (canServeOnError(info, requestDirectives)) {
          c.res = undefined
          c.res = useStored(c, variant, info, requestHeaders, nowMs, false)
          return
        }
      }
    }

    // Only GET responses are stored, and a 304 never replaces what it freshens.
    if (method !== 'GET' || status === 304) {
      return
    }

    const responseDirectives = parseCacheControl(res.headers.get('cache-control'))
    const storable = isStorable(
      status,
      requestDirectives,
      responseDirectives,
      res.headers,
      requestHeaders.has('authorization')
    )
    // A full response supersedes what is stored for the same variant, while an
    // error response leaves the stored responses untouched.
    if (!storable && !(entry && status < 400)) {
      return
    }

    const vary = selectingFields(res.headers.get('vary'), requestHeaders)
    const varyKey = JSON.stringify(vary)
    const variants = (entry?.variants ?? []).filter(
      (stored) => JSON.stringify(stored.vary) !== varyKey && !variantMatches(stored, requestHeaders)
    )

    if (storable) {
      const headers = new Headers(res.headers)
      // RFC 9111 §3.1: hop-by-hop fields and the fields listed by a qualified
      // private directive are not stored.
      for (const name of [
        ...HOP_BY_HOP_HEADERS,
        ...(res.headers.get('connection')?.split(',') ?? []),
        ...qualifiedFields(responseDirectives, 'private'),
      ]) {
        headers.delete(name.trim().toLowerCase())
      }

      variants.push({
        status,
        headers: headerPairs(headers),
        body: res.body === null ? null : new Uint8Array(await res.clone().arrayBuffer()),
        requestTime,
        responseTime,
        vary,
      })
    }

    if (variants.length) {
      store.set(key, { variants })
    } else {
      store.delete(key)
    }
  }
}

Read validate-exports

shell
cat /app/build/validate-exports.ts
output
export const validateExports = (
  source: Record<string, unknown>,
  target: Record<string, unknown>,
  fileName: string
) => {
  const isEntryInTarget = (entry: string): boolean => {
    if (entry in target) {
      return true
    }

    // e.g., "./utils/*" -> "./utils"
    const wildcardPrefix = entry.replace(/\/\*$/, '')
    if (entry.endsWith('/*')) {
      return Object.keys(target).some(
        (targetEntry) =>
          targetEntry.startsWith(wildcardPrefix + '/') && targetEntry !== wildcardPrefix
      )
    }

    const separatedEntry = entry.split('/')
    while (separatedEntry.length > 0) {
      const pattern = `${separatedEntry.join('/')}/*`
      if (pattern in target) {
        return true
      }
      separatedEntry.pop()
    }

    return false
  }

  Object.keys(source).forEach((sourceEntry) => {
    if (!isEntryInTarget(sourceEntry)) {
      throw new Error(`Missing "${sourceEntry}" in '${fileName}'`)
    }
  })
}

[stdout]
export const validateExports = (
  source: Record<string, unknown>,
  target: Record<string, unknown>,
  fileName: string
) => {
  const isEntryInTarget = (entry: string): boolean => {
    if (entry in target) {
      return true
    }

    // e.g., "./utils/*" -> "./utils"
    const wildcardPrefix = entry.replace(/\/\*$/, '')
    if (entry.endsWith('/*')) {
      return Object.keys(target).some(
        (targetEntry) =>
          targetEntry.startsWith(wildcardPrefix + '/') && targetEntry !== wildcardPrefix
      )
    }

    const separatedEntry = entry.split('/')
    while (separatedEntry.length > 0) {
      const pattern = `${separatedEntry.join('/')}/*`
      if (pattern in target) {
        return true
      }
      separatedEntry.pop()
    }

    return false
  }

  Object.keys(source).forEach((sourceEntry) => {
    if (!isEntryInTarget(sourceEntry)) {
      throw new Error(`Missing "${sourceEntry}" in '${fileName}'`)
    }
  })
}
Now wiring the export entries:

/app/package.json

− removed
    "./etag": {
      "types": "./dist/types/middleware/etag/index.d.ts",
      "import": "./dist/middleware/etag/index.js",
      "require": "./dist/cjs/middleware/etag/index.js"
    },
+ added
    "./etag": {
      "types": "./dist/types/middleware/etag/index.d.ts",
      "import": "./dist/middleware/etag/index.js",
      "require": "./dist/cjs/middleware/etag/index.js"
    },
    "./http-cache": {
      "types": "./dist/types/middleware/http-cache/index.d.ts",
      "import": "./dist/middleware/http-cache/index.js",
      "require": "./dist/cjs/middleware/http-cache/index.js"
    },

/app/package.json

− removed
      "etag": [
        "./dist/types/middleware/etag"
      ],
+ added
      "etag": [
        "./dist/types/middleware/etag"
      ],
      "http-cache": [
        "./dist/types/middleware/http-cache"
      ],

/app/jsr.json

− removed
    "./etag": "./src/middleware/etag/index.ts",
+ added
    "./etag": "./src/middleware/etag/index.ts",
    "./http-cache": "./src/middleware/http-cache/index.ts",

Read etag test style

shell
sed -n 1,40p /app/src/middleware/etag/index.test.ts
output
import { Hono } from '../../hono'
import { RETAINED_304_HEADERS, etag } from '.'

describe('Etag Middleware', () => {
  it('Should return etag header', async () => {
    const app = new Hono()
    app.use('/etag/*', etag())
    app.get('/etag/abc', (c) => {
      return c.text('Hono is hot')
    })
    app.get('/etag/def', (c) => {
      return c.json({ message: 'Hono is hot' })
    })
    let res = await app.request('http://localhost/etag/abc')
    expect(res.headers.get('ETag')).not.toBeFalsy()
    expect(res.headers.get('ETag')).toBe('"d104fafdb380655dab607c9bddc4d4982037afa1"')

    res = await app.request('http://localhost/etag/def')
    expect(res.headers.get('ETag')).not.toBeFalsy()
    expect(res.headers.get('ETag')).toBe('"67340414f1a52c4669a6cec71f0ae04532b29249"')
  })

  it('Should return etag header with another algorithm', async () => {
    const app = new Hono()
    app.use(
      '/etag/*',
      etag({
        generateDigest: (body) =>
          crypto.subtle.digest(
            {
              name: 'SHA-256',
            },
            body
          ),
      })
    )
    app.get('/etag/abc', (c) => {
      return c.text('Hono is hot')
    })
    app.get('/etag/def', (c) => {

[stdout]
import { Hono } from '../../hono'
import { RETAINED_304_HEADERS, etag } from '.'

describe('Etag Middleware', () => {
  it('Should return etag header', async () => {
    const app = new Hono()
    app.use('/etag/*', etag())
    app.get('/etag/abc', (c) => {
      return c.text('Hono is hot')
    })
    app.get('/etag/def', (c) => {
      return c.json({ message: 'Hono is hot' })
    })
    let res = await app.request('http://localhost/etag/abc')
    expect(res.headers.get('ETag')).not.toBeFalsy()
    expect(res.headers.get('ETag')).toBe('"d104fafdb380655dab607c9bddc4d4982037afa1"')

    res = await app.request('http://localhost/etag/def')
    expect(res.headers.get('ETag')).not.toBeFalsy()
    expect(res.headers.get('ETag')).toBe('"67340414f1a52c4669a6cec71f0ae04532b29249"')
  })

  it('Should return etag header with another algorithm', async () => {
    const app = new Hono()
    app.use(
      '/etag/*',
      etag({
        generateDigest: (body) =>
          crypto.subtle.digest(
            {
              name: 'SHA-256',
            },
            body
          ),
      })
    )
    app.get('/etag/abc', (c) => {
      return c.text('Hono is hot')
    })
    app.get('/etag/def', (c) => {

Check HEAD handling in Hono

shell
grep -rn "HEAD" /app/src/hono-base.ts /app/src/router.ts | head -20
output
/app/src/hono-base.ts:413:    // Handle HEAD method
/app/src/hono-base.ts:414:    if (method === 'HEAD') {

[stdout]
/app/src/hono-base.ts:413:    // Handle HEAD method
/app/src/hono-base.ts:414:    if (method === 'HEAD') {

/app/src/hono-base.ts

contents
400	  #handleError(err: unknown, c: Context<E>): Response | Promise<Response> {
401	    if (err instanceof Error) {
402	      return this.errorHandler(err, c)
403	    }
404	    throw err
405	  }
406	
407	  #dispatch(
408	    request: Request,
409	    executionCtx: ExecutionContext | FetchEventLike | undefined,
410	    env: E['Bindings'],
411	    method: string
412	  ): Response | Promise<Response> {
413	    // Handle HEAD method
414	    if (method === 'HEAD') {
415	      return (async () =>
416	        new Response(null, await this.#dispatch(request, executionCtx, env, 'GET')))()
417	    }
418	
419	    const path = this.getPath(request, { env })
420	    const matchResult = this.router.match(method, path)
421	
422	    const c = new Context(request, {
423	      path,
424	      matchResult,
425	      env,
426	      executionCtx,
427	      notFoundHandler: this.#notFoundHandler,
428	    })
429	
430	    // Do not `compose` if it has only one handler
431	    if (matchResult[0].length === 1) {
432	      let res: ReturnType<H>
433	      try {
434	        res = matchResult[0][0][0][0](c, async () => {
435	          c.res = await this.#notFoundHandler(c)
436	        })
437	      } catch (err) {
438	        return this.#handleError(err, c)
439	      }
Now the test suite:
write · /app/src/middleware/http-cache/index.test.ts
import type { Context } from '../../context'
import { Hono } from '../../hono'
import { httpCache } from '.'

const START = Date.UTC(2026, 0, 1, 0, 0, 0)
const httpDate = (ms: number) => new Date(ms).toUTCString()

type Origin = (c: Context, count: number) => Response | Promise<Response>

const setup = (origin: Origin) => {
  let time = START
  let count = 0
  const store = new Map<string, unknown>()
  const seen: Headers[] = []

  const app = new Hono()
  app.use('*', httpCache({ store, now: () => time }))
  app.all('*', (c) => {
    seen.push(new Headers(c.req.raw.headers))
    return origin(c, ++count)
  })

  return {
    app,
    store,
    seen,
    get count() {
      return count
    },
    get time() {
      return time
    },
    advance: (seconds: number) => {
      time += seconds * 1000
    },
    request: (path: string, init?: RequestInit) => app.request(`http://localhost${path}`, init),
  }
}

/** A response that is fresh for `maxAge` seconds and carries a strong validator. */
const cacheable = (body: string, headers: Record<string, string> = {}) =>
  new Response(body, { headers: { 'Cache-Control': 'max-age=60', ...headers } })

describe('HTTP Cache Middleware', () => {
  describe('Storing and reusing responses', () => {
    it('Should reuse a fresh stored response without reaching the handler', async () => {
      const env = setup(() => cacheable('Hono is hot'))

      const first = await env.request('/')
      expect(first.status).toBe(200)
      expect(await first.text()).toBe('Hono is hot')
      expect(first.headers.get('Age')).toBeNull()

      env.advance(10)
      const second = await env.request('/')
      expect(second.status).toBe(200)
      expect(await second.text()).toBe('Hono is hot')
      expect(second.headers.get('Age')).toBe('10')
      expect(second.headers.get('Content-Type')).toBe('text/plain;charset=UTF-8')
      expect(env.count).toBe(1)
    })

    it('Should reach the handler exactly once per request that is not a hit', async () => {
      const env = setup((_c, count) => cacheable(`response ${count}`))

      await env.request('/')
      env.advance(61)
      await env.request('/')
      env.advance(61)
      await env.request('/')
      expect(env.count).toBe(3)
    })

    it('Should use one store key per target URI', async () => {
      const env = setup(() => cacheable('Hono is hot'))

      await env.request('/first')
      await env.request('/second?q=1')
      expect([...env.store.keys()]).toEqual([
        'http://localhost/first',
        'http://localhost/second?q=1',
      ])
    })

    it('Should not store a response of a status that is not heuristically cacheable', async () => {
      const env = setup(() => new Response('teapot', { status: 418 }))

      await env.request('/')
      expect(env.store.size).toBe(0)
    })

    it('Should store a response of an uncacheable status when it is explicitly cacheable', async () => {
      const env = setup(() => new Response('teapot', { status: 418, headers: { Expires: httpDate(START + 60_000) } }))

      await env.request('/')
      const res = await env.request('/')
      expect(res.status).toBe(418)
      expect(await res.text()).toBe('teapot')
      expect(env.count).toBe(1)
    })

    it('Should store a 404 response, which is heuristically cacheable', async () => {
      const env = setup(() => new Response('nope', { status: 404, headers: { 'Cache-Control': 'max-age=60' } }))

      await env.request('/')
      const res = await env.request('/')
      expect(res.status).toBe(404)
      expect(env.count).toBe(1)
    })

    it('Should store a bodiless response', async () => {
      const env = setup(() => new Response(null, { status: 204, headers: { 'Cache-Control': 'max-age=60' } }))

      await env.request('/')
      const res = await env.request('/')
      expect(res.status).toBe(204)
      expect(res.body).toBeNull()
      expect(env.count).toBe(1)
    })

    it('Should replace a stored response with a newer full response', async () => {
      const env = setup((_c, count) => cacheable(`response ${count}`))

      expect(await (await env.request('/')).text()).toBe('response 1')
      env.advance(61)
      expect(await (await env.request('/')).text()).toBe('response 2')
      expect(await (await env.request('/')).text()).toBe('response 2')
      expect(env.count).toBe(2)
    })

    it('Should drop a stored response that a new uncacheable response supersedes', async () => {
      const env = setup((_c, count) =>
        count === 1 ? cacheable('cacheable') : new Response('private', { headers: { 'Cache-Control': 'no-store' } })
      )

      await env.request('/')
      env.advance(61)
      await env.request('/')
      expect(env.store.size).toBe(0)
    })

    it('Should not store hop-by-hop header fields', async () => {
      const env = setup(() =>
        cacheable('Hono is hot', { Connection: 'X-Internal', 'Keep-Alive': 'timeout=5', 'X-Internal': 'secret' })
      )

      await env.request('/')
      const res = await env.request('/')
      expect(res.headers.get('Keep-Alive')).toBeNull()
      expect(res.headers.get('X-Internal')).toBeNull()
    })

    it('Should use a fresh store per middleware instance by default', async () => {
      const app = new Hono()
      let count = 0
      app.use('/a', httpCache())
      app.use('/b', httpCache())
      app.all('*', () => cacheable(`response ${++count}`))

      await app.request('http://localhost/a')
      await app.request('http://localhost/b')
      expect(count).toBe(2)
      expect(await (await app.request('http://localhost/a')).text()).toBe('response 1')
      expect(await (await app.request('http://localhost/b')).text()).toBe('response 2')
      expect(count).toBe(2)
    })
  })

  describe('Freshness', () => {
    it('Should revalidate once the freshness lifetime has elapsed', async () => {
      const env = setup(() => cacheable('Hono is hot'))

      await env.request('/')
      env.advance(59)
      await env.request('/')
      expect(env.count).toBe(1)
      env.advance(1)
      await env.request('/')
      expect(env.count).toBe(2)
    })

    it('Should prefer s-maxage over max-age and Expires for a shared cache', async () => {
      const env = setup(() =>
        cacheable('Hono is hot', {
          'Cache-Control': 'max-age=600, s-maxage=30',
          Expires: httpDate(START + 600_000),
          Date: httpDate(START),
        })
      )

      await env.request('/')
      env.advance(29)
      await env.request('/')
      expect(env.count).toBe(1)
      env.advance(2)
      await env.request('/')
      expect(env.count).toBe(2)
    })

    it('Should prefer max-age over Expires', async () => {
      const env = setup(() =>
        cacheable('Hono is hot', {
          'Cache-Control': 'max-age=30',
          Date: httpDate(START),
          Expires: httpDate(START + 600_000),
        })
      )

      await env.request('/')
      env.advance(31)
      await env.request('/')
      expect(env.count).toBe(2)
    })

    it('Should use Expires relative to Date', async () => {
      const env = setup(() =>
        new Response('Hono is hot', {
          headers: { Date: httpDate(START), Expires: httpDate(START + 30_000) },
        })
      )

      await env.request('/')
      env.advance(29)
      await env.request('/')
      expect(env.count).toBe(1)
      env.advance(2)
      await env.request('/')
      expect(env.count).toBe(2)
    })

    it('Should treat an invalid Expires as already expired', async () => {
      const env = setup(() => new Response('Hono is hot', { headers: { Date: httpDate(START), Expires: '0' } }))

      await env.request('/')
      await env.request('/')
      expect(env.count).toBe(2)
    })

    it('Should take the earliest of duplicated Expires header lines', async () => {
      const env = setup(() => {
        const headers = new Headers({ Date: httpDate(START) })
        headers.append('Expires', httpDate(START + 600_000))
        headers.append('Expires', httpDate(START + 30_000))
        return new Response('Hono is hot', { headers })
      })

      await env.request('/')
      env.advance(31)
      await env.request('/')
      expect(env.count).toBe(2)
    })

    it('Should take the most restrictive of duplicated max-age directives', async () => {
      const env = setup(() => cacheable('Hono is hot', { 'Cache-Control': 'max-age=600, max-age=30' }))

      await env.request('/')
      env.advance(29)
      await env.request('/')
      expect(env.count).toBe(1)
      env.advance(2)
      await env.request('/')
      expect(env.count).toBe(2)
    })

    it('Should treat an invalid max-age as stale', async () => {
      const env = setup(() => cacheable('Hono is hot', { 'Cache-Control': 'max-age=abc' }))

      await env.request('/')
      await env.request('/')
      expect(env.count).toBe(2)
    })

    it('Should calculate heuristic freshness as 10% of the interval since Last-Modified', async () => {
      const env = setup(() =>
        new Response('Hono is hot', {
          headers: { Date: httpDate(START), 'Last-Modified': httpDate(START - 1000_000) },
        })
      )

      await env.request('/')
      env.advance(99)
      await env.request('/')
      expect(env.count).toBe(1)
      env.advance(2)
      await env.request('/')
      expect(env.count).toBe(2)
    })

    it('Should use a heuristic freshness of zero without Last-Modified', async () => {
      const env = setup(() => new Response('Hono is hot', { headers: { Date: httpDate(START) } }))

      await env.request('/')
      await env.request('/')
      expect(env.count).toBe(2)
    })
  })

  describe('Age', () => {
    it('Should include the upstream Age in the age of a stored response', async () => {
      const env = setup(() => cacheable('Hono is hot', { Age: '50' }))

      await env.request('/')
      env.advance(5)
      const res = await env.request('/')
      expect(res.headers.get('Age')).toBe('55')
      expect(env.count).toBe(1)

      env.advance(6)
      await env.request('/')
      expect(env.count).toBe(2)
    })

    it('Should use the apparent age when the Date of the response is in the past', async () => {
      const env = setup(() => cacheable('Hono is hot', { Date: httpDate(START - 20_000) }))

      const res = await env.request('/')
      expect(res.headers.get('Age')).toBeNull()

      env.advance(5)
      expect((await env.request('/')).headers.get('Age')).toBe('25')
      expect(env.count).toBe(1)
    })

    it('Should treat an invalid Age as stale', async () => {
      const env = setup(() => cacheable('Hono is hot', { Age: 'soon' }))

      await env.request('/')
      await env.request('/')
      expect(env.count).toBe(2)
    })
  })

  describe('Vary', () => {
    it('Should store one variant per selecting header field value', async () => {
      const env = setup((c) =>
        cacheable(`encoded as ${c.req.header('Accept-Encoding')}`, { Vary: 'Accept-Encoding' })
      )

      const gzip = await env.request('/', { headers: { 'Accept-Encoding': 'gzip' } })
      expect(await gzip.text()).toBe('encoded as gzip')
      const br = await env.request('/', { headers: { 'Accept-Encoding': 'br' } })
      expect(await br.text()).toBe('encoded as br')
      expect(env.count).toBe(2)

      expect(await (await env.request('/', { headers: { 'Accept-Encoding': 'gzip' } })).text()).toBe(
        'encoded as gzip'
      )
      expect(await (await env.request('/', { headers: { 'Accept-Encoding': 'br' } })).text()).toBe(
        'encoded as br'
      )
      expect(env.count).toBe(2)
      expect(env.store.size).toBe(1)
    })

    it('Should compare selecting field values after collapsing optional whitespace', async () => {
      const env = setup(() => cacheable('Hono is hot', { Vary: 'Accept-Encoding' }))

      await env.request('/', { headers: { 'Accept-Encoding': 'gzip, br' } })
      await env.request('/', { headers: { 'Accept-Encoding': '  gzip, \t br  ' } })
      expect(env.count).toBe(1)
    })

    it('Should match an absent selecting header field only with an absent one', async () => {
      const env = setup(() => cacheable('Hono is hot', { Vary: 'X-Flavor' }))

      await env.request('/')
      await env.request('/')
      expect(env.count).toBe(1)
      await env.request('/', { headers: { 'X-Flavor': '' } })
      expect(env.count).toBe(2)
    })

    it('Should never reuse a response that varies on *', async () => {
      const env = setup(() => cacheable('Hono is hot', { Vary: '*' }))

      await env.request('/')
      await env.request('/')
      await env.request('/')
      expect(env.count).toBe(3)
    })

    it('Should select the matching variant with the most recent Date', async () => {
      const env = setup((_c, count) =>
        cacheable(`response ${count}`, {
          Date: httpDate(START + count * 1000),
          Vary: count === 1 ? 'X-Flavor' : 'X-Other',
        })
      )

      await env.request('/', { headers: { 'X-Flavor': 'salty' } })
      await env.request('/', { headers: { 'X-Flavor': 'sweet' } })
      expect(env.count).toBe(2)

      // Both stored variants match a request without either field, so the most
      // recently dated one is selected.
      const res = await env.request('/')
      expect(await res.text()).toBe('response 2')
      expect(env.count).toBe(2)
    })
  })

  describe('Validation', () => {
    it('Should revalidate a stale response with If-None-Match and freshen it with a 304', async () => {
      const env = setup((c, count) => {
        if (c.req.header('If-None-Match') === '"v1"') {
          return new Response(null, { status: 304, headers: { 'Cache-Control': 'max-age=60', 'X-Round': `${count}` } })
        }
        return cacheable('Hono is hot', { ETag: '"v1"' })
      })

      await env.request('/')
      env.advance(61)

      const res = await env.request('/')
      expect(res.status).toBe(200)
      expect(await res.text()).toBe('Hono is hot')
      expect(res.headers.get('ETag')).toBe('"v1"')
      expect(res.headers.get('X-Round')).toBe('2')
      expect(res.headers.get('Age')).toBe('0')
      expect(env.seen[1].get('If-None-Match')).toBe('"v1"')
      expect(env.count).toBe(2)

      // The stored response has been freshened, so it is reusable again.
      env.advance(10)
      const reused = await env.request('/')
      expect(await reused.text()).toBe('Hono is hot')
      expect(reused.headers.get('Age')).toBe('10')
      expect(env.count).toBe(2)
    })

    it('Should revalidate with If-Modified-Since when only Last-Modified is stored', async () => {
      const lastModified = httpDate(START - 10_000)
      const env = setup((c) => {
        if (c.req.header('If-Modified-Since') === lastModified) {
          return new Response(null, { status: 304 })
        }
        return cacheable('Hono is hot', { 'Last-Modified': lastModified })
      })

      await env.request('/')
      env.advance(61)
      const res = await env.request('/')
      expect(res.status).toBe(200)
      expect(await res.text()).toBe('Hono is hot')
      expect(env.seen[1].get('If-Modified-Since')).toBe(lastModified)
      expect(env.count).toBe(2)
    })

    it('Should replace the client preconditions with the stored validators', async () => {
      const env = setup(() => cacheable('Hono is hot', { ETag: '"v1"' }))

      await env.request('/')
      env.advance(61)
      await env.request('/', {
        headers: { 'If-None-Match': '"client"', 'If-Modified-Since': httpDate(START) },
      })
      expect(env.seen[1].get('If-None-Match')).toBe('"v1"')
      expect(env.seen[1].get('If-Modified-Since')).toBeNull()
    })

    it('Should not send validators when nothing was stored', async () => {
      const env = setup(() => cacheable('Hono is hot', { ETag: '"v1"' }))

      await env.request('/', { headers: { 'If-None-Match': '"client"' } })
      expect(env.seen[0].get('If-None-Match')).toBe('"client"')
    })

    it('Should use a full response received while revalidating', async () => {
      const env = setup((c, count) =>
        c.req.header('If-None-Match') ? cacheable(`response ${count}`, { ETag: '"v2"' }) : cacheable('response 1', { ETag: '"v1"' })
      )

      await env.request('/')
      env.advance(61)
      const res = await env.request('/')
      expect(res.status).toBe(200)
      expect(await res.text()).toBe('response 2')
      expect(res.headers.get('Age')).toBeNull()
      expect(env.count).toBe(2)
    })

    it('Should not validate a stored response with a HEAD request', async () => {
      const env = setup(() => cacheable('Hono is hot', { ETag: '"v1"' }))

      await env.request('/')
      env.advance(61)
      await env.request('/', { method: 'HEAD' })
      expect(env.seen[1].get('If-None-Match')).toBeNull()
    })
  })

  describe('Preconditions', () => {
    const withValidators = () =>
      setup(() =>
        cacheable('Hono is hot', { ETag: '"v1"', 'Last-Modified': httpDate(START - 10_000), Vary: 'Accept' })
      )

    it('Should answer a matching If-None-Match with a 304 carrying the required header fields', async () => {
      const env = withValidators()
      await env.request('/')
      env.advance(5)

      const res = await env.request('/', { headers: { 'If-None-Match': '"v1"' } })
      expect(res.status).toBe(304)
      expect(res.headers.get('ETag')).toBe('"v1"')
      expect(res.headers.get('Cache-Control')).toBe('max-age=60')
      expect(res.headers.get('Vary')).toBe('Accept')
      expect(res.headers.get('Age')).toBe('5')
      expect(res.headers.get('Last-Modified')).toBeNull()
      expect(res.headers.get('Content-Type')).toBeNull()
      expect(env.count).toBe(1)
    })

    it('Should compare If-None-Match weakly', async () => {
      const env = withValidators()
      await env.request('/')
      expect((await env.request('/', { headers: { 'If-None-Match': 'W/"v1"' } })).status).toBe(304)
      expect((await env.request('/', { headers: { 'If-None-Match': '"other", "v1"' } })).status).toBe(304)
      expect((await env.request('/', { headers: { 'If-None-Match': '*' } })).status).toBe(304)
      expect((await env.request('/', { headers: { 'If-None-Match': '"other"' } })).status).toBe(200)
      expect(env.count).toBe(1)
    })

    it('Should answer If-Modified-Since when If-None-Match is absent', async () => {
      const env = withValidators()
      await env.request('/')
      expect((await env.request('/', { headers: { 'If-Modified-Since': httpDate(START) } })).status).toBe(304)
      expect(
        (await env.request('/', { headers: { 'If-Modified-Since': httpDate(START - 20_000) } })).status
      ).toBe(200)
      expect(env.count).toBe(1)
    })

    it('Should give If-None-Match precedence over If-Modified-Since', async () => {
      const env = withValidators()
      await env.request('/')
      const res = await env.request('/', {
        headers: { 'If-None-Match': '"other"', 'If-Modified-Since': httpDate(START) },
      })
      expect(res.status).toBe(200)
      expect(env.count).toBe(1)
    })

    it('Should ignore an If-Modified-Since that is not a valid HTTP-date', async () => {
      const env = withValidators()
      await env.request('/')
      expect((await env.request('/', { headers: { 'If-Modified-Since': 'yesterday' } })).status).toBe(200)
    })

    it('Should answer a failing If-Match with 412', async () => {
      const env = withValidators()
      await env.request('/')
      expect((await env.request('/', { headers: { 'If-Match': '"other"' } })).status).toBe(412)
      expect((await env.request('/', { headers: { 'If-Match': '"v1"' } })).status).toBe(200)
      expect((await env.request('/', { headers: { 'If-Match': '*' } })).status).toBe(200)
      expect((await env.request('/', { headers: { 'If-Match': 'W/"v1"' } })).status).toBe(412)
      expect(env.count).toBe(1)
    })

    it('Should answer a failing If-Unmodified-Since with 412', async () => {
      const env = withValidators()
      await env.request('/')
      expect(
        (await env.request('/', { headers: { 'If-Unmodified-Since': httpDate(START - 20_000) } })).status
      ).toBe(412)
      expect((await env.request('/', { headers: { 'If-Unmodified-Since': httpDate(START) } })).status).toBe(200)
      expect(env.count).toBe(1)
    })

    it('Should give If-Match precedence over If-Unmodified-Since', async () => {
      const env = withValidators()
      await env.request('/')
      const res = await env.request('/', {
        headers: { 'If-Match': '"v1"', 'If-Unmodified-Since': httpDate(START - 20_000) },
      })
      expect(res.status).toBe(200)
    })

    it('Should evaluate If-Match before If-None-Match', async () => {
      const env = withValidators()
      await env.request('/')
      const res = await env.request('/', { headers: { 'If-Match': '"other"', 'If-None-Match': '"v1"' } })
      expect(res.status).toBe(412)
    })

    it('Should evaluate preconditions against a freshened stored response', async () => {
      const env = setup((c) =>
        c.req.header('If-None-Match')
          ? new Response(null, { status: 304 })
          : cacheable('Hono is hot', { ETag: '"v1"' })
      )

      await env.request('/')
      env.advance(61)
      const res = await env.request('/', { headers: { 'If-None-Match': '"v1"' } })
      expect(res.status).toBe(304)
      expect(res.headers.get('ETag')).toBe('"v1"')
      expect(env.count).toBe(2)
    })

    it('Should fall back to the Date of the stored response without Last-Modified', async () => {
      const env = setup(() => cacheable('Hono is hot', { Date: httpDate(START - 30_000) }))

      await env.request('/')
      expect(
        (await env.request('/', { headers: { 'If-Modified-Since': httpDate(START - 20_000) } })).status
      ).toBe(304)
      expect(
        (await env.request('/', { headers: { 'If-Modified-Since': httpDate(START - 40_000) } })).status
      ).toBe(200)
    })
  })

  describe('Request cache directives', () => {
    it('Should revalidate when the request has no-cache', async () => {
      const env = setup(() => cacheable('Hono is hot'))

      await env.request('/')
      await env.request('/', { headers: { 'Cache-Control': 'no-cache' } })
      expect(env.count).toBe(2)
    })

    it('Should not store when the request has no-store', async () => {
      const env = setup(() => cacheable('Hono is hot'))

      await env.request('/', { headers: { 'Cache-Control': 'no-store' } })
      expect(env.store.size).toBe(0)
    })

    it('Should honor max-age', async () => {
      const env = setup(() => cacheable('Hono is hot'))

      await env.request('/')
      env.advance(30)
      await env.request('/', { headers: { 'Cache-Control': 'max-age=40' } })
      expect(env.count).toBe(1)
      await env.request('/', { headers: { 'Cache-Control': 'max-age=20' } })
      expect(env.count).toBe(2)
    })

    it('Should honor min-fresh', async () => {
      const env = setup(() => cacheable('Hono is hot'))

      await env.request('/')
      env.advance(30)
      await env.request('/', { headers: { 'Cache-Control': 'min-fresh=20' } })
      expect(env.count).toBe(1)
      await env.request('/', { headers: { 'Cache-Control': 'min-fresh=40' } })
      expect(env.count).toBe(2)
    })

    it('Should serve a stale response when the request has max-stale', async () => {
      const env = setup(() => cacheable('Hono is hot'))

      await env.request('/')
      env.advance(80)
      const res = await env.request('/', { headers: { 'Cache-Control': 'max-stale=30' } })
      expect(res.status).toBe(200)
      expect(res.headers.get('Age')).toBe('80')
      expect(env.count).toBe(1)

      await env.request('/', { headers: { 'Cache-Control': 'max-stale=10' } })
      expect(env.count).toBe(2)
    })

    it('Should serve any stale response when max-stale has no argument', async () => {
      const env = setup(() => cacheable('Hono is hot'))

      await env.request('/')
      env.advance(6000)
      await env.request('/', { headers: { 'Cache-Control': 'max-stale' } })
      expect(env.count).toBe(1)
    })

    it('Should not serve a stale response that must be revalidated', async () => {
      const env = setup(() => cacheable('Hono is hot', { 'Cache-Control': 'max-age=60, must-revalidate' }))

      await env.request('/')
      env.advance(80)
      await env.request('/', { headers: { 'Cache-Control': 'max-stale=300' } })
      expect(env.count).toBe(2)
    })

    it('Should answer only-if-cached from the store, and with 504 otherwise', async () => {
      const env = setup(() => cacheable('Hono is hot'))

      const miss = await env.request('/', { headers: { 'Cache-Control': 'only-if-cached' } })
      expect(miss.status).toBe(504)
      expect(env.count).toBe(0)

      await env.request('/')
      const hit = await env.request('/', { headers: { 'Cache-Control': 'only-if-cached' } })
      expect(hit.status).toBe(200)
      expect(env.count).toBe(1)

      env.advance(61)
      const stale = await env.request('/', { headers: { 'Cache-Control': 'only-if-cached' } })
      expect(stale.status).toBe(504)
      expect(env.count).toBe(1)
    })
  })

  describe('Response cache directives', () => {
    it('Should not store a response with no-store', async () => {
      const env = setup(() => cacheable('Hono is hot', { 'Cache-Control': 'max-age=60, no-store' }))

      await env.request('/')
      expect(env.store.size).toBe(0)
    })

    it('Should not store a response with private', async () => {
      const env = setup(() => cacheable('Hono is hot', { 'Cache-Control': 'max-age=60, private' }))

      await env.request('/')
      expect(env.store.size).toBe(0)
    })

    it('Should store a qualified private response without the listed fields', async () => {
      const env = setup(() =>
        cacheable('Hono is hot', {
          'Cache-Control': 'max-age=60, private="Set-Cookie, X-User"',
          'Set-Cookie': 'session=1',
          'X-User': 'me',
          'X-Public': 'yes',
        })
      )

      await env.request('/')
      const res = await env.request('/')
      expect(res.headers.get('Set-Cookie')).toBeNull()
      expect(res.headers.get('X-User')).toBeNull()
      expect(res.headers.get('X-Public')).toBe('yes')
      expect(env.count).toBe(1)
    })

    it('Should revalidate a stored response with no-cache', async () => {
      const env = setup(() => cacheable('Hono is hot', { 'Cache-Control': 'max-age=60, no-cache' }))

      await env.request('/')
      await env.request('/')
      expect(env.count).toBe(2)
    })

    it('Should serve a qualified no-cache response without the listed fields', async () => {
      const env = setup(() =>
        cacheable('Hono is hot', { 'Cache-Control': 'max-age=60, no-cache="X-Secret"', 'X-Secret': 'shh' })
      )

      const first = await env.request('/')
      expect(first.headers.get('X-Secret')).toBe('shh')
      const second = await env.request('/')
      expect(second.headers.get('X-Secret')).toBeNull()
      expect(env.count).toBe(1)
    })

    it('Should serve the fields of a qualified no-cache after successful validation', async () => {
      const env = setup((c) =>
        c.req.header('If-None-Match')
          ? new Response(null, { status: 304 })
          : cacheable('Hono is hot', {
              'Cache-Control': 'max-age=60, no-cache="X-Secret"',
              'X-Secret': 'shh',
              ETag: '"v1"',
            })
      )

      await env.request('/')
      const res = await env.request('/')
      expect(res.headers.get('X-Secret')).toBe('shh')
      expect(env.count).toBe(2)
    })

    it('Should not store a response to an authenticated request without an explicit directive', async () => {
      const env = setup(() => cacheable('Hono is hot'))

      await env.request('/', { headers: { Authorization: 'Bearer token' } })
      expect(env.store.size).toBe(0)
    })

    it('Should store a response to an authenticated request that allows it', async () => {
      for (const directive of ['public', 'must-revalidate', 's-maxage=60']) {
        const env = setup(() => cacheable('Hono is hot', { 'Cache-Control': `max-age=60, ${directive}` }))
        await env.request('/', { headers: { Authorization: 'Bearer token' } })
        expect(env.store.size).toBe(1)
      }
    })

    it('Should not store a status it does not understand when must-understand is present', async () => {
      const env = setup(() =>
        new Response('unknown', { status: 499, headers: { 'Cache-Control': 'max-age=60, must-understand, no-store' } })
      )

      await env.request('/')
      expect(env.store.size).toBe(0)
    })

    it('Should ignore no-store when must-understand applies to a known status', async () => {
      const env = setup(() => cacheable('Hono is hot', { 'Cache-Control': 'max-age=60, must-understand, no-store' }))

      await env.request('/')
      await env.request('/')
      expect(env.count).toBe(1)
    })
  })

  describe('stale-if-error', () => {
    const failing = (headers: Record<string, string>) =>
      setup((_c, count) =>
        count === 1 ? cacheable('Hono is hot', headers) : new Response('boom', { status: 503 })
      )

    it('Should serve a stale response when the handler fails', async () => {
      const env = failing({ 'Cache-Control': 'max-age=60, stale-if-error=120' })

      await env.request('/')
      env.advance(100)
      const res = await env.request('/')
      expect(res.status).toBe(200)
      expect(await res.text()).toBe('Hono is hot')
      expect(res.headers.get('Age')).toBe('100')
      expect(env.count).toBe(2)
    })

    it('Should forward the error once the stale-if-error window has passed', async () => {
      const env = failing({ 'Cache-Control': 'max-age=60, stale-if-error=30' })

      await env.request('/')
      env.advance(100)
      const res = await env.request('/')
      expect(res.status).toBe(503)
      expect(await res.text()).toBe('boom')
    })

    it('Should accept stale-if-error from the request', async () => {
      const env = failing({ 'Cache-Control': 'max-age=60' })

      await env.request('/')
      env.advance(100)
      const res = await env.request('/', { headers: { 'Cache-Control': 'stale-if-error=120' } })
      expect(res.status).toBe(200)
    })

    it('Should not serve a stale response for an error that is not a server error', async () => {
      const env = setup((_c, count) =>
        count === 1
          ? cacheable('Hono is hot', { 'Cache-Control': 'max-age=60, stale-if-error=120' })
          : new Response('gone', { status: 410 })
      )

      await env.request('/')
      env.advance(100)
      expect((await env.request('/')).status).toBe(410)
    })

    it('Should not serve a stale response that must be revalidated', async () => {
      const env = failing({ 'Cache-Control': 'max-age=60, stale-if-error=120, must-revalidate' })

      await env.request('/')
      env.advance(100)
      expect((await env.request('/')).status).toBe(503)
    })

    it('Should keep the stored response after an error', async () => {
      const env = failing({ 'Cache-Control': 'max-age=60, stale-if-error=120' })

      await env.request('/')
      env.advance(100)
      await env.request('/')
      expect(env.store.size).toBe(1)
    })
  })

  describe('HEAD requests', () => {
    it('Should answer a HEAD request from a stored GET response', async () => {
      const env = setup(() => cacheable('Hono is hot'))

      await env.request('/')
      const res = await env.request('/', { method: 'HEAD' })
      expect(res.status).toBe(200)
      expect(await res.text()).toBe('')
      expect(res.headers.get('Age')).toBe('0')
      expect(env.count).toBe(1)
    })

    it('Should never store a HEAD response', async () => {
      const env = setup(() => cacheable('Hono is hot'))

      await env.request('/', { method: 'HEAD' })
      expect(env.store.size).toBe(0)
      await env.request('/', { method: 'HEAD' })
      expect(env.count).toBe(2)
    })

    it('Should evaluate preconditions for a HEAD request', async () => {
      const env = setup(() => cacheable('Hono is hot', { ETag: '"v1"' }))

      await env.request('/')
      const res = await env.request('/', { method: 'HEAD', headers: { 'If-None-Match': '"v1"' } })
      expect(res.status).toBe(304)
      expect(env.count).toBe(1)
    })
  })

  describe('Invalidation', () => {
    const invalidating = () =>
      setup((c) =>
        c.req.method === 'GET'
          ? cacheable('Hono is hot')
          : new Response('done', { status: c.req.header('X-Fail') ? 500 : 200 })
      )

    it('Should invalidate the target URI on an unsafe request', async () => {
      const env = invalidating()

      await env.request('/')
      expect(env.store.size).toBe(1)
      await env.request('/', { method: 'POST' })
      expect(env.store.size).toBe(0)
      await env.request('/')
      expect(env.count).toBe(3)
    })

    it('Should not invalidate on an error response', async () => {
      const env = invalidating()

      await env.request('/')
      await env.request('/', { method: 'POST', headers: { 'X-Fail': '1' } })
      expect(env.store.size).toBe(1)
    })

    it('Should not invalidate on a safe request', async () => {
      const env = invalidating()

      await env.request('/')
      await env.request('/', { method: 'OPTIONS' })
      expect(env.store.size).toBe(1)
    })

    it('Should invalidate the same-origin Location and Content-Location targets', async () => {
      const env = setup((c) =>
        c.req.method === 'GET'
          ? cacheable('Hono is hot')
          : new Response('done', {
              status: 201,
              headers: { Location: '/created', 'Content-Location': 'http://localhost/other' },
            })
      )

      await env.request('/created')
      await env.request('/other')
      expect(env.store.size).toBe(2)
      await env.request('/', { method: 'POST' })
      expect(env.store.size).toBe(0)
    })

    it('Should not invalidate a cross-origin Location', async () => {
      const env = setup((c) =>
        c.req.method === 'GET'
          ? cacheable('Hono is hot')
          : new Response('done', { status: 201, headers: { Location: 'http://example.com/created' } })
      )

      await env.request('/created')
      await env.request('/', { method: 'POST' })
      expect(env.store.size).toBe(1)
    })

    it('Should not store a response to an unsafe request', async () => {
      const env = invalidating()

      await env.request('/', { method: 'POST' })
      expect(env.store.size).toBe(0)
    })
  })

  describe('Store', () => {
    it('Should use the provided store', async () => {
      const backing = new Map<string, unknown>()
      const store = {
        get: vi.fn((key: string) => backing.get(key)),
        set: vi.fn((key: string, value: unknown) => backing.set(key, value)),
        delete: vi.fn((key: string) => backing.delete(key)),
      }
      const app = new Hono()
      app.use(httpCache({ store }))
      app.get('/', () => cacheable('Hono is hot'))

      await app.request('http://localhost/')
      expect(store.set).toHaveBeenCalledTimes(1)
      const res = await app.request('http://localhost/')
      expect(await res.text()).toBe('Hono is hot')
      expect(store.get).toHaveBeenCalledTimes(2)
    })

    it('Should ignore a stored value it does not recognize', async () => {
      const env = setup(() => cacheable('Hono is hot'))

      env.store.set('http://localhost/', 'not an entry')
      await env.request('/')
      expect(env.count).toBe(1)
      await env.request('/')
      expect(env.count).toBe(1)
    })
  })
})

Run the http-cache tests

shell
npx vitest --run --project main src/middleware/http-cache/index.test.ts 2>&1 | tail -80
output
Loaded  vitest@4.1.9  and  @vitest/coverage-v8@4.1.7 .
Running mixed versions is not supported and may lead into bugs
Update your dependencies and make sure the versions match.

 RUN  v4.1.9 /app
      Coverage enabled with v8

 ❯ |main| src/middleware/http-cache/index.test.ts (81 tests | 1 failed) 53ms
       × Should serve the fields of a qualified no-cache after successful validation 4ms

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  |main| src/middleware/http-cache/index.test.ts > HTTP Cache Middleware > Response cache directives > Should serve the fields of a qualified no-cache after successful validation
AssertionError: expected null to be 'shh' // Object.is equality

- Expected:
"shh"

+ Received:
null

 ❯ src/middleware/http-cache/index.test.ts:769:43
    767|       await env.request('/')
    768|       const res = await env.request('/')
    769|       expect(res.headers.get('X-Secret')).toBe('shh')
       |                                           ^
    770|       expect(env.count).toBe(2)
    771|     })

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯


 Test Files  1 failed (1)
      Tests  1 failed | 80 passed (81)
   Start at  15:10:25
   Duration  286ms (transform 92ms, setup 0ms, import 108ms, tests 53ms, environment 0ms)

[stdout]
Loaded  vitest@4.1.9  and  @vitest/coverage-v8@4.1.7 .
Running mixed versions is not supported and may lead into bugs
Update your dependencies and make sure the versions match.

 RUN  v4.1.9 /app
      Coverage enabled with v8

 ❯ |main| src/middleware/http-cache/index.test.ts (81 tests | 1 failed) 53ms
       × Should serve the fields of a qualified no-cache after successful validation 4ms

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  |main| src/middleware/http-cache/index.test.ts > HTTP Cache Middleware > Response cache directives > Should serve the fields of a qualified no-cache after successful validation
AssertionError: expected null to be 'shh' // Object.is equality

- Expected:
"shh"

+ Received:
null

 ❯ src/middleware/http-cache/index.test.ts:769:43
    767|       await env.request('/')
    768|       const res = await env.request('/')
    769|       expect(res.headers.get('X-Secret')).toBe('shh')
       |                                           ^
    770|       expect(env.count).toBe(2)
    771|     })

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯


 Test Files  1 failed (1)
      Tests  1 failed | 80 passed (81)
   Start at  15:10:25
   Duration  286ms (transform 92ms, setup 0ms, import 108ms, tests 53ms, environment 0ms)

/app/src/middleware/http-cache/index.ts

− removed
const staleForbidden = (info: StoredInfo): boolean =>
  info.directives.has('no-cache') ||
+ added
const staleForbidden = (info: StoredInfo): boolean =>
  // The qualified form of no-cache only forbids reusing the fields it lists.
  hasUnqualified(info.directives, 'no-cache') ||

/app/src/middleware/http-cache/index.test.ts

− removed
      await env.request('/')
      const res = await env.request('/')
      expect(res.headers.get('X-Secret')).toBe('shh')
      expect(env.count).toBe(2)
+ added
      await env.request('/')
      env.advance(61)
      const res = await env.request('/')
      expect(res.headers.get('X-Secret')).toBe('shh')
      expect(env.count).toBe(2)

Re-run http-cache tests

shell
npx vitest --run --project main src/middleware/http-cache/index.test.ts 2>&1 | tail -30
output
hono-base.ts     |   38.34 |    29.09 |   32.43 |   38.93 | ...64,507,537-541 
  ...-exception.ts |       0 |        0 |       0 |       0 | 56-74             
  request.ts       |   15.15 |    20.83 |   13.33 |   16.12 | ...86-437,473-508 
 ...are/http-cache |   96.22 |    92.85 |     100 |   96.17 |                   
  index.ts         |   96.22 |    92.85 |     100 |   96.17 | ...02,428-430,646 
 ...reg-exp-router |   59.04 |    43.75 |   56.86 |   58.84 |                   
  matcher.ts       |   92.85 |    83.33 |     100 |   92.85 | 24                
  node.ts          |    64.7 |    42.69 |     100 |   63.63 | ...10,121,132,166 
  ...red-router.ts |    2.46 |        0 |       0 |    2.66 | ...98-153,160-164 
  router.ts        |   91.42 |    73.68 |    90.9 |   91.57 | ...81-182,194,198 
  trie.ts          |   65.95 |    42.85 |      75 |    65.9 | ...45-49,61,73-78 
 ...r/smart-router |      80 |       50 |     100 |   78.78 |                   
  router.ts        |      80 |       50 |     100 |   78.78 | 15,23,40-43,54,65 
 ...er/trie-router |    6.99 |     5.33 |   18.18 |    7.81 |                   
  node.ts          |    6.06 |     5.47 |    12.5 |    6.72 | ...2,37-39,45-243 
  router.ts        |   18.18 |        0 |   33.33 |   22.22 | 14-26             
 src/utils         |   15.16 |     5.78 |    5.88 |   15.56 |                   
  body.ts          |    7.54 |        0 |       0 |    7.69 | ...99-209,226-245 
  buffer.ts        |      12 |        0 |       0 |   12.24 | ...99-103,110-116 
  crypto.ts        |   16.66 |        0 |       0 |   16.66 | ...24,28-30,36-57 
  html.ts          |    8.64 |        0 |       0 |    8.97 | ...30-139,149-180 
  url.ts           |   21.76 |    12.38 |      15 |   22.37 | ...16,226-299,314 
-------------------|---------|----------|---------|---------|-------------------

=============================== Coverage summary ===============================
Statements   : 47.83% ( 750/1568 )
Branches     : 43.18% ( 434/1005 )
Functions    : 41.6% ( 109/262 )
Lines        : 48.37% ( 728/1505 )
================================================================================

[stdout]
  hono-base.ts     |   38.34 |    29.09 |   32.43 |   38.93 | ...64,507,537-541 
  ...-exception.ts |       0 |        0 |       0 |       0 | 56-74             
  request.ts       |   15.15 |    20.83 |   13.33 |   16.12 | ...86-437,473-508 
 ...are/http-cache |   96.22 |    92.85 |     100 |   96.17 |                   
  index.ts         |   96.22 |    92.85 |     100 |   96.17 | ...02,428-430,646 
 ...reg-exp-router |   59.04 |    43.75 |   56.86 |   58.84 |                   
  matcher.ts       |   92.85 |    83.33 |     100 |   92.85 | 24                
  node.ts          |    64.7 |    42.69 |     100 |   63.63 | ...10,121,132,166 
  ...red-router.ts |    2.46 |        0 |       0 |    2.66 | ...98-153,160-164 
  router.ts        |   91.42 |    73.68 |    90.9 |   91.57 | ...81-182,194,198 
  trie.ts          |   65.95 |    42.85 |      75 |    65.9 | ...45-49,61,73-78 
 ...r/smart-router |      80 |       50 |     100 |   78.78 |                   
  router.ts        |      80 |       50 |     100 |   78.78 | 15,23,40-43,54,65 
 ...er/trie-router |    6.99 |     5.33 |   18.18 |    7.81 |                   
  node.ts          |    6.06 |     5.47 |    12.5 |    6.72 | ...2,37-39,45-243 
  router.ts        |   18.18 |        0 |   33.33 |   22.22 | 14-26             
 src/utils         |   15.16 |     5.78 |    5.88 |   15.56 |                   
  body.ts          |    7.54 |        0 |       0 |    7.69 | ...99-209,226-245 
  buffer.ts        |      12 |        0 |       0 |   12.24 | ...99-103,110-116 
  crypto.ts        |   16.66 |        0 |       0 |   16.66 | ...24,28-30,36-57 
  html.ts          |    8.64 |        0 |       0 |    8.97 | ...30-139,149-180 
  url.ts           |   21.76 |    12.38 |      15 |   22.37 | ...16,226-299,314 
-------------------|---------|----------|---------|---------|-------------------

=============================== Coverage summary ===============================
Statements   : 47.83% ( 750/1568 )
Branches     : 43.18% ( 434/1005 )
Functions    : 41.6% ( 109/262 )
Lines        : 48.37% ( 728/1505 )
================================================================================

Show test summary

shell
npx vitest --run --project main src/middleware/http-cache/index.test.ts 2>&1 | grep -E "Tests|Test Files|×|✓ src" | head -20
output
Test Files  1 passed (1)
      Tests  81 passed (81)

[stdout]
 Test Files  1 passed (1)
      Tests  81 passed (81)

Get uncovered lines for http-cache

shell
npx vitest --run --project main src/middleware/http-cache/index.test.ts --coverage.reporter=text --coverage.include='src/middleware/http-cache/**' 2>&1 | grep -A5 "http-cache"
output
(Bash completed with no output)

Get uncovered line list

shell
npx vitest --run --project main src/middleware/http-cache/index.test.ts --coverage.reporter=json-summary --coverage.reporter=text 2>&1 | grep -E "index.ts *\|" 
output
index.ts         |   96.22 |    92.85 |     100 |   96.17 | ...02,428-430,646

[stdout]
  index.ts         |   96.22 |    92.85 |     100 |   96.17 | ...02,428-430,646

List uncovered lines and branches

shell
cat > /tmp/cov.mjs <<'EOF'
import fs from 'fs'
const raw = JSON.parse(fs.readFileSync('coverage/raw/default/coverage-final.json','utf8'))
for (const [file, data] of Object.entries(raw)) {
  if (!file.includes('http-cache')) continue
  const uncovered = []
  for (const [id, count] of Object.entries(data.s)) {
    if (count === 0) uncovered.push(data.statementMap[id].start.line)
  }
  const uncoveredBranches = []
  for (const [id, counts] of Object.entries(data.b)) {
    counts.forEach((c, i) => { if (c === 0) uncoveredBranches.push(`${data.branchMap[id].loc.start.line}#${i}`) })
  }
  console.log('statements:', [...new Set(uncovered)].sort((a,b)=>a-b).join(','))
  console.log('branches:', [...new Set(uncoveredBranches)].join(','))
}
EOF
node /tmp/cov.mjs
output
Exit code 1
node:fs:440
    return binding.readFileUtf8(path, stringToFlags(options.flag));
                   ^

Error: ENOENT: no such file or directory, open 'coverage/raw/default/coverage-final.json'
    at Object.readFileSync (node:fs:440:20)
    at file:///tmp/cov.mjs:2:27
    at ModuleJob.run (node:internal/modules/esm/module_job:343:25)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:681:26)
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:117:5) {
  errno: -2,
  code: 'ENOENT',
  syscall: 'open',
  path: 'coverage/raw/default/coverage-final.json'
}

Node.js v22.23.2

[error] tool reported failure

Generate json coverage and list uncovered lines

shell
npx vitest --run --project main src/middleware/http-cache/index.test.ts --coverage.reporter=json >/dev/null 2>&1; ls coverage/raw/default/ 2>/dev/null; node /tmp/cov.mjs
output
coverage-final.json
statements: 165,189,190,191,192,194,199,285,292,402,428,429,430,646
branches: 164#0,186#0,191#0,191#1,198#0,246#1,260#1,291#0,291#1,309#1,369#1,383#1,396#1,401#0,427#0,435#1,502#1,645#0,801#1,898#3,898#4

[stdout]
coverage-final.json
statements: 165,189,190,191,192,194,199,285,292,402,428,429,430,646
branches: 164#0,186#0,191#0,191#1,198#0,246#1,260#1,291#0,291#1,309#1,369#1,383#1,396#1,401#0,427#0,435#1,502#1,645#0,801#1,898#3,898#4

Show uncovered lines with context

shell
awk 'NR==164||NR==165||NR==186||NR>=189&&NR<=199||NR==246||NR==260||NR>=283&&NR<=294||NR==309||NR==369||NR==383||NR==396||NR>=401&&NR<=403||NR>=427&&NR<=431||NR==435||NR==502||NR>=645&&NR<=647||NR==801||NR==898 {printf "%d: %s\n", NR, $0}' src/middleware/http-cache/index.ts
output
164:   if (monthIndex < 0 || day < 1 || day > 31 || hour > 23 || minute > 59 || second > 60) {
165:     return null
186:   if (match) {
189:     const currentYear = new Date(nowMs).getUTCFullYear()
190:     let year = Math.floor(currentYear / 100) * 100 + +match[3]
191:     if (year > currentYear + 50) {
192:       year -= 100
193:     }
194:     return toEpoch(year, match[2], +match[1], +match[4], +match[5], +match[6])
195:   }
196: 
197:   match = ASCTIME_DATE.exec(value)
198:   if (match) {
199:     return toEpoch(+match[6], match[1], +match[2], +match[3], +match[4], +match[5])
246:   return earliest ?? null
260:   return dates.length === 1 ? parseHttpDate(dates[0], nowMs) : null
283:       index++
284:       while (value[index] === ' ' || value[index] === '\t') {
285:         index++
286:       }
287:       if (value[index] === '"') {
288:         index++
289:         let quoted = ''
290:         while (index < value.length && value[index] !== '"') {
291:           if (value[index] === '\\' && index + 1 < value.length) {
292:             index++
293:           }
294:           quoted += value[index++]
309:     if (name) {
369:       result = Math.min(result, parseDelta(occurrence) ?? 0)
383:       argument
396:   const tags = list.match(ENTITY_TAG) ?? []
401:   if (etag === null) {
402:     return false
403:   }
427:   if (source.has('set-cookie')) {
428:     target.delete('set-cookie')
429:     for (const cookie of source.getSetCookie()) {
430:       target.append('set-cookie', cookie)
431:     }
435:     if (name !== 'set-cookie' && name !== 'content-length' && !HOP_BY_HOP_HEADERS.includes(name)) {
502:     if (date >= selectedDate) {
645:   if (status < 200 || status === 206 || status === 304) {
646:     return false
647:   }
801:       Number.isFinite(info.age) ? Math.max(0, Math.floor(info.age / SECOND)) : MAX_DELTA_SECONDS
898:         validating &&

[stdout]
164:   if (monthIndex < 0 || day < 1 || day > 31 || hour > 23 || minute > 59 || second > 60) {
165:     return null
186:   if (match) {
189:     const currentYear = new Date(nowMs).getUTCFullYear()
190:     let year = Math.floor(currentYear / 100) * 100 + +match[3]
191:     if (year > currentYear + 50) {
192:       year -= 100
193:     }
194:     return toEpoch(year, match[2], +match[1], +match[4], +match[5], +match[6])
195:   }
196: 
197:   match = ASCTIME_DATE.exec(value)
198:   if (match) {
199:     return toEpoch(+match[6], match[1], +match[2], +match[3], +match[4], +match[5])
246:   return earliest ?? null
260:   return dates.length === 1 ? parseHttpDate(dates[0], nowMs) : null
283:       index++
284:       while (value[index] === ' ' || value[index] === '\t') {
285:         index++
286:       }
287:       if (value[index] === '"') {
288:         index++
289:         let quoted = ''
290:         while (index < value.length && value[index] !== '"') {
291:           if (value[index] === '\\' && index + 1 < value.length) {
292:             index++
293:           }
294:           quoted += value[index++]
309:     if (name) {
369:       result = Math.min(result, parseDelta(occurrence) ?? 0)
383:       argument
396:   const tags = list.match(ENTITY_TAG) ?? []
401:   if (etag === null) {
402:     return false
403:   }
427:   if (source.has('set-cookie')) {
428:     target.delete('set-cookie')
429:     for (const cookie of source.getSetCookie()) {
430:       target.append('set-cookie', cookie)
431:     }
435:     if (name !== 'set-cookie' && name !== 'content-length' && !HOP_BY_HOP_HEADERS.includes(name)) {
502:     if (date >= selectedDate) {
645:   if (status < 200 || status === 206 || status === 304) {
646:     return false
647:   }
801:       Number.isFinite(info.age) ? Math.max(0, Math.floor(info.age / SECOND)) : MAX_DELTA_SECONDS
898:         validating &&

/app/src/middleware/http-cache/index.ts

− removed
  let earliest: number | undefined
  for (const date of splitDateList(value)) {
    const parsed = parseHttpDate(date, nowMs)
    if (parsed === null) {
      return null
    }
    earliest = earliest === undefined ? parsed : Math.min(earliest, parsed)
  }
  return earliest ?? null
}
+ added
  let earliest = Infinity
  for (const date of splitDateList(value)) {
    const parsed = parseHttpDate(date, nowMs)
    if (parsed === null) {
      return null
    }
    earliest = Math.min(earliest, parsed)
  }
  return earliest
}

/app/src/middleware/http-cache/index.ts

− removed
    const age = String(
      Number.isFinite(info.age) ? Math.max(0, Math.floor(info.age / SECOND)) : MAX_DELTA_SECONDS
    )
    headers.set('Age', age)
+ added
    // RFC 9111 §5.1: an age that cannot be represented is sent as 2^31.
    const age = String(Math.min(Math.max(0, Math.floor(info.age / SECOND)), MAX_DELTA_SECONDS))
    headers.set('Age', age)

/app/src/middleware/http-cache/index.test.ts

− removed
    it('Should select the matching variant with the most recent Date', async () => {
      const env = setup((_c, count) =>
        cacheable(`response ${count}`, {
          Date: httpDate(START + count * 1000),
          Vary: count === 1 ? 'X-Flavor' : 'X-Other',
        })
      )

      await env.request('/', { headers: { 'X-Flavor': 'salty' } })
      await env.request('/', { headers: { 'X-Flavor': 'sweet' } })
      expect(env.count).toBe(2)

      // Both stored variants match a request without either field, so the most
      // recently dated one is selected.
      const res = await env.request('/')
      expect(await res.text()).toBe('response 2')
      expect(env.count).toBe(2)
    })
+ added
    it('Should select the matching variant with the most recent Date', async () => {
      // The two stored variants select on different header fields, so both of
      // them match the last request.
      const run = async (dates: [number, number]) => {
        const env = setup((_c, count) =>
          cacheable(`response ${count}`, {
            Date: httpDate(START + dates[count - 1] * 1000),
            Vary: count === 1 ? 'X-Flavor' : 'X-Other',
          })
        )

        await env.request('/', { headers: { 'X-Flavor': 'salty' } })
        await env.request('/', { headers: { 'X-Flavor': 'sweet', 'X-Other': '1' } })
        expect(env.count).toBe(2)

        const res = await env.request('/', { headers: { 'X-Flavor': 'salty', 'X-Other': '1' } })
        expect(env.count).toBe(2)
        return res.text()
      }

      expect(await run([1, 2])).toBe('response 2')
      expect(await run([2, 1])).toBe('response 1')
    })

    it('Should replace the stored variant a new response supersedes', async () => {
      const env = setup((_c, count) => cacheable(`response ${count}`, { Vary: 'X-Flavor' }))

      await env.request('/', { headers: { 'X-Flavor': 'salty' } })
      env.advance(61)
      await env.request('/', { headers: { 'X-Flavor': 'salty' } })
      expect(env.count).toBe(2)
      expect((env.store.get('http://localhost/') as { variants: unknown[] }).variants).toHaveLength(1)
    })
Now let me add the remaining edge-case tests:

/app/src/middleware/http-cache/index.test.ts

− removed
  describe('Store', () => {
+ added
  describe('Header parsing', () => {
    const expiring = (expires: string) =>
      setup(() => new Response('Hono is hot', { headers: { Date: httpDate(START), Expires: expires } }))

    it('Should read the obsolete rfc850 date format', async () => {
      const env = expiring('Thursday, 01-Jan-26 00:00:30 GMT')

      env.advance(29)
      await env.request('/')
      await env.request('/')
      expect(env.count).toBe(1)
      env.advance(2)
      await env.request('/')
      expect(env.count).toBe(2)
    })

    it('Should read the obsolete asctime date format', async () => {
      const env = expiring('Thu Jan  1 00:00:30 2026')

      await env.request('/')
      env.advance(29)
      await env.request('/')
      expect(env.count).toBe(1)
      env.advance(2)
      await env.request('/')
      expect(env.count).toBe(2)
    })

    it('Should reject a date with components out of range', async () => {
      const env = expiring('Thu, 32 Jan 2026 25:00:00 GMT')

      await env.request('/')
      await env.request('/')
      expect(env.count).toBe(2)
    })

    it('Should ignore a precondition carrying a list of dates', async () => {
      const env = setup(() => cacheable('Hono is hot', { 'Last-Modified': httpDate(START - 10_000) }))

      await env.request('/')
      const res = await env.request('/', {
        headers: { 'If-Modified-Since': `${httpDate(START)}, ${httpDate(START)}` },
      })
      expect(res.status).toBe(200)
    })

    it('Should tolerate whitespace and quoted arguments in Cache-Control', async () => {
      const env = setup(() =>
        cacheable('Hono is hot', {
          'Cache-Control': 'max-age = 60 , private="X-A\\"B, X-C" , no-cache="X-D"',
          'X-A"B': 'a',
          'X-C': 'c',
          'X-D': 'd',
          'X-E': 'e',
        })
      )

      await env.request('/')
      const res = await env.request('/')
      expect(env.count).toBe(1)
      expect(res.headers.get('X-A"B')).toBeNull()
      expect(res.headers.get('X-C')).toBeNull()
      expect(res.headers.get('X-D')).toBeNull()
      expect(res.headers.get('X-E')).toBe('e')
    })

    it('Should treat an invalid max-stale as the most restrictive value', async () => {
      const env = setup(() => cacheable('Hono is hot'))

      await env.request('/')
      env.advance(61)
      await env.request('/', { headers: { 'Cache-Control': 'max-stale=soon' } })
      expect(env.count).toBe(2)
    })

    it('Should report an age it cannot represent as 2^31', async () => {
      const env = setup(() => cacheable('Hono is hot', { Age: 'ancient' }))

      await env.request('/')
      const res = await env.request('/', { headers: { 'Cache-Control': 'max-stale' } })
      expect(res.headers.get('Age')).toBe('2147483648')
      expect(env.count).toBe(1)
    })

    it('Should answer If-Match with 412 when the stored response has no validator', async () => {
      const env = setup(() => cacheable('Hono is hot'))

      await env.request('/')
      expect((await env.request('/', { headers: { 'If-Match': '"v1"' } })).status).toBe(412)
      expect(env.count).toBe(1)
    })
  })

  describe('Updating stored header fields', () => {
    const validating = (headers: Record<string, string>) =>
      setup((c) =>
        c.req.header('If-None-Match')
          ? new Response(null, { status: 304, headers })
          : cacheable('Hono is hot', {
              ETag: '"v1"',
              'Content-Length': '11',
              'Set-Cookie': 'session=1',
            })
      )

    it('Should replace the stored fields provided by a 304', async () => {
      const env = validating({ 'Set-Cookie': 'session=2', 'X-Added': 'yes' })

      await env.request('/')
      env.advance(61)
      const res = await env.request('/')
      expect(res.headers.getSetCookie()).toEqual(['session=2'])
      expect(res.headers.get('X-Added')).toBe('yes')
      expect(await res.text()).toBe('Hono is hot')
    })

    it('Should not update Content-Length or hop-by-hop fields from a 304', async () => {
      const env = validating({ 'Content-Length': '999', Connection: 'close' })

      await env.request('/')
      env.advance(61)
      const res = await env.request('/')
      expect(res.headers.get('Content-Length')).toBe('11')
      expect(res.headers.get('Connection')).toBeNull()
    })

    it('Should forward a 304 that validates a representation it does not hold', async () => {
      const env = setup((c) =>
        c.req.header('If-None-Match')
          ? new Response(null, { status: 304, headers: { ETag: '"v2"' } })
          : cacheable('Hono is hot', { ETag: '"v1"' })
      )

      await env.request('/')
      env.advance(61)
      const res = await env.request('/')
      expect(res.status).toBe(304)
      expect(res.headers.get('ETag')).toBe('"v2"')
      expect(env.store.size).toBe(1)
    })
  })

  describe('Store', () => {

/app/src/middleware/http-cache/index.test.ts

− removed
    it('Should not store a response of a status that is not heuristically cacheable', async () => {
+ added
    it('Should not store a 206 response, which this cache does not understand', async () => {
      const env = setup(
        () =>
          new Response('partial', {
            status: 206,
            headers: { 'Cache-Control': 'max-age=60', 'Content-Range': 'bytes 0-6/11' },
          })
      )

      await env.request('/')
      expect(env.store.size).toBe(0)
    })

    it('Should not store a response of a status that is not heuristically cacheable', async () => {

Run tests again

shell
npx vitest --run --project main src/middleware/http-cache/index.test.ts 2>&1 | grep -E "Tests |Test Files|×|AssertionError|expected|❯ src" | head -40
output
× Should tolerate whitespace and quoted arguments in Cache-Control 8ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
AssertionError: expected 2 to be 1 // Object.is equality
 ❯ src/middleware/http-cache/index.test.ts:1064:25
 Test Files  1 failed (1)
      Tests  1 failed | 93 passed (94)

[stdout]
       × Should tolerate whitespace and quoted arguments in Cache-Control 8ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
AssertionError: expected 2 to be 1 // Object.is equality
 ❯ src/middleware/http-cache/index.test.ts:1064:25
 Test Files  1 failed (1)
      Tests  1 failed | 93 passed (94)

/app/src/middleware/http-cache/index.test.ts

− removed
          'Cache-Control': 'max-age = 60 , private="X-A\\"B, X-C" , no-cache="X-D"',
          'X-A"B': 'a',
          'X-C': 'c',
          'X-D': 'd',
          'X-E': 'e',
        })
      )

      await env.request('/')
      const res = await env.request('/')
      expect(env.count).toBe(1)
      expect(res.headers.get('X-A"B')).toBeNull()
      expect(res.headers.get('X-C')).toBeNull()
+ added
          // The quoted argument of private carries an escaped comma.
          'Cache-Control': 'max-age = 60 , private="X-A\\, X-C" , no-cache="X-D"',
          'X-A': 'a',
          'X-C': 'c',
          'X-D': 'd',
          'X-E': 'e',
        })
      )

      await env.request('/')
      const res = await env.request('/')
      expect(env.count).toBe(1)
      expect(res.headers.get('X-A')).toBeNull()
      expect(res.headers.get('X-C')).toBeNull()

Run tests and check coverage

shell
npx vitest --run --project main src/middleware/http-cache/index.test.ts 2>&1 | grep -E "Tests |Test Files|×|AssertionError|expected" | head -20; node /tmp/cov.mjs
output
Test Files  1 passed (1)
      Tests  94 passed (94)
statements: 192
branches: 191#0,309#1,383#1,396#1

[stdout]
 Test Files  1 passed (1)
      Tests  94 passed (94)
statements: 192
branches: 191#0,309#1,383#1,396#1

/app/src/middleware/http-cache/index.test.ts

− removed
    it('Should read the obsolete asctime date format', async () => {
+ added
    it('Should read a two-digit year more than 50 years ahead as a past year', async () => {
      const env = expiring('Thursday, 01-Jan-90 00:00:30 GMT')

      await env.request('/')
      await env.request('/')
      expect(env.count).toBe(2)
    })

    it('Should read the obsolete asctime date format', async () => {

/app/src/middleware/http-cache/index.test.ts

− removed
    it('Should treat an invalid max-stale as the most restrictive value', async () => {
+ added
    it('Should skip empty directives in Cache-Control', async () => {
      const env = setup(() => cacheable('Hono is hot', { 'Cache-Control': ', max-age=60,,' }))

      await env.request('/')
      await env.request('/')
      expect(env.count).toBe(1)
    })

    it('Should treat a precondition without an entity tag as no match', async () => {
      const env = setup(() => cacheable('Hono is hot', { ETag: '"v1"' }))

      await env.request('/')
      expect((await env.request('/', { headers: { 'If-Match': 'v1' } })).status).toBe(412)
      expect((await env.request('/', { headers: { 'If-None-Match': 'v1' } })).status).toBe(200)
      expect(env.count).toBe(1)
    })

    it('Should treat an invalid max-stale as the most restrictive value', async () => {

Run tests and check coverage

shell
npx vitest --run --project main src/middleware/http-cache/index.test.ts 2>&1 | grep -E "Tests |Test Files|×|AssertionError|expected" | head -20; node /tmp/cov.mjs
output
Test Files  1 passed (1)
      Tests  97 passed (97)
statements: 
branches: 383#1

[stdout]
 Test Files  1 passed (1)
      Tests  97 passed (97)
statements: 
branches: 383#1

Type check

shell
npx tsc -p tsconfig.spec.json --noEmit 2>&1 | head -20
output
src/middleware/http-cache/index.ts(397,21): error TS2345: Argument of type '"*"' is not assignable to parameter of type 'never'.

[stdout]
src/middleware/http-cache/index.ts(397,21): error TS2345: Argument of type '"*"' is not assignable to parameter of type 'never'.

Lint the new middleware

shell
npx eslint src/middleware/http-cache 2>&1 | head -30
output
(Bash completed with no output)

/app/src/middleware/http-cache/index.ts

contents
390	const ENTITY_TAG = /\*|(?:W\/)?"[^"]*"/g
391	
392	const stripWeak = (tag: string): string => tag.replace(/^W\//, '')
393	
394	/** Entity tag comparison as defined by RFC 9110 §8.8.3.2. */
395	const matchesEntityTag = (list: string, etag: string | null, strong: boolean): boolean => {
396	  const tags = list.match(ENTITY_TAG) ?? []
397	  if (tags.includes('*')) {
398	    // A stored response is a current representation of the target resource.
399	    return true
400	  }
401	  if (etag === null) {
402	    return false
403	  }
404	  return tags.some((tag) =>
405	    strong ? tag === etag && !tag.startsWith('W/') : stripWeak(tag) === stripWeak(etag)
406	  )
407	}

/app/src/middleware/http-cache/index.ts

− removed
  const tags = list.match(ENTITY_TAG) ?? []
+ added
  const tags: string[] = list.match(ENTITY_TAG) ?? []

Type check and format

shell
npx tsc -p tsconfig.spec.json --noEmit 2>&1 | head -20 && echo "TSC OK" && npx prettier --write "src/middleware/http-cache/*.ts" 2>&1 | tail -3
output
TSC OK
src/middleware/http-cache/index.test.ts 137ms
src/middleware/http-cache/index.ts 67ms
[This command modified 2 files you've previously read: src/middleware/http-cache/index.ts, src/middleware/http-cache/index.test.ts. Call Read before editing.]

[stdout]
TSC OK
src/middleware/http-cache/index.test.ts 137ms
src/middleware/http-cache/index.ts 67ms

Run tests and lint after formatting

shell
npx vitest --run --project main src/middleware/http-cache/index.test.ts 2>&1 | grep -E "Tests |Test Files|×" | head; npx eslint src/middleware/http-cache && echo "LINT OK"
output
Test Files  1 passed (1)
      Tests  97 passed (97)
LINT OK

[stdout]
 Test Files  1 passed (1)
      Tests  97 passed (97)
LINT OK

Run the full main test project

shell
npx vitest --run --project main 2>&1 | grep -E "Tests |Test Files|×" | head -20
output
Test Files  123 passed (123)
      Tests  3881 passed | 28 skipped (3909)

[stdout]
 Test Files  123 passed (123)
      Tests  3881 passed | 28 skipped (3909)

Format check, lint, and type check

shell
npx prettier --check "src/**/*.{js,ts,tsx}" 2>&1 | tail -3; npx eslint src build 2>&1 | tail -5; echo "---"; npx tsc -p tsconfig.spec.json --noEmit && echo "TSC OK"
output
Checking formatting...
All matched files use Prettier code style!
  28:64  warning  Unexpected any. Specify a different type  @typescript-eslint/no-explicit-any
  28:72  warning  Unexpected any. Specify a different type  @typescript-eslint/no-explicit-any

✖ 33 problems (0 errors, 33 warnings)

---
TSC OK

[stdout]
Checking formatting...
All matched files use Prettier code style!
  28:64  warning  Unexpected any. Specify a different type  @typescript-eslint/no-explicit-any
  28:72  warning  Unexpected any. Specify a different type  @typescript-eslint/no-explicit-any

✖ 33 problems (0 errors, 33 warnings)

---
TSC OK

Verify export wiring consistency

shell
which bun deno 2>&1; node -e "
const fs=require('fs');
const pkg=JSON.parse(fs.readFileSync('package.json','utf8'));
const jsr=JSON.parse(fs.readFileSync('jsr.json','utf8'));
const keys=Object.keys(pkg.exports).filter(k=>!(k in jsr.exports));
console.log('missing in jsr:',keys);
console.log('missing in pkg:',Object.keys(jsr.exports).filter(k=>!(k in pkg.exports)));
console.log('http-cache pkg:',JSON.stringify(pkg.exports['./http-cache']));
console.log('http-cache types:',JSON.stringify(pkg.typesVersions['*']['http-cache']));
console.log('http-cache jsr:',jsr.exports['./http-cache']);
"
output
/usr/local/bin/bun
missing in jsr: [ './utils/*' ]
missing in pkg: [
  './utils/body',     './utils/buffer',
  './utils/color',    './utils/concurrent',
  './utils/cookie',   './utils/crypto',
  './utils/encode',   './utils/filepath',
  './utils/handler',  './utils/headers',
  './utils/html',     './utils/http-status',
  './utils/accept',   './utils/jwt/jwa',
  './utils/jwt/jws',  './utils/jwt/jwt',
  './utils/jwt/types', './utils/jwt/utf8',
  './utils/mime',     './utils/stream',
  './utils/types',    './utils/url',
  './utils/ipaddr'
]
http-cache pkg: {"types":"./dist/types/middleware/http-cache/index.d.ts","import":"./dist/middleware/http-cache/index.js","require":"./dist/cjs/middleware/http-cache/index.js"}
http-cache types: ["./dist/types/middleware/http-cache"]
http-cache jsr: ./src/middleware/http-cache/index.ts

[stdout]
/usr/local/bin/bun
missing in jsr: [ './utils/*' ]
missing in pkg: [
  './utils/body',     './utils/buffer',
  './utils/color',    './utils/concurrent',
  './utils/cookie',   './utils/crypto',
  './utils/encode',   './utils/filepath',
  './utils/handler',  './utils/headers',
  './utils/html',     './utils/http-status',
  './utils/accept',   './utils/jwt/jwa',
  './utils/jwt/jws',  './utils/jwt/jwt',
  './utils/jwt/types', './utils/jwt/utf8',
  './utils/mime',     './utils/stream',
  './utils/types',    './utils/url',
  './utils/ipaddr'
]
http-cache pkg: {"types":"./dist/types/middleware/http-cache/index.d.ts","import":"./dist/middleware/http-cache/index.js","require":"./dist/cjs/middleware/http-cache/index.js"}
http-cache types: ["./dist/types/middleware/http-cache"]
http-cache jsr: ./src/middleware/http-cache/index.ts

Run the package build

shell
timeout 500 bun run build 2>&1 | tail -25
output
dist/cjs/jsx/hooks/index.js                           11.2kb
  dist/cjs/jsx/dom/intrinsic-element/components.js      11.1kb
  dist/cjs/helper/ssg/ssg.js                            11.0kb
  dist/cjs/utils/ipaddr.js                               9.2kb
  dist/cjs/request.js                                    8.9kb
  dist/cjs/helper/css/common.js                          8.7kb
  dist/cjs/utils/url.js                                  7.9kb
  dist/cjs/middleware/cache/index.js                     7.7kb
  dist/cjs/jsx/components.js                             7.7kb
  dist/cjs/utils/jwt/jwt.js                              7.6kb
  dist/cjs/router/trie-router/node.js                    7.2kb
  dist/cjs/middleware/secure-headers/secure-headers.js   7.0kb
  dist/cjs/jsx/intrinsic-element/components.js           6.9kb
  dist/cjs/router/reg-exp-router/router.js               6.8kb
  ...and 168 more output files...

⚡ Done in 203ms
Done removing private fields in 25.08ms
$ cp ./package.cjs.json ./dist/cjs/package.json && cp ./package.cjs.json ./dist/types/package.json
$ publint
Running publint v0.3.15 for hono...
Packing files with `bun pack`...
Linting...
Warnings:
1. pkg.exports["."].types types is interpreted as CJS when resolving with the "import" condition. This causes the types to be ambiguous when default importing the package due to its implied interop. Consider splitting out two "types" conditions for "import" and "require", and use the .mts extension, e.g. pkg.exports["."].import.types: "./dist/types/index.d.mts"

[stdout]
  dist/cjs/jsx/hooks/index.js                           11.2kb
  dist/cjs/jsx/dom/intrinsic-element/components.js      11.1kb
  dist/cjs/helper/ssg/ssg.js                            11.0kb
  dist/cjs/utils/ipaddr.js                               9.2kb
  dist/cjs/request.js                                    8.9kb
  dist/cjs/helper/css/common.js                          8.7kb
  dist/cjs/utils/url.js                                  7.9kb
  dist/cjs/middleware/cache/index.js                     7.7kb
  dist/cjs/jsx/components.js                             7.7kb
  dist/cjs/utils/jwt/jwt.js                              7.6kb
  dist/cjs/router/trie-router/node.js                    7.2kb
  dist/cjs/middleware/secure-headers/secure-headers.js   7.0kb
  dist/cjs/jsx/intrinsic-element/components.js           6.9kb
  dist/cjs/router/reg-exp-router/router.js               6.8kb
  ...and 168 more output files...

⚡ Done in 203ms
Done removing private fields in 25.08ms
$ cp ./package.cjs.json ./dist/cjs/package.json && cp ./package.cjs.json ./dist/types/package.json
$ publint
Running publint v0.3.15 for hono...
Packing files with `bun pack`...
Linting...
Warnings:
1. pkg.exports["."].types types is interpreted as CJS when resolving with the "import" condition. This causes the types to be ambiguous when default importing the package due to its implied interop. Consider splitting out two "types" conditions for "import" and "require", and use the .mts extension, e.g. pkg.exports["."].import.types: "./dist/types/index.d.mts"

Verify built artifacts load

shell
ls dist/middleware/http-cache/ dist/cjs/middleware/http-cache/ dist/types/middleware/http-cache/ 2>&1; node -e "import('/app/dist/middleware/http-cache/index.js').then(m=>console.log('ESM exports:',Object.keys(m)))"; node -e "console.log('CJS exports:',Object.keys(require('/app/dist/cjs/middleware/http-cache/index.js')))"
output
dist/cjs/middleware/http-cache/:
index.js

dist/middleware/http-cache/:
index.js

dist/types/middleware/http-cache/:
index.d.ts
ESM exports: [ 'httpCache' ]
CJS exports: [ 'httpCache' ]

[stdout]
dist/cjs/middleware/http-cache/:
index.js

dist/middleware/http-cache/:
index.js

dist/types/middleware/http-cache/:
index.d.ts
ESM exports: [ 'httpCache' ]
CJS exports: [ 'httpCache' ]

/app/src/middleware/http-cache/index.ts

contents
760	 *   return c.text('Hono is hot')
761	 * })
762	 * ```
763	 */
764	export const httpCache = (options?: HttpCacheOptions): MiddlewareHandler => {
765	  const store: HttpCacheStore = options?.store ?? new Map<string, unknown>()
766	  const now = options?.now ?? Date.now
767	
768	  /** Invalidates a URI reference of a response, if it shares the target origin (RFC 9111 §4.4). */
769	  const invalidate = (reference: string | null, base: string): void => {
770	    if (reference === null) {
771	      return
772	    }
773	    try {
774	      const url = new URL(reference, base)
775	      url.hash = ''
776	      if (url.origin === new URL(base).origin) {
777	        store.delete(url.href)
778	      }
779	    } catch {
780	      // A reference that cannot be resolved cannot be a cache key either.
781	    }
782	  }
783	
784	  /**
785	   * Builds the response to send from a stored response, after evaluating the
786	   * preconditions of the request against it.
787	   */
788	  const useStored = (
789	    c: Context,
790	    variant: StoredResponse,
791	    info: StoredInfo,
792	    requestHeaders: Headers,
793	    nowMs: number,
794	    validated: boolean
795	  ): Response => {
796	    const headers = new Headers(variant.headers)
797	    if (!validated) {
798	      // The qualified form of no-cache forbids reusing the listed fields
799	      // without successful validation (RFC 9111 §5.2.2.4).
800	      for (const field of qualifiedFields(info.directives, 'no-cache')) {
801	        headers.delete(field)
802	      }
803	    }
804	
805	    // RFC 9111 §5.1: an age that cannot be represented is sent as 2^31.
806	    const age = String(Math.min(Math.max(0, Math.floor(info.age / SECOND)), MAX_DELTA_SECONDS))
807	    headers.set('Age', age)
808	
809	    const precondition = evaluatePreconditions(requestHeaders, headers, variant, nowMs)
810	    if (precondition === 412) {
811	      return c.newResponse(null, { status: 412 })
812	    }
813	    if (precondition === 304) {
814	      const notModified = new Headers()
815	      for (const name of RETAINED_304_HEADERS) {
816	        const value = headers.get(name)
817	        if (value !== null) {
818	          notModified.set(name, value)
819	        }
820	      }
821	      notModified.set('Age', age)
822	      return c.newResponse(null, { status: 304, headers: notModified })
823	    }
824	
825	    const body =
826	      c.req.method === 'HEAD' || NULL_BODY_STATUSES.includes(variant.status) ? null : variant.body
827	    return c.newResponse(body, { status: variant.status as StatusCode, headers })
828	  }
829	
830	  return async function httpCache(c, next) {
831	    const method = c.req.method
832	    const key = c.req.url
833	    // The request may be replaced by a conditional one, so the header fields
834	    // the client sent are kept for Vary selection and precondition evaluation.
835	    const requestHeaders = c.req.raw.headers
836	    const requestDirectives = parseCacheControl(requestHeaders.get('cache-control'))
837	
838	    if (method !== 'GET' && method !== 'HEAD') {
839	      await next()
840	      // RFC 9111 §4.4: a non-error response to an unsafe request invalidates
841	      // the target URI, as well as the URIs of Location and Content-Location.
842	      const status = c.res.status
843	      if (!SAFE_METHODS.includes(method) && status >= 200 && status < 400) {
844	        store.delete(key)
845	        invalidate(c.res.headers.get('location'), key)
846	        invalidate(c.res.headers.get('content-location'), key)
847	      }
848	      return
849	    }
850	
851	    const entry = readEntry(store, key)
852	    let nowMs = now()
853	    const variant = entry && selectVariant(entry, requestHeaders, nowMs)
854	    let info = variant && describe(variant, nowMs)
855	
856	    if (variant && info) {
857	      if (canReuse(info, requestDirectives)) {
858	        return useStored(c, variant, info, requestHeaders, nowMs, false)
859	      }
860	    }
861	
862	    if (requestDirectives.has('only-if-cached')) {
863	      // RFC 9111 §5.2.1.7: the request must not be forwarded, so this is the
864	      // one case where the handler is not reached on a cache miss.
865	      return c.newResponse(null, { status: 504 })
866	    }
867	
868	    // RFC 9111 §4.3.1: revalidate with the validators of the stored response.
869	    // A HEAD response is never stored, so it never validates a stored response.
870	    let validating = false
871	    if (variant && info && method === 'GET') {
872	      const etag = info.headers.get('etag')
873	      const lastModified = info.headers.get('last-modified')
874	      if (etag !== null || lastModified !== null) {
875	        const conditional = new Headers(requestHeaders)
876	        // The cache evaluates the preconditions of the client itself.
877	        for (const name of PRECONDITION_HEADERS) {
878	          conditional.delete(name)
879	        }
880	        if (etag !== null) {
881	          conditional.set('If-None-Match', etag)
882	        }
883	        if (lastModified !== null) {
884	          conditional.set('If-Modified-Since', lastModified)
885	        }
886	        c.req.raw = new Request(c.req.raw, { headers: conditional })
887	        validating = true
888	      }
889	    }
890	
891	    const requestTime = now()
892	    await next()
893	    const responseTime = now()
894	    nowMs = responseTime
895	    const res = c.res
896	    const status = res.status
897	
898	    if (entry && variant && info) {
899	      const etag = res.headers.get('etag')
900	      const storedETag = info.headers.get('etag')
901	      if (
902	        validating &&
903	        status === 304 &&
904	        // RFC 9111 §4.3.4: a 304 only freshens the stored response it selects.
905	        (etag === null || storedETag === null || stripWeak(etag) === stripWeak(storedETag))
906	      ) {
907	        const headers = new Headers(variant.headers)
908	        updateHeaders(headers, res.headers)
909	        variant.headers = headerPairs(headers)
910	        variant.vary = selectingFields(headers.get('vary'), requestHeaders)
911	        variant.requestTime = requestTime
912	        variant.responseTime = responseTime
913	        store.set(key, entry)
914	
915	        info = describe(variant, nowMs)
916	        c.res = undefined
917	        c.res = useStored(c, variant, info, requestHeaders, nowMs, true)
918	        return
919	      }
920	
921	      if (ERROR_STATUSES.includes(status)) {
922	        info = describe(variant, nowMs)
923	        if (canServeOnError(info, requestDirectives)) {
924	          c.res = undefined
925	          c.res = useStored(c, variant, info, requestHeaders, nowMs, false)
926	          return
927	        }
928	      }
929	    }
930	
931	    // Only GET responses are stored, and a 304 never replaces what it freshens.
932	    if (method !== 'GET' || status === 304) {
933	      return
934	    }
935	
936	    const responseDirectives = parseCacheControl(res.headers.get('cache-control'))
937	    const storable = isStorable(
938	      status,
939	      requestDirectives,
940	      responseDirectives,
941	      res.headers,
942	      requestHeaders.has('authorization')
943	    )
944	    // A full response supersedes what is stored for the same variant, while an
945	    // error response leaves the stored responses untouched.
946	    if (!storable && !(entry && status < 400)) {
947	      return
948	    }
949	
950	    const vary = selectingFields(res.headers.get('vary'), requestHeaders)
951	    const varyKey = JSON.stringify(vary)
952	    const variants = (entry?.variants ?? []).filter(
953	      (stored) => JSON.stringify(stored.vary) !== varyKey && !variantMatches(stored, requestHeaders)
954	    )
955	
956	    if (storable) {
957	      const headers = new Headers(res.headers)
958	      // RFC 9111 §3.1: hop-by-hop fields and the fields listed by a qualified
959	      // private directive are not stored.
960	      for (const name of [
961	        ...HOP_BY_HOP_HEADERS,
962	        ...(res.headers.get('connection')?.split(',') ?? []),
963	        ...qualifiedFields(responseDirectives, 'private'),
964	      ]) {
965	        headers.delete(name.trim().toLowerCase())
966	      }
967	
968	      variants.push({
969	        status,
970	        headers: headerPairs(headers),
971	        body: res.body === null ? null : new Uint8Array(await res.clone().arrayBuffer()),
972	        requestTime,
973	        responseTime,
974	        vary,
975	      })
976	    }
977	
978	    if (variants.length) {
979	      store.set(key, { variants })
980	    } else {
981	      store.delete(key)
982	    }
983	  }
984	}
985

/app/src/middleware/http-cache/index.ts

− removed
    if (variant && info) {
      if (canReuse(info, requestDirectives)) {
        return useStored(c, variant, info, requestHeaders, nowMs, false)
      }
    }
+ added
    if (variant && info && canReuse(info, requestDirectives)) {
      return useStored(c, variant, info, requestHeaders, nowMs, false)
    }

/app/src/middleware/http-cache/index.ts

− removed
 * Out of scope: `stale-while-revalidate`, Range requests and 206 responses,
 * `Pragma`, `Warning`, trailers, and `immutable`.
 */
+ added
 * Out of scope: `stale-while-revalidate`, Range requests and 206 responses,
 * `Pragma`, `Warning`, trailers, and `immutable`. The `no-transform` directive
 * needs no handling, as this cache never transforms a payload.
 */

Check git status and gitignore

shell
git status --short | head -10; grep -n "dist" .gitignore
output
M jsr.json
 M package.json
?? src/middleware/http-cache/
1:dist
107:.vuepress/dist

[stdout]
 M jsr.json
 M package.json
?? src/middleware/http-cache/
1:dist
107:.vuepress/dist

Clean build output and re-verify

shell
rm -rf /app/dist /app/coverage && npx vitest --run --project main src/middleware/http-cache/index.test.ts 2>&1 | grep -E "Tests |Test Files|×"; npx prettier --check "src/middleware/http-cache/*.ts" && npx eslint src/middleware/http-cache && npx tsc -p tsconfig.spec.json --noEmit && echo "ALL CHECKS OK"
output
Test Files  1 passed (1)
      Tests  97 passed (97)
Checking formatting...
All matched files use Prettier code style!
ALL CHECKS OK

[stdout]
 Test Files  1 passed (1)
      Tests  97 passed (97)
Checking formatting...
All matched files use Prettier code style!
ALL CHECKS OK

/app/src/middleware/http-cache/index.test.ts

− removed
    it('Should not store hop-by-hop header fields', async () => {
+ added
    it('Should keep every field line of a stored response', async () => {
      const env = setup(() => {
        const headers = new Headers({ 'Cache-Control': 'max-age=60' })
        headers.append('Set-Cookie', 'a=1')
        headers.append('Set-Cookie', 'b=2')
        return new Response('Hono is hot', { headers })
      })

      await env.request('/')
      const res = await env.request('/')
      expect(res.headers.getSetCookie()).toEqual(['a=1', 'b=2'])
      expect(env.count).toBe(1)
    })

    it('Should not store hop-by-hop header fields', async () => {

Run tests with the set-cookie case

shell
npx vitest --run --project main src/middleware/http-cache/index.test.ts 2>&1 | grep -E "Tests |Test Files|×|AssertionError|Expected|Received" | head -20
output
Test Files  1 passed (1)
      Tests  98 passed (98)

[stdout]
 Test Files  1 passed (1)
      Tests  98 passed (98)

Check middleware barrel files

shell
cat /app/src/middleware.ts 2>/dev/null | head -30; echo "---"; grep -rn "etag" /app/src/index.ts /app/src/mod.ts 2>/dev/null | head
output
---

[stdout]
---

Full test suite and format check

shell
npx vitest --run --project main 2>&1 | grep -E "Tests |Test Files|×" | head; npx prettier --check "src/**/*.ts" 2>&1 | tail -2
output
Test Files  123 passed (123)
      Tests  3882 passed | 28 skipped (3910)
Checking formatting...
All matched files use Prettier code style!

[stdout]
 Test Files  123 passed (123)
      Tests  3882 passed | 28 skipped (3910)
Checking formatting...
All matched files use Prettier code style!

Stage changes

shell
git add -A && git status --short && git diff --cached --stat
output
M  jsr.json
M  package.json
A  src/middleware/http-cache/index.test.ts
A  src/middleware/http-cache/index.ts
 jsr.json                                |    1 +
 package.json                            |    8 +
 src/middleware/http-cache/index.test.ts | 1280 +++++++++++++++++++++++++++++++
 src/middleware/http-cache/index.ts      |  983 ++++++++++++++++++++++++
 4 files changed, 2272 insertions(+)

[stdout]
M  jsr.json
M  package.json
A  src/middleware/http-cache/index.test.ts
A  src/middleware/http-cache/index.ts
 jsr.json                                |    1 +
 package.json                            |    8 +
 src/middleware/http-cache/index.test.ts | 1280 +++++++++++++++++++++++++++++++
 src/middleware/http-cache/index.ts      |  983 ++++++++++++++++++++++++
 4 files changed, 2272 insertions(+)

Commit the work

shell
git commit -q -F - <<'EOF'
feat(http-cache): add a self-contained RFC 9111 shared cache middleware

`hono/cache` only delegates to the platform Web Cache API. This adds
`hono/http-cache`, a shared cache that keeps responses in a user-supplied
store (a plain `Map` by default) and treats the downstream handler as the
origin server, so a request satisfied from the store never reaches it.

It implements storage eligibility (RFC 9111 §3, including the Authorization
exceptions), freshness and the full age algorithm (§4.2), Vary selection
(§4.1), validation with conditional requests built from the stored
validators, 304 freshening and header update (§4.3, §3.2), invalidation on
unsafe requests (§4.4), and both cache directive sets (§5), plus precondition
evaluation in the order of RFC 9110 §13.2 whenever a stored response is used
and the `stale-if-error` extension of RFC 5861.

`stale-while-revalidate`, Range requests and 206 responses, `Pragma`,
`Warning`, trailers, and `immutable` are out of scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
git log --oneline -1
output
Author identity unknown

*** Please tell me who you are.

Run

  git config --global user.email "you@example.com"
  git config --global user.name "Your Name"

to set your account's default identity.
Omit --global to set the identity only in this repository.

fatal: unable to auto-detect email address (got 'root@8a8d32bd4b87.(none)')
41bdc428 4.13.2

[stdout]
Author identity unknown

*** Please tell me who you are.

Run

  git config --global user.email "you@example.com"
  git config --global user.name "Your Name"

to set your account's default identity.
Omit --global to set the identity only in this repository.

fatal: unable to auto-detect email address (got 'root@8a8d32bd4b87.(none)')
41bdc428 4.13.2

Configure identity and commit

shell
git config user.email "claude@anthropic.com" && git config user.name "Claude" && git commit -q -F - <<'EOF'
feat(http-cache): add a self-contained RFC 9111 shared cache middleware

`hono/cache` only delegates to the platform Web Cache API. This adds
`hono/http-cache`, a shared cache that keeps responses in a user-supplied
store (a plain `Map` by default) and treats the downstream handler as the
origin server, so a request satisfied from the store never reaches it.

It implements storage eligibility (RFC 9111 §3, including the Authorization
exceptions), freshness and the full age algorithm (§4.2), Vary selection
(§4.1), validation with conditional requests built from the stored
validators, 304 freshening and header update (§4.3, §3.2), invalidation on
unsafe requests (§4.4), and both cache directive sets (§5), plus precondition
evaluation in the order of RFC 9110 §13.2 whenever a stored response is used
and the `stale-if-error` extension of RFC 5861.

`stale-while-revalidate`, Range requests and 206 responses, `Pragma`,
`Warning`, trailers, and `immutable` are out of scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
git log --oneline -2 && git status --short
output
dc13159c feat(http-cache): add a self-contained RFC 9111 shared cache middleware
41bdc428 4.13.2

[stdout]
dc13159c feat(http-cache): add a self-contained RFC 9111 shared cache middleware
41bdc428 4.13.2
Final diff , what the agent changed

No file changes, the agent left the workspace untouched.

Verifier output , the proof behind the reward
tests/test.sh
[verifier] model.patch applied (80448 bytes)
[verifier] Resetting files touched by test.patch
[verifier] Applying test.patch

 RUN  v4.1.9 /app

 ❯  main  src/middleware/http-cache/index.test.ts (70 tests | 1 failed) 56ms
     ✓ stores a GET 200 with max-age and serves the repeat from the store with an Age header 14ms
     ✓ a stored hit never invokes the handler and each miss invokes it exactly once 2ms
     ✓ the store is keyed by the exact request URL including the query string 3ms
     ✓ POST responses are never stored even when public with max-age 0ms
     ✓ a 206 response is never stored 0ms
     ✓ a response with no-store is never stored 0ms
     ✓ a request no-store directive prevents storing the fetched response 0ms
     ✓ an unqualified private response is never stored by this shared cache 0ms
     ✓ a qualified private response is stored and later served with exactly the named field stripped 1ms
     ✓ a request with Authorization is not stored by default 0ms
     ✓ Authorization is overridden by response s-maxage or public 2ms
     ✓ a 500 without explicit freshness is not stored but a 500 with max-age is stored and served 0ms
     ✓ max-age governs the fresh window and an age equal to the lifetime is stale 0ms
     ✓ s-maxage overrides max-age in both directions in a shared cache 0ms
     ✓ Expires minus Date governs only when max-age is absent 0ms
     ✓ an unparseable Expires means the stored response is already stale 0ms
     ✓ Expires without a Date header measures from the time the response was received 0ms
     ✓ a 404 with Last-Modified is served fresh within the ten percent heuristic window and revalidated after 0ms
     ✓ a response with no validators and no explicit freshness is stored but immediately stale 0ms
     ✓ Age on a hit equals the corrected initial age plus resident time 0ms
     ✓ the apparent age from a skewed Date beats a smaller Age header 0ms
     ✓ duplicate max-age directives take the most restrictive value and a non-numeric max-age means stale 0ms
     ✓ obsolete HTTP-date formats are accepted and rfc850 two-digit years resolve against the injected clock 1ms
     ✓ duplicate Expires lines take the earliest date 0ms
     ✓ Vary on a request header stores independent variants under one key 1ms
     ✓ Vary matching treats a header absent in both requests as a match and absent versus present as a mismatch 1ms
     ✓ Vary values match after collapsing internal whitespace and Vary field names are case-insensitive 0ms
     ✓ a Vary star response is never served from the store 0ms
     ✓ re-fetching an existing variant replaces its stored body 0ms
     ✓ when two stored variants match one request the most recent Date wins 0ms
     ✓ variant selection uses the storing request header values as the secondary key 0ms
     ✓ a request no-cache directive forces validation even when fresh 1ms
     ✓ a request max-age refuses a stored response whose age exceeds it 1ms
     ✓ a request min-fresh directive demands the remaining freshness 1ms
     ✓ max-stale accepts staleness up to its bound and bare max-stale accepts any amount 1ms
     ✓ only-if-cached serves a fresh stored response and yields 504 on a miss without invoking the handler 0ms
     ✓ only-if-cached with a stale entry yields 504 because validation would need the origin 0ms
     ✓ must-revalidate defeats request max-stale 0ms
     ✓ proxy-revalidate binds this shared cache identically 0ms
     ✓ a response stale past s-maxage is never served stale even under bare max-stale 0ms
     ✓ an unqualified no-cache response is stored yet every use revalidates 1ms
     ✓ a qualified no-cache serves without revalidation with exactly the named field stripped 0ms
     ✓ stale-if-error serves the stale entry inside the window and the error passes through beyond it 0ms
     ✓ stale-if-error never overrides must-revalidate 0ms
     ✓ the request-side stale-if-error directive is honored 0ms
     ✓ a stale entry with an ETag revalidates with If-None-Match visible to the handler 0ms
     ✓ the conditional carries the stored validators: If-Modified-Since alone or both together 1ms
     ✓ a 304 freshens the stored response and its new Cache-Control extends freshness for later hits 0ms
     ✓ the 304 header update never overwrites the stored Content-Length 0ms
     ✓ a 304 bearing an ETag freshens exactly the stored variant carrying that ETag 1ms
     ✓ a 304 without an ETag freshens the variant that made the conditional 0ms
     ✓ a full 200 on revalidation replaces the stored entry and reaches the client 0ms
     ✓ a 304 arriving on a miss passes through and stores nothing 0ms
     ✓ after freshening the client conditional is evaluated against the freshened variant 0ms
     ✓ Age restarts from the validation exchange after a 304 0ms
     ✓ a 5xx with max-age fetched by revalidation follows normal storage rules 0ms
     ✓ If-None-Match matching the stored ETag yields a cache-generated 304 with the required headers 0ms
     ✓ If-None-Match uses weak comparison and accepts a list or a star 0ms
     ✓ If-Modified-Since is ignored when If-None-Match is present even a non-matching one 0ms
     × If-Modified-Since alone yields 304 unless it is unparseable 6ms
     ✓ preconditions evaluate against the Vary-selected variant only 1ms
     ✓ a POST with a 2xx response invalidates the stored entry for its URI 0ms
     ✓ PUT and DELETE invalidate likewise and a 3xx response also invalidates 0ms
     ✓ an unsafe method answered with an error invalidates nothing 0ms
     ✓ same-origin Location and Content-Location targets are invalidated too 0ms
     ✓ a cross-origin Location is left intact 0ms
     ✓ OPTIONS neither stores nor invalidates 0ms
     ✓ HEAD is answered from the stored GET entry and a HEAD miss never stores 0ms
     ✓ the wall clock is never read 1ms
     ✓ package.json and jsr.json wire hono/http-cache exactly like the etag entries 0ms

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯

 FAIL   main  src/middleware/http-cache/index.test.ts > HTTP Cache Middleware > If-Modified-Since alone yields 304 unless it is unparseable
AssertionError: expected null to be 'Sun, 31 Dec 2023 23:43:20 GMT' // Object.is equality

- Expected:
"Sun, 31 Dec 2023 23:43:20 GMT"

+ Received:
null

 ❯ src/middleware/http-cache/index.test.ts:1781:54
    1779|     expect(notModified.status).toBe(304)
    1780|     expect(await notModified.text()).toBe('')
    1781|     expect(notModified.headers.get('Last-Modified')).toBe(lastModified)
       |                                                      ^
    1782|     expect(notModified.headers.get('Age')).toBe('5')
    1783|

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯


 Test Files  1 failed (1)
      Tests  1 failed | 69 passed (70)
   Start at  15:18:45
   Duration  252ms (transform 121ms, setup 0ms, import 140ms, tests 56ms, environment 0ms)

JUNIT report written to /logs/verifier/f2p.xml

 RUN  v4.1.9 /app

 ✓  main  src/middleware/logger/index.test.ts (15 tests) 2036ms
     ✓ Time in seconds  1007ms
     ✓ Time in seconds  1004ms
 ✓  main  src/utils/jwt/jwt.test.ts (88 tests) 658ms
 ✓  main  src/jsx/dom/index.test.tsx (279 tests) 489ms
 ✓  main  src/middleware/compress/index.test.ts (45 tests) 362ms
       ✓ should compress streaming responses written in multiple chunks  313ms
 ✓  main  src/middleware/timeout/index.test.ts (4 tests) 4242ms
     ✓ Should trigger default timeout exception  1026ms
     ✓ Should apply custom exception with function  1105ms
     ✓ Error timeout with custom status code and message  1206ms
     ✓ No Timeout should pass  903ms
stdout | src/jsx/streaming.test.tsx > Streaming > reject()
undefined

stdout | src/jsx/streaming.test.tsx > Streaming > pops buildDataStack when a deferred re-render rejects
Error: boom
    at Content (/app/src/jsx/streaming.test.tsx:1078:13)
    at JSXFunctionNode.toStringToBuffer (/app/src/jsx/base.ts:293:40)
    at render (/app/src/jsx/base.ts:199:12)
    at runWithRenderContext (/app/src/jsx/context.ts:164:12)
    at JSXFunctionNode.toString (/app/src/jsx/base.ts:206:12)
    at /app/src/jsx/components.ts:18:65
    at Array.map (<anonymous>)
    at childrenToString (/app/src/jsx/components.ts:18:8)
    at /app/src/jsx/streaming.ts:76:20
    at /app/

… (truncated at 12,000 chars, full verifier log is in the trial artifacts)

Reproduce this trial: git checkout 1774af8 && PYTHONPATH=src python3 scripts/build_site.py , then open trial/trial_0ef53d1d0b3a424f. Re-running the agent live requires EVAL_PLATFORM_ENABLE_OAUTH_SMOKE=1 and is non-deterministic.

Trial trial_0ef53d1d0b3a424f · verifier authoritative; classifier explanatory.