UNPKG

@web-widget/shared-cache

Version:

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

1,249 lines (1,236 loc) 40.7 kB
// 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); } /** * Check if logger is available and can log at the specified level */ canLog(level) { return this.shouldLog(level); } }; function createLogger(logger, minLevel = 1 /* INFO */, prefix) { return new StructuredLogger(logger, minLevel, prefix); } function createSharedCacheLogger(logger, minLevel = 1 /* INFO */) { return new StructuredLogger(logger, minLevel, "SharedCache"); } var SharedCacheLogger = StructuredLogger; // 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); const hash = Array.prototype.map.call(new Uint8Array(buffer), (x) => ("00" + x.toString(16)).slice(-2)).join(""); return hash; }; // 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_HEADERS_NAME = "x-cache-status"; var HIT = "HIT"; var MISS = "MISS"; var EXPIRED = "EXPIRED"; var STALE = "STALE"; var BYPASS = "BYPASS"; var REVALIDATED = "REVALIDATED"; var DYNAMIC = "DYNAMIC"; // src/utils/cookies.ts import { RequestCookies } from "@edge-runtime/cookies"; // src/cache-key.ts function filter(array, options) { let result = array; const exclude = options?.exclude; const include = options?.include; const checkPresence = options?.checkPresence; if (exclude?.length) { result = result.filter(([key]) => !exclude.includes(key)); } if (include?.length) { result = result.filter(([key]) => include.includes(key)); } if (checkPresence?.length) { result = result.map( (item) => checkPresence.includes(item[0]) ? [item[0], ""] : item ); } return result; } async function shortHash(data) { return (await sha1(data))?.slice(0, 6); } function sort(array) { return array.sort((a, b) => a[0].localeCompare(b[0])); } function toLowerCase(options) { if (typeof options === "object") { const newOptions = { include: options.include?.map((name) => name.toLowerCase()), exclude: options.exclude?.map((name) => name.toLowerCase()), checkPresence: options.checkPresence?.map((name) => name.toLowerCase()) }; return newOptions; } return options; } async function cookie(request, options) { const cookie2 = new RequestCookies(request.headers); const entries = cookie2.getAll().map(({ name, value }) => [name, value]); return (await Promise.all( sort(filter(entries, options)).map( async ([key, value]) => value ? `${key}=${await shortHash(value)}` : key ) )).join("&"); } async function device(request, options) { const device2 = deviceType(request.headers); return filter([[device2, ""]], options).map(([key]) => key).join(""); } function host(url, options) { const host2 = url.host; return filter([[host2, ""]], options).map(([key]) => key).join(""); } function pathname(url, options) { const pathname2 = url.pathname; return filter([[pathname2, ""]], options).map(([key]) => key).join(""); } function search(url, options) { const { searchParams } = url; searchParams.sort(); const entries = Array.from(searchParams.entries()); const search2 = filter(entries, options).map(([key, value]) => { return value ? `${key}=${value}` : key; }).join("&"); return search2 ? `?${search2}` : ""; } async function vary(request, options) { const entries = Array.from(request.headers.entries()); return (await Promise.all( sort(filter(entries, toLowerCase(options))).map( async ([key, value]) => `${key}=${await shortHash(value)}` ) )).join("&"); } var CANNOT_INCLUDE_HEADERS = [ // Headers that have high cardinality and risk cache fragmentation "accept", "accept-charset", "accept-encoding", "accept-datetime", "accept-language", "referer", "user-agent", // Headers that implement cache or proxy features "connection", "content-length", "cache-control", "if-match", "if-modified-since", "if-none-match", "if-unmodified-since", "range", "upgrade", // Headers that are covered by other cache key features "cookie", "host", "vary", // Headers that contain cache status information CACHE_STATUS_HEADERS_NAME ]; async function header(request, options) { const entries = Array.from(request.headers.entries()); return (await Promise.all( sort(filter(entries, toLowerCase(options))).map(async ([key, value]) => { if (CANNOT_INCLUDE_HEADERS.includes(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.` ); } return value ? `${key}=${await shortHash(value)}` : key; }) )).join("&"); } var BUILT_IN_URL_PART_DEFINERS = { host, pathname, search }; var BUILT_IN_URL_PART_KEYS = ["host", "pathname", "search"]; var BUILT_IN_EXPANDED_PART_DEFINERS = { cookie, device, header }; var DEFAULT_CACHE_KEY_RULES = { host: true, pathname: true, search: true }; function createCacheKeyGenerator(cacheName, cacheKeyPartDefiners) { return async function cacheKeyGenerator(request, cacheKeyRules = DEFAULT_CACHE_KEY_RULES) { const { host: host2, pathname: pathname2, search: search2, ...fragmentRules } = cacheKeyRules; const prefix = cacheName ? cacheName === "default" ? "" : `${cacheName}/` : ""; const urlRules = { host: host2, pathname: pathname2, search: search2 }; const url = new URL(request.url); const urlPart = BUILT_IN_URL_PART_KEYS.filter( (name) => urlRules[name] ).map((name) => { const urlPartDefiner = BUILT_IN_URL_PART_DEFINERS[name]; const options = cacheKeyRules[name]; if (options === true) { return urlPartDefiner(url); } else if (options === false) { return ""; } else { return urlPartDefiner(url, options); } }); const fragmentPart = (await Promise.all( Object.keys(fragmentRules).sort().map((name) => { const expandedCacheKeyPartDefiners = BUILT_IN_EXPANDED_PART_DEFINERS[name] ?? cacheKeyPartDefiners?.[name]; if (expandedCacheKeyPartDefiners) { const options = cacheKeyRules[name]; if (options === true) { return expandedCacheKeyPartDefiners(request); } else if (options === false) { return ""; } else { return expandedCacheKeyPartDefiners( request, options ); } } throw new TypeError( `Unknown cache key part: "${name}". Register a custom part definer or use a built-in part (${Object.keys(BUILT_IN_EXPANDED_PART_DEFINERS).join(", ")}).` ); }) )).filter(Boolean); return fragmentPart.length ? `${prefix}${urlPart.join("")}#${fragmentPart.join(":")}` : `${prefix}${urlPart.join("")}`; }; } // src/utils/cache-semantics.ts import { default as default2 } from "@web-widget/http-cache-semantics"; // src/cache.ts var SharedCache = class { /** Cache key generator function for creating consistent cache keys */ #cacheKeyGenerator; /** 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 resolvedOptions = { ...options }; const cacheKeyGenerator = createCacheKeyGenerator( resolvedOptions._cacheName, resolvedOptions.cacheKeyPartDefiners ); this.#cacheKeyGenerator = async (request) => cacheKeyGenerator(request, { ...DEFAULT_CACHE_KEY_RULES, ...resolvedOptions.cacheKeyRules, ...request.sharedCache?.cacheKeyRules }); if (resolvedOptions.logger instanceof StructuredLogger) { this.#structuredLogger = resolvedOptions.logger; } else { this.#structuredLogger = createLogger( resolvedOptions.logger ); } this.#storage = storage; } /** * 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) { let r = null; if (request instanceof Request) { r = request; if (r.method !== "GET" && !options?.ignoreMethod) { return false; } } else { r = new Request(request); } r = r; this.#verifyCacheQueryOptions("delete", options); const cacheKey = await this.#cacheKeyGenerator(r); return deleteCacheItem(r, 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) { let r = null; if (request !== void 0) { if (request instanceof Request) { r = request; if (r.method !== "GET" && !options?.ignoreMethod) { return void 0; } } else if (typeof request === "string") { r = new Request(request); } } r = r; this.#verifyCacheQueryOptions("match", options); const cacheKey = await this.#cacheKeyGenerator(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 policy = default2.fromObject(cacheItem.policy); const { body, status, statusText } = cacheItem.response; const headers = policy.responseHeaders(); const stale = policy.stale(); const response = new Response(body, { status, statusText, headers }); if (!policy.satisfiesWithoutRevalidation(r, { ignoreRequestCacheControl: options?._ignoreRequestCacheControl, ignoreMethod: true, ignoreSearch: true, ignoreVary: true }) || stale) { if (!fetch) { return; } else if (stale && policy.useStaleWhileRevalidate()) { 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, { response: response.clone(), policy }, cacheKey, fetch, options ) ); this.#setCacheStatus(response, STALE); this.#structuredLogger.info( "Serving stale response", { url: r.url, cacheKey, cacheStatus: "STALE" }, "Revalidating in background" ); return response; } else { return this.#revalidate( r, { response, policy }, cacheKey, fetch, options ); } } this.#setCacheStatus(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) { return this.#putWithCustomCacheKey(request, response).catch((error) => { this.#structuredLogger.error("Put operation failed", { url: request instanceof Request ? request.url : request, error }); throw error; }); } /** * Internal method for putting responses with custom cache keys. * Implements the full Cache API put algorithm with HTTP validation. * * @param request - The request to cache * @param response - The response to cache * @param cacheKey - Optional custom cache key * @throws {TypeError} For various HTTP-compliant validation failures */ async #putWithCustomCacheKey(request, response, cacheKey) { let innerRequest = null; if (request instanceof Request) { innerRequest = request; } else { innerRequest = new Request(request); } if (!this.#urlIsHttpHttpsScheme(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 = this.#getFieldValues( innerResponse.headers.get("vary") ); 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 default2(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 } }; if (typeof cacheKey !== "string") { cacheKey = await this.#cacheKeyGenerator(innerRequest); } await setCacheItem( this.#storage, cacheKey, cacheItem, ttl, innerRequest, clonedResponse ); } /** * 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, resolveCacheItem, cacheKey, fetch, options) { const revalidationRequest = new Request(request, { headers: resolveCacheItem.policy.revalidationHeaders(request, { ignoreRequestCacheControl: options?._ignoreRequestCacheControl, ignoreMethod: true, ignoreSearch: true, ignoreVary: true }) }); 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 ); const response = modified ? revalidationResponse : resolveCacheItem.response; await this.#putWithCustomCacheKey(request, response, cacheKey); const clonedResponse = new Response(response.body, { status: response.status, statusText: response.statusText, headers: revalidatedPolicy.responseHeaders() }); if (modified) { this.#setCacheStatus(clonedResponse, EXPIRED); this.#structuredLogger.info( "Cache entry expired", { url: request.url, cacheKey, cacheStatus: "EXPIRED" }, "Serving fresh response" ); } else { this.#setCacheStatus(clonedResponse, REVALIDATED); this.#structuredLogger.info( "Cache entry revalidated", { url: request.url, cacheKey, cacheStatus: "REVALIDATED" }, "Cached response still fresh" ); } return clonedResponse; } /** * Sets the cache status header on a response. * Used to indicate the cache result to clients via the X-Shared-Cache header. * * @param response - Response object to modify * @param status - Cache status value to set */ #setCacheStatus(response, status) { response.headers.set(CACHE_STATUS_HEADERS_NAME, status); } /** * Validates cache query options, throwing errors for unsupported features. * Currently ignoreSearch and ignoreVary are not implemented. * * @param options - Cache query options to validate * @throws {Error} If unsupported options are specified */ #verifyCacheQueryOptions(method, options) { if (options) { ["ignoreSearch", "ignoreVary"].forEach((option) => { if (option in options) { throw new Error( `SharedCache.${method}() not implemented option: "${option}".` ); } }); } } /** * Checks if a URL uses an HTTP or HTTPS scheme. * Used to validate request URLs before caching as per HTTP specifications. * * @param url - URL string to validate * @returns True if the URL uses http: or https: scheme */ #urlIsHttpHttpsScheme(url) { return /^https?:/.test(url); } /** * Parses comma-separated header field values. * Used for parsing Vary header values and other structured headers. * * @param header - Header value string to parse * @returns Array of trimmed field values */ #getFieldValues(header2) { return header2.split(",").map((value) => value.trim()); } }; async function getCacheItem(request, storage, customCacheKey) { let cacheKey = customCacheKey; const ignoreVary = request.sharedCache?.ignoreVary; if (!ignoreVary) { cacheKey = await getEffectiveCacheKey(request, storage, customCacheKey); } return await storage.get(cacheKey); } async function deleteCacheItem(request, storage, customCacheKey) { let cacheKey = customCacheKey; const ignoreVary = request.sharedCache?.ignoreVary; if (!ignoreVary) { cacheKey = await getEffectiveCacheKey(request, storage, customCacheKey); } if (cacheKey === customCacheKey) { return storage.delete(cacheKey); } else { return await storage.delete(cacheKey) && await storage.delete(customCacheKey); } } async function setCacheItem(storage, customCacheKey, cacheItem, ttl, request, response) { let cacheKey = customCacheKey; const ignoreVary = request.sharedCache?.ignoreVary; if (!ignoreVary) { const vary3 = response.headers.get("vary"); const varyFilterOptions = await getAndSaveVaryFilterOptions( storage, customCacheKey, ttl, vary3 ); cacheKey = await getVaryCacheKey( request, customCacheKey, varyFilterOptions ); } await storage.set(cacheKey, cacheItem, ttl); } async function getEffectiveCacheKey(request, storage, customCacheKey) { const varyFilterOptions = await getVaryFilterOptions(storage, customCacheKey); return getVaryCacheKey(request, customCacheKey, varyFilterOptions); } async function getVaryFilterOptions(storage, customCacheKey) { const varyKey = `${customCacheKey}:vary`; return await storage.get(varyKey); } async function getAndSaveVaryFilterOptions(storage, customCacheKey, ttl, vary3) { if (!vary3 || vary3 === "*") { return; } const varyKey = `${customCacheKey}:vary`; const varyFilterOptions = { include: vary3.split(",").map((field) => field.trim()) }; await storage.set(varyKey, varyFilterOptions, ttl); return varyFilterOptions; } async function getVaryCacheKey(request, customCacheKey, varyFilterOptions) { if (!varyFilterOptions) { return customCacheKey; } const varyPart = await vary(request, varyFilterOptions); return varyPart ? `${customCacheKey}:${varyPart}` : customCacheKey; } // src/cache-storage.ts var SharedCacheStorage = class { /** The underlying key-value storage backend */ #storage; /** Map of cache name to cache instance for reuse */ #caches = /* @__PURE__ */ new Map(); /** Default options for created cache instances */ #options; /** * Creates a new SharedCacheStorage instance. * * @param storage - The key-value storage backend to use for all caches * @param options - Optional default configuration for created cache instances * @throws {TypeError} When storage is not provided */ constructor(storage, options) { if (!storage) { throw new TypeError( "Storage backend is required for SharedCacheStorage." ); } this.#storage = storage; this.#options = options; } /** * Deletes a named cache and all its contents. * * This method removes a cache by name and cleans up all associated data. * The operation is atomic - either the entire cache is deleted or none of it. * * Note: This implementation is currently not available and will throw an error. * Future versions may implement cache deletion with proper cleanup of storage keys. * * @param _cacheName - The name of the cache to delete * @returns Promise resolving to true if cache was deleted, false if it didn't exist * @throws {Error} Always throws as this method is not yet implemented */ async delete(_cacheName) { throw new Error( "SharedCacheStorage.delete() is not implemented. Cache deletion requires careful cleanup of storage keys and is not yet supported." ); } /** * Checks if a named cache exists. * * This method determines whether a cache with the given name exists in storage. * * Note: This implementation is currently not available and will throw an error. * Future versions may implement cache existence checking. * * @param _cacheName - The name of the cache to check * @returns Promise resolving to true if cache exists, false otherwise * @throws {Error} Always throws as this method is not yet implemented */ async has(_cacheName) { throw new Error( "SharedCacheStorage.has() is not implemented. Cache existence checking is not yet supported." ); } /** * Returns all cache names. * * This method lists all cache names that exist in storage. * * Note: This implementation is currently not available and will throw an error. * Future versions may implement cache enumeration. * * @returns Promise resolving to array of cache names * @throws {Error} Always throws as this method is not yet implemented */ async keys() { throw new Error( "SharedCacheStorage.keys() is not implemented. Cache enumeration is not yet supported." ); } /** * Searches across all caches for a matching request. * * This method performs a cross-cache search to find a cached response * that matches the given request. It's useful for scenarios where * content might be cached in multiple named caches. * * Note: This implementation is currently not available and will throw an error. * Future versions may implement cross-cache matching. * * @param _request - The request to match against * @param _options - Optional query options for the search * @returns Promise resolving to matching response or undefined * @throws {Error} Always throws as this method is not yet implemented */ async match(_request, _options) { throw new Error( "SharedCacheStorage.match() is not implemented. Cross-cache matching is not yet supported." ); } /** * Opens or creates a named cache instance. * * This method implements the CacheStorage.open() specification, returning * a Promise that resolves to a Cache object matching the given name. * * The implementation includes: * - Automatic cache instance creation for new names * - Instance reuse for existing cache names (singleton pattern) * - Proper cache configuration inheritance from storage options * - Memory-efficient lazy initialization * * Cache instances share the same storage backend but use prefixed keys * to maintain isolation between different named caches. * * @param cacheName - The name of the cache to open or create * @returns Promise resolving to the requested SharedCache instance * * @example * ```typescript * const apiCache = await cacheStorage.open('api-v1'); * const staticCache = await cacheStorage.open('static-assets'); * // Same cache name returns the same instance * const sameCache = await cacheStorage.open('api-v1'); * console.log(apiCache === sameCache); // true * ``` */ async open(cacheName) { const existingCache = this.#caches.get(cacheName); if (existingCache) { return existingCache; } const cacheOptions = { ...this.#options, _cacheName: cacheName }; const newCache = new SharedCache(this.#storage, cacheOptions); this.#caches.set(cacheName, newCache); return newCache; } }; // src/utils/vary.ts var FIELD_NAME_REGEXP = /^[!#$%&'*+\-.^\w`|~]+$/; function append(header2, field) { if (typeof header2 !== "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 (header2 === "*") { return header2; } let val = header2; const vals = parse(header2.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(header2) { let end = 0; let start = 0; const list = []; for (let i = 0, len = header2.length; i < len; i++) { switch (header2.charCodeAt(i)) { case 32: if (start === end) { start = end = i + 1; } break; case 44: list.push(header2.substring(start, end)); start = end = i + 1; break; default: end = i + 1; break; } } list.push(header2.substring(start, end)); return list; } function vary2(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/cache-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/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 setResponseHeader(response, name, value) { return modifyResponseHeaders(response, (headers) => { headers.set(name, value); }); } // src/fetch.ts var ORIGINAL_FETCH = globalThis.fetch; function createSharedCacheFetch(cache, options) { const fetcher = options?.fetch ?? ORIGINAL_FETCH; const defaults = options?.defaults ?? {}; return async function fetch(input, init) { if (!cache && globalThis.caches instanceof SharedCacheStorage) { cache = await globalThis.caches.open("default"); } if (!cache) { throw new TypeError( "Cache is required. Provide a cache instance or ensure globalThis.caches is available." ); } const request = new Request(input, init); const requestCache = getRequestCacheMode(request, init?.cache); const sharedCacheOptions = request.sharedCache = { // Start with global defaults ignoreRequestCacheControl: true, ignoreVary: false, // Apply user-provided defaults ...defaults, // Apply any existing request options ...request.sharedCache, // Finally apply init options (highest priority) ...init?.sharedCache }; const interceptor = createInterceptor( fetcher, sharedCacheOptions.cacheControlOverride, sharedCacheOptions.varyOverride ); if (requestCache && requestCache !== "default") { throw new Error( `Cache mode "${requestCache}" is not implemented. Only "default" mode is supported.` ); } const event = sharedCacheOptions.event || (sharedCacheOptions.waitUntil ? { waitUntil: sharedCacheOptions.waitUntil } : void 0); const cachedResponse = await cache.match(request, { _fetch: interceptor, _ignoreRequestCacheControl: sharedCacheOptions.ignoreRequestCacheControl, _event: event, ignoreMethod: request.method === "HEAD" // HEAD requests can match GET }); if (cachedResponse) { return setCacheStatus(cachedResponse, HIT); } const fetchedResponse = await interceptor(request); const cacheControl2 = fetchedResponse.headers.get("cache-control"); if (cacheControl2) { if (bypassCache(cacheControl2)) { return setCacheStatus(fetchedResponse, BYPASS); } else { const cacheSuccess = await cache.put(request, fetchedResponse).then( () => true, () => { return false; } ); return setCacheStatus(fetchedResponse, cacheSuccess ? MISS : DYNAMIC); } } else { return setCacheStatus(fetchedResponse, DYNAMIC); } }; } var sharedCacheFetch = createSharedCacheFetch(); function setCacheStatus(response, status) { if (!response.headers.has(CACHE_STATUS_HEADERS_NAME)) { return setResponseHeader(response, CACHE_STATUS_HEADERS_NAME, status); } return response; } function createInterceptor(fetcher, cacheControlOverride, varyOverride) { return async function fetch(...args) { const response = await fetcher(...args); if (response.ok && (cacheControlOverride || varyOverride)) { return modifyResponseHeaders(response, (headers) => { if (cacheControlOverride) { cacheControl(headers, cacheControlOverride); } if (varyOverride) { vary2(headers, varyOverride); } }); } return response; }; } function bypassCache(cacheControlHeader) { const cacheControl2 = cacheControlHeader.toLowerCase(); return cacheControl2.includes("no-store") || // Must not store cacheControl2.includes("no-cache") || // Must revalidate cacheControl2.includes("private") || // Not for shared caches cacheControl2.includes("s-maxage=0") || // Shared cache max-age is 0 // max-age=0 only if no s-maxage directive exists (shared cache priority) !cacheControl2.includes("s-maxage") && cacheControl2.includes("max-age=0"); } function getRequestCacheMode(request, defaultCacheMode) { try { return request.cache; } catch (_error) { return defaultCacheMode; } } export { BYPASS, CACHE_STATUS_HEADERS_NAME, CANNOT_INCLUDE_HEADERS, SharedCache as Cache, SharedCacheStorage as CacheStorage, DEFAULT_CACHE_KEY_RULES, DYNAMIC, EXPIRED, HIT, LogLevel, MISS, REVALIDATED, STALE, SharedCacheLogger, StructuredLogger, cookie, createCacheKeyGenerator, createSharedCacheFetch as createFetch, createLogger, createSharedCacheLogger, device, sharedCacheFetch as fetch, filter, header, host, pathname, search, vary }; /*! * vary * https://github.com/jshttp/vary/tree/master * Copyright(c) 2014-2017 Douglas Christopher Wilson * MIT Licensed */