serwist
Version:
A Swiss Army knife for service workers.
1,383 lines (1,361 loc) • 58.4 kB
JavaScript
import { f as finalAssertExports, l as logger, D as Deferred, S as SerwistError, g as getFriendlyURL, t as timeout, d as cacheMatchIgnoreParams, e as executeQuotaErrorCallbacks, c as cacheNames, h as canConstructResponseFromBodyStream } from './waitUntil.js';
import { openDB } from 'idb';
const defaultMethod = "GET";
const validMethods = [
"DELETE",
"GET",
"HEAD",
"PATCH",
"POST",
"PUT"
];
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
};
};
class Route {
handler;
match;
method;
catchHandler;
constructor(match, handler, method = defaultMethod){
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;
}
setCatchHandler(handler) {
this.catchHandler = normalizeHandler(handler);
}
}
class NavigationRoute extends Route {
_allowlist;
_denylist;
constructor(handler, { allowlist = [
/./
], denylist = [] } = {}){
if (process.env.NODE_ENV !== "production") {
finalAssertExports.isArrayOfClass(allowlist, RegExp, {
moduleName: "serwist",
className: "NavigationRoute",
funcName: "constructor",
paramName: "options.allowlist"
});
finalAssertExports.isArrayOfClass(denylist, RegExp, {
moduleName: "serwist",
className: "NavigationRoute",
funcName: "constructor",
paramName: "options.denylist"
});
}
super((options)=>this._match(options), handler);
this._allowlist = allowlist;
this._denylist = denylist;
}
_match({ url, request }) {
if (request && request.mode !== "navigate") {
return false;
}
const pathnameAndSearch = url.pathname + url.search;
for (const regExp of this._denylist){
if (regExp.test(pathnameAndSearch)) {
if (process.env.NODE_ENV !== "production") {
logger.log(`The navigation route ${pathnameAndSearch} is not being used, since the URL matches this denylist pattern: ${regExp.toString()}`);
}
return false;
}
}
if (this._allowlist.some((regExp)=>regExp.test(pathnameAndSearch))) {
if (process.env.NODE_ENV !== "production") {
logger.debug(`The navigation route ${pathnameAndSearch} is being used.`);
}
return true;
}
if (process.env.NODE_ENV !== "production") {
logger.log(`The navigation route ${pathnameAndSearch} is not being used, since the URL being navigated to doesn't match the allowlist.`);
}
return false;
}
}
const removeIgnoredSearchParams = (urlObject, ignoreURLParametersMatching = [])=>{
for (const paramName of [
...urlObject.searchParams.keys()
]){
if (ignoreURLParametersMatching.some((regExp)=>regExp.test(paramName))) {
urlObject.searchParams.delete(paramName);
}
}
return urlObject;
};
function* generateURLVariations(url, { directoryIndex = "index.html", ignoreURLParametersMatching = [
/^utm_/,
/^fbclid$/
], cleanURLs = true, urlManipulation } = {}) {
const urlObject = new URL(url, location.href);
urlObject.hash = "";
yield urlObject.href;
const urlWithoutIgnoredParams = removeIgnoredSearchParams(urlObject, ignoreURLParametersMatching);
yield urlWithoutIgnoredParams.href;
if (directoryIndex && urlWithoutIgnoredParams.pathname.endsWith("/")) {
const directoryURL = new URL(urlWithoutIgnoredParams.href);
directoryURL.pathname += directoryIndex;
yield directoryURL.href;
}
if (cleanURLs) {
const cleanURL = new URL(urlWithoutIgnoredParams.href);
cleanURL.pathname += ".html";
yield cleanURL.href;
}
if (urlManipulation) {
const additionalURLs = urlManipulation({
url: urlObject
});
for (const urlToAttempt of additionalURLs){
yield urlToAttempt.href;
}
}
}
class RegExpRoute extends Route {
constructor(regExp, handler, method){
if (process.env.NODE_ENV !== "production") {
finalAssertExports.isInstance(regExp, RegExp, {
moduleName: "serwist",
className: "RegExpRoute",
funcName: "constructor",
paramName: "pattern"
});
}
const match = ({ url })=>{
const result = regExp.exec(url.href);
if (!result) {
return;
}
if (url.origin !== location.origin && result.index !== 0) {
if (process.env.NODE_ENV !== "production") {
logger.debug(`The regular expression '${regExp.toString()}' only partially matched against the cross-origin URL '${url.toString()}'. RegExpRoute's will only handle cross-origin requests if they match the entire URL.`);
}
return;
}
return result.slice(1);
};
super(match, handler, method);
}
}
const parallel = async (limit, array, func)=>{
const work = array.map((item, index)=>({
index,
item
}));
const processor = async (res)=>{
const results = [];
while(true){
const next = work.pop();
if (!next) {
return res(results);
}
const result = await func(next.item);
results.push({
result: result,
index: next.index
});
}
};
const queues = Array.from({
length: limit
}, ()=>new Promise(processor));
const results = (await Promise.all(queues)).flat().sort((a, b)=>a.index < b.index ? -1 : 1).map((res)=>res.result);
return results;
};
const disableDevLogs = ()=>{
self.__WB_DISABLE_DEV_LOGS = true;
};
function toRequest(input) {
return typeof input === "string" ? new Request(input) : input;
}
class StrategyHandler {
event;
request;
url;
params;
_cacheKeys = {};
_strategy;
_handlerDeferred;
_extendLifetimePromises;
_plugins;
_pluginStateMap;
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 = new Map();
for (const plugin of this._plugins){
this._pluginStateMap.set(plugin, {});
}
this.event.waitUntil(this._handlerDeferred.promise);
}
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" ? undefined : 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: error,
event,
originalRequest: originalRequest.clone(),
request: pluginFilteredRequest.clone()
});
}
throw error;
}
}
async fetchAndCachePut(input) {
const response = await this.fetch(input);
const responseClone = response.clone();
void this.waitUntil(this.cachePut(input, responseClone));
return response;
}
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
}) || undefined;
}
return cachedResponse;
}
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;
}
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];
}
hasCallback(name) {
for (const plugin of this._strategy.plugins){
if (name in plugin) {
return true;
}
}
return false;
}
async runCallbacks(name, param) {
for (const callback of this.iterateCallbacks(name)){
await callback(param);
}
}
*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;
}
}
}
waitUntil(promise) {
this._extendLifetimePromises.push(promise);
return promise;
}
async doneWaiting() {
let promise = undefined;
while(promise = this._extendLifetimePromises.shift()){
await promise;
}
}
destroy() {
this._handlerDeferred.resolve(null);
}
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 undefined;
}
}
return undefined;
}
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
}) || undefined;
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 = undefined;
}
}
return responseToCache;
}
}
class Strategy {
cacheName;
plugins;
fetchOptions;
matchOptions;
constructor(options = {}){
this.cacheName = cacheNames.getRuntimeName(options.cacheName);
this.plugins = options.plugins || [];
this.fetchOptions = options.fetchOptions;
this.matchOptions = options.matchOptions;
}
handle(options) {
const [responseDone] = this.handleAll(options);
return responseDone;
}
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);
const handlerDone = this._awaitComplete(responseDone, handler, request, event);
return [
responseDone,
handlerDone
];
}
async _getResponse(handler, request, event) {
await handler.runCallbacks("handlerWillStart", {
event,
request
});
let response = undefined;
try {
response = await this._handle(request, handler);
if (response === undefined || 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 !== undefined) {
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 = undefined;
let error = undefined;
try {
response = await responseDone;
} catch (error) {}
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;
}
}
}
const cacheOkAndOpaquePlugin = {
cacheWillUpdate: async ({ response })=>{
if (response.status === 200 || response.status === 0) {
return response;
}
return null;
}
};
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();
}
}
};
class NetworkFirst extends Strategy {
_networkTimeoutSeconds;
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"
});
}
}
}
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;
}
_getTimeoutPromise({ request, logs, handler }) {
let timeoutId;
const timeoutPromise = 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 * 1000);
});
return {
promise: timeoutPromise,
id: timeoutId
};
}
async _getNetworkPromise({ timeoutId, request, logs, handler }) {
let error = undefined;
let response = undefined;
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;
}
}
class NetworkOnly extends Strategy {
_networkTimeoutSeconds;
constructor(options = {}){
super(options);
this._networkTimeoutSeconds = options.networkTimeoutSeconds || 0;
}
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 = undefined;
let response;
try {
const promises = [
handler.fetch(request)
];
if (this._networkTimeoutSeconds) {
const timeoutPromise = timeout(this._networkTimeoutSeconds * 1000);
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;
}
}
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";
class BackgroundSyncQueueDb {
_db = null;
async addEntry(entry) {
const db = await this.getDb();
const tx = db.transaction(REQUEST_OBJECT_STORE_NAME, "readwrite", {
durability: "relaxed"
});
await tx.store.add(entry);
await tx.done;
}
async getFirstEntryId() {
const db = await this.getDb();
const cursor = await db.transaction(REQUEST_OBJECT_STORE_NAME).store.openCursor();
return cursor?.value.id;
}
async getAllEntriesByQueueName(queueName) {
const db = await this.getDb();
const results = await db.getAllFromIndex(REQUEST_OBJECT_STORE_NAME, QUEUE_NAME_INDEX, IDBKeyRange.only(queueName));
return results ? results : new Array();
}
async getEntryCountByQueueName(queueName) {
const db = await this.getDb();
return db.countFromIndex(REQUEST_OBJECT_STORE_NAME, QUEUE_NAME_INDEX, IDBKeyRange.only(queueName));
}
async deleteEntry(id) {
const db = await this.getDb();
await db.delete(REQUEST_OBJECT_STORE_NAME, id);
}
async getFirstEntryByQueueName(queueName) {
return await this.getEndEntryFromIndex(IDBKeyRange.only(queueName), "next");
}
async getLastEntryByQueueName(queueName) {
return await this.getEndEntryFromIndex(IDBKeyRange.only(queueName), "prev");
}
async getEndEntryFromIndex(query, direction) {
const db = await this.getDb();
const cursor = await db.transaction(REQUEST_OBJECT_STORE_NAME).store.index(QUEUE_NAME_INDEX).openCursor(query, direction);
return cursor?.value;
}
async getDb() {
if (!this._db) {
this._db = await openDB(BACKGROUND_SYNC_DB_NAME, BACKGROUND_SYNC_DB_VERSION, {
upgrade: this._upgradeDb
});
}
return this._db;
}
_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);
}
}
const objStore = db.createObjectStore(REQUEST_OBJECT_STORE_NAME, {
autoIncrement: true,
keyPath: "id"
});
objStore.createIndex(QUEUE_NAME_INDEX, QUEUE_NAME_INDEX, {
unique: false
});
}
}
class BackgroundSyncQueueStore {
_queueName;
_queueDb;
constructor(queueName){
this._queueName = queueName;
this._queueDb = new BackgroundSyncQueueDb();
}
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);
}
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);
}
async popEntry() {
return this._removeEntry(await this._queueDb.getLastEntryByQueueName(this._queueName));
}
async shiftEntry() {
return this._removeEntry(await this._queueDb.getFirstEntryByQueueName(this._queueName));
}
async getAll() {
return await this._queueDb.getAllEntriesByQueueName(this._queueName);
}
async size() {
return await this._queueDb.getEntryCountByQueueName(this._queueName);
}
async deleteEntry(id) {
await this._queueDb.deleteEntry(id);
}
async _removeEntry(entry) {
if (entry) {
await this.deleteEntry(entry.id);
}
return entry;
}
}
const serializableProperties = [
"method",
"referrer",
"referrerPolicy",
"mode",
"credentials",
"cache",
"redirect",
"integrity",
"keepalive"
];
class StorableRequest {
_requestData;
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] !== undefined) {
requestData[prop] = request[prop];
}
}
return new StorableRequest(requestData);
}
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;
}
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;
}
toRequest() {
return new Request(this._requestData.url, this._requestData);
}
clone() {
return new StorableRequest(this.toObject());
}
}
const TAG_PREFIX = "serwist-background-sync";
const MAX_RETENTION_TIME = 60 * 24 * 7;
const queueNames = new Set();
const convertEntry = (queueStoreEntry)=>{
const queueEntry = {
request: new StorableRequest(queueStoreEntry.requestData).toRequest(),
timestamp: queueStoreEntry.timestamp
};
if (queueStoreEntry.metadata) {
queueEntry.metadata = queueStoreEntry.metadata;
}
return queueEntry;
};
class BackgroundSyncQueue {
_name;
_onSync;
_maxRetentionTime;
_queueStore;
_forceSyncFallback;
_syncInProgress = false;
_requestsAddedDuringSync = false;
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();
}
get name() {
return this._name;
}
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");
}
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");
}
async popRequest() {
return this._removeRequest("pop");
}
async shiftRequest() {
return this._removeRequest("shift");
}
async getAll() {
const allEntries = await this._queueStore.getAll();
const now = Date.now();
const unexpiredEntries = [];
for (const entry of allEntries){
const maxRetentionTimeInMs = this._maxRetentionTime * 60 * 1000;
if (now - entry.timestamp > maxRetentionTimeInMs) {
await this._queueStore.deleteEntry(entry.id);
} else {
unexpiredEntries.push(convertEntry(entry));
}
}
return unexpiredEntries;
}
async size() {
return await this._queueStore.size();
}
async _addRequest({ request, metadata, timestamp = Date.now() }, operation) {
const storableRequest = await StorableRequest.fromRequest(request.clone());
const entry = {
requestData: storableRequest.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();
}
}
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 * 1000;
if (now - entry.timestamp > maxRetentionTimeInMs) {
return this._removeRequest(operation);
}
return convertEntry(entry);
}
return undefined;
}
async replayRequests() {
let entry = undefined;
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 (error) {
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!`);
}
}
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);
}
}
}
}
_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 = undefined;
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");
}
void this._onSync({
queue: this
});
}
}
static get _queueNames() {
return queueNames;
}
}
class BackgroundSyncPlugin {
_queue;
constructor(name, options){
this._queue = new BackgroundSyncQueue(name, options);
}
async fetchDidFail({ request }) {
await this._queue.pushRequest({
request
});
}
}
const copyResponse = async (response, modifier)=>{
let origin = null;
if (response.url) {
const responseURL = new URL(response.url);
origin = responseURL.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);
};
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;
}
};
constructor(options = {}){
options.cacheName = cacheNames.getPrecacheName(options.cacheName);
super(options);
this._fallbackToNetwork = options.fallbackToNetwork === false ? false : true;
this.plugins.push(PrecacheStrategy.copyRedirectedCacheableResponsesPlugin);
}
async _handle(request, handler) {
const preloadResponse = await handler.getPreloadResponse();
if (preloadResponse) {
return preloadResponse;
}
const response = await handler.cacheMatch(request);
if (response) {
return response;
}
if (handler.event && handler.event.type === "install") {
return await this._handleInstall(request, handler);
}
return await this._handleFetch(request, handler);
}
async _handleFetch(request, handler) {
let response = undefined;
const params = handler.params || {};
if (this._fallbackToNetwork) {
if (process.env.NODE_ENV !== "production") {
logger.warn(`The precached response for ${getFriendlyURL(request.url)} in ${this.cacheName} was not found. Falling back to the network.`);
}
const integrityInManifest = params.integrity;
const integrityInRequest = request.integrity;
const noIntegrityConflict = !integrityInRequest || integrityInRequest === integrityInManifest;
response = await handler.fetch(new Request(request, {
integrity: request.mode !== "no-cors" ? integrityInRequest || integrityInManifest : undefined
}));
if (integrityInManifest && noIntegrityConflict && request.mode !== "no-cors") {
this._useDefaultCacheabilityPluginIfNeeded();
const wasCached = await handler.cachePut(request, response.clone());
if (process.env.NODE_ENV !== "production") {
if (wasCached) {
logger.log(`A response for ${getFriendlyURL(request.url)} was used to "repair" the precache.`);
}
}
}
} else {
throw new SerwistError("missing-precache-entry", {
cacheName: this.cacheName,
url: request.url
});
}
if (process.env.NODE_ENV !== "production") {
const cacheKey = params.cacheKey || await handler.getCacheKey(request, "read");
logger.groupCollapsed(`Precaching is responding to: ${getFriendlyURL(request.url)}`);
logger.log(`Serving the precached url: ${getFriendlyURL(cacheKey instanceof Request ? cacheKey.url : cacheKey)}`);
logger.groupCollapsed("View request details here.");
logger.log(request);
logger.groupEnd();
logger.groupCollapsed("View response details here.");
logger.log(response);
logger.groupEnd();
logger.groupEnd();
}
return response;
}
async _handleInstall(request, handler) {
this._useDefaultCacheabilityPluginIfNeeded();
const response = await handler.fetch(request);
const wasCached = await handler.cachePut(request, response.clone());
if (!wasCached) {
throw new SerwistError("bad-precaching-response", {
url: request.url,
status: response.status
});
}
return response;
}
_useDefaultCacheabilityPluginIfNeeded() {
let defaultPluginIndex = null;
let cacheWillUpdatePluginCount = 0;
for (const [index, plugin] of this.plugins.entries()){
if (plugin === PrecacheStrategy.copyRedirectedCacheableResponsesPlugin) {
continue;
}
if (plugin === PrecacheStrategy.defaultPrecacheCacheabilityPlugin) {
defaultPluginIndex = index;
}
if (plugin.cacheWillUpdate) {
cacheWillUpdatePluginCount++;
}
}
if (cacheWillUpdatePluginCount === 0) {
this.plugins.push(PrecacheStrategy.defaultPrecacheCacheabi