tests/test.sh exit code (0 → resolved); the classification below is post-hoc and cannot change it.List repo root and read package.json
ls && cat package.json | head -80
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
ls src/middleware/ && ls src/middleware/etag/ && ls src/middleware/cache/
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
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
grep -n "etag" /app/package.json /app/jsr.json
/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
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
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
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
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
cat /app/build/build.ts
/*
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
grep -n "cloneRawRequest\|class HonoRequest\|constructor" /app/src/request.ts | head -40
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
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
grep -n "header(\|get url\|get method" -A 12 /app/src/request.ts | sed -n '1,80p'
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
sed -n 1,60p /app/eslint.config.mjs; echo ---; cat /app/vitest.config.ts
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
grep -n "set res\|get res\|#res\|newResponse\|setHeaders" /app/src/context.ts | head -40
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
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
cat /app/src/compose.ts
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
grep -rn "c.res = undefined" /app/src --include=*.ts | head -20
/app/src/hono.test.ts:1191: c.res = undefined [stdout] /app/src/hono.test.ts:1191: c.res = undefined
Read res=undefined test
sed -n 1180,1210p /app/src/hono.test.ts
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
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
grep -n "export type Data\|type ResponseOrInit\|interface NewResponse\|type NewResponse" -A 12 /app/src/context.ts | head -60
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
grep -rn "ResponseInit" /app/src/types.ts /app/src/utils/http-status.ts /app/src/context.ts | head -20
/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
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 *
Create feature branch
git checkout -b feat/http-cache-middleware && git status --short
Switched to a new branch 'feat/http-cache-middleware' [stdout] Switched to a new branch 'feat/http-cache-middleware'
/**
* @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)
}
}
}
/**
* @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
cat /app/build/validate-exports.ts
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}'`)
}
})
}/app/package.json
"./etag": {
"types": "./dist/types/middleware/etag/index.d.ts",
"import": "./dist/middleware/etag/index.js",
"require": "./dist/cjs/middleware/etag/index.js"
}, "./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
"etag": [
"./dist/types/middleware/etag"
], "etag": [
"./dist/types/middleware/etag"
],
"http-cache": [
"./dist/types/middleware/http-cache"
],/app/jsr.json
"./etag": "./src/middleware/etag/index.ts",
"./etag": "./src/middleware/etag/index.ts",
"./http-cache": "./src/middleware/http-cache/index.ts",Read etag test style
sed -n 1,40p /app/src/middleware/etag/index.test.ts
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
grep -rn "HEAD" /app/src/hono-base.ts /app/src/router.ts | head -20
/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
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 }
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
npx vitest --run --project main src/middleware/http-cache/index.test.ts 2>&1 | tail -80
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
const staleForbidden = (info: StoredInfo): boolean =>
info.directives.has('no-cache') ||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
await env.request('/')
const res = await env.request('/')
expect(res.headers.get('X-Secret')).toBe('shh')
expect(env.count).toBe(2) 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
npx vitest --run --project main src/middleware/http-cache/index.test.ts 2>&1 | tail -30
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
npx vitest --run --project main src/middleware/http-cache/index.test.ts 2>&1 | grep -E "Tests|Test Files|×|✓ src" | head -20
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
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"
(Bash completed with no output)
Get uncovered line list
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 *\|"
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
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.mjsExit 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 failureGenerate json coverage and list uncovered lines
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
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
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.ts164: 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
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
} 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
const age = String(
Number.isFinite(info.age) ? Math.max(0, Math.floor(info.age / SECOND)) : MAX_DELTA_SECONDS
)
headers.set('Age', age) // 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
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)
}) 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)
})/app/src/middleware/http-cache/index.test.ts
describe('Store', () => { 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
it('Should not store a response of a status that is not heuristically cacheable', async () => { 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
npx vitest --run --project main src/middleware/http-cache/index.test.ts 2>&1 | grep -E "Tests |Test Files|×|AssertionError|expected|❯ src" | head -40
× 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
'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() // 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
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
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
it('Should read the obsolete asctime date format', async () => { 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
it('Should treat an invalid max-stale as the most restrictive value', async () => { 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
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
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#1Type check
npx tsc -p tsconfig.spec.json --noEmit 2>&1 | head -20
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
npx eslint src/middleware/http-cache 2>&1 | head -30
(Bash completed with no output)
/app/src/middleware/http-cache/index.ts
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
const tags = list.match(ENTITY_TAG) ?? []
const tags: string[] = list.match(ENTITY_TAG) ?? []
Type check and format
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
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
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"
Test Files 1 passed (1)
Tests 97 passed (97)
LINT OK
[stdout]
Test Files 1 passed (1)
Tests 97 passed (97)
LINT OKRun the full main test project
npx vitest --run --project main 2>&1 | grep -E "Tests |Test Files|×" | head -20
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
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"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
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']);
"/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.tsRun the package build
timeout 500 bun run build 2>&1 | tail -25
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
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')))"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
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
if (variant && info) {
if (canReuse(info, requestDirectives)) {
return useStored(c, variant, info, requestHeaders, nowMs, false)
}
} if (variant && info && canReuse(info, requestDirectives)) {
return useStored(c, variant, info, requestHeaders, nowMs, false)
}/app/src/middleware/http-cache/index.ts
* Out of scope: `stale-while-revalidate`, Range requests and 206 responses, * `Pragma`, `Warning`, trailers, and `immutable`. */
* 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
git status --short | head -10; grep -n "dist" .gitignore
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
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"
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
it('Should not store hop-by-hop header fields', async () => { 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
npx vitest --run --project main src/middleware/http-cache/index.test.ts 2>&1 | grep -E "Tests |Test Files|×|AssertionError|Expected|Received" | head -20
Test Files 1 passed (1)
Tests 98 passed (98)
[stdout]
Test Files 1 passed (1)
Tests 98 passed (98)Check middleware barrel files
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
--- [stdout] ---
Full test suite and format check
npx vitest --run --project main 2>&1 | grep -E "Tests |Test Files|×" | head; npx prettier --check "src/**/*.ts" 2>&1 | tail -2
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
git add -A && git status --short && git diff --cached --stat
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
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
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
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
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
No file changes, the agent left the workspace untouched.
[verifier] model.patch applied (80448 bytes)
[verifier] Resetting files touched by test.patch
[verifier] Applying test.patch
[1m[30m[46m RUN [49m[39m[22m [36mv4.1.9 [39m[90m/app[39m
[31m❯[39m [30m[45m main [49m[39m src/middleware/http-cache/index.test.ts [2m([22m[2m70 tests[22m[2m | [22m[31m1 failed[39m[2m)[22m[32m 56[2mms[22m[39m
[32m✓[39m stores a GET 200 with max-age and serves the repeat from the store with an Age header[32m 14[2mms[22m[39m
[32m✓[39m a stored hit never invokes the handler and each miss invokes it exactly once[32m 2[2mms[22m[39m
[32m✓[39m the store is keyed by the exact request URL including the query string[32m 3[2mms[22m[39m
[32m✓[39m POST responses are never stored even when public with max-age[32m 0[2mms[22m[39m
[32m✓[39m a 206 response is never stored[32m 0[2mms[22m[39m
[32m✓[39m a response with no-store is never stored[32m 0[2mms[22m[39m
[32m✓[39m a request no-store directive prevents storing the fetched response[32m 0[2mms[22m[39m
[32m✓[39m an unqualified private response is never stored by this shared cache[32m 0[2mms[22m[39m
[32m✓[39m a qualified private response is stored and later served with exactly the named field stripped[32m 1[2mms[22m[39m
[32m✓[39m a request with Authorization is not stored by default[32m 0[2mms[22m[39m
[32m✓[39m Authorization is overridden by response s-maxage or public[32m 2[2mms[22m[39m
[32m✓[39m a 500 without explicit freshness is not stored but a 500 with max-age is stored and served[32m 0[2mms[22m[39m
[32m✓[39m max-age governs the fresh window and an age equal to the lifetime is stale[32m 0[2mms[22m[39m
[32m✓[39m s-maxage overrides max-age in both directions in a shared cache[32m 0[2mms[22m[39m
[32m✓[39m Expires minus Date governs only when max-age is absent[32m 0[2mms[22m[39m
[32m✓[39m an unparseable Expires means the stored response is already stale[32m 0[2mms[22m[39m
[32m✓[39m Expires without a Date header measures from the time the response was received[32m 0[2mms[22m[39m
[32m✓[39m a 404 with Last-Modified is served fresh within the ten percent heuristic window and revalidated after[32m 0[2mms[22m[39m
[32m✓[39m a response with no validators and no explicit freshness is stored but immediately stale[32m 0[2mms[22m[39m
[32m✓[39m Age on a hit equals the corrected initial age plus resident time[32m 0[2mms[22m[39m
[32m✓[39m the apparent age from a skewed Date beats a smaller Age header[32m 0[2mms[22m[39m
[32m✓[39m duplicate max-age directives take the most restrictive value and a non-numeric max-age means stale[32m 0[2mms[22m[39m
[32m✓[39m obsolete HTTP-date formats are accepted and rfc850 two-digit years resolve against the injected clock[32m 1[2mms[22m[39m
[32m✓[39m duplicate Expires lines take the earliest date[32m 0[2mms[22m[39m
[32m✓[39m Vary on a request header stores independent variants under one key[32m 1[2mms[22m[39m
[32m✓[39m Vary matching treats a header absent in both requests as a match and absent versus present as a mismatch[32m 1[2mms[22m[39m
[32m✓[39m Vary values match after collapsing internal whitespace and Vary field names are case-insensitive[32m 0[2mms[22m[39m
[32m✓[39m a Vary star response is never served from the store[32m 0[2mms[22m[39m
[32m✓[39m re-fetching an existing variant replaces its stored body[32m 0[2mms[22m[39m
[32m✓[39m when two stored variants match one request the most recent Date wins[32m 0[2mms[22m[39m
[32m✓[39m variant selection uses the storing request header values as the secondary key[32m 0[2mms[22m[39m
[32m✓[39m a request no-cache directive forces validation even when fresh[32m 1[2mms[22m[39m
[32m✓[39m a request max-age refuses a stored response whose age exceeds it[32m 1[2mms[22m[39m
[32m✓[39m a request min-fresh directive demands the remaining freshness[32m 1[2mms[22m[39m
[32m✓[39m max-stale accepts staleness up to its bound and bare max-stale accepts any amount[32m 1[2mms[22m[39m
[32m✓[39m only-if-cached serves a fresh stored response and yields 504 on a miss without invoking the handler[32m 0[2mms[22m[39m
[32m✓[39m only-if-cached with a stale entry yields 504 because validation would need the origin[32m 0[2mms[22m[39m
[32m✓[39m must-revalidate defeats request max-stale[32m 0[2mms[22m[39m
[32m✓[39m proxy-revalidate binds this shared cache identically[32m 0[2mms[22m[39m
[32m✓[39m a response stale past s-maxage is never served stale even under bare max-stale[32m 0[2mms[22m[39m
[32m✓[39m an unqualified no-cache response is stored yet every use revalidates[32m 1[2mms[22m[39m
[32m✓[39m a qualified no-cache serves without revalidation with exactly the named field stripped[32m 0[2mms[22m[39m
[32m✓[39m stale-if-error serves the stale entry inside the window and the error passes through beyond it[32m 0[2mms[22m[39m
[32m✓[39m stale-if-error never overrides must-revalidate[32m 0[2mms[22m[39m
[32m✓[39m the request-side stale-if-error directive is honored[32m 0[2mms[22m[39m
[32m✓[39m a stale entry with an ETag revalidates with If-None-Match visible to the handler[32m 0[2mms[22m[39m
[32m✓[39m the conditional carries the stored validators: If-Modified-Since alone or both together[32m 1[2mms[22m[39m
[32m✓[39m a 304 freshens the stored response and its new Cache-Control extends freshness for later hits[32m 0[2mms[22m[39m
[32m✓[39m the 304 header update never overwrites the stored Content-Length[32m 0[2mms[22m[39m
[32m✓[39m a 304 bearing an ETag freshens exactly the stored variant carrying that ETag[32m 1[2mms[22m[39m
[32m✓[39m a 304 without an ETag freshens the variant that made the conditional[32m 0[2mms[22m[39m
[32m✓[39m a full 200 on revalidation replaces the stored entry and reaches the client[32m 0[2mms[22m[39m
[32m✓[39m a 304 arriving on a miss passes through and stores nothing[32m 0[2mms[22m[39m
[32m✓[39m after freshening the client conditional is evaluated against the freshened variant[32m 0[2mms[22m[39m
[32m✓[39m Age restarts from the validation exchange after a 304[32m 0[2mms[22m[39m
[32m✓[39m a 5xx with max-age fetched by revalidation follows normal storage rules[32m 0[2mms[22m[39m
[32m✓[39m If-None-Match matching the stored ETag yields a cache-generated 304 with the required headers[32m 0[2mms[22m[39m
[32m✓[39m If-None-Match uses weak comparison and accepts a list or a star[32m 0[2mms[22m[39m
[32m✓[39m If-Modified-Since is ignored when If-None-Match is present even a non-matching one[32m 0[2mms[22m[39m
[31m [31m×[31m If-Modified-Since alone yields 304 unless it is unparseable[39m[32m 6[2mms[22m[39m
[32m✓[39m preconditions evaluate against the Vary-selected variant only[32m 1[2mms[22m[39m
[32m✓[39m a POST with a 2xx response invalidates the stored entry for its URI[32m 0[2mms[22m[39m
[32m✓[39m PUT and DELETE invalidate likewise and a 3xx response also invalidates[32m 0[2mms[22m[39m
[32m✓[39m an unsafe method answered with an error invalidates nothing[32m 0[2mms[22m[39m
[32m✓[39m same-origin Location and Content-Location targets are invalidated too[32m 0[2mms[22m[39m
[32m✓[39m a cross-origin Location is left intact[32m 0[2mms[22m[39m
[32m✓[39m OPTIONS neither stores nor invalidates[32m 0[2mms[22m[39m
[32m✓[39m HEAD is answered from the stored GET entry and a HEAD miss never stores[32m 0[2mms[22m[39m
[32m✓[39m the wall clock is never read[32m 1[2mms[22m[39m
[32m✓[39m package.json and jsr.json wire hono/http-cache exactly like the etag entries[32m 0[2mms[22m[39m
[31m⎯⎯⎯⎯⎯⎯⎯[39m[1m[41m Failed Tests 1 [49m[22m[31m⎯⎯⎯⎯⎯⎯⎯[39m
[41m[1m FAIL [22m[49m [30m[45m main [49m[39m src/middleware/http-cache/index.test.ts[2m > [22mHTTP Cache Middleware[2m > [22mIf-Modified-Since alone yields 304 unless it is unparseable
[31m[1mAssertionError[22m: expected null to be 'Sun, 31 Dec 2023 23:43:20 GMT' // Object.is equality[39m
[32m- Expected:[39m
"Sun, 31 Dec 2023 23:43:20 GMT"
[31m+ Received:[39m
null
[36m [2m❯[22m src/middleware/http-cache/index.test.ts:[2m1781:54[22m[39m
[90m1779|[39m [34mexpect[39m(notModified[33m.[39mstatus)[33m.[39m[34mtoBe[39m([34m304[39m)
[90m1780|[39m [34mexpect[39m([35mawait[39m notModified[33m.[39m[34mtext[39m())[33m.[39m[34mtoBe[39m([32m''[39m)
[90m1781|[39m [34mexpect[39m(notModified[33m.[39mheaders[33m.[39m[35mget[39m([32m'Last-Modified'[39m))[33m.[39m[34mtoBe[39m(lastModified)
[90m |[39m [31m^[39m
[90m1782|[39m [34mexpect[39m(notModified[33m.[39mheaders[33m.[39m[35mget[39m([32m'Age'[39m))[33m.[39m[34mtoBe[39m([32m'5'[39m)
[90m1783|[39m
[31m[2m⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯[22m[39m
[2m Test Files [22m [1m[31m1 failed[39m[22m[90m (1)[39m
[2m Tests [22m [1m[31m1 failed[39m[22m[2m | [22m[1m[32m69 passed[39m[22m[90m (70)[39m
[2m Start at [22m 15:18:45
[2m Duration [22m 252ms[2m (transform 121ms, setup 0ms, import 140ms, tests 56ms, environment 0ms)[22m
JUNIT report written to /logs/verifier/f2p.xml
[1m[30m[46m RUN [49m[39m[22m [36mv4.1.9 [39m[90m/app[39m
[32m✓[39m [30m[45m main [49m[39m src/middleware/logger/index.test.ts [2m([22m[2m15 tests[22m[2m)[22m[33m 2036[2mms[22m[39m
[33m[2m✓[22m[39m Time in seconds [33m 1007[2mms[22m[39m
[33m[2m✓[22m[39m Time in seconds [33m 1004[2mms[22m[39m
[32m✓[39m [30m[45m main [49m[39m src/utils/jwt/jwt.test.ts [2m([22m[2m88 tests[22m[2m)[22m[33m 658[2mms[22m[39m
[32m✓[39m [30m[45m main [49m[39m src/jsx/dom/index.test.tsx [2m([22m[2m279 tests[22m[2m)[22m[33m 489[2mms[22m[39m
[32m✓[39m [30m[45m main [49m[39m src/middleware/compress/index.test.ts [2m([22m[2m45 tests[22m[2m)[22m[33m 362[2mms[22m[39m
[33m[2m✓[22m[39m should compress streaming responses written in multiple chunks [33m 313[2mms[22m[39m
[32m✓[39m [30m[45m main [49m[39m src/middleware/timeout/index.test.ts [2m([22m[2m4 tests[22m[2m)[22m[33m 4242[2mms[22m[39m
[33m[2m✓[22m[39m Should trigger default timeout exception [33m 1026[2mms[22m[39m
[33m[2m✓[22m[39m Should apply custom exception with function [33m 1105[2mms[22m[39m
[33m[2m✓[22m[39m Error timeout with custom status code and message [33m 1206[2mms[22m[39m
[33m[2m✓[22m[39m No Timeout should pass [33m 903[2mms[22m[39m
[90mstdout[2m | src/jsx/streaming.test.tsx[2m > [22m[2mStreaming[2m > [22m[2mreject()
[22m[39m[90mundefined[39m
[90mstdout[2m | src/jsx/streaming.test.tsx[2m > [22m[2mStreaming[2m > [22m[2mpops buildDataStack when a deferred re-render rejects
[22m[39mError: boom
at Content [90m(/app/[39msrc/jsx/streaming.test.tsx:1078:13[90m)[39m
at JSXFunctionNode.toStringToBuffer [90m(/app/[39msrc/jsx/base.ts:293:40[90m)[39m
at render [90m(/app/[39msrc/jsx/base.ts:199:12[90m)[39m
at runWithRenderContext [90m(/app/[39msrc/jsx/context.ts:164:12[90m)[39m
at JSXFunctionNode.toString [90m(/app/[39msrc/jsx/base.ts:206:12[90m)[39m
at [90m/app/[39msrc/jsx/components.ts:18:65
at Array.map (<anonymous>)
at childrenToString [90m(/app/[39msrc/jsx/components.ts:18:8[90m)[39m
at [90m/app/[39msrc/jsx/streaming.ts:76:20
at [90m/app/[39m
… (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.