serwist
Version:
A Swiss Army knife for service workers.
1,377 lines • 67.9 kB
JavaScript
import { c as timeout, d as finalAssertExports, f as SerwistError, l as logger, m as cacheNames$1, n as clientsClaim, r as cleanupOutdatedCaches, s as quotaErrorCallbacks, t as waitUntil, u as getFriendlyURL } from "./chunks/waitUntil-BHDx3Rgo.js";
import { C as BackgroundSyncQueue, D as copyResponse, E as disableDevLogs, S as BackgroundSyncPlugin, T as BackgroundSyncQueueStore, _ as NetworkFirst, a as createCacheKey, b as StrategyHandler, c as generateURLVariations, d as isNavigationPreloadSupported, f as NavigationRoute, g as NetworkOnly, h as normalizeHandler, i as PrecacheInstallReportPlugin, l as disableNavigationPreload, m as Route, n as printCleanupDetails, o as setCacheNameDetails, p as PrecacheStrategy, r as parseRoute, s as RegExpRoute, t as printInstallDetails, u as enableNavigationPreload, v as messages, w as StorableRequest, x as cacheOkAndOpaquePlugin, y as Strategy } from "./chunks/printInstallDetails-c9A08ZVZ.js";
import { r as resultingClientExists } from "./chunks/index.internal-Dxj9Ni9X.js";
import { deleteDB, openDB } from "idb";
import { parallel } from "@serwist/utils";
//#region src/cacheNames.ts
/**
* Get the current cache names and prefix/suffix used by Serwist.
*
* `cacheNames.precache` is used for precached assets,
* `cacheNames.googleAnalytics` is used by `@serwist/google-analytics` to
* store `analytics.js`, and `cacheNames.runtime` is used for everything else.
*
* `cacheNames.prefix` can be used to retrieve just the current prefix value.
* `cacheNames.suffix` can be used to retrieve just the current suffix value.
*
* @returns An object with `precache`, `runtime`, `prefix`, and `googleAnalytics` properties.
*/
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();
}
};
//#endregion
//#region src/lib/broadcastUpdate/constants.ts
const BROADCAST_UPDATE_MESSAGE_TYPE = "CACHE_UPDATED";
const BROADCAST_UPDATE_MESSAGE_META = "serwist-broadcast-update";
const BROADCAST_UPDATE_DEFAULT_HEADERS = [
"content-length",
"etag",
"last-modified"
];
//#endregion
//#region src/lib/broadcastUpdate/responsesAreSame.ts
/**
* Given two responses, compares several header values to see if they are
* the same or not.
*
* @param firstResponse The first response.
* @param secondResponse The second response.
* @param headersToCheck A list of headers to check.
* @returns
*/
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");
}
if (!headersToCheck.some((header) => {
return firstResponse.headers.has(header) && secondResponse.headers.has(header);
})) {
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;
});
};
//#endregion
//#region src/lib/broadcastUpdate/BroadcastCacheUpdate.ts
const isSafari = typeof navigator !== "undefined" && /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
/**
* Generates the default payload used in update messages. By default the
* payload includes the `cacheName` and `updatedURL` fields.
*
* @returns
* @private
*/
const defaultPayloadGenerator = (data) => {
return {
cacheName: data.cacheName,
updatedURL: data.request.url
};
};
/**
* A class that uses the `postMessage()` API to inform any open windows/tabs when
* a cached response has been updated.
*
* For efficiency's sake, the underlying response bodies are not compared;
* only specific response headers are checked.
*/
var BroadcastCacheUpdate = class {
_headersToCheck;
_generatePayload;
_notifyAllClients;
/**
* Construct an instance of `BroadcastCacheUpdate`.
*
* @param options
*/
constructor({ generatePayload, headersToCheck, notifyAllClients } = {}) {
this._headersToCheck = headersToCheck || BROADCAST_UPDATE_DEFAULT_HEADERS;
this._generatePayload = generatePayload || defaultPayloadGenerator;
this._notifyAllClients = notifyAllClients ?? true;
}
/**
* Compares two responses and sends a message (via `postMessage()`) to all window clients if the
* responses differ. Neither of the Responses can be opaque.
*
* The message that's posted has the following format (where `payload` can
* be customized via the `generatePayload` option the instance is created
* with):
*
* ```
* {
* type: 'CACHE_UPDATED',
* meta: 'workbox-broadcast-update',
* payload: {
* cacheName: 'the-cache-name',
* updatedURL: 'https://example.com/'
* }
* }
* ```
*
* @param options
* @returns Resolves once the update is sent.
*/
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;
if (!await resultingClientExists(resultingClientId) || 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) (await self.clients.get(options.event.clientId))?.postMessage(messageData);
}
}
};
//#endregion
//#region src/lib/broadcastUpdate/BroadcastUpdatePlugin.ts
/**
* A class implementing the `cacheDidUpdate` lifecycle callback. It will automatically
* broadcast a message whenever a cached response is updated.
*/
var BroadcastUpdatePlugin = class {
_broadcastUpdate;
/**
* Construct a {@linkcode BroadcastCacheUpdate} instance with
* the passed options and calls its {@linkcode BroadcastCacheUpdate.notifyIfUpdated}
* method whenever the plugin's {@linkcode BroadcastUpdatePlugin.cacheDidUpdate} callback
* is invoked.
*
* @param options
*/
constructor(options) {
this._broadcastUpdate = new BroadcastCacheUpdate(options);
}
/**
* @private
* @param options The input object to this function.
*/
cacheDidUpdate(options) {
this._broadcastUpdate.notifyIfUpdated(options);
}
};
//#endregion
//#region src/lib/cacheableResponse/CacheableResponse.ts
/**
* Allows you to set up rules determining what status codes and/or headers need
* to be present in order for a [response](https://developer.mozilla.org/en-US/docs/Web/API/Response)
* to be considered cacheable.
*/
var CacheableResponse = class {
_statuses;
_headers;
/**
* To construct a new `CacheableResponse` instance you must provide at least
* one of the `config` properties.
*
* If both `statuses` and `headers` are specified, then both conditions must
* be met for the response to be considered cacheable.
*
* @param config
*/
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);
}
/**
* Checks a response to see whether it's cacheable or not.
*
* @param response The response whose cacheability is being
* checked.
* @returns `true` if the response is cacheable, and `false`
* otherwise.
*/
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;
}
};
//#endregion
//#region src/lib/cacheableResponse/CacheableResponsePlugin.ts
/**
* A class implementing the `cacheWillUpdate` lifecycle callback. This makes it
* easier to add in cacheability checks to requests made via Serwist's built-in
* strategies.
*/
var CacheableResponsePlugin = class {
_cacheableResponse;
/**
* To construct a new `CacheableResponsePlugin` instance you must provide at
* least one of the `config` properties.
*
* If both `statuses` and `headers` are specified, then both conditions must
* be met for the response to be considered cacheable.
*
* @param config
*/
constructor(config) {
this._cacheableResponse = new CacheableResponse(config);
}
/**
* @param options
* @returns
* @private
*/
cacheWillUpdate = async ({ response }) => {
if (this._cacheableResponse.isResponseCacheable(response)) return response;
return null;
};
};
//#endregion
//#region src/lib/expiration/models/CacheTimestampsModel.ts
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;
};
/**
* Returns the timestamp model.
*
* @private
*/
var CacheTimestampsModel = class {
_cacheName;
_db = null;
/**
*
* @param cacheName
*
* @private
*/
constructor(cacheName) {
this._cacheName = cacheName;
}
/**
* Takes a URL and returns an ID that will be unique in the object store.
*
* @param url
* @returns
* @private
*/
_getId(url) {
return `${this._cacheName}|${normalizeURL(url)}`;
}
/**
* Performs an upgrade of indexedDB.
*
* @param db
*
* @private
*/
_upgradeDb(db) {
const objStore = db.createObjectStore(CACHE_OBJECT_STORE, { keyPath: "id" });
objStore.createIndex("cacheName", "cacheName", { unique: false });
objStore.createIndex("timestamp", "timestamp", { unique: false });
}
/**
* Performs an upgrade of indexedDB and deletes deprecated DBs.
*
* @param db
*
* @private
*/
_upgradeDbAndDeleteOldDbs(db) {
this._upgradeDb(db);
if (this._cacheName) deleteDB(this._cacheName);
}
/**
* @param url
* @param timestamp
*
* @private
*/
async setTimestamp(url, timestamp) {
url = normalizeURL(url);
const entry = {
id: this._getId(url),
cacheName: this._cacheName,
url,
timestamp
};
const tx = (await this.getDb()).transaction(CACHE_OBJECT_STORE, "readwrite", { durability: "relaxed" });
await tx.store.put(entry);
await tx.done;
}
/**
* Returns the timestamp stored for a given URL.
*
* @param url
* @returns
* @private
*/
async getTimestamp(url) {
return (await (await this.getDb()).get(CACHE_OBJECT_STORE, this._getId(url)))?.timestamp;
}
/**
* Iterates through all the entries in the object store (from newest to
* oldest) and removes entries once either `maxCount` is reached or the
* entry's timestamp is less than `minTimestamp`.
*
* @param minTimestamp
* @param maxCount
* @returns
* @private
*/
async expireEntries(minTimestamp, maxCount) {
let cursor = await (await this.getDb()).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;
}
/**
* Returns an open connection to the database.
*
* @private
*/
async getDb() {
if (!this._db) this._db = await openDB(DB_NAME, 1, { upgrade: this._upgradeDbAndDeleteOldDbs.bind(this) });
return this._db;
}
};
//#endregion
//#region src/lib/expiration/CacheExpiration.ts
/**
* Allows you to expires cached responses based on age or maximum number of entries.
* @see https://serwist.pages.dev/docs/serwist/core/cache-expiration
*/
var CacheExpiration = class {
_isRunning = false;
_rerunRequested = false;
_maxEntries;
_maxAgeSeconds;
_matchOptions;
_cacheName;
_timestampModel;
/**
* To construct a new `CacheExpiration` instance you must provide at least
* one of the `config` properties.
*
* @param cacheName Name of the cache to apply restrictions to.
* @param config
*/
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);
}
/**
* Expires entries for the given cache and given criteria.
*/
async expireEntries() {
if (this._isRunning) {
this._rerunRequested = true;
return;
}
this._isRunning = true;
const minTimestamp = this._maxAgeSeconds ? Date.now() - this._maxAgeSeconds * 1e3 : 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;
this.expireEntries();
}
}
/**
* Updates the timestamp for the given URL, allowing it to be correctly
* tracked by the class.
*
* @param url
*/
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());
}
/**
* Checks if a URL has expired or not before it's used.
*
* This looks the timestamp up in IndexedDB and can be slow.
*
* Note: This method does not remove an expired entry, call
* `expireEntries()` to remove such entries instead.
*
* @param url
* @returns
*/
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 * 1e3;
return timestamp !== void 0 ? timestamp < expireOlderThan : true;
}
/**
* Removes the IndexedDB used to keep track of cache expiration metadata.
*/
async delete() {
this._rerunRequested = false;
await this._timestampModel.expireEntries(Number.POSITIVE_INFINITY);
}
};
//#endregion
//#region src/registerQuotaErrorCallback.ts
/**
* Adds a function to the set of quotaErrorCallbacks that will be executed if
* there's a quota error.
*
* @param callback
*/
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);
};
//#endregion
//#region src/lib/expiration/ExpirationPlugin.ts
/**
* This plugin can be used in a {@linkcode Strategy} to regularly enforce a
* limit on the age and/or the number of cached requests.
*
* It can only be used with {@linkcode Strategy} instances that have a custom `cacheName` property set.
* In other words, it can't be used to expire entries in strategies that use the default runtime
* cache name.
*
* Whenever a cached response is used or updated, this plugin will look
* at the associated cache and remove any old or extra responses.
*
* When using `maxAgeSeconds`, responses may be used *once* after expiring
* because the expiration clean up will not have occurred until *after* the
* cached response has been used. If the response has a "Date" header, then a lightweight expiration
* check is performed, and the response will not be used immediately.
*
* When using `maxEntries`, the least recently requested entry will be removed
* from the cache.
*
* @see https://serwist.pages.dev/docs/serwist/runtime-caching/plugins/expiration-plugin
*/
var ExpirationPlugin = class {
_config;
_cacheExpirations;
/**
* @param config
*/
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 = /* @__PURE__ */ new Map();
if (!this._config.maxAgeFrom) this._config.maxAgeFrom = "last-fetched";
if (this._config.purgeOnQuotaError) registerQuotaErrorCallback(() => this.deleteCacheAndMetadata());
}
/**
* A simple helper method to return a CacheExpiration instance for a given
* cache name.
*
* @param cacheName
* @returns
* @private
*/
_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;
}
/**
* A lifecycle callback that will be triggered automatically when a
* response is about to be returned from a [`Cache`](https://developer.mozilla.org/en-US/docs/Web/API/Cache).
* It allows the response to be inspected for freshness and
* prevents it from being used if the response's `Date` header value is
* older than the configured `maxAgeSeconds`.
*
* @param options
* @returns `cachedResponse` if it is fresh and `null` if it is stale or
* not available.
* @private
*/
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 {
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;
}
/**
* @param cachedResponse
* @returns
* @private
*/
_isResponseDateFresh(cachedResponse) {
if (this._config.maxAgeFrom === "last-used") 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 * 1e3;
}
/**
* Extracts the `Date` header and parse it into an useful value.
*
* @param cachedResponse
* @returns
* @private
*/
_getDateHeaderTimestamp(cachedResponse) {
if (!cachedResponse.headers.has("date")) return null;
const dateHeader = cachedResponse.headers.get("date");
const headerTime = new Date(dateHeader).getTime();
if (Number.isNaN(headerTime)) return null;
return headerTime;
}
/**
* A lifecycle callback that will be triggered automatically when an entry is added
* to a cache.
*
* @param options
* @private
*/
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();
}
/**
* Deletes the underlying `Cache` instance associated with this instance and the metadata
* from IndexedDB used to keep track of expiration details for each `Cache` instance.
*
* When using cache expiration, calling this method is preferable to calling
* `caches.delete()` directly, since this will ensure that the IndexedDB
* metadata is also cleanly removed and that open IndexedDB instances are deleted.
*
* Note that if you're *not* using cache expiration for a given cache, calling
* `caches.delete()` and passing in the cache's name should be sufficient.
* There is no Serwist-specific method needed for cleanup in that case.
*/
async deleteCacheAndMetadata() {
for (const [cacheName, cacheExpiration] of this._cacheExpirations) {
await self.caches.delete(cacheName);
await cacheExpiration.delete();
}
this._cacheExpirations = /* @__PURE__ */ new Map();
}
};
//#endregion
//#region src/lib/googleAnalytics/constants.ts
const QUEUE_NAME = "serwist-google-analytics";
const MAX_RETENTION_TIME = 2880;
const COLLECT_PATHS_REGEX = /^\/(\w+\/)?collect/;
//#endregion
//#region src/lib/googleAnalytics/initializeGoogleAnalytics.ts
/**
* Creates the requestWillDequeue callback to be used with the background
* sync plugin. The callback takes the failed request and adds the
* `qt` param based on the current time, as well as applies any other
* user-defined hit modifications.
*
* @param config
* @returns The requestWillDequeue callback function.
* @private
*/
const createOnSyncCallback = (config) => {
return async ({ queue }) => {
let entry;
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!");
};
};
/**
* Creates GET and POST routes to catch failed Measurement Protocol hits.
*
* @param bgSyncPlugin
* @returns The created routes.
* @private
*/
const createCollectRoutes = (bgSyncPlugin) => {
const match = ({ url }) => url.hostname === "www.google-analytics.com" && COLLECT_PATHS_REGEX.test(url.pathname);
const handler = new NetworkOnly({ plugins: [bgSyncPlugin] });
return [new Route(match, handler, "GET"), new Route(match, handler, "POST")];
};
/**
* Creates a route with a network first strategy for the analytics.js script.
*
* @param cacheName
* @returns The created route.
* @private
*/
const createAnalyticsJsRoute = (cacheName) => {
const match = ({ url }) => url.hostname === "www.google-analytics.com" && url.pathname === "/analytics.js";
return new Route(match, new NetworkFirst({ cacheName }), "GET");
};
/**
* Creates a route with a network first strategy for the gtag.js script.
*
* @param cacheName
* @returns The created route.
* @private
*/
const createGtagJsRoute = (cacheName) => {
const match = ({ url }) => url.hostname === "www.googletagmanager.com" && url.pathname === "/gtag/js";
return new Route(match, new NetworkFirst({ cacheName }), "GET");
};
/**
* Creates a route with a network first strategy for the gtm.js script.
*
* @param cacheName
* @returns The created route.
* @private
*/
const createGtmJsRoute = (cacheName) => {
const match = ({ url }) => url.hostname === "www.googletagmanager.com" && url.pathname === "/gtm.js";
return new Route(match, new NetworkFirst({ cacheName }), "GET");
};
/**
* Initialize Serwist's offline Google Analytics v3 support.
*
* @param options
*/
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);
};
//#endregion
//#region src/lib/precaching/PrecacheFallbackPlugin.ts
/**
* Allows you to specify offline fallbacks to be used when a given strategy
* is unable to generate a response.
*
* It does this by intercepting the `handlerDidError` plugin callback
* and returning a precached response, taking the expected revision parameter
* into account automatically.
*/
var PrecacheFallbackPlugin = class {
_fallbackUrls;
_serwist;
/**
* Constructs a new instance with the associated `fallbackUrls`.
*
* @param config
*/
constructor({ fallbackUrls, serwist }) {
this._fallbackUrls = fallbackUrls;
this._serwist = serwist;
}
/**
* @returns The precache response for one of the fallback URLs, or `undefined` if
* nothing satisfies the conditions.
* @private
*/
async handlerDidError(param) {
for (const fallback of this._fallbackUrls) if (typeof fallback === "string") {
const fallbackResponse = await this._serwist.matchPrecache(fallback);
if (fallbackResponse !== void 0) return fallbackResponse;
} else if (fallback.matcher(param)) {
const fallbackResponse = await this._serwist.matchPrecache(fallback.url);
if (fallbackResponse !== void 0) return fallbackResponse;
}
}
};
//#endregion
//#region src/lib/rangeRequests/utils/calculateEffectiveBoundaries.ts
/**
* @param blob A source blob.
* @param start The offset to use as the start of the
* slice.
* @param end The offset to use as the end of the slice.
* @returns An object with `start` and `end` properties, reflecting
* the effective boundaries to use given the size of the blob.
* @private
*/
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 !== void 0 && end !== void 0) {
effectiveStart = start;
effectiveEnd = end + 1;
} else if (start !== void 0 && end === void 0) {
effectiveStart = start;
effectiveEnd = blobSize;
} else if (end !== void 0 && start === void 0) {
effectiveStart = blobSize - end;
effectiveEnd = blobSize;
}
return {
start: effectiveStart,
end: effectiveEnd
};
};
//#endregion
//#region src/lib/rangeRequests/utils/parseRangeHeader.ts
/**
* @param rangeHeader A `Range` header value.
* @returns An object with `start` and `end` properties, reflecting
* the parsed value of the `Range` header. If either the `start` or `end` are
* omitted, then `null` will be returned.
* @private
*/
const parseRangeHeader = (rangeHeader) => {
if (process.env.NODE_ENV !== "production") finalAssertExports.isType(rangeHeader, "string", {
moduleName: "@serwist/range-requests",
funcName: "parseRangeHeader",
paramName: "rangeHeader"
});
const normalizedRangeHeader = rangeHeader.trim().toLowerCase();
if (!normalizedRangeHeader.startsWith("bytes=")) throw new SerwistError("unit-must-be-bytes", { normalizedRangeHeader });
if (normalizedRangeHeader.includes(",")) throw new SerwistError("single-range-only", { normalizedRangeHeader });
const rangeParts = /(\d*)-(\d*)/.exec(normalizedRangeHeader);
if (!rangeParts || !(rangeParts[1] || rangeParts[2])) throw new SerwistError("invalid-range-values", { normalizedRangeHeader });
return {
start: rangeParts[1] === "" ? void 0 : Number(rangeParts[1]),
end: rangeParts[2] === "" ? void 0 : Number(rangeParts[2])
};
};
//#endregion
//#region src/lib/rangeRequests/createPartialResponse.ts
/**
* Given a request and a response, this will return a
* promise that resolves to a partial response.
*
* If the original response already contains partial content (i.e. it has
* a status of 206), then this assumes it already fulfills the `Range`
* requirements, and will return it as-is.
*
* @param request A request, which should contain a `Range`
* header.
* @param originalResponse A response.
* @returns Either a `206 Partial Content` response, with
* the response body set to the slice of content specified by the request's
* `Range` header, or a `416 Range Not Satisfiable` response if the
* conditions of the `Range` header can't be met.
*/
const createPartialResponse = async (request, originalResponse) => {
try {
if (process.env.NODE_ENV !== "production") {
finalAssertExports.isInstance(request, Request, {
moduleName: "@serwist/range-requests",
funcName: "createPartialResponse",
paramName: "request"
});
finalAssertExports.isInstance(originalResponse, Response, {
moduleName: "@serwist/range-requests",
funcName: "createPartialResponse",
paramName: "originalResponse"
});
}
if (originalResponse.status === 206) return originalResponse;
const rangeHeader = request.headers.get("range");
if (!rangeHeader) throw new SerwistError("no-range-header");
const boundaries = parseRangeHeader(rangeHeader);
const originalBlob = await originalResponse.blob();
const effectiveBoundaries = calculateEffectiveBoundaries(originalBlob, boundaries.start, boundaries.end);
const slicedBlob = originalBlob.slice(effectiveBoundaries.start, effectiveBoundaries.end);
const slicedBlobSize = slicedBlob.size;
const slicedResponse = new Response(slicedBlob, {
status: 206,
statusText: "Partial Content",
headers: originalResponse.headers
});
slicedResponse.headers.set("Content-Length", String(slicedBlobSize));
slicedResponse.headers.set("Content-Range", `bytes ${effectiveBoundaries.start}-${effectiveBoundaries.end - 1}/${originalBlob.size}`);
return slicedResponse;
} catch (error) {
if (process.env.NODE_ENV !== "production") {
logger.warn("Unable to construct a partial response; returning a 416 Range Not Satisfiable response instead.");
logger.groupCollapsed("View details here.");
logger.log(error);
logger.log(request);
logger.log(originalResponse);
logger.groupEnd();
}
return new Response("", {
status: 416,
statusText: "Range Not Satisfiable"
});
}
};
//#endregion
//#region src/lib/rangeRequests/RangeRequestsPlugin.ts
/**
* Makes it easy for a request with a `Range` header to be fulfilled by a cached response.
*
* It does this by intercepting the `cachedResponseWillBeUsed` plugin callback
* and returning the appropriate subset of the cached response body.
*/
var RangeRequestsPlugin = class {
/**
* @param options
* @returns If request contains a `Range` header, then a
* partial response whose body is a subset of `cachedResponse` is
* returned. Otherwise, `cachedResponse` is returned as-is.
* @private
*/
cachedResponseWillBeUsed = async ({ request, cachedResponse }) => {
if (cachedResponse && request.headers.has("range")) return await createPartialResponse(request, cachedResponse);
return cachedResponse;
};
};
//#endregion
//#region src/lib/strategies/CacheFirst.ts
/**
* An implementation of the [cache first](https://developer.chrome.com/docs/workbox/caching-strategies-overview/#cache_first_falling_back_to_network)
* request strategy.
*
* A cache first strategy is useful for assets that have been revisioned,
* such as URLs like "/styles/example.a8f5f1.css", since they
* can be cached for long periods of time.
*
* If the network request fails, and there is no cache match, this will throw
* a {@linkcode SerwistError} exception.
*/
var CacheFirst = class extends Strategy {
/**
* @private
* @param request A request to run this strategy for.
* @param handler The event that triggered the request.
* @returns
*/
async _handle(request, handler) {
const logs = [];
if (process.env.NODE_ENV !== "production") finalAssertExports.isInstance(request, Request, {
moduleName: "serwist",
className: this.constructor.name,
funcName: "makeRequest",
paramName: "request"
});
let response = await handler.cacheMatch(request);
let error;
if (!response) {
if (process.env.NODE_ENV !== "production") logs.push(`No response found in the '${this.cacheName}' cache. Will respond with a network request.`);
try {
response = await handler.fetchAndCachePut(request);
} catch (err) {
if (err instanceof Error) error = err;
}
if (process.env.NODE_ENV !== "production") if (response) logs.push("Got response from network.");
else logs.push("Unable to get a response from the network.");
} else if (process.env.NODE_ENV !== "production") logs.push(`Found a cached response in the '${this.cacheName}' cache.`);
if (process.env.NODE_ENV !== "production") {
logger.groupCollapsed(messages.strategyStart(this.constructor.name, request));
for (const log of logs) logger.log(log);
messages.printFinalResponse(response);
logger.groupEnd();
}
if (!response) throw new SerwistError("no-response", {
url: request.url,
error
});
return response;
}
};
//#endregion
//#region src/lib/strategies/CacheOnly.ts
/**
* An implementation of the [cache only](https://developer.chrome.com/docs/workbox/caching-strategies-overview/#cache_only)
* request strategy.
*
* This class is useful if you already have your own precaching step.
*
* If there is no cache match, this will throw a {@linkcode SerwistError} exception.
*/
var CacheOnly = class extends Strategy {
/**
* @private
* @param request A request to run this strategy for.
* @param handler The event that triggered the request.
* @returns
*/
async _handle(request, handler) {
if (process.env.NODE_ENV !== "production") finalAssertExports.isInstance(request, Request, {
moduleName: "serwist",
className: this.constructor.name,
funcName: "makeRequest",
paramName: "request"
});
const response = await handler.cacheMatch(request);
if (process.env.NODE_ENV !== "production") {
logger.groupCollapsed(messages.strategyStart(this.constructor.name, request));
if (response) {
logger.log(`Found a cached response in the '${this.cacheName}' cache.`);
messages.printFinalResponse(response);
} else logger.log(`No response found in the '${this.cacheName}' cache.`);
logger.groupEnd();
}
if (!response) throw new SerwistError("no-response", { url: request.url });
return response;
}
};
//#endregion
//#region src/lib/strategies/StaleWhileRevalidate.ts
/**
* An implementation of the
* [stale-while-revalidate](https://developer.chrome.com/docs/workbox/caching-strategies-overview/#stale_while_revalidate)
* request strategy.
*
* Resources are requested from both the cache and the network in parallel.
* The strategy will respond with the cached version if available, otherwise
* wait for the network response. The cache is updated with the network response
* with each successful request.
*
* By default, this strategy will cache responses with a 200 status code as
* well as [opaque responses](https://developer.chrome.com/docs/workbox/caching-resources-during-runtime/#opaque-responses).
* Opaque responses are cross-origin requests where the response doesn't
* support [CORS](https://enable-cors.org/).
*
* If the network request fails, and there is no cache match, this will throw
* a {@linkcode SerwistError} exception.
*/
var StaleWhileRevalidate = class extends Strategy {
/**
* @param options
*/
constructor(options = {}) {
super(options);
if (!this.plugins.some((p) => "cacheWillUpdate" in p)) this.plugins.unshift(cacheOkAndOpaquePlugin);
}
/**
* @private
* @param request A request to run this strategy for.
* @param handler The event that triggered the request.
* @returns
*/
async _handle(request, handler) {
const logs = [];
if (process.env.NODE_ENV !== "production") finalAssertExports.isInstance(request, Request, {
moduleName: "serwist",
className: this.constructor.name,
funcName: "handle",
paramName: "request"
});
const fetchAndCachePromise = handler.fetchAndCachePut(request).catch(() => {});
handler.waitUntil(fetchAndCachePromise);
let response = await handler.cacheMatch(request);
let error;
if (response) {
if (process.env.NODE_ENV !== "production") logs.push(`Found a cached response in the '${this.cacheName}' cache. Will update with the network response in the background.`);
} else {
if (process.env.NODE_ENV !== "production") logs.push(`No response found in the '${this.cacheName}' cache. Will wait for the network response.`);
try {
response = await fetchAndCachePromise;
} catch (err) {
if (err instanceof Error) error = err;
}
}
if (process.env.NODE_ENV !== "production") {
logger.groupCollapsed(messages.strategyStart(this.constructor.name, request));
for (const log of logs) logger.log(log);
messages.printFinalResponse(response);
logger.groupEnd();
}
if (!response) throw new SerwistError("no-response", {
url: request.url,
error
});
return response;
}
};
//#endregion
//#region src/PrecacheRoute.ts
/**
* A subclass of {@linkcode Route} that takes a {@linkcode Serwist} instance and uses it to match
* incoming requests and handle fetching responses from the precache.
*/
var PrecacheRoute = class extends Route {
/**
* @param serwist A {@linkcode Serwist} instance.
* @param options Options to control how requests are matched
* against the list of precached URLs.
*/
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) return {
cacheKey,
integrity: serwist.getIntegrityForPrecacheKey(cacheKey)
};
}
if (process.env.NODE_ENV !== "production") logger.debug(`Precaching did not find a match for ${getFriendlyURL(request.url)}.`);
};
super(match, serwist.precacheStrategy);
}
};
//#endregion
//#region src/utils/PrecacheCacheKeyPlugin.ts
/**
* A plugin, designed to be used with PrecacheController, to translate URLs into
* the corresponding cache key, based on the current revision info.
*
* @private
*/
var PrecacheCacheKeyPlugin = class {
_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;
};
};
//#endregion
//#region src/utils/parsePrecacheOptions.ts
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
}
};
};
//#endregion
//#region src/Serwist.ts
/**
* A class that helps bootstrap the service worker.
*
* @see https://serwist.pages.dev/docs/serwist/core/serwist
*/
var Serwist = class {
_urlsToCacheKeys = /* @__PURE__ */ new Map();
_urlsToCacheModes = /* @__PURE__ */ new Map();
_cacheKeysToIntegrities = /* @__PURE__ */ new Map();
_concurrentPrecaching;
_precacheStrategy;
_routes;
_defaultHandlerMap;
_catchHandler;
_requestRules;
constructor({ precacheEntries, precacheOptions, skipWaiting = false, importScripts, navigationPreload = false, cacheId, clientsClaim: clientsClaim$1 = false, runtimeCaching, offlineAnalyticsConfig, disableDevLogs: disableDevLogs$1 = false, fallbacks, requestRules } = {}) {
const { precacheStrategyOptions, precacheRouteOptions, precacheMiscOptions } = parsePrecacheOptions(this, precacheOptions);
this._concurrentPrecaching = precacheMiscOptions.concurrency;
this._precacheStrategy = new PrecacheStrategy(precacheStrategyOptions);
this._routes = /* @__PURE__ */ new Map();
this._defaultHandlerMap = /* @__PURE__ */ new Map();
this._requestRules = requestRules;
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 !== void 0) 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 !== void 0) if (typeof offlineAnalyticsConfig === "boolean") offlineAnalyticsConfig && initializeGoogleAnalytics({ serwist: this });
else initializeGoogleAnalytics({
...offlineAnalyticsConfig,
serwist: this
});
if (runtimeCaching !== void 0) {
if (fallbacks !== void 0) {
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();
}
/**
* The strategy used to precache assets and respond to `fetch` events.
*/
get precacheStrategy() {
return this._precacheStrategy;
}
/**
* A `Map` of HTTP method name (`'GET'`, etc.) to an array of all corresponding registered {@linkcode Route}
* instances.
*/
get routes() {
return this._routes;
}
/**
* Adds Serwist's event listeners for you. Before calling it, add your own listeners should you need to.
*/
addEventListeners() {
self.addEventListener("install", this.handleInstall);
self.addEventListener("activate", this.handleActivate);
self.addEventListene