import { sha256 } from "../../utils/crypto.js"; import { cloneRawRequest } from "../../request.js"; //#region src/middleware/cache/index.ts /** * status codes that can be cached by default. */ const defaultCacheableStatusCodes = [200]; const defaultMaxQueryBodySize = 65536; const cacheKeyPath = "/.hono/cache"; const cacheKeyParameter = "__hono_cache_key"; const cacheMethodKeyParameter = "__hono_cache_method"; const queryDigestKeyParameter = "__hono_query_digest"; const cacheVaryKeyParameter = "__hono_cache_vary"; const queryRepresentationMetadataHeaders = [ "content-type", "content-encoding", "content-language", "content-location" ]; const shouldSkipCacheControl = (cacheControl) => !!cacheControl && /(?:^|,\s*)(?:private|no-(?:store|cache))(?:\s*(?:=|,|$))/i.test(cacheControl); const parseVaryDirectives = (vary) => { if (vary == null) return []; return (Array.isArray(vary) ? vary : vary.split(",")).map((directive) => directive.trim().toLowerCase()).filter(Boolean); }; const createCacheKey = (key, requestUrl, request, varyHeaders) => { const url = new URL(cacheKeyPath, requestUrl); url.searchParams.append(cacheKeyParameter, key); url.searchParams.append(cacheMethodKeyParameter, request.method); if (request.method === "QUERY") url.searchParams.append(queryDigestKeyParameter, request.digest); for (const header of varyHeaders) url.searchParams.append(cacheVaryKeyParameter, JSON.stringify(header)); return url.href; }; const shouldSkipCache = (res, optionsVaryDirectives, responseVary) => responseVary.length && (!optionsVaryDirectives || responseVary.some((name) => !optionsVaryDirectives.has(name))) || shouldSkipCacheControl(res.headers.get("Cache-Control")) || res.headers.has("Set-Cookie"); const reportCacheNotAvailable = (onCacheNotAvailable, reason) => { if (onCacheNotAvailable === false) {} else if (onCacheNotAvailable) onCacheNotAvailable(reason); else console.log(reason); }; const createQueryDigest = async (c, maxQueryBodySize) => { if (!globalThis.crypto?.subtle) return; if (c.req.raw.bodyUsed && Object.keys(c.req.bodyCache)[0] === "formData") return; try { const requestHeaders = c.req.raw.headers; const metadata = new TextEncoder().encode(JSON.stringify(queryRepresentationMetadataHeaders.map((header) => [header, requestHeaders.get(header)]))); const body = (await cloneRawRequest(c.req)).body; const chunks = []; let bodySize = 0; if (body) { const reader = body.getReader(); for (;;) { const { done, value } = await reader.read(); if (done) break; bodySize += value.byteLength; if (bodySize > maxQueryBodySize) { reader.cancel().catch(() => {}); return; } chunks.push(value); } } const data = new Uint8Array(metadata.byteLength + bodySize); data.set(metadata); let offset = metadata.byteLength; for (const chunk of chunks) { data.set(chunk, offset); offset += chunk.byteLength; } return await sha256(data) ?? void 0; } catch { return; } }; /** * Cache Middleware for Hono. * * @see {@link https://hono.dev/docs/middleware/builtin/cache} * * @param {Object} options - The options for the cache middleware. * @param {string | Function} options.cacheName - The name of the cache. Can be used to store multiple caches with different identifiers. * @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. * @param {string} [options.cacheControl] - A string of directives for the `Cache-Control` header. * @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. * @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. * @param {number} [options.maxQueryBodySize=65536] - The maximum QUERY request body size in bytes that can be cached. Larger QUERY requests bypass the cache. * @param {number[]} [options.cacheableStatusCodes=[200]] - An array of status codes that can be cached. * @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. * @returns {MiddlewareHandler} The middleware handler function. * @throws {Error} If the `vary` option includes "*". * * @example * ```ts * app.use( * '*', * cache({ * cacheName: 'my-app', * cacheControl: 'max-age=3600', * }) * ) * ``` */ const cache = (options) => { if (!globalThis.caches) { reportCacheNotAvailable(options.onCacheNotAvailable, "Cache Middleware is not enabled because caches is not defined."); return async (_c, next) => await next(); } if (!globalThis.crypto?.subtle) reportCacheNotAvailable(options.onCacheNotAvailable, "Cache Middleware cannot cache QUERY requests because Web Crypto is not available."); if (options.wait === void 0) options.wait = false; const cacheControlDirectives = options.cacheControl?.split(",").map((directive) => directive.toLowerCase()); const optionsVaryList = parseVaryDirectives(options.vary); const varyDirectives = optionsVaryList.length ? new Set(optionsVaryList) : void 0; if (varyDirectives?.has("*")) throw new Error("Middleware vary configuration cannot include \"*\", as it disallows effective caching."); const cacheableStatusCodes = new Set(options.cacheableStatusCodes ?? defaultCacheableStatusCodes); const maxQueryBodySize = options.maxQueryBodySize ?? defaultMaxQueryBodySize; const addHeader = (c, responseVary) => { if (cacheControlDirectives) { const existingDirectives = c.res.headers.get("Cache-Control")?.split(",").map((d) => d.trim().split("=", 1)[0].toLowerCase()) ?? []; for (const directive of cacheControlDirectives) { let [name, value] = directive.trim().split("=", 2); name = name.toLowerCase(); if (!existingDirectives.includes(name)) c.header("Cache-Control", `${name}${value ? `=${value}` : ""}`, { append: true }); } } if (varyDirectives) { if (responseVary.length === 0) c.header("Vary", Array.from(varyDirectives).join(", ")); else { const merged = new Set(varyDirectives); for (const directive of responseVary) merged.add(directive); if (merged.has("*")) c.header("Vary", "*"); else c.header("Vary", Array.from(merged).join(", ")); } } }; return async function cache(c, next) { if (c.req.method !== "GET" && c.req.method !== "QUERY" || c.req.raw.headers.has("Authorization")) { await next(); return; } let cacheKeyRequest = { method: "GET" }; if (c.req.method === "QUERY") { const digest = await createQueryDigest(c, maxQueryBodySize); if (digest === void 0) { await next(); return; } cacheKeyRequest = { method: "QUERY", digest }; } let key = c.req.url; if (options.keyGenerator) key = await options.keyGenerator(c); const varyHeaders = []; if (varyDirectives) for (const directive of varyDirectives) { const value = c.req.raw.headers.get(directive) ?? ""; varyHeaders.push([directive, value]); } key = createCacheKey(key, c.req.url, cacheKeyRequest, varyHeaders); const cacheName = typeof options.cacheName === "function" ? await options.cacheName(c) : options.cacheName; const cache = await caches.open(cacheName); const response = await cache.match(key); if (response) return new Response(response.body, response); await next(); if (!cacheableStatusCodes.has(c.res.status)) return; const responseVary = parseVaryDirectives(c.res.headers.get("Vary")); addHeader(c, responseVary); if (shouldSkipCache(c.res, varyDirectives, responseVary)) return; const res = c.res.clone(); if (options.wait) await cache.put(key, res); else c.executionCtx.waitUntil(cache.put(key, res)); }; }; //#endregion export { cache };