UNPKG

serwist

Version:

A Swiss Army knife for service workers.

1,383 lines (1,361 loc) 58.4 kB
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