UNPKG

serwist

Version:

A Swiss Army knife for service workers.

1,268 lines (1,248 loc) 59.3 kB
import { R as Route, g as generateURLVariations, B as BackgroundSyncPlugin, N as NetworkFirst, a as NetworkOnly, P as PrecacheStrategy, e as enableNavigationPreload, s as setCacheNameDetails, b as NavigationRoute, S as Strategy, d as disableDevLogs, c as createCacheKey, f as defaultMethod, n as normalizeHandler, p as parseRoute, h as PrecacheInstallReportPlugin, i as parallel, j as printInstallDetails, k as printCleanupDetails, m as messages, l as cacheOkAndOpaquePlugin } from './chunks/printInstallDetails.js'; export { v as BackgroundSyncQueue, w as BackgroundSyncQueueStore, u as RegExpRoute, x as StorableRequest, t as StrategyHandler, o as copyResponse, q as disableNavigationPreload, r as isNavigationPreloadSupported } from './chunks/printInstallDetails.js'; import { l as logger, g as getFriendlyURL, c as cacheNames$1, a as clientsClaim, b as cleanupOutdatedCaches, f as finalAssertExports, S as SerwistError, w as waitUntil, t as timeout, q as quotaErrorCallbacks } from './chunks/waitUntil.js'; import { r as resultingClientExists } from './chunks/resultingClientExists.js'; import { deleteDB, openDB } from 'idb'; class PrecacheRoute extends Route { constructor(serwist, options){ const match = ({ request })=>{ const urlsToCacheKeys = serwist.getUrlsToPrecacheKeys(); for (const possibleURL of generateURLVariations(request.url, options)){ const cacheKey = urlsToCacheKeys.get(possibleURL); if (cacheKey) { const integrity = serwist.getIntegrityForPrecacheKey(cacheKey); return { cacheKey, integrity }; } } if (process.env.NODE_ENV !== "production") { logger.debug(`Precaching did not find a match for ${getFriendlyURL(request.url)}.`); } return; }; super(match, serwist.precacheStrategy); } } const QUEUE_NAME = "serwist-google-analytics"; const MAX_RETENTION_TIME = 60 * 48; const GOOGLE_ANALYTICS_HOST = "www.google-analytics.com"; const GTM_HOST = "www.googletagmanager.com"; const ANALYTICS_JS_PATH = "/analytics.js"; const GTAG_JS_PATH = "/gtag/js"; const GTM_JS_PATH = "/gtm.js"; const COLLECT_PATHS_REGEX = /^\/(\w+\/)?collect/; const createOnSyncCallback = (config)=>{ return async ({ queue })=>{ let entry = undefined; while(entry = await queue.shiftRequest()){ const { request, timestamp } = entry; const url = new URL(request.url); try { const params = request.method === "POST" ? new URLSearchParams(await request.clone().text()) : url.searchParams; const originalHitTime = timestamp - (Number(params.get("qt")) || 0); const queueTime = Date.now() - originalHitTime; params.set("qt", String(queueTime)); if (config.parameterOverrides) { for (const param of Object.keys(config.parameterOverrides)){ const value = config.parameterOverrides[param]; params.set(param, value); } } if (typeof config.hitFilter === "function") { config.hitFilter.call(null, params); } await fetch(new Request(url.origin + url.pathname, { body: params.toString(), method: "POST", mode: "cors", credentials: "omit", headers: { "Content-Type": "text/plain" } })); if (process.env.NODE_ENV !== "production") { logger.log(`Request for '${getFriendlyURL(url.href)}' has been replayed`); } } catch (err) { await queue.unshiftRequest(entry); if (process.env.NODE_ENV !== "production") { logger.log(`Request for '${getFriendlyURL(url.href)}' failed to replay, putting it back in the queue.`); } throw err; } } if (process.env.NODE_ENV !== "production") { logger.log("All Google Analytics request successfully replayed; " + "the queue is now empty!"); } }; }; const createCollectRoutes = (bgSyncPlugin)=>{ const match = ({ url })=>url.hostname === GOOGLE_ANALYTICS_HOST && COLLECT_PATHS_REGEX.test(url.pathname); const handler = new NetworkOnly({ plugins: [ bgSyncPlugin ] }); return [ new Route(match, handler, "GET"), new Route(match, handler, "POST") ]; }; const createAnalyticsJsRoute = (cacheName)=>{ const match = ({ url })=>url.hostname === GOOGLE_ANALYTICS_HOST && url.pathname === ANALYTICS_JS_PATH; const handler = new NetworkFirst({ cacheName }); return new Route(match, handler, "GET"); }; const createGtagJsRoute = (cacheName)=>{ const match = ({ url })=>url.hostname === GTM_HOST && url.pathname === GTAG_JS_PATH; const handler = new NetworkFirst({ cacheName }); return new Route(match, handler, "GET"); }; const createGtmJsRoute = (cacheName)=>{ const match = ({ url })=>url.hostname === GTM_HOST && url.pathname === GTM_JS_PATH; const handler = new NetworkFirst({ cacheName }); return new Route(match, handler, "GET"); }; const initializeGoogleAnalytics = ({ serwist, cacheName, ...options })=>{ const resolvedCacheName = cacheNames$1.getGoogleAnalyticsName(cacheName); const bgSyncPlugin = new BackgroundSyncPlugin(QUEUE_NAME, { maxRetentionTime: MAX_RETENTION_TIME, onSync: createOnSyncCallback(options) }); const routes = [ createGtmJsRoute(resolvedCacheName), createAnalyticsJsRoute(resolvedCacheName), createGtagJsRoute(resolvedCacheName), ...createCollectRoutes(bgSyncPlugin) ]; for (const route of routes){ serwist.registerRoute(route); } }; class PrecacheFallbackPlugin { _fallbackUrls; _serwist; constructor({ fallbackUrls, serwist }){ this._fallbackUrls = fallbackUrls; this._serwist = serwist; } async handlerDidError(param) { for (const fallback of this._fallbackUrls){ if (typeof fallback === "string") { const fallbackResponse = await this._serwist.matchPrecache(fallback); if (fallbackResponse !== undefined) { return fallbackResponse; } } else if (fallback.matcher(param)) { const fallbackResponse = await this._serwist.matchPrecache(fallback.url); if (fallbackResponse !== undefined) { return fallbackResponse; } } } return undefined; } } class PrecacheCacheKeyPlugin { _precacheController; constructor({ precacheController }){ this._precacheController = precacheController; } cacheKeyWillBeUsed = async ({ request, params })=>{ const cacheKey = params?.cacheKey || this._precacheController.getPrecacheKeyForUrl(request.url); return cacheKey ? new Request(cacheKey, { headers: request.headers }) : request; }; } const parsePrecacheOptions = (serwist, precacheOptions = {})=>{ const { cacheName: precacheCacheName, plugins: precachePlugins = [], fetchOptions: precacheFetchOptions, matchOptions: precacheMatchOptions, fallbackToNetwork: precacheFallbackToNetwork, directoryIndex: precacheDirectoryIndex, ignoreURLParametersMatching: precacheIgnoreUrls, cleanURLs: precacheCleanUrls, urlManipulation: precacheUrlManipulation, cleanupOutdatedCaches, concurrency = 10, navigateFallback, navigateFallbackAllowlist, navigateFallbackDenylist } = precacheOptions ?? {}; return { precacheStrategyOptions: { cacheName: cacheNames$1.getPrecacheName(precacheCacheName), plugins: [ ...precachePlugins, new PrecacheCacheKeyPlugin({ precacheController: serwist }) ], fetchOptions: precacheFetchOptions, matchOptions: precacheMatchOptions, fallbackToNetwork: precacheFallbackToNetwork }, precacheRouteOptions: { directoryIndex: precacheDirectoryIndex, ignoreURLParametersMatching: precacheIgnoreUrls, cleanURLs: precacheCleanUrls, urlManipulation: precacheUrlManipulation }, precacheMiscOptions: { cleanupOutdatedCaches, concurrency, navigateFallback, navigateFallbackAllowlist, navigateFallbackDenylist } }; }; class Serwist { _urlsToCacheKeys = new Map(); _urlsToCacheModes = new Map(); _cacheKeysToIntegrities = new Map(); _concurrentPrecaching; _precacheStrategy; _routes; _defaultHandlerMap; _catchHandler; constructor({ precacheEntries, precacheOptions, skipWaiting = false, importScripts, navigationPreload = false, cacheId, clientsClaim: clientsClaim$1 = false, runtimeCaching, offlineAnalyticsConfig, disableDevLogs: disableDevLogs$1 = false, fallbacks } = {}){ const { precacheStrategyOptions, precacheRouteOptions, precacheMiscOptions } = parsePrecacheOptions(this, precacheOptions); this._concurrentPrecaching = precacheMiscOptions.concurrency; this._precacheStrategy = new PrecacheStrategy(precacheStrategyOptions); this._routes = new Map(); this._defaultHandlerMap = new Map(); this.handleInstall = this.handleInstall.bind(this); this.handleActivate = this.handleActivate.bind(this); this.handleFetch = this.handleFetch.bind(this); this.handleCache = this.handleCache.bind(this); if (!!importScripts && importScripts.length > 0) self.importScripts(...importScripts); if (navigationPreload) enableNavigationPreload(); if (cacheId !== undefined) { setCacheNameDetails({ prefix: cacheId }); } if (skipWaiting) { self.skipWaiting(); } else { self.addEventListener("message", (event)=>{ if (event.data && event.data.type === "SKIP_WAITING") { self.skipWaiting(); } }); } if (clientsClaim$1) clientsClaim(); if (!!precacheEntries && precacheEntries.length > 0) { this.addToPrecacheList(precacheEntries); } if (precacheMiscOptions.cleanupOutdatedCaches) { cleanupOutdatedCaches(precacheStrategyOptions.cacheName); } this.registerRoute(new PrecacheRoute(this, precacheRouteOptions)); if (precacheMiscOptions.navigateFallback) { this.registerRoute(new NavigationRoute(this.createHandlerBoundToUrl(precacheMiscOptions.navigateFallback), { allowlist: precacheMiscOptions.navigateFallbackAllowlist, denylist: precacheMiscOptions.navigateFallbackDenylist })); } if (offlineAnalyticsConfig !== undefined) { if (typeof offlineAnalyticsConfig === "boolean") { offlineAnalyticsConfig && initializeGoogleAnalytics({ serwist: this }); } else { initializeGoogleAnalytics({ ...offlineAnalyticsConfig, serwist: this }); } } if (runtimeCaching !== undefined) { if (fallbacks !== undefined) { const fallbackPlugin = new PrecacheFallbackPlugin({ fallbackUrls: fallbacks.entries, serwist: this }); runtimeCaching.forEach((cacheEntry)=>{ if (cacheEntry.handler instanceof Strategy && !cacheEntry.handler.plugins.some((plugin)=>"handlerDidError" in plugin)) { cacheEntry.handler.plugins.push(fallbackPlugin); } }); } for (const entry of runtimeCaching){ this.registerCapture(entry.matcher, entry.handler, entry.method); } } if (disableDevLogs$1) disableDevLogs(); } get precacheStrategy() { return this._precacheStrategy; } get routes() { return this._routes; } addEventListeners() { self.addEventListener("install", this.handleInstall); self.addEventListener("activate", this.handleActivate); self.addEventListener("fetch", this.handleFetch); self.addEventListener("message", this.handleCache); } addToPrecacheList(entries) { if (process.env.NODE_ENV !== "production") { finalAssertExports.isArray(entries, { moduleName: "serwist", className: "Serwist", funcName: "addToCacheList", paramName: "entries" }); } const urlsToWarnAbout = []; for (const entry of entries){ if (typeof entry === "string") { urlsToWarnAbout.push(entry); } else if (entry && !entry.integrity && entry.revision === undefined) { urlsToWarnAbout.push(entry.url); } const { cacheKey, url } = createCacheKey(entry); const cacheMode = typeof entry !== "string" && entry.revision ? "reload" : "default"; if (this._urlsToCacheKeys.has(url) && this._urlsToCacheKeys.get(url) !== cacheKey) { throw new SerwistError("add-to-cache-list-conflicting-entries", { firstEntry: this._urlsToCacheKeys.get(url), secondEntry: cacheKey }); } if (typeof entry !== "string" && entry.integrity) { if (this._cacheKeysToIntegrities.has(cacheKey) && this._cacheKeysToIntegrities.get(cacheKey) !== entry.integrity) { throw new SerwistError("add-to-cache-list-conflicting-integrities", { url }); } this._cacheKeysToIntegrities.set(cacheKey, entry.integrity); } this._urlsToCacheKeys.set(url, cacheKey); this._urlsToCacheModes.set(url, cacheMode); if (urlsToWarnAbout.length > 0) { const warningMessage = `Serwist is precaching URLs without revision info: ${urlsToWarnAbout.join(", ")}\nThis is generally NOT safe. Learn more at https://bit.ly/wb-precache`; if (process.env.NODE_ENV === "production") { console.warn(warningMessage); } else { logger.warn(warningMessage); } } } } handleInstall(event) { return waitUntil(event, async ()=>{ const installReportPlugin = new PrecacheInstallReportPlugin(); this.precacheStrategy.plugins.push(installReportPlugin); await parallel(this._concurrentPrecaching, Array.from(this._urlsToCacheKeys.entries()), async ([url, cacheKey])=>{ const integrity = this._cacheKeysToIntegrities.get(cacheKey); const cacheMode = this._urlsToCacheModes.get(url); const request = new Request(url, { integrity, cache: cacheMode, credentials: "same-origin" }); await Promise.all(this.precacheStrategy.handleAll({ event, request, url: new URL(request.url), params: { cacheKey } })); }); const { updatedURLs, notUpdatedURLs } = installReportPlugin; if (process.env.NODE_ENV !== "production") { printInstallDetails(updatedURLs, notUpdatedURLs); } return { updatedURLs, notUpdatedURLs }; }); } handleActivate(event) { return waitUntil(event, async ()=>{ const cache = await self.caches.open(this.precacheStrategy.cacheName); const currentlyCachedRequests = await cache.keys(); const expectedCacheKeys = new Set(this._urlsToCacheKeys.values()); const deletedCacheRequests = []; for (const request of currentlyCachedRequests){ if (!expectedCacheKeys.has(request.url)) { await cache.delete(request); deletedCacheRequests.push(request.url); } } if (process.env.NODE_ENV !== "production") { printCleanupDetails(deletedCacheRequests); } return { deletedCacheRequests }; }); } handleFetch(event) { const { request } = event; const responsePromise = this.handleRequest({ request, event }); if (responsePromise) { event.respondWith(responsePromise); } } handleCache(event) { if (event.data && event.data.type === "CACHE_URLS") { const { payload } = event.data; if (process.env.NODE_ENV !== "production") { logger.debug("Caching URLs from the window", payload.urlsToCache); } const requestPromises = Promise.all(payload.urlsToCache.map((entry)=>{ let request; if (typeof entry === "string") { request = new Request(entry); } else { request = new Request(...entry); } return this.handleRequest({ request, event }); })); event.waitUntil(requestPromises); if (event.ports?.[0]) { void requestPromises.then(()=>event.ports[0].postMessage(true)); } } } setDefaultHandler(handler, method = defaultMethod) { this._defaultHandlerMap.set(method, normalizeHandler(handler)); } setCatchHandler(handler) { this._catchHandler = normalizeHandler(handler); } registerCapture(capture, handler, method) { const route = parseRoute(capture, handler, method); this.registerRoute(route); return route; } registerRoute(route) { if (process.env.NODE_ENV !== "production") { finalAssertExports.isType(route, "object", { moduleName: "serwist", className: "Serwist", funcName: "registerRoute", paramName: "route" }); finalAssertExports.hasMethod(route, "match", { moduleName: "serwist", className: "Serwist", funcName: "registerRoute", paramName: "route" }); finalAssertExports.isType(route.handler, "object", { moduleName: "serwist", className: "Serwist", funcName: "registerRoute", paramName: "route" }); finalAssertExports.hasMethod(route.handler, "handle", { moduleName: "serwist", className: "Serwist", funcName: "registerRoute", paramName: "route.handler" }); finalAssertExports.isType(route.method, "string", { moduleName: "serwist", className: "Serwist", funcName: "registerRoute", paramName: "route.method" }); } if (!this._routes.has(route.method)) { this._routes.set(route.method, []); } this._routes.get(route.method).push(route); } unregisterRoute(route) { if (!this._routes.has(route.method)) { throw new SerwistError("unregister-route-but-not-found-with-method", { method: route.method }); } const routeIndex = this._routes.get(route.method).indexOf(route); if (routeIndex > -1) { this._routes.get(route.method).splice(routeIndex, 1); } else { throw new SerwistError("unregister-route-route-not-registered"); } } getUrlsToPrecacheKeys() { return this._urlsToCacheKeys; } getPrecachedUrls() { return [ ...this._urlsToCacheKeys.keys() ]; } getPrecacheKeyForUrl(url) { const urlObject = new URL(url, location.href); return this._urlsToCacheKeys.get(urlObject.href); } getIntegrityForPrecacheKey(cacheKey) { return this._cacheKeysToIntegrities.get(cacheKey); } async matchPrecache(request) { const url = request instanceof Request ? request.url : request; const cacheKey = this.getPrecacheKeyForUrl(url); if (cacheKey) { const cache = await self.caches.open(this.precacheStrategy.cacheName); return cache.match(cacheKey); } return undefined; } createHandlerBoundToUrl(url) { const cacheKey = this.getPrecacheKeyForUrl(url); if (!cacheKey) { throw new SerwistError("non-precached-url", { url }); } return (options)=>{ options.request = new Request(url); options.params = { cacheKey, ...options.params }; return this.precacheStrategy.handle(options); }; } handleRequest({ request, event }) { if (process.env.NODE_ENV !== "production") { finalAssertExports.isInstance(request, Request, { moduleName: "serwist", className: "Serwist", funcName: "handleRequest", paramName: "options.request" }); } const url = new URL(request.url, location.href); if (!url.protocol.startsWith("http")) { if (process.env.NODE_ENV !== "production") { logger.debug("Router only supports URLs that start with 'http'."); } return; } const sameOrigin = url.origin === location.origin; const { params, route } = this.findMatchingRoute({ event, request, sameOrigin, url }); let handler = route?.handler; const debugMessages = []; if (process.env.NODE_ENV !== "production") { if (handler) { debugMessages.push([ "Found a route to handle this request:", route ]); if (params) { debugMessages.push([ `Passing the following params to the route's handler:`, params ]); } } } const method = request.method; if (!handler && this._defaultHandlerMap.has(method)) { if (process.env.NODE_ENV !== "production") { debugMessages.push(`Failed to find a matching route. Falling back to the default handler for ${method}.`); } handler = this._defaultHandlerMap.get(method); } if (!handler) { if (process.env.NODE_ENV !== "production") { logger.debug(`No route found for: ${getFriendlyURL(url)}`); } return; } if (process.env.NODE_ENV !== "production") { logger.groupCollapsed(`Router is responding to: ${getFriendlyURL(url)}`); for (const msg of debugMessages){ if (Array.isArray(msg)) { logger.log(...msg); } else { logger.log(msg); } } logger.groupEnd(); } let responsePromise; try { responsePromise = handler.handle({ url, request, event, params }); } catch (err) { responsePromise = Promise.reject(err); } const catchHandler = route?.catchHandler; if (responsePromise instanceof Promise && (this._catchHandler || catchHandler)) { responsePromise = responsePromise.catch(async (err)=>{ if (catchHandler) { if (process.env.NODE_ENV !== "production") { logger.groupCollapsed(`Error thrown when responding to: ${getFriendlyURL(url)}. Falling back to route's Catch Handler.`); logger.error("Error thrown by:", route); logger.error(err); logger.groupEnd(); } try { return await catchHandler.handle({ url, request, event, params }); } catch (catchErr) { if (catchErr instanceof Error) { err = catchErr; } } } if (this._catchHandler) { if (process.env.NODE_ENV !== "production") { logger.groupCollapsed(`Error thrown when responding to: ${getFriendlyURL(url)}. Falling back to global Catch Handler.`); logger.error("Error thrown by:", route); logger.error(err); logger.groupEnd(); } return this._catchHandler.handle({ url, request, event }); } throw err; }); } return responsePromise; } findMatchingRoute({ url, sameOrigin, request, event }) { const routes = this._routes.get(request.method) || []; for (const route of routes){ let params; const matchResult = route.match({ url, sameOrigin, request, event }); if (matchResult) { if (process.env.NODE_ENV !== "production") { if (matchResult instanceof Promise) { logger.warn(`While routing ${getFriendlyURL(url)}, an async matchCallback function was used. Please convert the following route to use a synchronous matchCallback function:`, route); } } params = matchResult; if (Array.isArray(params) && params.length === 0) { params = undefined; } else if (matchResult.constructor === Object && Object.keys(matchResult).length === 0) { params = undefined; } else if (typeof matchResult === "boolean") { params = undefined; } return { route, params }; } } return {}; } } const cacheNames = { get googleAnalytics () { return cacheNames$1.getGoogleAnalyticsName(); }, get precache () { return cacheNames$1.getPrecacheName(); }, get prefix () { return cacheNames$1.getPrefix(); }, get runtime () { return cacheNames$1.getRuntimeName(); }, get suffix () { return cacheNames$1.getSuffix(); } }; const BROADCAST_UPDATE_MESSAGE_TYPE = "CACHE_UPDATED"; const BROADCAST_UPDATE_MESSAGE_META = "serwist-broadcast-update"; const BROADCAST_UPDATE_DEFAULT_NOTIFY = true; const BROADCAST_UPDATE_DEFAULT_HEADERS = [ "content-length", "etag", "last-modified" ]; const responsesAreSame = (firstResponse, secondResponse, headersToCheck)=>{ if (process.env.NODE_ENV !== "production") { if (!(firstResponse instanceof Response && secondResponse instanceof Response)) { throw new SerwistError("invalid-responses-are-same-args"); } } const atLeastOneHeaderAvailable = headersToCheck.some((header)=>{ return firstResponse.headers.has(header) && secondResponse.headers.has(header); }); if (!atLeastOneHeaderAvailable) { if (process.env.NODE_ENV !== "production") { logger.warn("Unable to determine where the response has been updated because none of the headers that would be checked are present."); logger.debug("Attempting to compare the following: ", firstResponse, secondResponse, headersToCheck); } return true; } return headersToCheck.every((header)=>{ const headerStateComparison = firstResponse.headers.has(header) === secondResponse.headers.has(header); const headerValueComparison = firstResponse.headers.get(header) === secondResponse.headers.get(header); return headerStateComparison && headerValueComparison; }); }; const isSafari = typeof navigator !== "undefined" && /^((?!chrome|android).)*safari/i.test(navigator.userAgent); const defaultPayloadGenerator = (data)=>{ return { cacheName: data.cacheName, updatedURL: data.request.url }; }; class BroadcastCacheUpdate { _headersToCheck; _generatePayload; _notifyAllClients; constructor({ generatePayload, headersToCheck, notifyAllClients } = {}){ this._headersToCheck = headersToCheck || BROADCAST_UPDATE_DEFAULT_HEADERS; this._generatePayload = generatePayload || defaultPayloadGenerator; this._notifyAllClients = notifyAllClients ?? BROADCAST_UPDATE_DEFAULT_NOTIFY; } async notifyIfUpdated(options) { if (process.env.NODE_ENV !== "production") { finalAssertExports.isType(options.cacheName, "string", { moduleName: "serwist", className: "BroadcastCacheUpdate", funcName: "notifyIfUpdated", paramName: "cacheName" }); finalAssertExports.isInstance(options.newResponse, Response, { moduleName: "serwist", className: "BroadcastCacheUpdate", funcName: "notifyIfUpdated", paramName: "newResponse" }); finalAssertExports.isInstance(options.request, Request, { moduleName: "serwist", className: "BroadcastCacheUpdate", funcName: "notifyIfUpdated", paramName: "request" }); } if (!options.oldResponse) { return; } if (!responsesAreSame(options.oldResponse, options.newResponse, this._headersToCheck)) { if (process.env.NODE_ENV !== "production") { logger.log("Newer response found (and cached) for:", options.request.url); } const messageData = { type: BROADCAST_UPDATE_MESSAGE_TYPE, meta: BROADCAST_UPDATE_MESSAGE_META, payload: this._generatePayload(options) }; if (options.request.mode === "navigate") { let resultingClientId; if (options.event instanceof FetchEvent) { resultingClientId = options.event.resultingClientId; } const resultingWin = await resultingClientExists(resultingClientId); if (!resultingWin || isSafari) { await timeout(3500); } } if (this._notifyAllClients) { const windows = await self.clients.matchAll({ type: "window" }); for (const win of windows){ win.postMessage(messageData); } } else { if (options.event instanceof FetchEvent) { const client = await self.clients.get(options.event.clientId); client?.postMessage(messageData); } } } } } class BroadcastUpdatePlugin { _broadcastUpdate; constructor(options){ this._broadcastUpdate = new BroadcastCacheUpdate(options); } cacheDidUpdate(options) { void this._broadcastUpdate.notifyIfUpdated(options); } } class CacheableResponse { _statuses; _headers; constructor(config = {}){ if (process.env.NODE_ENV !== "production") { if (!(config.statuses || config.headers)) { throw new SerwistError("statuses-or-headers-required", { moduleName: "serwist", className: "CacheableResponse", funcName: "constructor" }); } if (config.statuses) { finalAssertExports.isArray(config.statuses, { moduleName: "serwist", className: "CacheableResponse", funcName: "constructor", paramName: "config.statuses" }); } if (config.headers) { finalAssertExports.isType(config.headers, "object", { moduleName: "serwist", className: "CacheableResponse", funcName: "constructor", paramName: "config.headers" }); } } this._statuses = config.statuses; if (config.headers) { this._headers = new Headers(config.headers); } } isResponseCacheable(response) { if (process.env.NODE_ENV !== "production") { finalAssertExports.isInstance(response, Response, { moduleName: "serwist", className: "CacheableResponse", funcName: "isResponseCacheable", paramName: "response" }); } let cacheable = true; if (this._statuses) { cacheable = this._statuses.includes(response.status); } if (this._headers && cacheable) { for (const [headerName, headerValue] of this._headers.entries()){ if (response.headers.get(headerName) !== headerValue) { cacheable = false; break; } } } if (process.env.NODE_ENV !== "production") { if (!cacheable) { logger.groupCollapsed(`The request for '${getFriendlyURL(response.url)}' returned a response that does not meet the criteria for being cached.`); logger.groupCollapsed("View cacheability criteria here."); logger.log(`Cacheable statuses: ${JSON.stringify(this._statuses)}`); logger.log(`Cacheable headers: ${JSON.stringify(this._headers, null, 2)}`); logger.groupEnd(); const logFriendlyHeaders = {}; response.headers.forEach((value, key)=>{ logFriendlyHeaders[key] = value; }); logger.groupCollapsed("View response status and headers here."); logger.log(`Response status: ${response.status}`); logger.log(`Response headers: ${JSON.stringify(logFriendlyHeaders, null, 2)}`); logger.groupEnd(); logger.groupCollapsed("View full response details here."); logger.log(response.headers); logger.log(response); logger.groupEnd(); logger.groupEnd(); } } return cacheable; } } class CacheableResponsePlugin { _cacheableResponse; constructor(config){ this._cacheableResponse = new CacheableResponse(config); } cacheWillUpdate = async ({ response })=>{ if (this._cacheableResponse.isResponseCacheable(response)) { return response; } return null; }; } const DB_NAME = "serwist-expiration"; const CACHE_OBJECT_STORE = "cache-entries"; const normalizeURL = (unNormalizedUrl)=>{ const url = new URL(unNormalizedUrl, location.href); url.hash = ""; return url.href; }; class CacheTimestampsModel { _cacheName; _db = null; constructor(cacheName){ this._cacheName = cacheName; } _getId(url) { return `${this._cacheName}|${normalizeURL(url)}`; } _upgradeDb(db) { const objStore = db.createObjectStore(CACHE_OBJECT_STORE, { keyPath: "id" }); objStore.createIndex("cacheName", "cacheName", { unique: false }); objStore.createIndex("timestamp", "timestamp", { unique: false }); } _upgradeDbAndDeleteOldDbs(db) { this._upgradeDb(db); if (this._cacheName) { void deleteDB(this._cacheName); } } async setTimestamp(url, timestamp) { url = normalizeURL(url); const entry = { id: this._getId(url), cacheName: this._cacheName, url, timestamp }; const db = await this.getDb(); const tx = db.transaction(CACHE_OBJECT_STORE, "readwrite", { durability: "relaxed" }); await tx.store.put(entry); await tx.done; } async getTimestamp(url) { const db = await this.getDb(); const entry = await db.get(CACHE_OBJECT_STORE, this._getId(url)); return entry?.timestamp; } async expireEntries(minTimestamp, maxCount) { const db = await this.getDb(); let cursor = await db.transaction(CACHE_OBJECT_STORE, "readwrite").store.index("timestamp").openCursor(null, "prev"); const urlsDeleted = []; let entriesNotDeletedCount = 0; while(cursor){ const result = cursor.value; if (result.cacheName === this._cacheName) { if (minTimestamp && result.timestamp < minTimestamp || maxCount && entriesNotDeletedCount >= maxCount) { cursor.delete(); urlsDeleted.push(result.url); } else { entriesNotDeletedCount++; } } cursor = await cursor.continue(); } return urlsDeleted; } async getDb() { if (!this._db) { this._db = await openDB(DB_NAME, 1, { upgrade: this._upgradeDbAndDeleteOldDbs.bind(this) }); } return this._db; } } class CacheExpiration { _isRunning = false; _rerunRequested = false; _maxEntries; _maxAgeSeconds; _matchOptions; _cacheName; _timestampModel; constructor(cacheName, config = {}){ if (process.env.NODE_ENV !== "production") { finalAssertExports.isType(cacheName, "string", { moduleName: "serwist", className: "CacheExpiration", funcName: "constructor", paramName: "cacheName" }); if (!(config.maxEntries || config.maxAgeSeconds)) { throw new SerwistError("max-entries-or-age-required", { moduleName: "serwist", className: "CacheExpiration", funcName: "constructor" }); } if (config.maxEntries) { finalAssertExports.isType(config.maxEntries, "number", { moduleName: "serwist", className: "CacheExpiration", funcName: "constructor", paramName: "config.maxEntries" }); } if (config.maxAgeSeconds) { finalAssertExports.isType(config.maxAgeSeconds, "number", { moduleName: "serwist", className: "CacheExpiration", funcName: "constructor", paramName: "config.maxAgeSeconds" }); } } this._maxEntries = config.maxEntries; this._maxAgeSeconds = config.maxAgeSeconds; this._matchOptions = config.matchOptions; this._cacheName = cacheName; this._timestampModel = new CacheTimestampsModel(cacheName); } async expireEntries() { if (this._isRunning) { this._rerunRequested = true; return; } this._isRunning = true; const minTimestamp = this._maxAgeSeconds ? Date.now() - this._maxAgeSeconds * 1000 : 0; const urlsExpired = await this._timestampModel.expireEntries(minTimestamp, this._maxEntries); const cache = await self.caches.open(this._cacheName); for (const url of urlsExpired){ await cache.delete(url, this._matchOptions); } if (process.env.NODE_ENV !== "production") { if (urlsExpired.length > 0) { logger.groupCollapsed(`Expired ${urlsExpired.length} ` + `${urlsExpired.length === 1 ? "entry" : "entries"} and removed ` + `${urlsExpired.length === 1 ? "it" : "them"} from the ` + `'${this._cacheName}' cache.`); logger.log(`Expired the following ${urlsExpired.length === 1 ? "URL" : "URLs"}:`); for (const url of urlsExpired){ logger.log(` ${url}`); } logger.groupEnd(); } else { logger.debug("Cache expiration ran and found no entries to remove."); } } this._isRunning = false; if (this._rerunRequested) { this._rerunRequested = false; void this.expireEntries(); } } async updateTimestamp(url) { if (process.env.NODE_ENV !== "production") { finalAssertExports.isType(url, "string", { moduleName: "serwist", className: "CacheExpiration", funcName: "updateTimestamp", paramName: "url" }); } await this._timestampModel.setTimestamp(url, Date.now()); } async isURLExpired(url) { if (!this._maxAgeSeconds) { if (process.env.NODE_ENV !== "production") { throw new SerwistError("expired-test-without-max-age", { methodName: "isURLExpired", paramName: "maxAgeSeconds" }); } return false; } const timestamp = await this._timestampModel.getTimestamp(url); const expireOlderThan = Date.now() - this._maxAgeSeconds * 1000; return timestamp !== undefined ? timestamp < expireOlderThan : true; } async delete() { this._rerunRequested = false; await this._timestampModel.expireEntries(Number.POSITIVE_INFINITY); } } const registerQuotaErrorCallback = (callback)=>{ if (process.env.NODE_ENV !== "production") { finalAssertExports.isType(callback, "function", { moduleName: "@serwist/core", funcName: "register", paramName: "callback" }); } quotaErrorCallbacks.add(callback); if (process.env.NODE_ENV !== "production") { logger.log("Registered a callback to respond to quota errors.", callback); } }; class ExpirationPlugin { _config; _cacheExpirations; constructor(config = {}){ if (process.env.NODE_ENV !== "production") { if (!(config.maxEntries || config.maxAgeSeconds)) { throw new SerwistError("max-entries-or-age-required", { moduleName: "serwist", className: "ExpirationPlugin", funcName: "constructor" }); } if (config.maxEntries) { finalAssertExports.isType(config.maxEntries, "number", { moduleName: "serwist", className: "ExpirationPlugin", funcName: "constructor", paramName: "config.maxEntries" }); } if (config.maxAgeSeconds) { finalAssertExports.isType(config.maxAgeSeconds, "number", { moduleName: "serwist", className: "ExpirationPlugin", funcName: "constructor", paramName: "config.maxAgeSeconds" }); } if (config.maxAgeFrom) { finalAssertExports.isType(config.maxAgeFrom, "string", { moduleName: "serwist", className: "ExpirationPlugin", funcName: "constructor", paramName: "config.maxAgeFrom" }); } } this._config = config; this._cacheExpirations = new Map(); if (!this._config.maxAgeFrom) { this._config.maxAgeFrom = "last-fetched"; } if (this._config.purgeOnQuotaError) { registerQuotaErrorCallback(()=>this.deleteCacheAndMetadata()); } } _getCacheExpiration(cacheName) { if (cacheName === cacheNames$1.getRuntimeName()) { throw new SerwistError("expire-custom-caches-only"); } let cacheExpiration = this._cacheExpirations.get(cacheName); if (!cacheExpiration) { cacheExpiration = new CacheExpiration(cacheName, this._config); this._cacheExpirations.set(cacheName, cacheExpiration); } return cacheExpiration; } cachedResponseWillBeUsed({ event, cacheName, request, cachedResponse }) { if (!cachedResponse) { return null; } const isFresh = this._isResponseDateFresh(cachedResponse); const cacheExpiration = this._getCacheExpiration(cacheName); const isMaxAgeFromLastUsed = this._config.maxAgeFrom === "last-used"; const done = (async ()=>{ if (isMaxAgeFromLastUsed) { await cacheExpiration.updateTimestamp(request.url); } await cacheExpiration.expireEntries(); })(); try { event.waitUntil(done); } catch (error) { if (process.env.NODE_ENV !== "production") { if (event instanceof FetchEvent) { logger.warn(`Unable to ensure service worker stays alive when updating cache entry for '${getFriendlyURL(event.request.url)}'.`); } } } return isFresh ? cachedResponse : null; } _isResponseDateFresh(cachedResponse) { const isMaxAgeFromLastUsed = this._config.maxAgeFrom === "last-used"; if (isMaxAgeFromLastUsed) { return true; } const now = Date.now(); if (!this._config.maxAgeSeconds) { return true; } const dateHeaderTimestamp = this._getDateHeaderTimestamp(cachedResponse); if (dateHeaderTimestamp === null) { return true; } return dateHeaderTimestamp >= now - this._config.maxAgeSeconds * 1000; } _getDateHeaderTimestamp(cachedResponse) { if (!cachedResponse.headers.has("date")) { return null; } const dateHeader = cachedResponse.headers.get("date"); const parsedDate = new Date(dateHeader); const headerTime = parsedDate.getTime(); if (Number.isNaN(headerTime)) { return null; } return headerTime; } async cacheDidUpdate({ cacheName, request }) { if (process.env.NODE_ENV !== "production") { finalAssertExports.isType(cacheName, "string", { moduleName: "serwist", className: "Plugin", funcName: "cacheDidUpdate", paramName: "cacheName" }); finalAssertExports.isInstance(request, Request, { moduleName: "serwist", className: "Plugin", funcName: "cacheDidUpdate", paramName: "request" }); } const cacheExpiration = this._getCacheExpiration(cacheName); await cacheExpiration.updateTimestamp(request.url); await cacheExpiration.expireEntries(); } async deleteCacheAndMetadata() { for (const [cacheName, cacheExpiration] of this._cacheExpirations){ await self.caches.delete(cacheName); await cacheExpiration.delete(); } this._cacheExpirations = new Map(); } } const calculateEffectiveBoundaries = (blob, start, end)=>{ if (process.env.NODE_ENV !== "production") { finalAssertExports.isInstance(blob, Blob, { moduleName: "@serwist/range-requests", funcName: "calculateEffectiveBoundaries", paramName: "blob" }); } const blobSize = blob.size; if (end && end > blobSize || start && start < 0) { throw new SerwistError("range-not-satisfiable", { size: blobSize, end, start }); } let effectiveStart; let effectiveEnd; if (start !== undefined && end !== undefined) { effectiveStart = start; effectiveEnd = end + 1; } else if (start !== undefined && end === undefined) { effectiveStart = start; effectiveEnd = blobSize; } else if (end !== undefined && start === undefined) {