UNPKG

@vite-pwa/workbox-window

Version:

Simplifies communications with Workbox packages running in the service worker

584 lines (574 loc) 19 kB
"use strict"; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var src_exports = {}; __export(src_exports, { Workbox: () => Workbox, WorkboxEvent: () => WorkboxEvent, messageSW: () => messageSW }); module.exports = __toCommonJS(src_exports); // src/messageSW.ts function messageSW(sw, data) { return new Promise((resolve) => { const messageChannel = new MessageChannel(); messageChannel.port1.onmessage = (event) => { resolve(event.data); }; sw.postMessage(data, [messageChannel.port2]); }); } // src/utils/dontWaitFor.ts function dontWaitFor(promise) { void promise.then(() => { }); } // src/utils/logger.ts var logger = process.env.NODE_ENV === "production" ? null : (() => { if (!("__WB_DISABLE_DEV_LOGS" in globalThis)) self.__WB_DISABLE_DEV_LOGS = false; let inGroup = false; const methodToColorMap = { debug: "#7f8c8d", // Gray log: "#2ecc71", // Green warn: "#f39c12", // Yellow error: "#c0392b", // Red groupCollapsed: "#3498db", // Blue groupEnd: null // No colored prefix on groupEnd }; const print = function(method, args) { if (self.__WB_DISABLE_DEV_LOGS) return; if (method === "groupCollapsed") { if (typeof navigator !== "undefined" && /^((?!chrome|android).)*safari/i.test(navigator.userAgent)) { console[method](...args); return; } } const styles = [ `background: ${methodToColorMap[method]}`, "border-radius: 0.5em", "color: white", "font-weight: bold", "padding: 2px 0.5em" ]; const logPrefix = inGroup ? [] : ["%c@vite-pwa/workbox-window", styles.join(";")]; console[method](...logPrefix, ...args); if (method === "groupCollapsed") inGroup = true; if (method === "groupEnd") inGroup = false; }; const api = {}; const loggerMethods = Object.keys(methodToColorMap); for (const key of loggerMethods) { const method = key; api[method] = (...args) => { print(method, args); }; } return api; })(); // src/utils/Deferred.ts var Deferred = class { promise; resolve; reject; /** * Creates a promise and exposes its resolve and reject functions as methods. */ constructor() { this.promise = new Promise((resolve, reject) => { this.resolve = resolve; this.reject = reject; }); } }; // src/utils/WorkboxEventTarget.ts var WorkboxEventTarget = class { _eventListenerRegistry = /* @__PURE__ */ new Map(); /** * @param {string} type * @param {Function} listener * @private */ addEventListener(type, listener) { const foo = this._getEventListenersByType(type); foo.add(listener); } /** * @param {string} type * @param {Function} listener * @private */ removeEventListener(type, listener) { this._getEventListenersByType(type).delete(listener); } /** * @param {object} event * @private */ dispatchEvent(event) { event.target = this; const listeners = this._getEventListenersByType(event.type); for (const listener of listeners) listener(event); } /** * Returns a Set of listeners associated with the passed event type. * If no handlers have been registered, an empty Set is returned. * * @param {string} type The event type. * @return {Set<ListenerCallback>} An array of handler functions. * @private */ _getEventListenersByType(type) { if (!this._eventListenerRegistry.has(type)) this._eventListenerRegistry.set(type, /* @__PURE__ */ new Set()); return this._eventListenerRegistry.get(type); } }; // src/utils/urlsMatch.ts function urlsMatch(url1, url2) { const { href } = location; return new URL(url1, href).href === new URL(url2, href).href; } // src/utils/WorkboxEvent.ts var WorkboxEvent = class { constructor(type, props) { this.type = type; Object.assign(this, props); } target; sw; originalEvent; isExternal; }; // src/Workbox.ts var WAITING_TIMEOUT_DURATION = 200; var REGISTRATION_TIMEOUT_DURATION = 6e4; var SKIP_WAITING_MESSAGE = { type: "SKIP_WAITING" }; var Workbox = class extends WorkboxEventTarget { _scriptURL; _registerOptions = {}; _updateFoundCount = 0; // Deferreds we can resolve later. _swDeferred = new Deferred(); _activeDeferred = new Deferred(); _controllingDeferred = new Deferred(); _registrationTime = 0; _isUpdate; _compatibleControllingSW; _registration; _sw; _ownSWs = /* @__PURE__ */ new Set(); _externalSW; _waitingTimeout; /** * Creates a new Workbox instance with a script URL and service worker * options. The script URL and options are the same as those used when * calling [navigator.serviceWorker.register(scriptURL, options)](https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/register). * * @param {string|TrustedScriptURL} scriptURL The service worker script * associated with this instance. Using a * [`TrustedScriptURL`](https://web.dev/trusted-types/) is supported. * @param {object} [registerOptions] The service worker options associated * with this instance. */ // eslint-disable-next-line @typescript-eslint/ban-types constructor(scriptURL, registerOptions = {}) { super(); this._scriptURL = scriptURL; this._registerOptions = registerOptions; navigator.serviceWorker.addEventListener("message", this._onMessage); } /** * Registers a service worker for this instances script URL and service * worker options. By default this method delays registration until after * the window has loaded. * * @param {object} [options] * @param {Function} [options.immediate] Setting this to true will * register the service worker immediately, even if the window has * not loaded (not recommended). */ async register({ immediate = false } = {}) { if (process.env.NODE_ENV !== "production") { if (this._registrationTime) { logger.error( "Cannot re-register a Workbox instance after it has been registered. Create a new instance instead." ); return; } } if (!immediate && document.readyState !== "complete") await new Promise((resolve) => window.addEventListener("load", resolve)); this._isUpdate = Boolean(navigator.serviceWorker.controller); this._compatibleControllingSW = this._getControllingSWIfCompatible(); this._registration = await this._registerScript(); if (this._compatibleControllingSW) { this._sw = this._compatibleControllingSW; this._activeDeferred.resolve(this._compatibleControllingSW); this._controllingDeferred.resolve(this._compatibleControllingSW); this._compatibleControllingSW.addEventListener( "statechange", this._onStateChange, { once: true } ); } const waitingSW = this._registration.waiting; if (waitingSW && urlsMatch(waitingSW.scriptURL, this._scriptURL.toString())) { this._sw = waitingSW; dontWaitFor( Promise.resolve().then(() => { this.dispatchEvent( new WorkboxEvent("waiting", { sw: waitingSW, wasWaitingBeforeRegister: true }) ); if (process.env.NODE_ENV !== "production") { logger.warn( "A service worker was already waiting to activate before this script was registered..." ); } }) ); } if (this._sw) { this._swDeferred.resolve(this._sw); this._ownSWs.add(this._sw); } if (process.env.NODE_ENV !== "production") { logger.log( "Successfully registered service worker.", this._scriptURL.toString() ); if (navigator.serviceWorker.controller) { if (this._compatibleControllingSW) { logger.debug( "A service worker with the same script URL is already controlling this page." ); } else { logger.debug( "A service worker with a different script URL is currently controlling the page. The browser is now fetching the new script now..." ); } } const currentPageIsOutOfScope = () => { const scopeURL = new URL( this._registerOptions.scope || this._scriptURL.toString(), document.baseURI ); const scopeURLBasePath = new URL("./", scopeURL.href).pathname; return !location.pathname.startsWith(scopeURLBasePath); }; if (currentPageIsOutOfScope()) { logger.warn( "The current page is not in scope for the registered service worker. Was this a mistake?" ); } } this._registration.addEventListener("updatefound", this._onUpdateFound); navigator.serviceWorker.addEventListener( "controllerchange", this._onControllerChange ); return this._registration; } /** * Checks for updates of the registered service worker. */ async update() { if (!this._registration) { if (process.env.NODE_ENV !== "production") { logger.error( "Cannot update a Workbox instance without being registered. Register the Workbox instance first." ); } return; } await this._registration.update(); } /** * Resolves to the service worker registered by this instance as soon as it * is active. If a service worker was already controlling at registration * time then it will resolve to that if the script URLs (and optionally * script versions) match, otherwise it will wait until an update is found * and activates. * * @return {Promise<ServiceWorker>} */ get active() { return this._activeDeferred.promise; } /** * Resolves to the service worker registered by this instance as soon as it * is controlling the page. If a service worker was already controlling at * registration time then it will resolve to that if the script URLs (and * optionally script versions) match, otherwise it will wait until an update * is found and starts controlling the page. * Note: the first time a service worker is installed it will active but * not start controlling the page unless `clients.claim()` is called in the * service worker. * * @return {Promise<ServiceWorker>} */ get controlling() { return this._controllingDeferred.promise; } /** * Resolves with a reference to a service worker that matches the script URL * of this instance, as soon as it's available. * * If, at registration time, there's already an active or waiting service * worker with a matching script URL, it will be used (with the waiting * service worker taking precedence over the active service worker if both * match, since the waiting service worker would have been registered more * recently). * If there's no matching active or waiting service worker at registration * time then the promise will not resolve until an update is found and starts * installing, at which point the installing service worker is used. * * @return {Promise<ServiceWorker>} */ getSW() { return this._sw !== void 0 ? Promise.resolve(this._sw) : this._swDeferred.promise; } /** * Sends the passed data object to the service worker registered by this * instance (via {@link workbox-window.Workbox#getSW}) and resolves * with a response (if any). * * A response can be set in a message handler in the service worker by * calling `event.ports[0].postMessage(...)`, which will resolve the promise * returned by `messageSW()`. If no response is set, the promise will never * resolve. * * @param {object} data An object to send to the service worker * @return {Promise<object>} */ // We might be able to change the 'data' type to Record<string, unknown> in the future. async messageSW(data) { const sw = await this.getSW(); return messageSW(sw, data); } /** * Sends a `{type: 'SKIP_WAITING'}` message to the service worker that's * currently in the `waiting` state associated with the current registration. * * If there is no current registration or no service worker is `waiting`, * calling this will have no effect. */ messageSkipWaiting() { if (this._registration && this._registration.waiting) void messageSW(this._registration.waiting, SKIP_WAITING_MESSAGE); } /** * Checks for a service worker already controlling the page and returns * it if its script URL matches. * * @private * @return {ServiceWorker|undefined} */ _getControllingSWIfCompatible() { const controller = navigator.serviceWorker.controller; if (controller && urlsMatch(controller.scriptURL, this._scriptURL.toString())) return controller; else return void 0; } /** * Registers a service worker for this instances script URL and register * options and tracks the time registration was complete. * * @private */ async _registerScript() { try { const reg = await navigator.serviceWorker.register( this._scriptURL, this._registerOptions ); this._registrationTime = performance.now(); return reg; } catch (error) { if (process.env.NODE_ENV !== "production") logger.error(error); throw error; } } /** * @private */ _onUpdateFound = (originalEvent) => { const registration = this._registration; const installingSW = registration.installing; const updateLikelyTriggeredExternally = !!(this._updateFoundCount > 0 || !urlsMatch(installingSW.scriptURL, this._scriptURL.toString()) || performance.now() > this._registrationTime + REGISTRATION_TIMEOUT_DURATION); if (updateLikelyTriggeredExternally) { this._externalSW = installingSW; registration.removeEventListener("updatefound", this._onUpdateFound); } else { this._sw = installingSW; this._ownSWs.add(installingSW); this._swDeferred.resolve(installingSW); if (process.env.NODE_ENV !== "production") { if (navigator.serviceWorker.controller) logger.log("Updated service worker found. Installing now..."); else logger.log("Service worker is installing..."); } } this.dispatchEvent( new WorkboxEvent("installing", { sw: installingSW, originalEvent, isExternal: updateLikelyTriggeredExternally, isUpdate: this._isUpdate }) ); ++this._updateFoundCount; installingSW.addEventListener("statechange", this._onStateChange); }; /** * @private * @param {Event} originalEvent */ _onStateChange = (originalEvent) => { const registration = this._registration; const sw = originalEvent.target; const { state } = sw; const isExternal = sw === this._externalSW; const eventProps = { sw, isExternal, originalEvent }; if (!isExternal && this._isUpdate) eventProps.isUpdate = true; this.dispatchEvent( new WorkboxEvent(state, eventProps) ); if (state === "installed") { this._waitingTimeout = self.setTimeout(() => { if (state === "installed" && registration.waiting === sw) { this.dispatchEvent(new WorkboxEvent("waiting", eventProps)); if (process.env.NODE_ENV !== "production") { if (isExternal) { logger.warn( "An external service worker has installed but is waiting for this client to close before activating..." ); } else { logger.warn( "The service worker has installed but is waiting for existing clients to close before activating..." ); } } } }, WAITING_TIMEOUT_DURATION); } else if (state === "activating") { clearTimeout(this._waitingTimeout); if (!isExternal) this._activeDeferred.resolve(sw); } if (process.env.NODE_ENV !== "production") { switch (state) { case "installed": if (isExternal) { logger.warn( "An external service worker has installed. You may want to suggest users reload this page." ); } else { logger.log("Registered service worker installed."); } break; case "activated": if (isExternal) { logger.warn("An external service worker has activated."); } else { logger.log("Registered service worker activated."); if (sw !== navigator.serviceWorker.controller) { logger.warn( "The registered service worker is active but not yet controlling the page. Reload or run `clients.claim()` in the service worker." ); } } break; case "redundant": if (sw === this._compatibleControllingSW) logger.log("Previously controlling service worker now redundant!"); else if (!isExternal) logger.log("Registered service worker now redundant!"); break; } } }; /** * @private * @param {Event} originalEvent */ _onControllerChange = (originalEvent) => { const sw = this._sw; const isExternal = sw !== navigator.serviceWorker.controller; this.dispatchEvent( new WorkboxEvent("controlling", { isExternal, originalEvent, sw, isUpdate: this._isUpdate }) ); if (!isExternal) { if (process.env.NODE_ENV !== "production") logger.log("Registered service worker now controlling this page."); this._controllingDeferred.resolve(sw); } }; /** * @private * @param {Event} originalEvent */ _onMessage = async (originalEvent) => { const { data, ports, source } = originalEvent; await this.getSW(); if (this._ownSWs.has(source)) { this.dispatchEvent( new WorkboxEvent("message", { // Can't change type 'any' of data. data, originalEvent, ports, sw: source }) ); } }; }; // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { Workbox, WorkboxEvent, messageSW });