serwist
Version:
A Swiss Army knife for service workers.
1,268 lines (1,248 loc) • 59.3 kB
JavaScript
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) {