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