UNPKG

onesignal-web-sdk

Version:

Web push notifications from OneSignal.

349 lines (294 loc) 12.5 kB
import { InvalidArgumentError, InvalidArgumentReason } from '../errors/InvalidArgumentError'; import SdkEnvironment from '../managers/SdkEnvironment'; import { ServiceWorkerActiveState } from '../helpers/ServiceWorkerHelper'; import { WindowEnvironmentKind } from '../models/WindowEnvironmentKind'; import { Serializable } from '../models/Serializable'; import Environment from '../Environment'; import Log from './Log'; import { ContextSWInterface } from '../models/ContextSW'; export enum WorkerMessengerCommand { WorkerVersion = "GetWorkerVersion", Subscribe = "Subscribe", SubscribeNew = "SubscribeNew", AmpSubscriptionState = "amp-web-push-subscription-state", AmpSubscribe = "amp-web-push-subscribe", AmpUnsubscribe = "amp-web-push-unsubscribe", NotificationDisplayed = 'notification.displayed', NotificationClicked = 'notification.clicked', NotificationDismissed = 'notification.dismissed', RedirectPage = 'command.redirect', } export interface WorkerMessengerMessage { command: WorkerMessengerCommand; payload: WorkerMessengerPayload; } export interface WorkerMessengerReplyBufferRecord { callback: Function; onceListenerOnly: boolean; } export class WorkerMessengerReplyBuffer { private replies: { [index: string]: WorkerMessengerReplyBufferRecord[] | null }; constructor() { this.replies = {}; } public addListener(command: WorkerMessengerCommand, callback: Function, onceListenerOnly: boolean) { const record: WorkerMessengerReplyBufferRecord = {callback, onceListenerOnly}; const replies = this.replies[command.toString()]; if (replies) replies.push(record); else this.replies[command.toString()] = [record]; } public findListenersForMessage(command: WorkerMessengerCommand): WorkerMessengerReplyBufferRecord[] { return this.replies[command.toString()] || []; } public deleteListenerRecords(command: WorkerMessengerCommand) { this.replies[command.toString()] = null; } public deleteAllListenerRecords() { this.replies = {}; } public deleteListenerRecord(command: WorkerMessengerCommand, targetRecord: object) { const listenersForCommand = this.replies[command.toString()]; if (listenersForCommand == null) return; for (let listenerRecordIndex = listenersForCommand.length - 1; listenerRecordIndex >= 0; listenerRecordIndex--) { const listenerRecord = listenersForCommand[listenerRecordIndex]; if (listenerRecord === targetRecord) { listenersForCommand.splice(listenerRecordIndex, 1); } } } } export type WorkerMessengerPayload = Serializable | number | string | object | boolean; /** * A Promise-based PostMessage helper to ease back-and-forth replies between * service workers and window frames. */ export class WorkerMessenger { private context: ContextSWInterface; private replies: WorkerMessengerReplyBuffer; constructor(context: ContextSWInterface, replies: WorkerMessengerReplyBuffer = new WorkerMessengerReplyBuffer()) { this.context = context; this.replies = replies; } /** * Broadcasts a message from a service worker to all clients, including uncontrolled clients. */ async broadcast(command: WorkerMessengerCommand, payload: WorkerMessengerPayload) { const env = SdkEnvironment.getWindowEnv(); if (env !== WindowEnvironmentKind.ServiceWorker) { return; } else { const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); for (let client of clients) { Log.debug(`[Worker Messenger] [SW -> Page] Broadcasting '${command.toString()}' to window client ${client.url}.`) client.postMessage({ command: command, payload: payload } as any); } } } /* For pages: Sends a postMessage() to the service worker controlling the page. Waits until the service worker is controlling the page before sending the message. */ async unicast(command: WorkerMessengerCommand, payload?: WorkerMessengerPayload, windowClient?: WindowClient) { const env = SdkEnvironment.getWindowEnv(); if (env === WindowEnvironmentKind.ServiceWorker) { if (!windowClient) { throw new InvalidArgumentError('windowClient', InvalidArgumentReason.Empty); } else { Log.debug(`[Worker Messenger] [SW -> Page] Unicasting '${command.toString()}' to window client ${windowClient.url}.`) windowClient.postMessage({ command: command, payload: payload } as any); } } else { if (!(await this.isWorkerControllingPage())) { Log.debug("[Worker Messenger] The page is not controlled by the service worker yet. Waiting...", self.registration); } await this.waitUntilWorkerControlsPage(); Log.debug(`[Worker Messenger] [Page -> SW] Unicasting '${command.toString()}' to service worker.`) navigator.serviceWorker.controller.postMessage({ command: command, payload: payload }) } } /** * Due to https://github.com/w3c/ServiceWorker/issues/1156, listen() must * synchronously add self.addEventListener('message') if we are running in the * service worker. * * @param listenIfPageUncontrolled If true, begins listening for service * worker messages even if the service worker does not control this page. This * parameter is set to true on HTTPS iframes expecting service worker messages * that live under an HTTP page. */ public async listen(listenIfPageUncontrolled?: boolean) { if (!Environment.supportsServiceWorkers()) return; const env = SdkEnvironment.getWindowEnv(); if (env === WindowEnvironmentKind.ServiceWorker) { self.addEventListener('message', this.onWorkerMessageReceivedFromPage.bind(this)); Log.debug('[Worker Messenger] Service worker is now listening for messages.'); } else await this.listenForPage(listenIfPageUncontrolled); } /** * Listens for messages for the service worker. * * Waits until the service worker is controlling the page before listening for * messages. */ private async listenForPage(listenIfPageUncontrolled?: boolean) { if (!listenIfPageUncontrolled) { if (!(await this.isWorkerControllingPage())) { Log.debug(`(${location.origin}) [Worker Messenger] The page is not controlled by the service worker yet. Waiting...`, self.registration); } await this.waitUntilWorkerControlsPage(); Log.debug(`(${location.origin}) [Worker Messenger] The page is now controlled by the service worker.`); } navigator.serviceWorker.addEventListener('message', this.onPageMessageReceivedFromServiceWorker.bind(this)); Log.debug(`(${location.origin}) [Worker Messenger] Page is now listening for messages.`); } onWorkerMessageReceivedFromPage(event: ServiceWorkerMessageEvent) { const data: WorkerMessengerMessage = event.data; /* If this message doesn't contain our expected fields, discard the message */ /* The payload may be null. AMP web push sends commands to our service worker in the format: { command: "amp-web-push-subscription-state", payload: null } { command: "amp-web-push-unsubscribe", payload: null } { command: "amp-web-push-subscribe", payload: null } */ if (!data || !data.command) { return; } const listenerRecords = this.replies.findListenersForMessage(data.command); const listenersToRemove = []; const listenersToCall = []; Log.debug(`[Worker Messenger] Service worker received message:`, event.data); for (let listenerRecord of listenerRecords) { if (listenerRecord.onceListenerOnly) { listenersToRemove.push(listenerRecord); } listenersToCall.push(listenerRecord); } for (let i = listenersToRemove.length - 1; i >= 0; i--) { const listenerRecord = listenersToRemove[i]; this.replies.deleteListenerRecord(data.command, listenerRecord); } for (let listenerRecord of listenersToCall) { listenerRecord.callback.apply(null, [data.payload]); } } /* Occurs when the page receives a message from the service worker. A map of callbacks is checked to see if anyone is listening to the specific message topic. If no one is listening to the message, it is discarded; otherwise, the listener callback is executed. */ onPageMessageReceivedFromServiceWorker(event: ServiceWorkerMessageEvent) { const data: WorkerMessengerMessage = event.data; /* If this message doesn't contain our expected fields, discard the message */ if (!data || !data.command) { return; } const listenerRecords = this.replies.findListenersForMessage(data.command); const listenersToRemove = []; const listenersToCall = []; Log.debug(`[Worker Messenger] Page received message:`, event.data); for (let listenerRecord of listenerRecords) { if (listenerRecord.onceListenerOnly) { listenersToRemove.push(listenerRecord); } listenersToCall.push(listenerRecord); } for (let i = listenersToRemove.length - 1; i >= 0; i--) { const listenerRecord = listenersToRemove[i]; this.replies.deleteListenerRecord(data.command, listenerRecord); } for (let listenerRecord of listenersToCall) { listenerRecord.callback.apply(null, [data.payload]); } } /* Subscribes a callback to be notified every time a service worker sends a message to the window frame with the specific command. */ on(command: WorkerMessengerCommand, callback: (WorkerMessengerPayload) => void): void { this.replies.addListener(command, callback, false); } /* Subscribes a callback to be notified the next time a service worker sends a message to the window frame with the specific command. The callback is executed once at most. */ once(command: WorkerMessengerCommand, callback: (WorkerMessengerPayload) => void): void { this.replies.addListener(command, callback, true); } /** Unsubscribe a callback from being notified about service worker messages with the specified command. */ off(command?: WorkerMessengerCommand): void { if (command) { this.replies.deleteListenerRecords(command); } else { this.replies.deleteAllListenerRecords(); } } /* Service worker postMessage() communication relies on the property navigator.serviceWorker.controller to be non-null. The controller property references the active service worker controlling the page. Without this property, there is no service worker to message. The controller property is set when a service worker has successfully registered, installed, and activated a worker, and when a page isn't loaded in a hard refresh mode bypassing the cache. It's possible for a service worker to take a second page load to be fully activated. */ async isWorkerControllingPage(): Promise<boolean> { const env = SdkEnvironment.getWindowEnv(); if (env === WindowEnvironmentKind.ServiceWorker) return !!self.registration.active; else { const workerState = await this.context.serviceWorkerManager.getActiveState(); return workerState === ServiceWorkerActiveState.WorkerA || workerState === ServiceWorkerActiveState.WorkerB; } } /** * For pages, waits until one of our workers is activated. * * For service workers, waits until the registration is active. */ async waitUntilWorkerControlsPage() { return new Promise<void>(async resolve => { if (await this.isWorkerControllingPage()) resolve(); else { const env = SdkEnvironment.getWindowEnv(); if (env === WindowEnvironmentKind.ServiceWorker) { self.addEventListener('activate', async e => { if (await this.isWorkerControllingPage()) resolve(); }); } else { navigator.serviceWorker.addEventListener('controllerchange', async e => { if (await this.isWorkerControllingPage()) resolve(); }); } } }); } }