UNPKG

serwist

Version:

A Swiss Army knife for service workers.

791 lines (765 loc) 30.8 kB
import { P as PrecacheStrategy, c as createCacheKey, h as PrecacheInstallReportPlugin, i as parallel, j as printInstallDetails, k as printCleanupDetails, R as Route, g as generateURLVariations, f as defaultMethod, n as normalizeHandler, p as parseRoute, S as Strategy, b as NavigationRoute, B as BackgroundSyncPlugin, N as NetworkFirst, a as NetworkOnly, e as enableNavigationPreload, s as setCacheNameDetails, d as disableDevLogs } from './chunks/printInstallDetails.js'; import { c as cacheNames, f as finalAssertExports, S as SerwistError, l as logger, w as waitUntil, g as getFriendlyURL, b as cleanupOutdatedCaches, a as clientsClaim } from './chunks/waitUntil.js'; import 'idb'; class PrecacheCacheKeyPlugin { _precacheController; constructor({ precacheController }){ this._precacheController = precacheController; } cacheKeyWillBeUsed = async ({ request, params })=>{ const cacheKey = params?.cacheKey || this._precacheController.getCacheKeyForURL(request.url); return cacheKey ? new Request(cacheKey, { headers: request.headers }) : request; }; } class PrecacheController { _installAndActiveListenersAdded; _concurrentPrecaching; _strategy; _urlsToCacheKeys = new Map(); _urlsToCacheModes = new Map(); _cacheKeysToIntegrities = new Map(); constructor({ cacheName, plugins = [], fallbackToNetwork = true, concurrentPrecaching = 1 } = {}){ this._concurrentPrecaching = concurrentPrecaching; this._strategy = new PrecacheStrategy({ cacheName: cacheNames.getPrecacheName(cacheName), plugins: [ ...plugins, new PrecacheCacheKeyPlugin({ precacheController: this }) ], fallbackToNetwork }); this.install = this.install.bind(this); this.activate = this.activate.bind(this); } get strategy() { return this._strategy; } precache(entries) { this.addToCacheList(entries); if (!this._installAndActiveListenersAdded) { self.addEventListener("install", this.install); self.addEventListener("activate", this.activate); this._installAndActiveListenersAdded = true; } } addToCacheList(entries) { if (process.env.NODE_ENV !== "production") { finalAssertExports.isArray(entries, { moduleName: "serwist/legacy", className: "PrecacheController", 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); } } } } install(event) { return waitUntil(event, async ()=>{ const installReportPlugin = new PrecacheInstallReportPlugin(); this.strategy.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.strategy.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 }; }); } activate(event) { return waitUntil(event, async ()=>{ const cache = await self.caches.open(this.strategy.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 }; }); } getURLsToCacheKeys() { return this._urlsToCacheKeys; } getCachedURLs() { return [ ...this._urlsToCacheKeys.keys() ]; } getCacheKeyForURL(url) { const urlObject = new URL(url, location.href); return this._urlsToCacheKeys.get(urlObject.href); } getIntegrityForCacheKey(cacheKey) { return this._cacheKeysToIntegrities.get(cacheKey); } async matchPrecache(request) { const url = request instanceof Request ? request.url : request; const cacheKey = this.getCacheKeyForURL(url); if (cacheKey) { const cache = await self.caches.open(this.strategy.cacheName); return cache.match(cacheKey); } return undefined; } createHandlerBoundToURL(url) { const cacheKey = this.getCacheKeyForURL(url); if (!cacheKey) { throw new SerwistError("non-precached-url", { url }); } return (options)=>{ options.request = new Request(url); options.params = { cacheKey, ...options.params }; return this.strategy.handle(options); }; } } let defaultPrecacheController = undefined; const getSingletonPrecacheController = ()=>{ if (!defaultPrecacheController) { defaultPrecacheController = new PrecacheController(); } return defaultPrecacheController; }; const setSingletonPrecacheController = (precacheController)=>{ defaultPrecacheController = precacheController; return defaultPrecacheController; }; class PrecacheFallbackPlugin { _fallbackUrls; _precacheController; constructor({ fallbackUrls, precacheController }){ this._fallbackUrls = fallbackUrls; this._precacheController = precacheController || getSingletonPrecacheController(); } async handlerDidError(param) { for (const fallback of this._fallbackUrls){ if (typeof fallback === "string") { const fallbackResponse = await this._precacheController.matchPrecache(fallback); if (fallbackResponse !== undefined) { return fallbackResponse; } } else if (fallback.matcher(param)) { const fallbackResponse = await this._precacheController.matchPrecache(fallback.url); if (fallbackResponse !== undefined) { return fallbackResponse; } } } return undefined; } } class PrecacheRoute extends Route { constructor(precacheController, options){ const match = ({ request })=>{ const urlsToCacheKeys = precacheController.getURLsToCacheKeys(); for (const possibleURL of generateURLVariations(request.url, options)){ const cacheKey = urlsToCacheKeys.get(possibleURL); if (cacheKey) { const integrity = precacheController.getIntegrityForCacheKey(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, precacheController.strategy); } } class Router { _routes; _defaultHandlerMap; _fetchListenerHandler = null; _cacheListenerHandler = null; _catchHandler; constructor(){ this._routes = new Map(); this._defaultHandlerMap = new Map(); } get routes() { return this._routes; } addFetchListener() { if (!this._fetchListenerHandler) { this._fetchListenerHandler = (event)=>{ const { request } = event; const responsePromise = this.handleRequest({ request, event }); if (responsePromise) { event.respondWith(responsePromise); } }; self.addEventListener("fetch", this._fetchListenerHandler); } } removeFetchListener() { if (this._fetchListenerHandler) { self.removeEventListener("fetch", this._fetchListenerHandler); this._fetchListenerHandler = null; } } addCacheListener() { if (!this._cacheListenerHandler) { this._cacheListenerHandler = (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)=>{ if (typeof entry === "string") { entry = [ entry ]; } const request = new Request(...entry); return this.handleRequest({ request, event }); })); event.waitUntil(requestPromises); if (event.ports?.[0]) { void requestPromises.then(()=>event.ports[0].postMessage(true)); } } }; self.addEventListener("message", this._cacheListenerHandler); } } removeCacheListener() { if (this._cacheListenerHandler) { self.removeEventListener("message", this._cacheListenerHandler); } } handleRequest({ request, event }) { if (process.env.NODE_ENV !== "production") { finalAssertExports.isInstance(request, Request, { moduleName: "serwist/legacy", className: "Router", 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 {}; } 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/legacy", className: "Router", funcName: "registerRoute", paramName: "route" }); finalAssertExports.hasMethod(route, "match", { moduleName: "serwist/legacy", className: "Router", funcName: "registerRoute", paramName: "route" }); finalAssertExports.isType(route.handler, "object", { moduleName: "serwist/legacy", className: "Router", funcName: "registerRoute", paramName: "route" }); finalAssertExports.hasMethod(route.handler, "handle", { moduleName: "serwist/legacy", className: "Router", funcName: "registerRoute", paramName: "route.handler" }); finalAssertExports.isType(route.method, "string", { moduleName: "serwist/legacy", className: "Router", 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"); } } } const addPlugins = (plugins)=>{ const precacheController = getSingletonPrecacheController(); precacheController.strategy.plugins.push(...plugins); }; let defaultRouter = undefined; const getSingletonRouter = ()=>{ if (!defaultRouter) { defaultRouter = new Router(); defaultRouter.addFetchListener(); defaultRouter.addCacheListener(); } return defaultRouter; }; const setSingletonRouter = (router)=>{ if (defaultRouter) { defaultRouter.removeFetchListener(); defaultRouter.removeCacheListener(); } defaultRouter = router; defaultRouter.addFetchListener(); defaultRouter.addCacheListener(); return defaultRouter; }; const registerRoute = (capture, handler, method)=>{ return getSingletonRouter().registerCapture(capture, handler, method); }; const addRoute = (options)=>{ const precacheRoute = new PrecacheRoute(getSingletonPrecacheController(), options); registerRoute(precacheRoute); }; const createHandlerBoundToURL = (url)=>{ const precacheController = getSingletonPrecacheController(); return precacheController.createHandlerBoundToURL(url); }; const fallbacks = ({ precacheController = getSingletonPrecacheController(), router = getSingletonRouter(), runtimeCaching, entries, precacheOptions })=>{ precacheController.precache(entries); router.registerRoute(new PrecacheRoute(precacheController, precacheOptions)); const fallbackPlugin = new PrecacheFallbackPlugin({ fallbackUrls: entries }); runtimeCaching.forEach((cacheEntry)=>{ if (cacheEntry.handler instanceof Strategy && !cacheEntry.handler.plugins.some((plugin)=>"handlerDidError" in plugin)) { cacheEntry.handler.plugins.push(fallbackPlugin); } return cacheEntry; }); return runtimeCaching; }; const getCacheKeyForURL = (url)=>{ const precacheController = getSingletonPrecacheController(); return precacheController.getCacheKeyForURL(url); }; const handlePrecaching = ({ precacheController = getSingletonPrecacheController(), router = getSingletonRouter(), precacheEntries, precacheOptions, cleanupOutdatedCaches: cleanupOutdatedCaches$1 = false, navigateFallback, navigateFallbackAllowlist, navigateFallbackDenylist })=>{ if (!!precacheEntries && precacheEntries.length > 0) { precacheController.precache(precacheEntries); router.registerRoute(new PrecacheRoute(precacheController, precacheOptions)); if (cleanupOutdatedCaches$1) cleanupOutdatedCaches(); if (navigateFallback) { router.registerRoute(new NavigationRoute(createHandlerBoundToURL(navigateFallback), { allowlist: navigateFallbackAllowlist, denylist: navigateFallbackDenylist })); } } }; 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 = ({ router = getSingletonRouter(), cacheName, ...options } = {})=>{ const resolvedCacheName = cacheNames.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){ router.registerRoute(route); } }; const registerRuntimeCaching = (...runtimeCachingList)=>{ for (const entry of runtimeCachingList){ registerRoute(entry.matcher, entry.handler, entry.method); } }; const installSerwist = ({ precacheController = getSingletonPrecacheController(), router = getSingletonRouter(), precacheEntries, precacheOptions, cleanupOutdatedCaches, navigateFallback, navigateFallbackAllowlist, navigateFallbackDenylist, skipWaiting, importScripts, navigationPreload = false, cacheId, clientsClaim: clientsClaim$1 = false, runtimeCaching, offlineAnalyticsConfig, disableDevLogs: disableDevLogs$1 = false, fallbacks: fallbacks$1 })=>{ 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(); handlePrecaching({ precacheController, router, precacheEntries, precacheOptions, cleanupOutdatedCaches, navigateFallback, navigateFallbackAllowlist, navigateFallbackDenylist }); if (runtimeCaching !== undefined) { if (fallbacks$1 !== undefined) { runtimeCaching = fallbacks({ precacheController, router, runtimeCaching, entries: fallbacks$1.entries, precacheOptions }); } registerRuntimeCaching(...runtimeCaching); } if (offlineAnalyticsConfig !== undefined) { if (typeof offlineAnalyticsConfig === "boolean") { offlineAnalyticsConfig && initializeGoogleAnalytics({ router }); } else { initializeGoogleAnalytics({ ...offlineAnalyticsConfig, router }); } } if (disableDevLogs$1) disableDevLogs(); }; const matchPrecache = (request)=>{ return getSingletonPrecacheController().matchPrecache(request); }; const precache = (entries)=>{ getSingletonPrecacheController().precache(entries); }; const precacheAndRoute = (entries, options)=>{ precache(entries); addRoute(options); }; const setCatchHandler = (handler)=>{ getSingletonRouter().setCatchHandler(handler); }; const setDefaultHandler = (handler)=>{ getSingletonRouter().setDefaultHandler(handler); }; const unregisterRoute = (route)=>{ getSingletonRouter().unregisterRoute(route); }; export { PrecacheController, PrecacheFallbackPlugin, PrecacheRoute, Router, addPlugins, addRoute, createHandlerBoundToURL, fallbacks, getCacheKeyForURL, getSingletonPrecacheController, getSingletonRouter, handlePrecaching, initializeGoogleAnalytics, installSerwist, matchPrecache, precache, precacheAndRoute, registerRoute, registerRuntimeCaching, setCatchHandler, setDefaultHandler, setSingletonPrecacheController, setSingletonRouter, unregisterRoute };