serwist
Version:
A Swiss Army knife for service workers.
1,542 lines (1,541 loc) • 69.8 kB
JavaScript
import { a as Deferred, c as timeout, d as finalAssertExports, f as SerwistError, i as executeQuotaErrorCallbacks, l as logger, m as cacheNames, o as cacheMatchIgnoreParams, p as canConstructResponseFromBodyStream, u as getFriendlyURL } from "./waitUntil-BHDx3Rgo.js";
import { openDB } from "idb";
//#region src/copyResponse.ts
/**
* Allows developers to copy a response and modify its `headers`, `status`,
* or `statusText` values (the [valid options](https://developer.mozilla.org/en-US/docs/Web/API/Response/Response#options)
* when constructing a `Response` object).
* To modify these values, pass a function as the second argument. That
* function will be invoked with the options of the initial `Response` object.
* The return value of this function will be used as the options for the new `Response` object.
* To change the values either modify the passed parameter(s) and return it or return a totally
* new object.
*
* This method is intentionally limited to same-origin responses, regardless of
* whether CORS was used or not.
*
* @param response The initial response.
* @param modifier The function used to modify the options of the `Response` object.
*/
const copyResponse = async (response, modifier) => {
let origin = null;
if (response.url) origin = new URL(response.url).origin;
if (origin !== self.location.origin) throw new SerwistError("cross-origin-copy-response", { origin });
const clonedResponse = response.clone();
const responseInit = {
headers: new Headers(clonedResponse.headers),
status: clonedResponse.status,
statusText: clonedResponse.statusText
};
const modifiedResponseInit = modifier ? modifier(responseInit) : responseInit;
const body = canConstructResponseFromBodyStream() ? clonedResponse.body : await clonedResponse.blob();
return new Response(body, modifiedResponseInit);
};
//#endregion
//#region src/disableDevLogs.ts
/**
* Disables Serwist's logging in development mode.
*
* @see https://serwist.pages.dev/docs/serwist/core/disable-dev-logs
*/
const disableDevLogs = () => {
self.__WB_DISABLE_DEV_LOGS = true;
};
//#endregion
//#region src/lib/backgroundSync/BackgroundSyncQueueDb.ts
const BACKGROUND_SYNC_DB_VERSION = 3;
const BACKGROUND_SYNC_DB_NAME = "serwist-background-sync";
const REQUEST_OBJECT_STORE_NAME = "requests";
const QUEUE_NAME_INDEX = "queueName";
/**
* A class to interact directly an IndexedDB created specifically to save and
* retrieve QueueStoreEntries. This class encapsulates all the schema details
* to store the representation of a Queue.
*
* @private
*/
var BackgroundSyncQueueDb = class {
_db = null;
/**
* Add QueueStoreEntry to underlying db.
*
* @param entry
*/
async addEntry(entry) {
const tx = (await this.getDb()).transaction(REQUEST_OBJECT_STORE_NAME, "readwrite", { durability: "relaxed" });
await tx.store.add(entry);
await tx.done;
}
/**
* Returns the first entry id in the ObjectStore.
*
* @returns
*/
async getFirstEntryId() {
return (await (await this.getDb()).transaction(REQUEST_OBJECT_STORE_NAME).store.openCursor())?.value.id;
}
/**
* Get all the entries filtered by index
*
* @param queueName
* @returns
*/
async getAllEntriesByQueueName(queueName) {
const results = await (await this.getDb()).getAllFromIndex(REQUEST_OBJECT_STORE_NAME, QUEUE_NAME_INDEX, IDBKeyRange.only(queueName));
return results ? results : [];
}
/**
* Returns the number of entries filtered by index
*
* @param queueName
* @returns
*/
async getEntryCountByQueueName(queueName) {
return (await this.getDb()).countFromIndex(REQUEST_OBJECT_STORE_NAME, QUEUE_NAME_INDEX, IDBKeyRange.only(queueName));
}
/**
* Deletes a single entry by id.
*
* @param id the id of the entry to be deleted
*/
async deleteEntry(id) {
await (await this.getDb()).delete(REQUEST_OBJECT_STORE_NAME, id);
}
/**
*
* @param queueName
* @returns
*/
async getFirstEntryByQueueName(queueName) {
return await this.getEndEntryFromIndex(IDBKeyRange.only(queueName), "next");
}
/**
*
* @param queueName
* @returns
*/
async getLastEntryByQueueName(queueName) {
return await this.getEndEntryFromIndex(IDBKeyRange.only(queueName), "prev");
}
/**
* Returns either the first or the last entries, depending on direction.
* Filtered by index.
*
* @param direction
* @param query
* @returns
* @private
*/
async getEndEntryFromIndex(query, direction) {
return (await (await this.getDb()).transaction(REQUEST_OBJECT_STORE_NAME).store.index(QUEUE_NAME_INDEX).openCursor(query, direction))?.value;
}
/**
* Returns an open connection to the database.
*
* @private
*/
async getDb() {
if (!this._db) this._db = await openDB(BACKGROUND_SYNC_DB_NAME, BACKGROUND_SYNC_DB_VERSION, { upgrade: this._upgradeDb });
return this._db;
}
/**
* Upgrades QueueDB
*
* @param db
* @param oldVersion
* @private
*/
_upgradeDb(db, oldVersion) {
if (oldVersion > 0 && oldVersion < BACKGROUND_SYNC_DB_VERSION) {
if (db.objectStoreNames.contains(REQUEST_OBJECT_STORE_NAME)) db.deleteObjectStore(REQUEST_OBJECT_STORE_NAME);
}
db.createObjectStore(REQUEST_OBJECT_STORE_NAME, {
autoIncrement: true,
keyPath: "id"
}).createIndex(QUEUE_NAME_INDEX, QUEUE_NAME_INDEX, { unique: false });
}
};
//#endregion
//#region src/lib/backgroundSync/BackgroundSyncQueueStore.ts
/**
* A class to manage storing requests from a Queue in IndexedDB,
* indexed by their queue name for easier access.
*
* Most developers will not need to access this class directly;
* it is exposed for advanced use cases.
*/
var BackgroundSyncQueueStore = class {
_queueName;
_queueDb;
/**
* Associates this instance with a Queue instance, so entries added can be
* identified by their queue name.
*
* @param queueName
*/
constructor(queueName) {
this._queueName = queueName;
this._queueDb = new BackgroundSyncQueueDb();
}
/**
* Append an entry last in the queue.
*
* @param entry
*/
async pushEntry(entry) {
if (process.env.NODE_ENV !== "production") {
finalAssertExports.isType(entry, "object", {
moduleName: "serwist",
className: "BackgroundSyncQueueStore",
funcName: "pushEntry",
paramName: "entry"
});
finalAssertExports.isType(entry.requestData, "object", {
moduleName: "serwist",
className: "BackgroundSyncQueueStore",
funcName: "pushEntry",
paramName: "entry.requestData"
});
}
delete entry.id;
entry.queueName = this._queueName;
await this._queueDb.addEntry(entry);
}
/**
* Prepend an entry first in the queue.
*
* @param entry
*/
async unshiftEntry(entry) {
if (process.env.NODE_ENV !== "production") {
finalAssertExports.isType(entry, "object", {
moduleName: "serwist",
className: "BackgroundSyncQueueStore",
funcName: "unshiftEntry",
paramName: "entry"
});
finalAssertExports.isType(entry.requestData, "object", {
moduleName: "serwist",
className: "BackgroundSyncQueueStore",
funcName: "unshiftEntry",
paramName: "entry.requestData"
});
}
const firstId = await this._queueDb.getFirstEntryId();
if (firstId) entry.id = firstId - 1;
else delete entry.id;
entry.queueName = this._queueName;
await this._queueDb.addEntry(entry);
}
/**
* Removes and returns the last entry in the queue matching the `queueName`.
*
* @returns
*/
async popEntry() {
return this._removeEntry(await this._queueDb.getLastEntryByQueueName(this._queueName));
}
/**
* Removes and returns the first entry in the queue matching the `queueName`.
*
* @returns
*/
async shiftEntry() {
return this._removeEntry(await this._queueDb.getFirstEntryByQueueName(this._queueName));
}
/**
* Returns all entries in the store matching the `queueName`.
*
* @returns
*/
async getAll() {
return await this._queueDb.getAllEntriesByQueueName(this._queueName);
}
/**
* Returns the number of entries in the store matching the `queueName`.
*
* @returns
*/
async size() {
return await this._queueDb.getEntryCountByQueueName(this._queueName);
}
/**
* Deletes the entry for the given ID.
*
* WARNING: this method does not ensure the deleted entry belongs to this
* queue (i.e. matches the `queueName`). But this limitation is acceptable
* as this class is not publicly exposed. An additional check would make
* this method slower than it needs to be.
*
* @param id
*/
async deleteEntry(id) {
await this._queueDb.deleteEntry(id);
}
/**
* Removes and returns the first or last entry in the queue (based on the
* `direction` argument) matching the `queueName`.
*
* @returns
* @private
*/
async _removeEntry(entry) {
if (entry) await this.deleteEntry(entry.id);
return entry;
}
};
//#endregion
//#region src/lib/backgroundSync/StorableRequest.ts
const serializableProperties = [
"method",
"referrer",
"referrerPolicy",
"mode",
"credentials",
"cache",
"redirect",
"integrity",
"keepalive"
];
/**
* A class to make it easier to serialize and de-serialize requests so they
* can be stored in IndexedDB.
*
* Most developers will not need to access this class directly;
* it is exposed for advanced use cases.
*/
var StorableRequest = class StorableRequest {
_requestData;
/**
* Converts a Request object to a plain object that can be structured
* cloned or stringified to JSON.
*
* @param request
* @returns
*/
static async fromRequest(request) {
const requestData = {
url: request.url,
headers: {}
};
if (request.method !== "GET") requestData.body = await request.clone().arrayBuffer();
request.headers.forEach((value, key) => {
requestData.headers[key] = value;
});
for (const prop of serializableProperties) if (request[prop] !== void 0) requestData[prop] = request[prop];
return new StorableRequest(requestData);
}
/**
* Accepts an object of request data that can be used to construct a
* `Request` object but can also be stored in IndexedDB.
*
* @param requestData An object of request data that includes the `url` plus any relevant property of
* [`requestInit`](https://fetch.spec.whatwg.org/#requestinit).
*/
constructor(requestData) {
if (process.env.NODE_ENV !== "production") {
finalAssertExports.isType(requestData, "object", {
moduleName: "serwist",
className: "StorableRequest",
funcName: "constructor",
paramName: "requestData"
});
finalAssertExports.isType(requestData.url, "string", {
moduleName: "serwist",
className: "StorableRequest",
funcName: "constructor",
paramName: "requestData.url"
});
}
if (requestData.mode === "navigate") requestData.mode = "same-origin";
this._requestData = requestData;
}
/**
* Returns a deep clone of the instance's `requestData` object.
*
* @returns
*/
toObject() {
const requestData = Object.assign({}, this._requestData);
requestData.headers = Object.assign({}, this._requestData.headers);
if (requestData.body) requestData.body = requestData.body.slice(0);
return requestData;
}
/**
* Converts this instance to a Request.
*
* @returns
*/
toRequest() {
return new Request(this._requestData.url, this._requestData);
}
/**
* Creates and returns a deep clone of the instance.
*
* @returns
*/
clone() {
return new StorableRequest(this.toObject());
}
};
//#endregion
//#region src/lib/backgroundSync/BackgroundSyncQueue.ts
const TAG_PREFIX = "serwist-background-sync";
const MAX_RETENTION_TIME = 1440 * 7;
const queueNames = /* @__PURE__ */ new Set();
/**
* Converts a QueueStore entry into the format exposed by Queue. This entails
* converting the request data into a real request and omitting the `id` and
* `queueName` properties.
*
* @param queueStoreEntry
* @returns
* @private
*/
const convertEntry = (queueStoreEntry) => {
const queueEntry = {
request: new StorableRequest(queueStoreEntry.requestData).toRequest(),
timestamp: queueStoreEntry.timestamp
};
if (queueStoreEntry.metadata) queueEntry.metadata = queueStoreEntry.metadata;
return queueEntry;
};
/**
* A class to manage storing failed requests in IndexedDB and retrying them
* later. All parts of the storing and replaying process are observable via
* callbacks.
*/
var BackgroundSyncQueue = class {
_name;
_onSync;
_maxRetentionTime;
_queueStore;
_forceSyncFallback;
_syncInProgress = false;
_requestsAddedDuringSync = false;
/**
* Creates an instance of Queue with the given options
*
* @param name The unique name for this queue. This name must be
* unique as it's used to register sync events and store requests
* in IndexedDB specific to this instance. An error will be thrown if
* a duplicate name is detected.
* @param options
*/
constructor(name, { forceSyncFallback, onSync, maxRetentionTime } = {}) {
if (queueNames.has(name)) throw new SerwistError("duplicate-queue-name", { name });
queueNames.add(name);
this._name = name;
this._onSync = onSync || this.replayRequests;
this._maxRetentionTime = maxRetentionTime || MAX_RETENTION_TIME;
this._forceSyncFallback = Boolean(forceSyncFallback);
this._queueStore = new BackgroundSyncQueueStore(this._name);
this._addSyncListener();
}
/**
* @returns
*/
get name() {
return this._name;
}
/**
* Stores the passed request in IndexedDB (with its timestamp and any
* metadata) at the end of the queue.
*
* @param entry
*/
async pushRequest(entry) {
if (process.env.NODE_ENV !== "production") {
finalAssertExports.isType(entry, "object", {
moduleName: "serwist",
className: "BackgroundSyncQueue",
funcName: "pushRequest",
paramName: "entry"
});
finalAssertExports.isInstance(entry.request, Request, {
moduleName: "serwist",
className: "BackgroundSyncQueue",
funcName: "pushRequest",
paramName: "entry.request"
});
}
await this._addRequest(entry, "push");
}
/**
* Stores the passed request in IndexedDB (with its timestamp and any
* metadata) at the beginning of the queue.
*
* @param entry
*/
async unshiftRequest(entry) {
if (process.env.NODE_ENV !== "production") {
finalAssertExports.isType(entry, "object", {
moduleName: "serwist",
className: "BackgroundSyncQueue",
funcName: "unshiftRequest",
paramName: "entry"
});
finalAssertExports.isInstance(entry.request, Request, {
moduleName: "serwist",
className: "BackgroundSyncQueue",
funcName: "unshiftRequest",
paramName: "entry.request"
});
}
await this._addRequest(entry, "unshift");
}
/**
* Removes and returns the last request in the queue (along with its
* timestamp and any metadata).
*
* @returns
*/
async popRequest() {
return this._removeRequest("pop");
}
/**
* Removes and returns the first request in the queue (along with its
* timestamp and any metadata).
*
* @returns
*/
async shiftRequest() {
return this._removeRequest("shift");
}
/**
* Returns all the entries that have not expired (per `maxRetentionTime`).
* Any expired entries are removed from the queue.
*
* @returns
*/
async getAll() {
const allEntries = await this._queueStore.getAll();
const now = Date.now();
const unexpiredEntries = [];
for (const entry of allEntries) {
const maxRetentionTimeInMs = this._maxRetentionTime * 60 * 1e3;
if (now - entry.timestamp > maxRetentionTimeInMs) await this._queueStore.deleteEntry(entry.id);
else unexpiredEntries.push(convertEntry(entry));
}
return unexpiredEntries;
}
/**
* Returns the number of entries present in the queue.
* Note that expired entries (per `maxRetentionTime`) are also included in this count.
*
* @returns
*/
async size() {
return await this._queueStore.size();
}
/**
* Adds the entry to the QueueStore and registers for a sync event.
*
* @param entry
* @param operation
* @private
*/
async _addRequest({ request, metadata, timestamp = Date.now() }, operation) {
const entry = {
requestData: (await StorableRequest.fromRequest(request.clone())).toObject(),
timestamp
};
if (metadata) entry.metadata = metadata;
switch (operation) {
case "push":
await this._queueStore.pushEntry(entry);
break;
case "unshift":
await this._queueStore.unshiftEntry(entry);
break;
}
if (process.env.NODE_ENV !== "production") logger.log(`Request for '${getFriendlyURL(request.url)}' has been added to background sync queue '${this._name}'.`);
if (this._syncInProgress) this._requestsAddedDuringSync = true;
else await this.registerSync();
}
/**
* Removes and returns the first or last (depending on `operation`) entry
* from the {@linkcode BackgroundSyncQueueStore} that's not older than the `maxRetentionTime`.
*
* @param operation
* @returns
* @private
*/
async _removeRequest(operation) {
const now = Date.now();
let entry;
switch (operation) {
case "pop":
entry = await this._queueStore.popEntry();
break;
case "shift":
entry = await this._queueStore.shiftEntry();
break;
}
if (entry) {
const maxRetentionTimeInMs = this._maxRetentionTime * 60 * 1e3;
if (now - entry.timestamp > maxRetentionTimeInMs) return this._removeRequest(operation);
return convertEntry(entry);
}
}
/**
* Loops through each request in the queue and attempts to re-fetch it.
* If any request fails to re-fetch, it's put back in the same position in
* the queue (which registers a retry for the next sync event).
*/
async replayRequests() {
let entry;
while (entry = await this.shiftRequest()) try {
await fetch(entry.request.clone());
if (process.env.NODE_ENV !== "production") logger.log(`Request for '${getFriendlyURL(entry.request.url)}' has been replayed in queue '${this._name}'`);
} catch {
await this.unshiftRequest(entry);
if (process.env.NODE_ENV !== "production") logger.log(`Request for '${getFriendlyURL(entry.request.url)}' failed to replay, putting it back in queue '${this._name}'`);
throw new SerwistError("queue-replay-failed", { name: this._name });
}
if (process.env.NODE_ENV !== "production") logger.log(`All requests in queue '${this.name}' have successfully replayed; the queue is now empty!`);
}
/**
* Registers a sync event with a tag unique to this instance.
*/
async registerSync() {
if ("sync" in self.registration && !this._forceSyncFallback) try {
await self.registration.sync.register(`${TAG_PREFIX}:${this._name}`);
} catch (err) {
if (process.env.NODE_ENV !== "production") logger.warn(`Unable to register sync event for '${this._name}'.`, err);
}
}
/**
* In sync-supporting browsers, this adds a listener for the sync event.
* In non-sync-supporting browsers, or if _forceSyncFallback is true, this
* will retry the queue on service worker startup.
*
* @private
*/
_addSyncListener() {
if ("sync" in self.registration && !this._forceSyncFallback) self.addEventListener("sync", (event) => {
if (event.tag === `${TAG_PREFIX}:${this._name}`) {
if (process.env.NODE_ENV !== "production") logger.log(`Background sync for tag '${event.tag}' has been received`);
const syncComplete = async () => {
this._syncInProgress = true;
let syncError;
try {
await this._onSync({ queue: this });
} catch (error) {
if (error instanceof Error) {
syncError = error;
throw syncError;
}
} finally {
if (this._requestsAddedDuringSync && !(syncError && !event.lastChance)) await this.registerSync();
this._syncInProgress = false;
this._requestsAddedDuringSync = false;
}
};
event.waitUntil(syncComplete());
}
});
else {
if (process.env.NODE_ENV !== "production") logger.log("Background sync replaying without background sync event");
this._onSync({ queue: this });
}
}
/**
* Returns the set of queue names. This is primarily used to reset the list
* of queue names in tests.
*
* @returns
* @private
*/
static get _queueNames() {
return queueNames;
}
};
//#endregion
//#region src/lib/backgroundSync/BackgroundSyncPlugin.ts
/**
* A class implementing the `fetchDidFail` lifecycle callback. This makes it
* easier to add failed requests to a {@linkcode BackgroundSyncQueue}.
*/
var BackgroundSyncPlugin = class {
_queue;
/**
* @param name See the {@linkcode BackgroundSyncQueue}
* documentation for parameter details.
* @param options See the {@linkcode BackgroundSyncQueue}
* documentation for parameter details.
* @see https://serwist.pages.dev/docs/serwist/core/background-sync-queue
*/
constructor(name, options) {
this._queue = new BackgroundSyncQueue(name, options);
}
/**
* @param options
* @private
*/
async fetchDidFail({ request }) {
await this._queue.pushRequest({ request });
}
};
//#endregion
//#region src/lib/strategies/plugins/cacheOkAndOpaquePlugin.ts
const cacheOkAndOpaquePlugin = {
/**
* Returns a valid response (to allow caching) if the status is 200 (OK) or
* 0 (opaque).
*
* @param options
* @returns
* @private
*/
cacheWillUpdate: async ({ response }) => {
if (response.status === 200 || response.status === 0) return response;
return null;
} };
//#endregion
//#region src/lib/strategies/StrategyHandler.ts
function toRequest(input) {
return typeof input === "string" ? new Request(input) : input;
}
/**
* A class created every time a {@linkcode Strategy} instance calls {@linkcode Strategy.handle} or
* {@linkcode Strategy.handleAll} that wraps all fetch and cache actions around plugin callbacks
* and keeps track of when the strategy is "done" (i.e. when all added `event.waitUntil()` promises
* have resolved).
*/
var StrategyHandler = class {
/**
* The event associated with this request.
*/
event;
/**
* The request the strategy is processing (passed to the strategy's
* `handle()` or `handleAll()` method).
*/
request;
/**
* A `URL` instance of `request.url` (if passed to the strategy's
* `handle()` or `handleAll()` method).
* Note: the `url` param will be present if the strategy is invoked
* from a {@linkcode Route} object.
*/
url;
/**
* Some additional params (if passed to the strategy's
* `handle()` or `handleAll()` method).
*
* Note: the `params` param will be present if the strategy is invoked
* from a {@linkcode Route} object and that route's matcher returned a truthy
* value (it will be that value).
*/
params;
_cacheKeys = {};
_strategy;
_handlerDeferred;
_extendLifetimePromises;
_plugins;
_pluginStateMap;
/**
* Creates a new instance associated with the passed strategy and event
* that's handling the request.
*
* The constructor also initializes the state that will be passed to each of
* the plugins handling this request.
*
* @param strategy
* @param options
*/
constructor(strategy, options) {
if (process.env.NODE_ENV !== "production") {
finalAssertExports.isInstance(options.event, ExtendableEvent, {
moduleName: "serwist",
className: "StrategyHandler",
funcName: "constructor",
paramName: "options.event"
});
finalAssertExports.isInstance(options.request, Request, {
moduleName: "serwist",
className: "StrategyHandler",
funcName: "constructor",
paramName: "options.request"
});
}
this.event = options.event;
this.request = options.request;
if (options.url) {
this.url = options.url;
this.params = options.params;
}
this._strategy = strategy;
this._handlerDeferred = new Deferred();
this._extendLifetimePromises = [];
this._plugins = [...strategy.plugins];
this._pluginStateMap = /* @__PURE__ */ new Map();
for (const plugin of this._plugins) this._pluginStateMap.set(plugin, {});
this.event.waitUntil(this._handlerDeferred.promise);
}
/**
* Fetches a given request (and invokes any applicable plugin callback
* methods), taking the `fetchOptions` (for non-navigation requests) and
* `plugins` provided to the {@linkcode Strategy} object into account.
*
* The following plugin lifecycle methods are invoked when using this method:
* - `requestWillFetch()`
* - `fetchDidSucceed()`
* - `fetchDidFail()`
*
* @param input The URL or request to fetch.
* @returns
*/
async fetch(input) {
const { event } = this;
let request = toRequest(input);
const preloadResponse = await this.getPreloadResponse();
if (preloadResponse) return preloadResponse;
const originalRequest = this.hasCallback("fetchDidFail") ? request.clone() : null;
try {
for (const cb of this.iterateCallbacks("requestWillFetch")) request = await cb({
request: request.clone(),
event
});
} catch (err) {
if (err instanceof Error) throw new SerwistError("plugin-error-request-will-fetch", { thrownErrorMessage: err.message });
}
const pluginFilteredRequest = request.clone();
try {
let fetchResponse;
fetchResponse = await fetch(request, request.mode === "navigate" ? void 0 : this._strategy.fetchOptions);
if (process.env.NODE_ENV !== "production") logger.debug(`Network request for '${getFriendlyURL(request.url)}' returned a response with status '${fetchResponse.status}'.`);
for (const callback of this.iterateCallbacks("fetchDidSucceed")) fetchResponse = await callback({
event,
request: pluginFilteredRequest,
response: fetchResponse
});
return fetchResponse;
} catch (error) {
if (process.env.NODE_ENV !== "production") logger.log(`Network request for '${getFriendlyURL(request.url)}' threw an error.`, error);
if (originalRequest) await this.runCallbacks("fetchDidFail", {
error,
event,
originalRequest: originalRequest.clone(),
request: pluginFilteredRequest.clone()
});
throw error;
}
}
/**
* Calls `this.fetch()` and (in the background) caches the generated response.
*
* The call to `this.cachePut()` automatically invokes `this.waitUntil()`,
* so you do not have to call `waitUntil()` yourself.
*
* @param input The request or URL to fetch and cache.
* @returns
*/
async fetchAndCachePut(input) {
const response = await this.fetch(input);
const responseClone = response.clone();
this.waitUntil(this.cachePut(input, responseClone));
return response;
}
/**
* Matches a request from the cache (and invokes any applicable plugin
* callback method) using the `cacheName`, `matchOptions`, and `plugins`
* provided to the `Strategy` object.
*
* The following lifecycle methods are invoked when using this method:
* - `cacheKeyWillBeUsed`
* - `cachedResponseWillBeUsed`
*
* @param key The `Request` or `URL` object to use as the cache key.
* @returns A matching response, if found.
*/
async cacheMatch(key) {
const request = toRequest(key);
let cachedResponse;
const { cacheName, matchOptions } = this._strategy;
const effectiveRequest = await this.getCacheKey(request, "read");
const multiMatchOptions = {
...matchOptions,
cacheName
};
cachedResponse = await caches.match(effectiveRequest, multiMatchOptions);
if (process.env.NODE_ENV !== "production") if (cachedResponse) logger.debug(`Found a cached response in '${cacheName}'.`);
else logger.debug(`No cached response found in '${cacheName}'.`);
for (const callback of this.iterateCallbacks("cachedResponseWillBeUsed")) cachedResponse = await callback({
cacheName,
matchOptions,
cachedResponse,
request: effectiveRequest,
event: this.event
}) || void 0;
return cachedResponse;
}
/**
* Puts a request/response pair into the cache (and invokes any applicable
* plugin callback method) using the `cacheName` and `plugins` provided to
* the {@linkcode Strategy} object.
*
* The following plugin lifecycle methods are invoked when using this method:
* - `cacheKeyWillBeUsed`
* - `cacheWillUpdate`
* - `cacheDidUpdate`
*
* @param key The request or URL to use as the cache key.
* @param response The response to cache.
* @returns `false` if a `cacheWillUpdate` caused the response to
* not be cached, and `true` otherwise.
*/
async cachePut(key, response) {
const request = toRequest(key);
await timeout(0);
const effectiveRequest = await this.getCacheKey(request, "write");
if (process.env.NODE_ENV !== "production") {
if (effectiveRequest.method && effectiveRequest.method !== "GET") throw new SerwistError("attempt-to-cache-non-get-request", {
url: getFriendlyURL(effectiveRequest.url),
method: effectiveRequest.method
});
}
if (!response) {
if (process.env.NODE_ENV !== "production") logger.error(`Cannot cache non-existent response for '${getFriendlyURL(effectiveRequest.url)}'.`);
throw new SerwistError("cache-put-with-no-response", { url: getFriendlyURL(effectiveRequest.url) });
}
const responseToCache = await this._ensureResponseSafeToCache(response);
if (!responseToCache) {
if (process.env.NODE_ENV !== "production") logger.debug(`Response '${getFriendlyURL(effectiveRequest.url)}' will not be cached.`, responseToCache);
return false;
}
const { cacheName, matchOptions } = this._strategy;
const cache = await self.caches.open(cacheName);
if (process.env.NODE_ENV !== "production") {
const vary = response.headers.get("Vary");
if (vary && matchOptions?.ignoreVary !== true) logger.debug(`The response for ${getFriendlyURL(effectiveRequest.url)} has a 'Vary: ${vary}' header. Consider setting the {ignoreVary: true} option on your strategy to ensure cache matching and deletion works as expected.`);
}
const hasCacheUpdateCallback = this.hasCallback("cacheDidUpdate");
const oldResponse = hasCacheUpdateCallback ? await cacheMatchIgnoreParams(cache, effectiveRequest.clone(), ["__WB_REVISION__"], matchOptions) : null;
if (process.env.NODE_ENV !== "production") logger.debug(`Updating the '${cacheName}' cache with a new Response for ${getFriendlyURL(effectiveRequest.url)}.`);
try {
await cache.put(effectiveRequest, hasCacheUpdateCallback ? responseToCache.clone() : responseToCache);
} catch (error) {
if (error instanceof Error) {
if (error.name === "QuotaExceededError") await executeQuotaErrorCallbacks();
throw error;
}
}
for (const callback of this.iterateCallbacks("cacheDidUpdate")) await callback({
cacheName,
oldResponse,
newResponse: responseToCache.clone(),
request: effectiveRequest,
event: this.event
});
return true;
}
/**
* Checks the `plugins` provided to the {@linkcode Strategy} object for `cacheKeyWillBeUsed`
* callbacks and executes found callbacks in sequence. The final `Request`
* object returned by the last plugin is treated as the cache key for cache
* reads and/or writes. If no `cacheKeyWillBeUsed` plugin callbacks have
* been registered, the passed request is returned unmodified.
*
* @param request
* @param mode
* @returns
*/
async getCacheKey(request, mode) {
const key = `${request.url} | ${mode}`;
if (!this._cacheKeys[key]) {
let effectiveRequest = request;
for (const callback of this.iterateCallbacks("cacheKeyWillBeUsed")) effectiveRequest = toRequest(await callback({
mode,
request: effectiveRequest,
event: this.event,
params: this.params
}));
this._cacheKeys[key] = effectiveRequest;
}
return this._cacheKeys[key];
}
/**
* Returns `true` if the strategy has at least one plugin with the given
* callback.
*
* @param name The name of the callback to check for.
* @returns
*/
hasCallback(name) {
for (const plugin of this._strategy.plugins) if (name in plugin) return true;
return false;
}
/**
* Runs all plugin callbacks matching the given name, in order, passing the
* given param object as the only argument.
*
* Note: since this method runs all plugins, it's not suitable for cases
* where the return value of a callback needs to be applied prior to calling
* the next callback. See {@linkcode StrategyHandler.iterateCallbacks} for how to handle that case.
*
* @param name The name of the callback to run within each plugin.
* @param param The object to pass as the first (and only) param when executing each callback. This object will be merged with the
* current plugin state prior to callback execution.
*/
async runCallbacks(name, param) {
for (const callback of this.iterateCallbacks(name)) await callback(param);
}
/**
* Accepts a callback name and returns an iterable of matching plugin callbacks.
*
* @param name The name fo the callback to run
* @returns
*/
*iterateCallbacks(name) {
for (const plugin of this._strategy.plugins) if (typeof plugin[name] === "function") {
const state = this._pluginStateMap.get(plugin);
const statefulCallback = (param) => {
const statefulParam = {
...param,
state
};
return plugin[name](statefulParam);
};
yield statefulCallback;
}
}
/**
* Adds a promise to the
* [extend lifetime promises](https://w3c.github.io/ServiceWorker/#extendableevent-extend-lifetime-promises)
* of the event event associated with the request being handled (usually a `FetchEvent`).
*
* Note: you can await {@linkcode StrategyHandler.doneWaiting} to know when all added promises have settled.
*
* @param promise A promise to add to the extend lifetime promises of
* the event that triggered the request.
*/
waitUntil(promise) {
this._extendLifetimePromises.push(promise);
return promise;
}
/**
* Returns a promise that resolves once all promises passed to
* `this.waitUntil()` have settled.
*
* Note: any work done after `doneWaiting()` settles should be manually
* passed to an event's `waitUntil()` method (not `this.waitUntil()`), otherwise
* the service worker thread may be killed prior to your work completing.
*/
async doneWaiting() {
let promise;
while (promise = this._extendLifetimePromises.shift()) await promise;
}
/**
* Stops running the strategy and immediately resolves any pending
* `waitUntil()` promise.
*/
destroy() {
this._handlerDeferred.resolve(null);
}
/**
* This method checks if the navigation preload `Response` is available.
*
* @param request
* @param event
* @returns
*/
async getPreloadResponse() {
if (this.event instanceof FetchEvent && this.event.request.mode === "navigate" && "preloadResponse" in this.event) try {
const possiblePreloadResponse = await this.event.preloadResponse;
if (possiblePreloadResponse) {
if (process.env.NODE_ENV !== "production") logger.log(`Using a preloaded navigation response for '${getFriendlyURL(this.event.request.url)}'`);
return possiblePreloadResponse;
}
} catch (error) {
if (process.env.NODE_ENV !== "production") logger.error(error);
return;
}
}
/**
* This method will call `cacheWillUpdate` on the available plugins (or use
* status === 200) to determine if the response is safe and valid to cache.
*
* @param response
* @returns
* @private
*/
async _ensureResponseSafeToCache(response) {
let responseToCache = response;
let pluginsUsed = false;
for (const callback of this.iterateCallbacks("cacheWillUpdate")) {
responseToCache = await callback({
request: this.request,
response: responseToCache,
event: this.event
}) || void 0;
pluginsUsed = true;
if (!responseToCache) break;
}
if (!pluginsUsed) {
if (responseToCache && responseToCache.status !== 200) {
if (process.env.NODE_ENV !== "production") if (responseToCache.status === 0) logger.warn(`The response for '${this.request.url}' is an opaque response. The caching strategy that you're using will not cache opaque responses by default.`);
else logger.debug(`The response for '${this.request.url}' returned a status code of '${response.status}' and won't be cached as a result.`);
responseToCache = void 0;
}
}
return responseToCache;
}
};
//#endregion
//#region src/lib/strategies/Strategy.ts
/**
* Abstract class for implementing runtime caching strategies.
*
* Custom strategies should extend this class and leverage `StrategyHandler`, which will ensure all relevant cache options,
* fetch options, and plugins are used (per the current strategy instance), to perform all fetching and caching logic.
*/
var Strategy = class {
cacheName;
plugins;
fetchOptions;
matchOptions;
/**
* Creates a new instance of the strategy and sets all documented option
* properties as public instance properties.
*
* Note: if a custom strategy class extends the base Strategy class and does
* not need more than these properties, it does not need to define its own
* constructor.
*
* @param options
*/
constructor(options = {}) {
this.cacheName = cacheNames.getRuntimeName(options.cacheName);
this.plugins = options.plugins || [];
this.fetchOptions = options.fetchOptions;
this.matchOptions = options.matchOptions;
}
/**
* Performs a request strategy and returns a promise that will resolve to
* a response, invoking all relevant plugin callbacks.
*
* When a strategy instance is registered with a route, this method is automatically
* called when the route matches.
*
* Alternatively, this method can be used in a standalone `fetch` event
* listener by passing it to `event.respondWith()`.
*
* @param options A `FetchEvent` or an object with the properties listed below.
* @param options.request A request to run this strategy for.
* @param options.event The event associated with the request.
* @param options.url
* @param options.params
*/
handle(options) {
const [responseDone] = this.handleAll(options);
return responseDone;
}
/**
* Similar to `handle()`, but instead of just returning a promise that
* resolves to a response, it will return an tuple of `[response, done]` promises,
* where `response` is equivalent to what `handle()` returns, and `done` is a
* promise that will resolve once all promises added to `event.waitUntil()` as a part
* of performing the strategy have completed.
*
* You can await the `done` promise to ensure any extra work performed by
* the strategy (usually caching responses) completes successfully.
*
* @param options A `FetchEvent` or `HandlerCallbackOptions` object.
* @returns A tuple of [response, done] promises that can be used to determine when the response resolves as
* well as when the handler has completed all its work.
*/
handleAll(options) {
if (options instanceof FetchEvent) options = {
event: options,
request: options.request
};
const event = options.event;
const request = typeof options.request === "string" ? new Request(options.request) : options.request;
const handler = new StrategyHandler(this, options.url ? {
event,
request,
url: options.url,
params: options.params
} : {
event,
request
});
const responseDone = this._getResponse(handler, request, event);
return [responseDone, this._awaitComplete(responseDone, handler, request, event)];
}
async _getResponse(handler, request, event) {
await handler.runCallbacks("handlerWillStart", {
event,
request
});
let response;
try {
response = await this._handle(request, handler);
if (response === void 0 || response.type === "error") throw new SerwistError("no-response", { url: request.url });
} catch (error) {
if (error instanceof Error) for (const callback of handler.iterateCallbacks("handlerDidError")) {
response = await callback({
error,
event,
request
});
if (response !== void 0) break;
}
if (!response) throw error;
if (process.env.NODE_ENV !== "production") throw logger.log(`While responding to '${getFriendlyURL(request.url)}', an ${error instanceof Error ? error.toString() : ""} error occurred. Using a fallback response provided by a handlerDidError plugin.`);
}
for (const callback of handler.iterateCallbacks("handlerWillRespond")) response = await callback({
event,
request,
response
});
return response;
}
async _awaitComplete(responseDone, handler, request, event) {
let response;
let error;
try {
response = await responseDone;
} catch {}
try {
await handler.runCallbacks("handlerDidRespond", {
event,
request,
response
});
await handler.doneWaiting();
} catch (waitUntilError) {
if (waitUntilError instanceof Error) error = waitUntilError;
}
await handler.runCallbacks("handlerDidComplete", {
event,
request,
response,
error
});
handler.destroy();
if (error) throw error;
}
};
//#endregion
//#region src/lib/strategies/utils/messages.ts
const messages = {
strategyStart: (strategyName, request) => `Using ${strategyName} to respond to '${getFriendlyURL(request.url)}'`,
printFinalResponse: (response) => {
if (response) {
logger.groupCollapsed("View the final response here.");
logger.log(response || "[No response returned]");
logger.groupEnd();
}
}
};
//#endregion
//#region src/lib/strategies/NetworkFirst.ts
/**
* An implementation of the [network first](https://developer.chrome.com/docs/workbox/caching-strategies-overview/#network_first_falling_back_to_cache)
* request strategy.
*
* 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 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 NetworkFirst = class extends Strategy {
_networkTimeoutSeconds;
/**
* @param options
* This option can be used to combat
* "[lie-fi](https://developers.google.com/web/fundamentals/performance/poor-connectivity/#lie-fi)"
* scenarios.
*/
constructor(options = {}) {
super(options);
if (!this.plugins.some((p) => "cacheWillUpdate" in p)) this.plugins.unshift(cacheOkAndOpaquePlugin);
this._networkTimeoutSeconds = options.networkTimeoutSeconds || 0;
if (process.env.NODE_ENV !== "production") {
if (this._networkTimeoutSeconds) finalAssertExports.isType(this._networkTimeoutSeconds, "number", {
moduleName: "serwist",
className: this.constructor.name,
funcName: "constructor",
paramName: "networkTimeoutSeconds"
});
}
}
/**
* @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: "makeRequest"
});
const promises = [];
let timeoutId;
if (this._networkTimeoutSeconds) {
const { id, promise } = this._getTimeoutPromise({
request,
logs,
handler
});
timeoutId = id;
promises.push(promise);
}
const networkPromise = this._getNetworkPromise({
timeoutId,
request,
logs,
handler
});
promises.push(networkPromise);
const response = await handler.waitUntil((async () => {
return await handler.waitUntil(Promise.race(promises)) || await networkPromise;
})());
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 });
return response;
}
/**
* @param options
* @returns
* @private
*/
_getTimeoutPromise({ request, logs, handler }) {
let timeoutId;
return {
promise: new Promise((resolve) => {
const onNetworkTimeout = async () => {
if (process.env.NODE_ENV !== "production") logs.push(`Timing out the network response at ${this._networkTimeoutSeconds} seconds.`);
resolve(await handler.cacheMatch(request));
};
timeoutId = setTimeout(onNetworkTimeout, this._networkTimeoutSeconds * 1e3);
}),
id: timeoutId
};
}
/**
* @param options
* @param options.timeoutId
* @param options.request
* @param options.logs A reference to the logs Array.
* @param options.event
* @returns
*
* @private
*/
async _getNetworkPromise({ timeoutId, request, logs, handler }) {
let error;
let response;
try {
response = await handler.fetchAndCachePut(request);
} catch (fetchError) {
if (fetchError instanceof Error) error = fetchError;
}
if (timeoutId) clearTimeout(timeoutId);
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. Will respond with a cached response.");
if (error || !response) {
response = await handler.cacheMatch(request);
if (process.env.NODE_ENV !== "production") if (response) logs.push(`Found a cached response in the '${this.cacheName}' cache.`);
else logs.push(`No response found in the '${this.cacheName}' cache.`);
}
return response;
}
};
//#endregion
//#region src/lib/strategies/NetworkOnly.ts
/**
* An implementation of the [network only](https://developer.chrome.com/docs/workbox/caching-strategies-overview/#network_only)
* request strategy.
*
* This class is useful if you require specific requests to only be fulfilled from the network.
*
* If the network request fails, this will throw a {@linkcode SerwistError} exception.
*/
var NetworkOnly = class extends Strategy {
_networkTimeoutSeconds;
/**
* @param options
*/
constructor(options = {}) {
super(options);
this._networkTimeoutSeconds = options.networkTimeoutSeconds || 0;
}
/**
* @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: "_handle",
paramName: "request"
});
let error;
let response;
try {
const promises = [handler.fetch(request)];
if (this._networkTimeoutSeconds) {
const timeoutPromise = timeout(this._networkTimeoutSeconds * 1e3);
promises.push(timeoutPromise);
}
response = await Promise.race(promises);
if (!response) throw new Error(`Timed out the network response after ${this._networkTimeoutSeconds} seconds.`);
} catch (err) {
if (err instanceof Error) error = err;
}
if (process.env.NODE_ENV !== "production") {
logger.groupCollapsed(messages.strategyStart(this.constructor.name, request));
if (response) logger.log("Got response from network.");
else logger.log("Unable to get a response from the network.");
messages.printFinalResponse(response);
logger.groupEnd();
}
if (!response) throw new SerwistError("no-response", {
url: request.url,
error
});
return response;
}
};
//#endregion
//#region src/constants.ts
/**
* The list of valid HTTP methods associated with requests that could be routed.
*
* @private
*/
const validMethods = [
"DELETE",
"GET",
"HEAD",
"PATCH",
"POST",
"PUT"
];
//#endregion
//#region src/utils/normalizeHandler.ts
/**
* @param handler Either a function, or an object with a
* 'handle' method.
* @returns An object with a handle method.
*
* @private
*/
const normalizeHandler = (handler) => {
if (handler && typeof handler === "object") {
if (process.env.NODE_ENV !== "production") finalAssertExports.hasMethod(handler, "handle", {
moduleName: "serwist",
className: "Route",
funcName: "constructor",
paramName: "handler"
});
return handler;
}
if (process.env.NODE_ENV !== "production") finalAssertExports.isType(handler, "function", {
moduleName: "serwist",
className: "Route",
funcName: "constructor",
paramName: "handler"
});
return { handle: handler };
};
//#endregion
//#region src/Route.ts
/**
* A `Route` consists of a pair of callback functions, `match` and `handler`.
* The `match` callback determines if a route should be used to handle a
* request by returning a truthy value if it can. The `handler` callback
* is called when the route matches and should return a promise that resolves
* to a response.
*/
var Route = class {
handler;
match;
method;
catchHandler;
/**
* Constructor for Route class.
*
* @param match A callback function that determines whether the
* route matches a given `fetch` event by returning a truthy value.
* @param handler A callback function that returns a `Promise` resolving
* to a `Response`.
* @param method The HTTP method to match the route against. Defaults
* to `GET`.
*/
constructor(match, handler, method = "GET") {
if (process.env.NODE_ENV !== "production") {
finalAssertExports.isType(match, "function", {
moduleName: "serwist",
className: "Route",
funcName: "constructor",
paramName: "match"
});
if (method) finalAssertExports.isOneOf(method, validMethods, { paramName: "method" });
}
this.handler = normalizeHandler(handler);
this.match = match;
this.method = method;
}
/**
*
* @param handler A callback function that returns a Promise resolving
* to a Response.
*/
setCatchHandler(handler) {
this.catchHandler = normalizeHandler(handler);
}
};
//#endregion
//#region src/lib/strategies/PrecacheStrategy.ts
/**
* A {@linkcode Strategy} implementation specifically designed to both cache
* and fetch precached assets.
*
* Note: an instance of this class is created automatically when creating a
* {@linkcode Serwist} instance; it's generally not necessary to create this yourself.
*/
var PrecacheStrategy = class PrecacheStrategy extends Strategy {
_fallbackToNetwork;
static defaultPrecacheCacheabilityPlugin = { async cacheWillUpdate({ response }) {
if (!response || response.status >= 400) return null;
return response;
} };
static copyRedirectedCacheableResponsesPlugin = { async cacheWillUpdate({ response }) {
return response.redirected ? await copyResponse(response) : response;
} };
/**
* @param options
*/
constructor(options = {}) {
options.cacheName = cacheNames.getPrecacheName(options.cacheName);
super(options);
this._fallbackToNetwork = options.fallbackToNetwork !== false;
this.plugins.push(PrecacheStrategy.copyRedirectedCacheableResponsesPlugin);
}
/**
* @private
* @param request A request to run this strategy for.
* @param handler The event that triggered the request.
* @returns
*/
async _handle(request, handler) {