@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
JavaScript
// 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