UNPKG

@serwist/window

Version:

Simplifies communications with Serwist packages running in the service worker

545 lines (544 loc) 22.3 kB
import { t as isCurrentPageOutOfScope } from "./chunks/isCurrentPageOutOfScope-vi3IAsNY.js"; import { Deferred, logger } from "serwist/internal"; //#region src/messageSW.ts /** * Sends a data object to a service worker via `postMessage` and resolves with * a response (if any). * * A response can be sent by calling `event.ports[0].postMessage(...)`, which will * resolve the promise returned by `messageSW()`. If no response is sent, the promise * will never resolve. * * @param sw The service worker to send the message to. * @param data An object to send to the service worker. * @returns */ const messageSW = (sw, data) => { return new Promise((resolve) => { const messageChannel = new MessageChannel(); messageChannel.port1.onmessage = (event) => { resolve(event.data); }; sw.postMessage(data, [messageChannel.port2]); }); }; //#endregion //#region src/utils/SerwistEvent.ts /** * A minimal `Event` subclass shim. * This doesn't *actually* subclass `Event` because not all browsers support * constructable `EventTarget`, and using a real `Event` will error. * @private */ var SerwistEvent = class { target; sw; originalEvent; isExternal; constructor(type, props) { this.type = type; Object.assign(this, props); } }; //#endregion //#region src/utils/SerwistEventTarget.ts /** * A minimal `EventTarget` shim. * This is necessary because not all browsers support constructable * `EventTarget`, so using a real `EventTarget` will error. * @private */ var SerwistEventTarget = class { _eventListenerRegistry = /* @__PURE__ */ new Map(); /** * @param type * @param listener * @private */ addEventListener(type, listener) { this._getEventListenersByType(type).add(listener); } /** * @param type * @param listener * @private */ removeEventListener(type, listener) { this._getEventListenersByType(type).delete(listener); } /** * @param 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 type The event type. * @returns 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); } }; //#endregion //#region src/utils/urlsMatch.ts /** * Returns true if two URLs have the same `.href` property. The URLs can be * relative, and if they are the current location href is used to resolve URLs. * * @private * @param url1 * @param url2 * @returns */ function urlsMatch(url1, url2) { const { href } = location; return new URL(url1, href).href === new URL(url2, href).href; } //#endregion //#region src/Serwist.ts const WAITING_TIMEOUT_DURATION = 200; const REGISTRATION_TIMEOUT_DURATION = 6e4; const SKIP_WAITING_MESSAGE = { type: "SKIP_WAITING" }; /** * A class to aid in handling service worker registration, updates, and * reacting to service worker lifecycle events. * * @fires `@serwist/window.Serwist.message` * @fires `@serwist/window.Serwist.installed` * @fires `@serwist/window.Serwist.waiting` * @fires `@serwist/window.Serwist.controlling` * @fires `@serwist/window.Serwist.activated` * @fires `@serwist/window.Serwist.redundant` */ var Serwist = class extends SerwistEventTarget { _scriptURL; _registerOptions = {}; _updateFoundCount = 0; _swDeferred = new Deferred(); _activeDeferred = new Deferred(); _controllingDeferred = new Deferred(); _registrationTime = 0; _isUpdate; _compatibleControllingSW; _registration; _sw; _ownSWs = /* @__PURE__ */ new Set(); _externalSW; _waitingTimeout; /** * Creates a new Serwist 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 scriptURL The service worker script associated with this instance. Using a * [`TrustedScriptURL`](https://developer.mozilla.org/en-US/docs/Web/API/TrustedScriptURL) is supported. * @param registerOptions The service worker options associated with this instance. */ 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 options */ async register({ immediate = false } = {}) { if (process.env.NODE_ENV !== "production") { if (this._registrationTime) { logger.error("Cannot re-register a Serwist instance after it has been registered. Create a new instance instead."); return; } } if (!immediate && document.readyState !== "complete") await new Promise((res) => window.addEventListener("load", res)); 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; Promise.resolve().then(() => { this.dispatchEvent(new SerwistEvent("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..."); if (isCurrentPageOutOfScope(this._registerOptions.scope || this._scriptURL.toString())) 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 Serwist instance without being registered. Register the Serwist 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. * * @returns */ 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. * * @returns */ 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. * * @returns */ 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 `getSW`) and resolves with a response (if any). * * A response can be sent by calling `event.ports[0].postMessage(...)`, which will * resolve the promise returned by `messageSW()`. If no response is sent, the promise * will never resolve. * * @param data An object to send to the service worker * @returns */ async messageSW(data) { return messageSW(await this.getSW(), data); } /** * Sends a `{ type: "SKIP_WAITING" }` message to the service worker that is * currently waiting and 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?.waiting) 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 * @returns */ _getControllingSWIfCompatible() { const controller = navigator.serviceWorker.controller; if (controller && urlsMatch(controller.scriptURL, this._scriptURL.toString())) return controller; } /** * 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 * @param originalEvent */ _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 (this._isUpdate) logger.log("Updated service worker found. Installing now..."); else logger.log("Service worker is installing..."); } this.dispatchEvent(new SerwistEvent("installing", { sw: installingSW, originalEvent, isExternal: updateLikelyTriggeredExternally, isUpdate: this._isUpdate })); ++this._updateFoundCount; installingSW.addEventListener("statechange", this._onStateChange); }; /** * @private * @param 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 SerwistEvent(state, eventProps)); if (state === "installed") this._waitingTimeout = self.setTimeout(() => { if (state === "installed" && registration.waiting === sw) { this.dispatchEvent(new SerwistEvent("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 originalEvent */ _onControllerChange = (originalEvent) => { const sw = this._sw; const isExternal = sw !== navigator.serviceWorker.controller; this.dispatchEvent(new SerwistEvent("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 originalEvent */ _onMessage = async (originalEvent) => { const { data, ports, source } = originalEvent; await this.getSW(); if (this._ownSWs.has(source)) this.dispatchEvent(new SerwistEvent("message", { data, originalEvent, ports, sw: source })); }; }; /** * The `message` event is dispatched any time a `postMessage` is received. * * @event workbox-window.Workbox#message * @type {SerwistEvent} * @property {*} data The `data` property from the original `message` event. * @property {Event} originalEvent The original [`message`]{@link https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent} * event. * @property {string} type `message`. * @property {MessagePort[]} ports The `ports` value from `originalEvent`. * @property {Workbox} target The `Workbox` instance. */ /** * The `installed` event is dispatched if the state of a * {@link workbox-window.Workbox} instance's * {@link https://developers.google.com/web/tools/workbox/modules/workbox-precaching#def-registered-sw|registered service worker} * changes to `installed`. * * Then can happen either the very first time a service worker is installed, * or after an update to the current service worker is found. In the case * of an update being found, the event's `isUpdate` property will be `true`. * * @event workbox-window.Workbox#installed * @type {SerwistEvent} * @property {ServiceWorker} sw The service worker instance. * @property {Event} originalEvent The original [`statechange`]{@link https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorker/onstatechange} * event. * @property {boolean|undefined} isUpdate True if a service worker was already * controlling when this `Workbox` instance called `register()`. * @property {boolean|undefined} isExternal True if this event is associated * with an [external service worker]{@link https://developers.google.com/web/tools/workbox/modules/workbox-window#when_an_unexpected_version_of_the_service_worker_is_found}. * @property {string} type `installed`. * @property {Workbox} target The `Workbox` instance. */ /** * The `waiting` event is dispatched if the state of a * {@link workbox-window.Workbox} instance's * [registered service worker]{@link https://developers.google.com/web/tools/workbox/modules/workbox-precaching#def-registered-sw} * changes to `installed` and then doesn't immediately change to `activating`. * It may also be dispatched if a service worker with the same * [`scriptURL`]{@link https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorker/scriptURL} * was already waiting when the {@link workbox-window.Workbox#register} * method was called. * * @event workbox-window.Workbox#waiting * @type {SerwistEvent} * @property {ServiceWorker} sw The service worker instance. * @property {Event|undefined} originalEvent The original * [`statechange`]{@link https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorker/onstatechange} * event, or `undefined` in the case where the service worker was waiting * to before `.register()` was called. * @property {boolean|undefined} isUpdate True if a service worker was already * controlling when this `Workbox` instance called `register()`. * @property {boolean|undefined} isExternal True if this event is associated * with an [external service worker]{@link https://developers.google.com/web/tools/workbox/modules/workbox-window#when_an_unexpected_version_of_the_service_worker_is_found}. * @property {boolean|undefined} wasWaitingBeforeRegister True if a service worker with * a matching `scriptURL` was already waiting when this `Workbox` * instance called `register()`. * @property {string} type `waiting`. * @property {Workbox} target The `Workbox` instance. */ /** * The `controlling` event is dispatched if a * [`controllerchange`]{@link https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/oncontrollerchange} * fires on the service worker [container]{@link https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer} * and the [`scriptURL`]{@link https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorker/scriptURL} * of the new [controller]{@link https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/controller} * matches the `scriptURL` of the `Workbox` instance's * [registered service worker]{@link https://developers.google.com/web/tools/workbox/modules/workbox-precaching#def-registered-sw}. * * @event workbox-window.Workbox#controlling * @type {SerwistEvent} * @property {ServiceWorker} sw The service worker instance. * @property {Event} originalEvent The original [`controllerchange`]{@link https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/oncontrollerchange} * event. * @property {boolean|undefined} isUpdate True if a service worker was already * controlling when this service worker was registered. * @property {boolean|undefined} isExternal True if this event is associated * with an [external service worker]{@link https://developers.google.com/web/tools/workbox/modules/workbox-window#when_an_unexpected_version_of_the_service_worker_is_found}. * @property {string} type `controlling`. * @property {Workbox} target The `Workbox` instance. */ /** * The `activated` event is dispatched if the state of a * {@link workbox-window.Workbox} instance's * {@link https://developers.google.com/web/tools/workbox/modules/workbox-precaching#def-registered-sw|registered service worker} * changes to `activated`. * * @event workbox-window.Workbox#activated * @type {SerwistEvent} * @property {ServiceWorker} sw The service worker instance. * @property {Event} originalEvent The original [`statechange`]{@link https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorker/onstatechange} * event. * @property {boolean|undefined} isUpdate True if a service worker was already * controlling when this `Workbox` instance called `register()`. * @property {boolean|undefined} isExternal True if this event is associated * with an [external service worker]{@link https://developers.google.com/web/tools/workbox/modules/workbox-window#when_an_unexpected_version_of_the_service_worker_is_found}. * @property {string} type `activated`. * @property {Workbox} target The `Workbox` instance. */ /** * The `redundant` event is dispatched if the state of a * {@link workbox-window.Workbox} instance's * [registered service worker]{@link https://developers.google.com/web/tools/workbox/modules/workbox-precaching#def-registered-sw} * changes to `redundant`. * * @event workbox-window.Workbox#redundant * @type {SerwistEvent} * @property {ServiceWorker} sw The service worker instance. * @property {Event} originalEvent The original [`statechange`]{@link https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorker/onstatechange} * event. * @property {boolean|undefined} isUpdate True if a service worker was already * controlling when this `Workbox` instance called `register()`. * @property {string} type `redundant`. * @property {Workbox} target The `Workbox` instance. */ /** * The `installing` event is dispatched if the service worker * finds the new version and starts installing. * * @event workbox-window.Workbox#installing * @type {SerwistEvent} * @property {ServiceWorker} sw The installing service worker instance. * @property {Event} originalEvent The original [`statechange`]{@link https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorker/onstatechange} * event. * @property {boolean|undefined} isUpdate True if a service worker was already * controlling when this `Workbox` instance called `register()`. * @property {string} type `installing`. * @property {Workbox} target The `Workbox` instance. */ //#endregion export { Serwist, SerwistEvent, messageSW }; //# sourceMappingURL=index.mjs.map