UNPKG

@web-widget/shared-cache

Version:

Standards-compliant HTTP cache implementation for server-side JavaScript with RFC 7234 compliance and cross-runtime support

1,577 lines (1,563 loc) 52.5 kB
// src/cache.ts import CachePolicy from "@web-widget/http-cache-semantics"; // src/utils/logger.ts var LogLevel = /* @__PURE__ */ ((LogLevel2) => { LogLevel2[LogLevel2["DEBUG"] = 0] = "DEBUG"; LogLevel2[LogLevel2["INFO"] = 1] = "INFO"; LogLevel2[LogLevel2["WARN"] = 2] = "WARN"; LogLevel2[LogLevel2["ERROR"] = 3] = "ERROR"; return LogLevel2; })(LogLevel || {}); function createLogMessage(operation, prefix, details) { const baseMessage = prefix ? `${prefix}: ${operation}` : operation; return details ? `${baseMessage} - ${details}` : baseMessage; } var StructuredLogger = class _StructuredLogger { logger; minLevel; prefix; constructor(logger, minLevel = 1 /* INFO */, prefix) { this.logger = logger; this.minLevel = minLevel; this.prefix = prefix; } /** * Log debug information about operations */ debug(operation, context, details) { if (this.shouldLog(0 /* DEBUG */)) { const message = createLogMessage(operation, this.prefix, details); this.logger?.debug(message, context); } } /** * Log informational messages about successful operations */ info(operation, context, details) { if (this.shouldLog(1 /* INFO */)) { const message = createLogMessage(operation, this.prefix, details); this.logger?.info(message, context); } } /** * Log warning messages about potentially problematic situations */ warn(operation, context, details) { if (this.shouldLog(2 /* WARN */)) { const message = createLogMessage(operation, this.prefix, details); this.logger?.warn(message, context); } } /** * Log error messages about failed operations */ error(operation, context, details) { if (this.shouldLog(3 /* ERROR */)) { const message = createLogMessage(operation, this.prefix, details); this.logger?.error(message, context); } } /** * Handle promise rejections with proper error logging */ handleAsyncError = (operation, context) => { return (error) => { this.error( operation, { ...context, error }, "Promise rejected" ); }; }; /** * Check if a log level should be output based on minimum level setting */ shouldLog(level) { return Boolean(this.logger && level >= this.minLevel); } /** * Create a new logger instance with a different minimum level */ withLevel(minLevel) { return new _StructuredLogger(this.logger, minLevel, this.prefix); } /** * Create a new logger instance with a different prefix */ withPrefix(prefix) { return new _StructuredLogger(this.logger, this.minLevel, prefix); } }; function createLogger(logger, minLevel = 1 /* INFO */, prefix) { return new StructuredLogger(logger, minLevel, prefix); } // src/utils/crypto.ts var sha1 = async (data) => { const sourceBuffer = new TextEncoder().encode(String(data)); if (!crypto || !crypto.subtle) { throw new Error("SHA-1 is not supported"); } const buffer = await crypto.subtle.digest("SHA-1", sourceBuffer); return Array.prototype.map.call(new Uint8Array(buffer), (x) => ("00" + x.toString(16)).slice(-2)).join(""); }; // src/utils/user-agent.ts var MOBILE_REGEX = /phone|windows\s+phone|ipod|blackberry|(?:android|bb\d+|meego|silk|googlebot) .+? mobile|palm|windows\s+ce|opera mini|avantgo|mobilesafari|docomo|KAIOS/i; var TABLET_REGEX = /ipad|playbook|(?:android|bb\d+|meego|silk)(?! .+? mobile)/i; function deviceType(headers) { const userAgent = headers.get("User-Agent") || ""; const isChMobile = headers.get("Sec-CH-UA-Mobile") === "?1"; if (isChMobile || MOBILE_REGEX.test(userAgent)) { return "mobile"; } else if (TABLET_REGEX.test(userAgent)) { return "tablet"; } else { return "desktop"; } } // src/constants.ts var CACHE_STATUS_HEADER_NAME = "x-cache-status"; var CACHE_KEY_HEADER_NAME = "x-cache-key"; var SHARED_CACHE_STATUS = { /** Response served from cache without validation */ HIT: "HIT", /** Response not found in cache, fetched from origin */ MISS: "MISS", /** Cached response was expired, fresh response fetched */ EXPIRED: "EXPIRED", /** Stale response served when origin is unreachable (stale-if-error) */ STALE: "STALE", /** Expired response served while revalidating in the background */ UPDATING: "UPDATING", /** Cache was bypassed due to cache-control directives */ BYPASS: "BYPASS", /** Cached response was revalidated and determined still fresh */ REVALIDATED: "REVALIDATED", /** Response is dynamic and cannot be cached */ DYNAMIC: "DYNAMIC" }; var HIT = SHARED_CACHE_STATUS.HIT; var MISS = SHARED_CACHE_STATUS.MISS; var EXPIRED = SHARED_CACHE_STATUS.EXPIRED; var STALE = SHARED_CACHE_STATUS.STALE; var UPDATING = SHARED_CACHE_STATUS.UPDATING; var BYPASS = SHARED_CACHE_STATUS.BYPASS; var REVALIDATED = SHARED_CACHE_STATUS.REVALIDATED; var DYNAMIC = SHARED_CACHE_STATUS.DYNAMIC; // src/utils/cookies.ts import { RequestCookies } from "@edge-runtime/cookies"; // src/key.ts var CACHE_KEY_FRAGMENT_SEPARATOR = "|"; var CACHE_KEY_VALUE_DIGEST_SEPARATOR = "@"; var CACHE_KEY_INTRA_FRAGMENT_SEPARATOR = "&"; var CACHE_KEY_VARY_SEPARATOR = "|v|"; var CACHE_KEY_VARY_META_SUFFIX = "|vary|"; var URL_PART_KEYS = ["scheme", "host", "pathname", "search"]; var REQUEST_PART_KEYS = ["cookie", "device", "header"]; var CACHE_KEY_RULE_KEYS = /* @__PURE__ */ new Set([ ...URL_PART_KEYS, ...REQUEST_PART_KEYS ]); var cacheKeyContexts = /* @__PURE__ */ new WeakMap(); var CANNOT_INCLUDE_HEADERS = [ "accept", "accept-charset", "accept-encoding", "accept-datetime", "accept-language", "referer", "user-agent", "connection", "content-length", "cache-control", "if-match", "if-modified-since", "if-none-match", "if-unmodified-since", "range", "upgrade", "cookie", "host", "vary", CACHE_STATUS_HEADER_NAME, CACHE_KEY_HEADER_NAME ]; var FORBIDDEN_HEADERS = new Set(CANNOT_INCLUDE_HEADERS); function getCacheKeyContext(request) { let context = cacheKeyContexts.get(request); if (!context) { let headerEntries; let cookieEntries; context = { getHeaderEntries() { if (!headerEntries) { headerEntries = Array.from(request.headers.entries()).map( ([key, value]) => [key.toLowerCase(), value] ); } return headerEntries; }, getCookieEntries() { if (!cookieEntries) { cookieEntries = new RequestCookies(request.headers).getAll().map(({ name, value }) => [name, value]); } return cookieEntries; } }; cacheKeyContexts.set(request, context); } return context; } function sortEntries(array) { return array.sort((a, b) => a[0].localeCompare(b[0])); } function compileFilterOptions(options, lowercaseKeys = false) { if (!options) { return void 0; } const normalize = (values) => values?.map((name) => lowercaseKeys ? name.toLowerCase() : name); const include = normalize(options.include); const exclude = normalize(options.exclude); const checkPresence = normalize(options.checkPresence); const includeOnly = Boolean( include?.length && !exclude?.length && !checkPresence?.length ); return { includeOnly, exclude: exclude ? new Set(exclude) : void 0, include: include ? new Set(include) : void 0, includeList: includeOnly ? [...include].sort((a, b) => a.localeCompare(b)) : void 0, checkPresence: checkPresence ? new Set(checkPresence) : void 0 }; } function compileRule(rule) { if (!isEnabled(rule)) { return void 0; } return { filter: rule === true ? void 0 : compileFilterOptions(rule) }; } function applyCompiledFilter(entries, compiled, { prefiltered = false } = {}) { if (prefiltered || compiled?.includeOnly) { return entries; } let result = entries; if (compiled?.exclude?.size) { result = result.filter(([key]) => !compiled.exclude.has(key)); } if (compiled?.include?.size) { result = result.filter(([key]) => compiled.include.has(key)); } if (compiled?.checkPresence?.size) { result = result.map( (item) => compiled.checkPresence.has(item[0]) ? [item[0], ""] : item ); } return sortEntries(result); } function resolveNormalizeOptions(normalize) { if (normalize === false) { return {}; } if (typeof normalize === "object") { return { ...normalize }; } return {}; } function normalizeUrl(url, options = {}) { if (!options.trailingSlash && !options.pathnameLowerCase && !options.ignoreSpaces) { return url; } const normalized = new URL(url); let pathname = normalized.pathname; if (options.pathnameLowerCase) { pathname = pathname.toLowerCase(); } if (options.trailingSlash && pathname.length > 1 && pathname.endsWith("/")) { pathname = pathname.replace(/\/+$/, "") || "/"; } if (options.ignoreSpaces) { pathname = pathname.replace(/%20/gi, "").replace(/\s+/g, ""); } normalized.pathname = pathname; if (options.ignoreSpaces && normalized.search) { const params = new URLSearchParams(normalized.search); const canonical = new URLSearchParams(); for (const [key, value] of params.entries()) { canonical.append(key, value.replace(/\s+/g, "")); } canonical.sort(); normalized.search = canonical.toString(); } return normalized; } function isEnabled(rule) { return rule !== false && rule !== void 0; } async function formatHashedSegment(keys, canonicalValues) { return `${keys}${CACHE_KEY_VALUE_DIGEST_SEPARATOR}${await sha1(canonicalValues)}`; } function readIncludedKeyValueEntries(request, source, includeList) { const entries = []; if (source === "cookie") { const cookies = new RequestCookies(request.headers); for (const name of includeList) { const cookie = cookies.get(name); if (cookie) { entries.push([name, cookie.value]); } } return entries; } for (const name of includeList) { const value = request.headers.get(name); if (value !== null) { entries.push([name, value]); } } return entries; } function readKeyValueEntries(request, source, compiled, context = getCacheKeyContext(request)) { if (compiled?.includeOnly && compiled.includeList) { return readIncludedKeyValueEntries(request, source, compiled.includeList); } if (source === "cookie") { return context.getCookieEntries(); } return context.getHeaderEntries(); } function prepareKeyValueEntries(entries, compiled, { prefiltered = false, forbiddenKeys } = {}) { const filtered = applyCompiledFilter(entries, compiled, { prefiltered }); if (!filtered.length) { return void 0; } const keyParts = []; const canonicalParts = []; const displayParts = []; for (const [key, value] of filtered) { if (forbiddenKeys?.has(key)) { throw new TypeError( `Cannot include header "${key}" in cache key. This header is excluded to prevent cache fragmentation or conflicts with other cache features.` ); } keyParts.push(key); canonicalParts.push(`${key}=${value}`); displayParts.push(value ? `${key}=${value}` : key); } const separator = CACHE_KEY_INTRA_FRAGMENT_SEPARATOR; return { keys: keyParts.join(separator), canonicalValues: canonicalParts.join(separator), displayValues: displayParts.join(separator) }; } var URL_PART_RENDERERS = { scheme: (url, rule) => scalarPart(`${url.protocol}//`, rule.filter), host: (url, rule) => scalarPart(url.host, rule.filter), pathname: (url, rule) => scalarPart(url.pathname, rule.filter), search: (url, rule) => renderSearchPart(url, rule) }; function renderSearchPart(url, rule) { const searchParams = new URLSearchParams(url.search); const filter = rule.filter; let entries; if (filter?.includeOnly && filter.includeList) { entries = []; for (const key of filter.includeList) { const value = searchParams.get(key); if (value !== null) { entries.push([key, value]); } } } else { searchParams.sort(); entries = Array.from(searchParams.entries()); } const collected = prepareKeyValueEntries(entries, filter, { prefiltered: filter?.includeOnly }); return collected ? `?${collected.displayValues}` : ""; } function collectKeyValueMaterial(request, source, compiled, { prefix, forbiddenHeaders = false } = {}) { const collected = prepareKeyValueEntries( readKeyValueEntries(request, source, compiled), compiled, { prefiltered: compiled?.includeOnly, forbiddenKeys: forbiddenHeaders ? FORBIDDEN_HEADERS : void 0 } ); if (!collected) { return void 0; } const keys = prefix ? `${prefix}:${collected.keys}` : collected.keys; const canonical = prefix ? `${prefix}:${collected.canonicalValues}` : collected.canonicalValues; return { keys, canonical }; } async function formatHashedKeyValues(request, compiled, source, forbidden = false) { const material = collectKeyValueMaterial(request, source, compiled, { forbiddenHeaders: forbidden }); if (!material) { return ""; } return formatHashedSegment(material.keys, material.canonical); } function scalarPart(value, compiled) { if (!compiled) { return value; } if (compiled.includeOnly && compiled.include) { return compiled.include.has(value) ? value : ""; } return applyCompiledFilter([[value, ""]], compiled)[0]?.[0] ?? ""; } function validateCacheKeyRules(rules) { for (const key of Object.keys(rules)) { if (!CACHE_KEY_RULE_KEYS.has(key)) { throw new TypeError( `Unknown cache key part: "${key}". Use built-in parts (${[...CACHE_KEY_RULE_KEYS].join(", ")}).` ); } } } function compileCacheKeyPlan(rules) { validateCacheKeyRules(rules); const { scheme, host, pathname, search, cookie, device, header } = rules; const fragments = []; for (const name of REQUEST_PART_KEYS) { const rule = { cookie, device, header }[name]; if (!isEnabled(rule)) { continue; } fragments.push({ name, filter: name === "header" ? compileFilterOptions(rule === true ? void 0 : rule, true) : compileFilterOptions(rule === true ? void 0 : rule) }); } return { url: { scheme: compileRule(scheme), host: compileRule(host), pathname: compileRule(pathname), search: compileRule(search) }, fragments, syncOnly: fragments.length === 0 }; } var DEFAULT_CACHE_KEY_RULES = { scheme: true, host: true, pathname: true, search: true }; function buildUrlSegment(url, urlRules) { const segments = []; for (const name of URL_PART_KEYS) { const rule = urlRules[name]; if (rule) { segments.push(URL_PART_RENDERERS[name](url, rule)); } } return segments.join(""); } function hashVaryKeyPart(request, options) { return formatHashedKeyValues( request, compileFilterOptions(options, true), "header" ); } function collectScalarFragment(name, value, compiled) { if (!scalarPart(value, compiled)) { return void 0; } return { keys: name, canonical: `${name}:${value}` }; } function buildFragmentContribution(fragment, request) { if (fragment.name === "device") { return collectScalarFragment( fragment.name, deviceType(request.headers), fragment.filter ); } return collectKeyValueMaterial(request, fragment.name, fragment.filter, { prefix: fragment.name, forbiddenHeaders: fragment.name === "header" }); } async function buildFragmentSuffix(contributions) { const keys = contributions.map((part) => part.keys).join(CACHE_KEY_FRAGMENT_SEPARATOR); const canonical = contributions.map((part) => part.canonical).join(CACHE_KEY_FRAGMENT_SEPARATOR); return `${keys}${CACHE_KEY_VALUE_DIGEST_SEPARATOR}${await sha1(canonical)}`; } function buildCacheKeySync(request, cacheKeyRules, resolvedNormalize) { const plan = compileCacheKeyPlan(cacheKeyRules); if (!plan.syncOnly) { return void 0; } const url = normalizeUrl(new URL(request.url), resolvedNormalize); return buildUrlSegment(url, plan.url); } async function buildCacheKey(request, cacheKeyRules, resolvedNormalize) { const plan = compileCacheKeyPlan(cacheKeyRules); const url = normalizeUrl(new URL(request.url), resolvedNormalize); const baseKey = buildUrlSegment(url, plan.url); if (plan.syncOnly) { return baseKey; } getCacheKeyContext(request); const contributions = []; for (const fragment of plan.fragments) { const contribution = buildFragmentContribution(fragment, request); if (contribution) { contributions.push(contribution); } } return contributions.length ? `${baseKey}#${await buildFragmentSuffix(contributions)}` : baseKey; } function createCacheKeyGenerator(cacheKeyNormalize) { const resolvedNormalize = resolveNormalizeOptions(cacheKeyNormalize); const cacheKeyGenerator = async function cacheKeyGenerator2(request, cacheKeyRules = DEFAULT_CACHE_KEY_RULES) { return buildCacheKey(request, cacheKeyRules, resolvedNormalize); }; cacheKeyGenerator.sync = function cacheKeyGeneratorSync(request, cacheKeyRules = DEFAULT_CACHE_KEY_RULES) { return buildCacheKeySync(request, cacheKeyRules, resolvedNormalize); }; return cacheKeyGenerator; } function parseVaryHeader(vary2) { return { include: vary2.split(",").map((field) => field.trim().toLowerCase()).filter(Boolean) }; } async function appendVaryKeySuffix(request, baseKey, varyFilter) { if (!varyFilter?.include?.length) { return baseKey; } getCacheKeyContext(request); const part = await hashVaryKeyPart(request, varyFilter); return part ? `${baseKey}${CACHE_KEY_VARY_SEPARATOR}${part}` : baseKey; } async function readStoredVaryFilter(storage, baseKey) { return await storage.get(`${baseKey}${CACHE_KEY_VARY_META_SUFFIX}`); } async function writeStoredVaryFilter(storage, baseKey, ttl, varyHeader) { if (!varyHeader || varyHeader === "*") { return void 0; } const filter = parseVaryHeader(varyHeader); await storage.set(`${baseKey}${CACHE_KEY_VARY_META_SUFFIX}`, filter, ttl); return filter; } // src/utils/conditional.ts var NOT_MODIFIED_OMIT_HEADERS = /* @__PURE__ */ new Set([ "content-length", "content-type", "content-encoding", "transfer-encoding" ]); function hasConditionalRequestHeaders(request) { return request.headers.has("if-none-match") || request.headers.has("if-modified-since"); } function satisfiesConditionalRequest(request, responseHeaders) { const ifNoneMatch = request.headers.get("if-none-match"); if (ifNoneMatch) { const cachedEtag = responseHeaders.get("etag"); if (!cachedEtag) { return false; } if (ifNoneMatch.trim() === "*") { return true; } const cachedTag = normalizeEntityTag(cachedEtag); return ifNoneMatch.split(",").some((tag) => { return normalizeEntityTag(tag) === cachedTag; }); } const ifModifiedSince = request.headers.get("if-modified-since"); if (ifModifiedSince) { const lastModified = responseHeaders.get("last-modified"); if (!lastModified) { return false; } const ifModifiedSinceTime = Date.parse(ifModifiedSince); const lastModifiedTime = Date.parse(lastModified); if (!Number.isFinite(ifModifiedSinceTime) || !Number.isFinite(lastModifiedTime)) { return false; } return lastModifiedTime <= ifModifiedSinceTime; } return false; } function normalizeEntityTag(tag) { return tag.trim().replace(/^\s*W\//, ""); } function createNotModifiedResponse(responseHeaders) { const headers = new Headers(); responseHeaders.forEach((value, name) => { if (!NOT_MODIFIED_OMIT_HEADERS.has(name.toLowerCase())) { headers.set(name, value); } }); return new Response(null, { status: 304, statusText: "Not Modified", headers }); } function isErrorResponse(response) { return response.status >= 500; } // src/utils/response.ts function modifyResponseHeaders(response, modifier) { try { modifier(response.headers); return response; } catch (_error) { const newHeaders = new Headers(response.headers); modifier(newHeaders); return new Response(response.body, { status: response.status, statusText: response.statusText, headers: newHeaders }); } } function encodeCacheKeyHeaderValue(cacheKey) { let encoded = ""; for (const char of cacheKey) { const code = char.codePointAt(0); if (code === 0 || code === 10 || code === 13 || code > 255) { encoded += encodeURIComponent(char); } else { encoded += char; } } return encoded; } function setResponseHeader(response, name, value) { return modifyResponseHeaders(response, (headers) => { headers.set(name, value); }); } function applyCacheStatus(response, status, { copy = false } = {}) { if (response.headers.has(CACHE_STATUS_HEADER_NAME)) { return response; } if (copy) { return setResponseHeader(response, CACHE_STATUS_HEADER_NAME, status); } response.headers.set(CACHE_STATUS_HEADER_NAME, status); return response; } // src/cache.ts var CACHE_NAMESPACE_SEPARATOR = ""; function createNamespacedStorage(storage, cacheName) { if (!cacheName || cacheName === "default") { return storage; } const prefix = `${cacheName}${CACHE_NAMESPACE_SEPARATOR}`; return { get: (cacheKey) => storage.get(`${prefix}${cacheKey}`), set: (cacheKey, value, ttl) => storage.set(`${prefix}${cacheKey}`, value, ttl), delete: (cacheKey) => storage.delete(`${prefix}${cacheKey}`) }; } var SharedCache = class { /** Cache key generator factory */ #cacheKeyGeneratorFactory; /** Base cache key rules configured for this cache instance */ #defaultCacheKeyRules; /** Structured logger instance with consistent formatting */ #structuredLogger; /** Underlying storage backend */ #storage; /** * Creates a new SharedCache instance. * * @param storage - The key-value storage backend for persistence * @param options - Configuration options for cache behavior * @throws {TypeError} When storage is not provided */ constructor(storage, options) { if (!storage) { throw new TypeError("Missing storage."); } const cacheKeyGenerator = createCacheKeyGenerator( options?._cacheKeyNormalize ); this.#cacheKeyGeneratorFactory = cacheKeyGenerator; this.#defaultCacheKeyRules = { ...DEFAULT_CACHE_KEY_RULES, ...options?.cacheKeyRules }; if (options?.logger instanceof StructuredLogger) { this.#structuredLogger = options.logger; } else { this.#structuredLogger = createLogger(options?.logger); } this.#storage = createNamespacedStorage(storage, options?._cacheName); } /** * Computes the cache key for a request using the current cache key rules. * Useful for debugging and diagnostics in callers that need to surface the key. * * @param request - Request to compute key for * @returns Promise resolving to the computed cache key */ async getCacheKey(request) { const resolved = request instanceof Request ? request : new Request(request); const rules = { ...this.#defaultCacheKeyRules, ...resolved.sharedCache?.cacheKeyRules }; const syncKey = this.#cacheKeyGeneratorFactory.sync(resolved, rules); if (syncKey !== void 0) { return syncKey; } return this.#cacheKeyGeneratorFactory(resolved, rules); } /** * The add() method is not implemented in this cache implementation. * This method is part of the Cache interface but not commonly used in practice. * * @param _request - The request to add (unused) * @throws {Error} Always throws as this method is not implemented */ async add(_request) { throw new Error("SharedCache.add() is not implemented. Use put() instead."); } /** * The addAll() method is not implemented in this cache implementation. * This method is part of the Cache interface but not commonly used in practice. * * @param _requests - The requests to add (unused) * @throws {Error} Always throws as this method is not implemented */ async addAll(_requests) { throw new Error( "SharedCache.addAll() is not implemented. Use put() for each request instead." ); } /** * The delete() method of the Cache interface finds the Cache entry whose key * matches the request, and if found, deletes the Cache entry and returns a Promise * that resolves to true. If no Cache entry is found, it resolves to false. * * This implementation follows the algorithm specified in the Cache API specification: * https://w3c.github.io/ServiceWorker/#cache-delete * * @param request - The Request for which you are looking to delete. This can be a Request object or a URL. * @param options - An object whose properties control how matching is done in the delete operation. * @returns A Promise that resolves to true if the cache entry is deleted, or false otherwise. */ async delete(request, options) { verifyCacheQueryOptions("delete", options); const resolved = resolveCacheRequest(request, options); if (!resolved) { return false; } const cacheKey = await this.getCacheKey(resolved); return deleteCacheItem(resolved, this.#storage, cacheKey); } /** * The keys() method is not implemented in this cache implementation. * This method would return all Request objects that serve as keys for cached responses. * * @param _request - Optional request to match against (unused) * @param _options - Optional query options (unused) * @throws {Error} Always throws as this method is not implemented */ async keys(_request, _options) { throw new Error("SharedCache.keys() is not implemented."); } /** * The match() method of the Cache interface returns a Promise that resolves * to the Response associated with the first matching request in the Cache * object. If no match is found, the Promise resolves to undefined. * * This implementation includes advanced features: * - HTTP cache validation (ETag, Last-Modified) * - Stale-while-revalidate support * - Custom cache key generation * - Proper Vary header handling * * @param request - The Request for which you are attempting to find responses in the Cache. * This can be a Request object or a URL. * @param options - An object that sets options for the match operation. * @returns A Promise that resolves to the first Response that matches the request * or to undefined if no match is found. */ async match(request, options) { verifyCacheQueryOptions("match", options); const r = resolveCacheRequest(request, options); if (!r) { return void 0; } const cacheKey = await this.getCacheKey(r); const cacheItem = await getCacheItem(r, this.#storage, cacheKey); if (!cacheItem) { this.#structuredLogger.debug("Cache miss", { url: r.url, cacheKey, method: r.method }); return; } this.#structuredLogger.debug("Cache item found", { url: r.url, cacheKey, method: r.method }); const fetch = options?._fetch; const policyObject = sanitizeStoredPolicy(cacheItem.policy); const policy = CachePolicy.fromObject(policyObject); const evaluationRequest = normalizePolicyRequest(r, policyObject, options); const evaluation = policy.evaluateRequest(evaluationRequest); const { body, status, statusText } = cacheItem.response; if (evaluation.revalidation) { const responseHeaders2 = evaluation.response?.headers ?? policy.responseHeaders(); const response2 = new Response(body, { status, statusText, headers: responseHeaders2 }); if (!fetch) { return; } if (evaluation.revalidation.synchronous === false) { const event = options?._event; const waitUntil = event?.waitUntil.bind(event) ?? ((promise) => { promise.catch( this.#structuredLogger.handleAsyncError( "Stale-while-revalidate", { url: r.url, cacheKey } ) ); }); waitUntil( this.#revalidate( r, evaluationRequest, { response: response2, policy, storedBody: body }, cacheKey, fetch, evaluation.revalidation.headers ) ); applyCacheStatus(response2, UPDATING); this.#structuredLogger.info( "Serving stale response", { url: r.url, cacheKey, cacheStatus: "UPDATING" }, "Revalidating in background" ); return response2; } return this.#revalidate( r, evaluationRequest, { response: response2, policy, storedBody: body }, cacheKey, fetch, evaluation.revalidation.headers ); } if (!evaluation.response) { return; } const responseHeaders = evaluation.response.headers; if (hasConditionalRequestHeaders(r) && satisfiesConditionalRequest(r, responseHeaders)) { const notModified = createNotModifiedResponse(responseHeaders); applyCacheStatus(notModified, HIT); this.#structuredLogger.info("Cache hit", { url: r.url, cacheKey, cacheStatus: "HIT" }); return notModified; } const response = new Response(body, { status, statusText, headers: responseHeaders }); applyCacheStatus(response, HIT); this.#structuredLogger.info("Cache hit", { url: r.url, cacheKey, cacheStatus: "HIT" }); return response; } /** * The matchAll() method is not implemented in this cache implementation. * This method would return all matching responses for a given request. * * @param _request - Optional request to match against (unused) * @param _options - Optional query options (unused) * @throws {Error} Always throws as this method is not implemented */ async matchAll(_request, _options) { throw new Error("SharedCache.matchAll() is not implemented."); } /** * The put() method of the Cache interface allows key/value pairs to be added * to the current Cache object. * * This implementation includes several HTTP-compliant validations: * - Only HTTP/HTTPS schemes are supported for GET requests * - 206 (Partial Content) responses are rejected * - Vary: * responses are rejected * - Body usage validation to prevent corruption * * @param request - The Request object or URL that you want to add to the cache. * @param response - The Response you want to match up to the request. * @throws {TypeError} For various validation failures as per Cache API specification */ async put(request, response) { const innerRequest = request instanceof Request ? request : new Request(request); if (!/^https?:/.test(innerRequest.url) || innerRequest.method !== "GET") { throw new TypeError( `SharedCache.put: Expected an http/s scheme when method is not GET.` ); } const innerResponse = response; if (innerResponse.status === 206) { throw new TypeError(`SharedCache.put: Got 206 status.`); } if (innerResponse.headers.has("vary")) { const fieldValues = innerResponse.headers.get("vary").split(",").map((value) => value.trim()); for (const fieldValue of fieldValues) { if (fieldValue === "*") { throw new TypeError(`SharedCache.put: Got * vary field value.`); } } } if (innerResponse.body && (innerResponse.bodyUsed || innerResponse.body.locked)) { throw new TypeError( `SharedCache.put: Response body is locked or disturbed.` ); } const clonedResponse = innerResponse.clone(); const policy = new CachePolicy(innerRequest, clonedResponse); const ttl = policy.timeToLive(); const storable = policy.storable(); if (!storable || ttl <= 0) { this.#structuredLogger.debug( "Response not cacheable", { url: innerRequest.url, storable, ttl, status: innerResponse.status }, storable ? "TTL is zero/negative" : "Policy indicates not storable" ); return; } this.#structuredLogger.debug("Storing response in cache", { url: innerRequest.url, status: innerResponse.status, ttl }); const cacheItem = { policy: policy.toObject(), response: { body: await clonedResponse.text(), status: clonedResponse.status, statusText: clonedResponse.statusText } }; const cacheKey = await this.getCacheKey(innerRequest); try { await setCacheItem( this.#storage, cacheKey, cacheItem, ttl, innerRequest, clonedResponse ); } catch (error) { this.#structuredLogger.error("Put operation failed", { url: innerRequest.url, error }); throw error; } } /** * Performs cache revalidation using conditional requests. * Implements HTTP conditional request logic as per RFC 7234. * * @param request - Original request being revalidated * @param resolveCacheItem - Cached item with policy to revalidate * @param cacheKey - Cache key for storing updated response * @param fetch - Fetch function for network requests * @param options - Cache query options * @returns Updated response with appropriate cache status */ async #revalidate(request, evaluationRequest, resolveCacheItem, cacheKey, fetch, revalidationHeaders) { const revalidationRequest = new Request(evaluationRequest, { headers: revalidationHeaders }); let revalidationResponse; this.#structuredLogger.debug("Starting revalidation", { url: request.url, cacheKey }); try { revalidationResponse = await fetch(revalidationRequest); this.#structuredLogger.debug("Revalidation response received", { url: request.url, status: revalidationResponse.status, cacheKey }); } catch (error) { this.#structuredLogger.warn( "Revalidation network error", { url: request.url, cacheKey, error }, "Using fallback 500 response" ); revalidationResponse = new Response( error instanceof Error ? error.message : "Internal Server Error", { status: 500 } ); } if (revalidationResponse.status >= 500) { this.#structuredLogger.error( "Revalidation failed", { url: request.url, status: revalidationResponse.status, cacheKey }, "Server returned 5xx status" ); } const { modified, policy: revalidatedPolicy } = resolveCacheItem.policy.revalidatedPolicy( revalidationRequest, revalidationResponse ); let responseBody; let responseStatus; let responseStatusText; if (modified) { responseBody = await revalidationResponse.clone().text(); responseStatus = revalidationResponse.status; responseStatusText = revalidationResponse.statusText; } else { responseBody = resolveCacheItem.storedBody ?? await resolveCacheItem.response.clone().text(); responseStatus = resolveCacheItem.response.status; responseStatusText = resolveCacheItem.response.statusText; } await this.#storeRevalidatedCacheItem( request, revalidatedPolicy, { body: responseBody, status: responseStatus, statusText: responseStatusText }, cacheKey ); const clonedResponse = new Response(responseBody, { status: responseStatus, statusText: responseStatusText, headers: revalidatedPolicy.responseHeaders() }); if (modified) { applyCacheStatus(clonedResponse, EXPIRED); this.#structuredLogger.info( "Cache entry expired", { url: request.url, cacheKey, cacheStatus: "EXPIRED" }, "Serving fresh response" ); } else if (isErrorResponse(revalidationResponse)) { applyCacheStatus(clonedResponse, STALE); this.#structuredLogger.info( "Serving stale response", { url: request.url, cacheKey, cacheStatus: "STALE" }, "Origin error within stale-if-error window" ); } else { applyCacheStatus(clonedResponse, REVALIDATED); this.#structuredLogger.info( "Cache entry revalidated", { url: request.url, cacheKey, cacheStatus: "REVALIDATED" }, "Cached response still fresh" ); } return clonedResponse; } /** * Stores a revalidated cache entry using the policy from revalidatedPolicy(). * Avoids rebuilding CachePolicy from response headers, which would persist stale Age values. */ async #storeRevalidatedCacheItem(request, revalidatedPolicy, response, cacheKey) { const ttl = revalidatedPolicy.timeToLive(); const storable = revalidatedPolicy.storable(); if (!storable || ttl <= 0) { this.#structuredLogger.debug( "Revalidated response not cacheable", { url: request.url, storable, ttl, status: response.status }, storable ? "TTL is zero/negative" : "Policy indicates not storable" ); return; } const cacheItem = { policy: revalidatedPolicy.toObject(), response }; const responseForVary = new Response(response.body, { status: response.status, statusText: response.statusText, headers: revalidatedPolicy.responseHeaders() }); await setCacheItem( this.#storage, cacheKey, cacheItem, ttl, request, responseForVary ); } }; function normalizePolicyRequest(request, policyObject, options) { const headers = new Headers(request.headers); if (options?._ignoreRequestCacheControl) { headers.delete("cache-control"); headers.delete("pragma"); } if (policyObject.reqh) { for (const [name, value] of Object.entries(policyObject.reqh)) { headers.set(name, value); } } return new Request(policyObject.u, { method: policyObject.m, headers }); } function sanitizeStoredPolicy(policy) { if (!policy.resh || policy.resh.age === void 0) { return policy; } const resh = { ...policy.resh }; delete resh.age; return { ...policy, resh }; } function resolveCacheRequest(request, options) { const resolved = request instanceof Request ? request : new Request(request); if (resolved.method !== "GET" && !options?.ignoreMethod) { return void 0; } return resolved; } function verifyCacheQueryOptions(method, options) { if (!options) { return; } for (const option of ["ignoreSearch", "ignoreVary"]) { if (option in options) { throw new Error( `SharedCache.${method}() not implemented option: "${option}".` ); } } } async function resolveVaryStorageKey(request, storage, baseKey) { if (request.sharedCache?.ignoreVary) { return baseKey; } const varyFilter = await readStoredVaryFilter(storage, baseKey); return appendVaryKeySuffix(request, baseKey, varyFilter); } async function getCacheItem(request, storage, baseKey) { const cacheKey = await resolveVaryStorageKey(request, storage, baseKey); return await storage.get(cacheKey); } async function deleteCacheItem(request, storage, baseKey) { const cacheKey = await resolveVaryStorageKey(request, storage, baseKey); if (cacheKey === baseKey) { return storage.delete(cacheKey); } return await storage.delete(cacheKey) && await storage.delete(baseKey); } async function setCacheItem(storage, baseKey, cacheItem, ttl, request, response) { let cacheKey = baseKey; if (!request.sharedCache?.ignoreVary) { const varyFilter = await writeStoredVaryFilter( storage, baseKey, ttl, response.headers.get("vary") ); cacheKey = await appendVaryKeySuffix(request, baseKey, varyFilter); } await storage.set(cacheKey, cacheItem, ttl); } // src/storage.ts var SharedCacheStorage = class { #storage; #caches = /* @__PURE__ */ new Map(); #options; constructor(storage, options) { if (!storage) { throw new TypeError( "Storage backend is required for SharedCacheStorage." ); } this.#storage = storage; this.#options = options; } async delete(_cacheName) { throw new Error("SharedCacheStorage.delete() is not implemented."); } async has(_cacheName) { throw new Error("SharedCacheStorage.has() is not implemented."); } async keys() { throw new Error("SharedCacheStorage.keys() is not implemented."); } async match(_request, _options) { throw new Error("SharedCacheStorage.match() is not implemented."); } async open(cacheName) { const existingCache = this.#caches.get(cacheName); if (existingCache) { return existingCache; } const newCache = new SharedCache(this.#storage, { ...this.#options, _cacheName: cacheName }); this.#caches.set(cacheName, newCache); return newCache; } }; // src/utils/vary.ts var FIELD_NAME_REGEXP = /^[!#$%&'*+\-.^\w`|~]+$/; function append(header, field) { if (typeof header !== "string") { throw new TypeError("header argument is required"); } if (!field) { throw new TypeError("field argument is required"); } const fields = !Array.isArray(field) ? parse(String(field)) : field; for (let j = 0; j < fields.length; j++) { if (!FIELD_NAME_REGEXP.test(fields[j])) { throw new TypeError("field argument contains an invalid header name"); } } if (header === "*") { return header; } let val = header; const vals = parse(header.toLowerCase()); if (fields.indexOf("*") !== -1 || vals.indexOf("*") !== -1) { return "*"; } for (let i = 0; i < fields.length; i++) { const fld = fields[i].toLowerCase(); if (vals.indexOf(fld) === -1) { vals.push(fld); val = val ? val + ", " + fields[i] : fields[i]; } } return val; } function parse(header) { let end = 0; let start = 0; const list = []; for (let i = 0, len = header.length; i < len; i++) { switch (header.charCodeAt(i)) { case 32: if (start === end) { start = end = i + 1; } break; case 44: list.push(header.substring(start, end)); start = end = i + 1; break; default: end = i + 1; break; } } list.push(header.substring(start, end)); return list; } function vary(headers, field) { if (!headers || !headers.get || !headers.set) { throw new TypeError("headers argument is required"); } let val = headers.get("Vary") ?? ""; if (val = append(val, field)) { headers.set("Vary", val); } } // src/utils/control.ts function cacheControl(headers, cacheControl2) { const directives = Array.isArray(cacheControl2) ? cacheControl2 : cacheControl2.split(","); appendCacheControl(headers, directives); } function appendCacheControl(headers, directives) { const existingDirectives = headers.get("cache-control")?.split(",").map((d) => d.trim().split("=", 1)[0]) ?? []; for (const directive of directives) { const [_name, value] = directive.trim().split("=", 2); const name = _name.toLowerCase(); if (!existingDirectives.includes(name)) { headers.append("cache-control", `${name}${value ? `=${value}` : ""}`); } } } // src/origin.ts function toAbortError(reason) { if (reason instanceof Error) { return reason; } return new DOMException( reason != null ? String(reason) : "Aborted", "AbortError" ); } function toOriginFailureResponse(error) { const message = error instanceof Error ? error.message : "Internal Server Error"; return new Response(message, { status: 500 }); } function settleOriginFailure(phase, error, resolve, reject) { if (phase === "miss") { reject(error); return; } resolve(toOriginFailureResponse(error)); } async function invokeOrigin(origin, request, context) { const { signal, phase } = context; if (!signal) { try { return await origin(request, context); } catch (error) { if (phase === "miss") { throw error; } return toOriginFailureResponse(error); } } if (signal.aborted) { if (phase === "miss") { throw toAbortError(signal.reason); } return toOriginFailureResponse(signal.reason); } return new Promise((resolve, reject) => { const onAbort = () => { settleOriginFailure(phase, toAbortError(signal.reason), resolve, reject); }; signal.addEventListener("abort", onAbort); let result; try { result = origin(request, context); } catch (error) { signal.removeEventListener("abort", onAbort); settleOriginFailure(phase, error, resolve, reject); return; } Promise.resolve(result).then((response) => { signal.removeEventListener("abort", onAbort); if (signal.aborted) { settleOriginFailure( phase, toAbortError(signal.reason), resolve, reject ); return; } resolve(response); }).catch((error) => { signal.removeEventListener("abort", onAbort); settleOriginFailure(phase, error, resolve, reject); }); }); } // src/resolve.ts function setCacheKey(response, cacheKey) { if (cacheKey) { return setResponseHeader( response, CACHE_KEY_HEADER_NAME, encodeCacheKeyHeaderValue(cacheKey) ); } return response; } function applyResponseHeaderOverrides(response, cacheControlOverride, varyOverride) { if (response.ok && (cacheControlOverride || varyOverride)) { return modifyResponseHeaders(response, (headers) => { if (cacheControlOverride) { cacheControl(headers, cacheControlOverride); } if (varyOverride) { vary(headers, varyOverride); } }); } return response; } async function appendDebugCacheKey(request, baseKey, response, ignoreVary) { if (!baseKey || ignoreVary) { return baseKey; } const varyHeader = response.headers.get("vary"); if (!varyHeader || varyHeader === "*") { return baseKey; } return appendVaryKeySuffix(request, baseKey, parseVaryHeader(varyHeader)); } function bypassCache(cacheControlHeader) { const normalized = cacheControlHeader.toLowerCase(); return normalized.includes("no-store") || // Must not store normalized.includes("no-cache") || // Must revalidate normalized.includes("private") || // Not for shared caches normalized.includes("s-maxage=0") || // Shared cache max-age is 0 // max-age=0 only if no s-maxage directive exists (shared cache priority) !normalized.includes("s-maxage") && normalized.includes("max-age=0"); } async function resolveWithCache(cache, request, origin, options = {}) { const sharedCacheOptions = request.sharedCache = { ignoreRequestCacheControl: true, ignoreVary: false, ...options, ...request.sharedCache }; const debugCacheKey = sharedCacheOptions.debugCacheKey ? await cache.getCacheKey(request) : void 0; const cacheControlOverride = sharedCacheOptions.cacheControlOverride; const varyOverride = sharedCacheOptions.varyOverride; const outerSignal = options.signal; const revalidateFetch = async (input, init) => { const revalidationRequest = new Request(input, init); const response = await invokeOrigin(origin, revalidationRequest, { phase: "revalidate", // NOTE: Propagate the outer abort signal so middleware can terminate revalidation. signal: revalidationRequest.signal ?? outerSignal, revalidationRequest }); return applyResponseHeaderOverrides( response, cacheControlOverride, varyOverride ); }; const event = sharedCacheOptions.event || (sharedCacheOptions.waitUntil ? { waitUntil: sharedCacheOptions.waitUntil } : void 0); const cachedResponse = await cache.match(request, { _fetch: revalidateFetch, _ignoreRequestCacheControl: sharedCacheOptions.ignoreRequestCacheControl, _event: event, ignoreMethod: request.method === "HEAD" // HEAD requests can match GET }); if (cachedResponse) { const effectiveCacheKey = debugCacheKey ? await appendDebugCacheKey( request, debugCacheKey, cachedResponse, sharedCacheOptions.ignoreVary ) : debugCacheKey; return setCacheKey( applyCacheStatus(cachedResponse, HIT, { copy: true }), effectiveCacheKey ); } const fetchedResponse = applyResponseHeaderOverrides( await invokeOrigin(origin, request, { phase: "miss", signal: outerSignal ?? request.signal }), cacheControlOverride, varyOverride ); const responseCacheControl = fetchedResponse.headers.get("cache-control"); if (responseCacheControl) { if (bypassCache(responseCacheControl)) { return setCacheKey( applyCacheStatus(fetchedResponse, BYPASS, { copy: true }), debugCacheKey ); } const cacheSuccess = await cache.put(request, fetchedResponse).then( () => true, () => false ); const effectiveCacheKey = cacheSuccess && debugCacheKey ? await appendDebugCacheKey( request, debugCacheKey, fetchedResponse, sharedCacheOptions.ignoreVary ) : debugCacheKey; return setCacheKey( applyCacheStatus(fetchedResponse, cacheSuccess ? MISS : DYNAMIC, { copy: true }), effectiveCacheKey ); } return setCacheKey( applyCacheStatus(fetchedResponse, DYNAMIC, { c