diff --git a/src/OneSignal.ts b/src/OneSignal.ts index 5e08babe1..662006fba 100755 --- a/src/OneSignal.ts +++ b/src/OneSignal.ts @@ -58,6 +58,7 @@ import { AppUserConfigNotifyButton, DelayedPromptType } from './models/Prompts'; import LocalStorage from './utils/LocalStorage'; import { AuthHashOptionsValidatorHelper } from './helpers/page/AuthHashOptionsValidatorHelper'; import { TagsObject } from './models/Tags'; +import { WorkerMessengerCommand } from './libraries/WorkerMessenger'; export default class OneSignal { /** @@ -844,6 +845,13 @@ export default class OneSignal { }); } + public static async setSWLogging(enable: boolean): Promise { + OneSignal.context.workerMessenger.directPostMessageToSW( + WorkerMessengerCommand.SetLogging, + { shouldLog: !!enable } + ); + } + static __doNotShowWelcomeNotification: boolean; static VERSION = Environment.version(); static _VERSION = Environment.version(); diff --git a/src/helpers/ServiceWorkerHelper.ts b/src/helpers/ServiceWorkerHelper.ts index 9830c1fde..403b6f20a 100755 --- a/src/helpers/ServiceWorkerHelper.ts +++ b/src/helpers/ServiceWorkerHelper.ts @@ -1,5 +1,5 @@ import { OneSignalApiSW } from "../OneSignalApiSW"; -import Log from "../libraries/sw/Log"; +import Log from "../libraries/Log"; import Path from "../models/Path"; import { Session, initializeNewSession, SessionOrigin, SessionStatus } from "../models/Session"; import { OneSignalUtils } from "../utils/OneSignalUtils"; diff --git a/src/helpers/sw/CancelableTimeout.ts b/src/helpers/sw/CancelableTimeout.ts index cc4583c50..1f32ac70d 100644 --- a/src/helpers/sw/CancelableTimeout.ts +++ b/src/helpers/sw/CancelableTimeout.ts @@ -1,4 +1,4 @@ -import Log from "../../libraries/sw/Log"; +import SWLog from "../../libraries/SWLog"; export interface CancelableTimeoutPromise { cancel: () => void; @@ -6,7 +6,7 @@ export interface CancelableTimeoutPromise { } const doNothing = () => { - Log.debug("Do nothing"); + SWLog.debug("Do nothing"); }; export function cancelableTimeout(callback: () => Promise, delayInSeconds: number): CancelableTimeoutPromise { @@ -25,15 +25,15 @@ export function cancelableTimeout(callback: () => Promise, delayInSeconds: await callback(); resolve(); } catch(e) { - Log.error("Failed to execute callback", e); + SWLog.error("Failed to execute callback", e); reject(); } - }, + }, delayInMilliseconds); - + clearTimeoutHandle = () => { - Log.debug("Cancel called"); - self.clearTimeout(timerId); + SWLog.debug("Cancel called"); + self.clearTimeout(timerId); if (!startedExecution) { resolve(); } @@ -41,7 +41,7 @@ export function cancelableTimeout(callback: () => Promise, delayInSeconds: }); if (!clearTimeoutHandle) { - Log.warn("clearTimeoutHandle was not assigned."); + SWLog.warn("clearTimeoutHandle was not assigned."); return { promise, cancel: doNothing, diff --git a/src/libraries/SWLog.ts b/src/libraries/SWLog.ts new file mode 100644 index 000000000..52872a50d --- /dev/null +++ b/src/libraries/SWLog.ts @@ -0,0 +1,97 @@ + +export interface SWLogOptions { + console?: SWLogConsole; +}; + +export type SWConsoleLoggerFn = (...args: any[]) => void; + +export interface SWLogConsole extends Partial { + log: SWConsoleLoggerFn; + debug: SWConsoleLoggerFn; + trace: SWConsoleLoggerFn; + info: SWConsoleLoggerFn; + warn: SWConsoleLoggerFn; + error: SWConsoleLoggerFn; +}; + +const NOOP = () => {}; + +type SWLogBuiltinConsoles = "env" | "null" | "default"; + + +// class semantics here are for compat -- the singleton is `SWConsoleLog` +export default class SWLog { + private static _nullConsole: SWLogConsole | undefined + private static _singletonConsole: SWLogConsole | undefined; + + public static get nullConsole(): SWLogConsole { + // test spies need an invariant object reference for `called` + SWLog._nullConsole = + SWLog._nullConsole || + ["log", "debug", "trace", "info", "warn", "error"].reduce( + (l, m) => ({ ...l, [m]: NOOP }), + {} + ) as SWLogConsole; + + return SWLog._nullConsole; + } + + // NB properties' being calculated just in time mitigates hard-to-debug + // sequencing errors from TestEnvironment's twiddling of global state + public static get consoles(): Record { + return { + env: (() => { + const console: Console = (global !== undefined) + ? global.console + : window.console; + return console as SWLogConsole; + })(), + + null: this.nullConsole, + default: this.nullConsole, // quiet by default, change if desired + } + } + + // NB built-in `Console` interface is freely castable to `SWLogConsole` + public static resetConsole(console?: SWLogConsole): void { + SWLog._singletonConsole = console; + } + + public static get singletonConsole(): SWLogConsole { + if (SWLog._singletonConsole === undefined) + SWLog.resetConsole(SWLog.consoles.default); + return SWLog._singletonConsole!; // runtime-ensured; safe + } + + public static get enabled(): boolean { + const singletonConsole = SWLog.singletonConsole + return !!singletonConsole && singletonConsole !== SWLog.consoles.null; + } + + // below are relays to "a `SWLogConsole`" (iow "a `Partial`"): + // removing class semantics would make them redundant + + static log(...args: any[]): void { + SWLog.singletonConsole.log(...args); + } + + static trace(...args: any[]): void { + SWLog.singletonConsole.trace(...args); + } + + static debug(...args: any[]): void { + SWLog.singletonConsole.debug(...args); + } + + static info(...args: any[]): void { + SWLog.singletonConsole.info(...args); + } + + static warn(...args: any[]): void { + SWLog.singletonConsole.warn(...args); + } + + static error(...args: any[]): void { + SWLog.singletonConsole.error(...args); + } +} diff --git a/src/libraries/sw/Log.ts b/src/libraries/sw/Log.ts deleted file mode 100644 index 7687cd924..000000000 --- a/src/libraries/sw/Log.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { OSServiceWorkerFields } from "../../service-worker/types"; - -declare var self: ServiceWorkerGlobalScope & OSServiceWorkerFields; - -export default class Log { - static debug(...args: any[]): void { - if (!!self.shouldLog) { - console.debug(...args); - } - } - static trace(...args: any[]): void { - if (!!self.shouldLog) { - console.trace(...args); - } - } - static info(...args: any[]): void { - if (!!self.shouldLog) { - console.info(...args); - } - } - static warn(...args: any[]): void { - if (!!self.shouldLog) { - console.warn(...args); - } - } - static error(...args: any[]): void { - if (!!self.shouldLog) { - console.error(...args); - } - } -} \ No newline at end of file diff --git a/src/service-worker/ServiceWorker.ts b/src/service-worker/ServiceWorker.ts index b658905a9..b0e9b8838 100755 --- a/src/service-worker/ServiceWorker.ts +++ b/src/service-worker/ServiceWorker.ts @@ -7,16 +7,15 @@ import OneSignalApiBase from "../OneSignalApiBase"; import OneSignalApiSW from "../OneSignalApiSW"; import Database from "../services/Database"; -import { UnsubscriptionStrategy } from "../models/UnsubscriptionStrategy"; import { RawPushSubscription } from "../models/RawPushSubscription"; import { SubscriptionStateKind } from "../models/SubscriptionStateKind"; import { SubscriptionStrategyKind } from "../models/SubscriptionStrategyKind"; import { PushDeviceRecord } from "../models/PushDeviceRecord"; import { UpsertSessionPayload, DeactivateSessionPayload, - PageVisibilityRequest, PageVisibilityResponse, SessionStatus + PageVisibilityRequest, SessionStatus } from "../models/Session"; -import Log from "../libraries/sw/Log"; +import SWLog from "../libraries/SWLog"; import { ConfigHelper } from "../helpers/ConfigHelper"; import { OneSignalUtils } from "../utils/OneSignalUtils"; import { Utils } from "../context/shared/utils/Utils"; @@ -28,6 +27,7 @@ import { NotificationReceived, NotificationClicked } from "../models/Notificatio import { cancelableTimeout } from "../helpers/sw/CancelableTimeout"; import { DeviceRecord } from '../models/DeviceRecord'; import { awaitableTimeout } from "../utils/AwaitableTimeout"; +import { applyWorkerMessagingCommandHandlers, buildWorkerMessagingCommandHandlers } from "./WorkerMessengerCommandHandling"; declare var self: ServiceWorkerGlobalScope & OSServiceWorkerFields; @@ -60,7 +60,7 @@ export class ServiceWorker { } static get log() { - return Log; + return SWLog; } /** @@ -112,11 +112,11 @@ export class ServiceWorker { switch(data.command) { case WorkerMessengerCommand.SessionUpsert: - Log.debug("[Service Worker] Received SessionUpsert", payload); + SWLog.debug("[Service Worker] Received SessionUpsert", payload); ServiceWorker.debounceRefreshSession(event, payload as UpsertSessionPayload); break; case WorkerMessengerCommand.SessionDeactivate: - Log.debug("[Service Worker] Received SessionDeactivate", payload); + SWLog.debug("[Service Worker] Received SessionDeactivate", payload); ServiceWorker.debounceRefreshSession(event, payload as DeactivateSessionPayload); break; default: @@ -136,14 +136,16 @@ export class ServiceWorker { Also see: https://github.com/w3c/ServiceWorker/issues/1156 */ - Log.debug('Setting up message listeners.'); + SWLog.debug('Setting up message listeners.'); // self.addEventListener('message') is statically added inside the listen() method ServiceWorker.workerMessenger.listen(); // Install messaging event handlers for page <-> service worker communication - ServiceWorker.setupMessageListeners(); + + const messageHandlers = buildWorkerMessagingCommandHandlers(this.workerMessenger); + applyWorkerMessagingCommandHandlers(ServiceWorker.workerMessenger, messageHandlers); } - static async getAppId(): Promise { + public static async getAppId(): Promise { if (self.location.search) { const match = self.location.search.match(/appId=([0-9a-z-]+)&?/i); // Successful regex matches are at position 1 @@ -156,89 +158,13 @@ export class ServiceWorker { return appId; } - static setupMessageListeners() { - ServiceWorker.workerMessenger.on(WorkerMessengerCommand.WorkerVersion, _ => { - Log.debug('[Service Worker] Received worker version message.'); - ServiceWorker.workerMessenger.broadcast(WorkerMessengerCommand.WorkerVersion, Environment.version()); - }); - ServiceWorker.workerMessenger.on(WorkerMessengerCommand.Subscribe, async (appConfigBundle: any) => { - const appConfig = appConfigBundle; - Log.debug('[Service Worker] Received subscribe message.'); - const context = new ContextSW(appConfig); - const rawSubscription = await context.subscriptionManager.subscribe(SubscriptionStrategyKind.ResubscribeExisting); - const subscription = await context.subscriptionManager.registerSubscription(rawSubscription); - ServiceWorker.workerMessenger.broadcast(WorkerMessengerCommand.Subscribe, subscription.serialize()); - }); - ServiceWorker.workerMessenger.on(WorkerMessengerCommand.SubscribeNew, async (appConfigBundle: any) => { - const appConfig = appConfigBundle; - Log.debug('[Service Worker] Received subscribe new message.'); - const context = new ContextSW(appConfig); - const rawSubscription = await context.subscriptionManager.subscribe(SubscriptionStrategyKind.SubscribeNew); - const subscription = await context.subscriptionManager.registerSubscription(rawSubscription); - ServiceWorker.workerMessenger.broadcast(WorkerMessengerCommand.SubscribeNew, subscription.serialize()); - }); - ServiceWorker.workerMessenger.on(WorkerMessengerCommand.AmpSubscriptionState, async (_appConfigBundle: any) => { - Log.debug('[Service Worker] Received AMP subscription state message.'); - const pushSubscription = await self.registration.pushManager.getSubscription(); - if (!pushSubscription) { - await ServiceWorker.workerMessenger.broadcast(WorkerMessengerCommand.AmpSubscriptionState, false); - } else { - const permission = await self.registration.pushManager.permissionState(pushSubscription.options); - const { optedOut } = await Database.getSubscription(); - const isSubscribed = !!pushSubscription && permission === "granted" && optedOut !== true; - await ServiceWorker.workerMessenger.broadcast(WorkerMessengerCommand.AmpSubscriptionState, isSubscribed); - } - }); - ServiceWorker.workerMessenger.on(WorkerMessengerCommand.AmpSubscribe, async () => { - Log.debug('[Service Worker] Received AMP subscribe message.'); - const appId = await ServiceWorker.getAppId(); - const appConfig = await ConfigHelper.getAppConfig({ appId }, OneSignalApiSW.downloadServerAppConfig); - const context = new ContextSW(appConfig); - const rawSubscription = await context.subscriptionManager.subscribe(SubscriptionStrategyKind.ResubscribeExisting); - const subscription = await context.subscriptionManager.registerSubscription(rawSubscription); - await Database.put('Ids', { type: 'appId', id: appId }); - ServiceWorker.workerMessenger.broadcast(WorkerMessengerCommand.AmpSubscribe, subscription.deviceId); - }); - ServiceWorker.workerMessenger.on(WorkerMessengerCommand.AmpUnsubscribe, async () => { - Log.debug('[Service Worker] Received AMP unsubscribe message.'); - const appId = await ServiceWorker.getAppId(); - const appConfig = await ConfigHelper.getAppConfig({ appId }, OneSignalApiSW.downloadServerAppConfig); - const context = new ContextSW(appConfig); - await context.subscriptionManager.unsubscribe(UnsubscriptionStrategy.MarkUnsubscribed); - ServiceWorker.workerMessenger.broadcast(WorkerMessengerCommand.AmpUnsubscribe, null); - }); - ServiceWorker.workerMessenger.on( - WorkerMessengerCommand.AreYouVisibleResponse, async (payload: PageVisibilityResponse) => { - Log.debug('[Service Worker] Received response for AreYouVisible', payload); - if (!self.clientsStatus) { return; } - - const timestamp = payload.timestamp; - if (self.clientsStatus.timestamp !== timestamp) { return; } - - self.clientsStatus.receivedResponsesCount++; - if (payload.focused) { - self.clientsStatus.hasAnyActiveSessions = true; - } - } - ); - ServiceWorker.workerMessenger.on( - WorkerMessengerCommand.SetLogging, async (payload: {shouldLog: boolean}) => { - if (payload.shouldLog) { - self.shouldLog = true; - } else { - self.shouldLog = undefined; - } - } - ); - } - /** * Occurs when a push message is received. * This method handles the receipt of a push signal on all web browsers except Safari, which uses the OS to handle * notifications. */ static onPushReceived(event: PushEvent): void { - Log.debug(`Called %conPushReceived(${JSON.stringify(event, null, 4)}):`, Utils.getConsoleStyle('code'), event); + SWLog.debug(`Called %conPushReceived(${JSON.stringify(event, null, 4)}):`, Utils.getConsoleStyle('code'), event); event.waitUntil( ServiceWorker.parseOrFetchNotifications(event) @@ -249,7 +175,7 @@ export class ServiceWorker { const appId = await ServiceWorker.getAppId(); for (const rawNotification of notifications) { - Log.debug('Raw Notification from OneSignal:', rawNotification); + SWLog.debug('Raw Notification from OneSignal:', rawNotification); const notification = ServiceWorker.buildStructuredNotificationObject(rawNotification); const notificationReceived: NotificationReceived = { @@ -266,10 +192,10 @@ export class ServiceWorker { notificationEventPromiseFns.push((notif => { return ServiceWorker.displayNotification(notif) .then(() => { - return ServiceWorker.workerMessenger.broadcast(WorkerMessengerCommand.NotificationDisplayed, notif).catch(e => Log.error(e)); + return ServiceWorker.workerMessenger.broadcast(WorkerMessengerCommand.NotificationDisplayed, notif).catch(e => SWLog.error(e)); }) .then(() => ServiceWorker.executeWebhooks('notification.displayed', notif) - .then(() => ServiceWorker.sendConfirmedDelivery(notif)).catch(e => Log.error(e))); + .then(() => ServiceWorker.sendConfirmedDelivery(notif)).catch(e => SWLog.error(e))); }).bind(null, notification)); } @@ -278,9 +204,9 @@ export class ServiceWorker { }, Promise.resolve()); }) .catch(e => { - Log.debug('Failed to display a notification:', e); + SWLog.debug('Failed to display a notification:', e); if (ServiceWorker.UNSUBSCRIBED_FROM_NOTIFICATIONS) { - Log.debug('Because we have just unsubscribed from notifications, we will not show anything.'); + SWLog.debug('Because we have just unsubscribed from notifications, we will not show anything.'); return undefined; } }) @@ -329,7 +255,7 @@ export class ServiceWorker { 'Content-Type': 'application/json' }; } - Log.debug( + SWLog.debug( `Executing ${event} webhook ${isServerCorsEnabled ? 'with' : 'without'} CORS %cPOST ${webhookTargetUrl}`, Utils.getConsoleStyle('code'), ':', postData ); @@ -367,7 +293,7 @@ export class ServiceWorker { device_type: DeviceRecord.prototype.getDeliveryPlatform() }; - Log.debug(`Called %csendConfirmedDelivery(${ + SWLog.debug(`Called %csendConfirmedDelivery(${ JSON.stringify(notification, null, 4) })`, Utils.getConsoleStyle('code')); @@ -433,7 +359,7 @@ export class ServiceWorker { } static async refreshSession(event: ExtendableMessageEvent, options: DeactivateSessionPayload): Promise { - Log.debug("[Service Worker] refreshSession"); + SWLog.debug("[Service Worker] refreshSession"); /** * if https -> getActiveClients -> check for the first focused * unfortunately, not enough for safari, it always returns false for focused state of a client @@ -451,7 +377,7 @@ export class ServiceWorker { await ServiceWorker.checkIfAnyClientsFocusedAndUpdateSession(event, windowClients, options); } else { const hasAnyActiveSessions: boolean = windowClients.some(w => (w as WindowClient).focused); - Log.debug("[Service Worker] isHttps hasAnyActiveSessions", hasAnyActiveSessions); + SWLog.debug("[Service Worker] isHttps hasAnyActiveSessions", hasAnyActiveSessions); await ServiceWorker.updateSessionBasedOnHasActive(event, hasAnyActiveSessions, options); } return; @@ -485,7 +411,7 @@ export class ServiceWorker { if (!self.clientsStatus) { return; } if (self.clientsStatus.timestamp !== timestamp) { return; } - Log.debug("updateSessionBasedOnHasActive", self.clientsStatus); + SWLog.debug("updateSessionBasedOnHasActive", self.clientsStatus); await ServiceWorker.updateSessionBasedOnHasActive(event, self.clientsStatus.hasAnyActiveSessions, sessionInfo); self.clientsStatus = undefined; @@ -496,7 +422,7 @@ export class ServiceWorker { } static debounceRefreshSession(event: ExtendableMessageEvent, options: DeactivateSessionPayload) { - Log.debug("[Service Worker] debounceRefreshSession", options); + SWLog.debug("[Service Worker] debounceRefreshSession", options); if (self.cancel) { self.cancel(); @@ -605,7 +531,7 @@ export class ServiceWorker { * @param notification A structured notification object. */ static async displayNotification(notification, overrides?) { - Log.debug(`Called %cdisplayNotification(${JSON.stringify(notification, null, 4)}):`, Utils.getConsoleStyle('code'), notification); + SWLog.debug(`Called %cdisplayNotification(${JSON.stringify(notification, null, 4)}):`, Utils.getConsoleStyle('code'), notification); // Use the default title if one isn't provided const defaultTitle = await ServiceWorker._getTitle(); @@ -714,10 +640,10 @@ export class ServiceWorker { * Supported on: Chrome 50+ only */ static onNotificationClosed(event) { - Log.debug(`Called %conNotificationClosed(${JSON.stringify(event, null, 4)}):`, Utils.getConsoleStyle('code'), event); + SWLog.debug(`Called %conNotificationClosed(${JSON.stringify(event, null, 4)}):`, Utils.getConsoleStyle('code'), event); const notification = event.notification.data; - ServiceWorker.workerMessenger.broadcast(WorkerMessengerCommand.NotificationDismissed, notification).catch(e => Log.error(e)); + ServiceWorker.workerMessenger.broadcast(WorkerMessengerCommand.NotificationDismissed, notification).catch(e => SWLog.error(e)); event.waitUntil( ServiceWorker.executeWebhooks('notification.dismissed', notification) ); @@ -762,7 +688,7 @@ export class ServiceWorker { * dismissed by clicking the 'X' icon. See the notification close event for the dismissal event. */ static async onNotificationClicked(event: NotificationEventInit) { - Log.debug(`Called %conNotificationClicked(${JSON.stringify(event, null, 4)}):`, Utils.getConsoleStyle('code'), event); + SWLog.debug(`Called %conNotificationClicked(${JSON.stringify(event, null, 4)}):`, Utils.getConsoleStyle('code'), event); // Close the notification first here, before we do anything that might fail event.notification.close(); @@ -796,7 +722,7 @@ export class ServiceWorker { url: launchUrl, timestamp: new Date().getTime(), }; - Log.info("NotificationClicked", notificationClicked); + SWLog.info("NotificationClicked", notificationClicked); saveNotificationClickedPromise = (async notificationClicked => { try { const existingSession = await Database.getCurrentSession(); @@ -812,7 +738,7 @@ export class ServiceWorker { await Database.upsertSession(existingSession); } } catch(e) { - Log.error("Failed to save clicked notification.", e); + SWLog.error("Failed to save clicked notification.", e); } })(notificationClicked); @@ -845,7 +771,7 @@ export class ServiceWorker { try { clientOrigin = new URL(clientUrl).origin; } catch (e) { - Log.error(`Failed to get the HTTP site's actual origin:`, e); + SWLog.error(`Failed to get the HTTP site's actual origin:`, e); } let launchOrigin = null; try { @@ -863,7 +789,7 @@ export class ServiceWorker { if (client instanceof WindowClient) await client.focus(); } catch (e) { - Log.error("Failed to focus:", client, e); + SWLog.error("Failed to focus:", client, e); } } else { /* @@ -874,38 +800,38 @@ export class ServiceWorker { */ if (client['isSubdomainIframe']) { try { - Log.debug('Client is subdomain iFrame. Attempting to focus() client.'); + SWLog.debug('Client is subdomain iFrame. Attempting to focus() client.'); if (client instanceof WindowClient) await client.focus(); } catch (e) { - Log.error("Failed to focus:", client, e); + SWLog.error("Failed to focus:", client, e); } if (notificationOpensLink) { - Log.debug(`Redirecting HTTP site to ${launchUrl}.`); + SWLog.debug(`Redirecting HTTP site to ${launchUrl}.`); await Database.put("NotificationOpened", { url: launchUrl, data: notificationData, timestamp: Date.now() }); ServiceWorker.workerMessenger.unicast(WorkerMessengerCommand.RedirectPage, launchUrl, client); } else { - Log.debug('Not navigating because link is special.'); + SWLog.debug('Not navigating because link is special.'); } } else if (client instanceof WindowClient && client.navigate) { try { - Log.debug('Client is standard HTTPS site. Attempting to focus() client.'); + SWLog.debug('Client is standard HTTPS site. Attempting to focus() client.'); if (client instanceof WindowClient) await client.focus(); } catch (e) { - Log.error("Failed to focus:", client, e); + SWLog.error("Failed to focus:", client, e); } try { if (notificationOpensLink) { - Log.debug(`Redirecting HTTPS site to (${launchUrl}).`); + SWLog.debug(`Redirecting HTTPS site to (${launchUrl}).`); await Database.put("NotificationOpened", { url: launchUrl, data: notificationData, timestamp: Date.now() }); await client.navigate(launchUrl); } else { - Log.debug('Not navigating because link is special.'); + SWLog.debug('Not navigating because link is special.'); } } catch (e) { - Log.error("Failed to navigate:", client, launchUrl, e); + SWLog.error("Failed to navigate:", client, launchUrl, e); } } else { // If client.navigate() isn't available, we have no other option but to open a new tab to the URL. @@ -968,11 +894,11 @@ export class ServiceWorker { * @param url May not be well-formed. */ static async openUrl(url: string): Promise { - Log.debug('Opening notification URL:', url); + SWLog.debug('Opening notification URL:', url); try { return await self.clients.openWindow(url); } catch (e) { - Log.warn(`Failed to open the URL '${url}':`, e); + SWLog.warn(`Failed to open the URL '${url}':`, e); return null; } } @@ -987,12 +913,12 @@ export class ServiceWorker { * @param event */ static onServiceWorkerActivated(event: ExtendableEvent) { - Log.info(`OneSignal Service Worker activated (version ${Environment.version()})`); + SWLog.info(`OneSignal Service Worker activated (version ${Environment.version()})`); event.waitUntil(self.clients.claim()); } static async onPushSubscriptionChange(event: PushSubscriptionChangeEvent) { - Log.debug(`Called %conPushSubscriptionChange(${JSON.stringify(event, null, 4)}):`, Utils.getConsoleStyle('code'), event); + SWLog.debug(`Called %conPushSubscriptionChange(${JSON.stringify(event, null, 4)}):`, Utils.getConsoleStyle('code'), event); const appId = await ServiceWorker.getAppId(); if (!appId) { @@ -1107,7 +1033,7 @@ export class ServiceWorker { const isValidPayload = ServiceWorker.isValidPushPayload(event.data); if (isValidPayload) { - Log.debug("Received a valid encrypted push payload."); + SWLog.debug("Received a valid encrypted push payload."); return Promise.resolve([event.data.json()]); } @@ -1132,11 +1058,11 @@ export class ServiceWorker { OneSignalUtils.isValidUuid(payload.custom.i)) { return true; } else { - Log.debug('isValidPushPayload: Valid JSON but missing notification UUID:', payload); + SWLog.debug('isValidPushPayload: Valid JSON but missing notification UUID:', payload); return false; } } catch (e) { - Log.debug('isValidPushPayload: Parsing to JSON failed with:', e); + SWLog.debug('isValidPushPayload: Parsing to JSON failed with:', e); return false; } } diff --git a/src/service-worker/WorkerMessengerCommandHandling.ts b/src/service-worker/WorkerMessengerCommandHandling.ts new file mode 100644 index 000000000..bcc0ef956 --- /dev/null +++ b/src/service-worker/WorkerMessengerCommandHandling.ts @@ -0,0 +1,117 @@ +import Environment from "../Environment"; +import { ConfigHelper } from "../helpers/ConfigHelper"; +import SWLog from "../libraries/SWLog"; +import { WorkerMessenger, WorkerMessengerCommand } from "../libraries/WorkerMessenger"; +import ContextSW from "../models/ContextSW"; +import { PageVisibilityResponse } from "../models/Session"; +import { SubscriptionStrategyKind } from "../models/SubscriptionStrategyKind"; +import { UnsubscriptionStrategy } from "../models/UnsubscriptionStrategy"; +import OneSignalApiSW from "../OneSignalApiSW"; +import Database from "../services/Database"; +import { OSServiceWorkerFields } from "./types"; +import { ServiceWorker as OSServiceWorker } from "./ServiceWorker"; + +declare var self: ServiceWorkerGlobalScope & OSServiceWorkerFields; + +// Behaviorally identical extraction from setupMessageListeners for test surface area. +export type WorkerMessengerCommandHandler = (_: any) => Promise | void; +export type WorkerMessengerCommandHandlers = + Partial>; + +export function buildWorkerMessagingCommandHandlers( + workerMessenger: WorkerMessenger +): WorkerMessengerCommandHandlers { + return { + [WorkerMessengerCommand.WorkerVersion]: function(_: any) { + SWLog.debug('[Service Worker] Received worker version message.'); + workerMessenger.broadcast(WorkerMessengerCommand.WorkerVersion, Environment.version()); + }, + [WorkerMessengerCommand.Subscribe]: async (appConfigBundle: any) => { + const appConfig = appConfigBundle; + SWLog.debug('[Service Worker] Received subscribe message.'); + const context = new ContextSW(appConfig); + const rawSubscription = await context.subscriptionManager.subscribe(SubscriptionStrategyKind.ResubscribeExisting); + const subscription = await context.subscriptionManager.registerSubscription(rawSubscription); + workerMessenger.broadcast(WorkerMessengerCommand.Subscribe, subscription.serialize()); + }, + [WorkerMessengerCommand.SubscribeNew]: async (appConfigBundle: any) => { + const appConfig = appConfigBundle; + SWLog.debug('[Service Worker] Received subscribe new message.'); + const context = new ContextSW(appConfig); + const rawSubscription = await context.subscriptionManager.subscribe(SubscriptionStrategyKind.SubscribeNew); + const subscription = await context.subscriptionManager.registerSubscription(rawSubscription); + workerMessenger.broadcast(WorkerMessengerCommand.SubscribeNew, subscription.serialize()); + }, + [WorkerMessengerCommand.AmpSubscriptionState]: async (_appConfigBundle: any) => { + SWLog.debug('[Service Worker] Received AMP subscription state message.'); + const pushSubscription = await self.registration.pushManager.getSubscription(); + if (!pushSubscription) { + await workerMessenger.broadcast(WorkerMessengerCommand.AmpSubscriptionState, false); + } else { + const permission = await self.registration.pushManager.permissionState(pushSubscription.options); + const { optedOut } = await Database.getSubscription(); + const isSubscribed = !!pushSubscription && permission === "granted" && optedOut !== true; + await workerMessenger.broadcast(WorkerMessengerCommand.AmpSubscriptionState, isSubscribed); + } + }, + [WorkerMessengerCommand.AmpSubscribe]: async () => { + SWLog.debug('[Service Worker] Received AMP subscribe message.'); + const appId = await OSServiceWorker.getAppId(); + const appConfig = await ConfigHelper.getAppConfig({ appId }, OneSignalApiSW.downloadServerAppConfig); + const context = new ContextSW(appConfig); + const rawSubscription = await context.subscriptionManager.subscribe(SubscriptionStrategyKind.ResubscribeExisting); + const subscription = await context.subscriptionManager.registerSubscription(rawSubscription); + await Database.put('Ids', { type: 'appId', id: appId }); + workerMessenger.broadcast(WorkerMessengerCommand.AmpSubscribe, subscription.deviceId); + }, + [WorkerMessengerCommand.AmpUnsubscribe]: async () => { + SWLog.debug('[Service Worker] Received AMP unsubscribe message.'); + const appId = await OSServiceWorker.getAppId(); + const appConfig = await ConfigHelper.getAppConfig({ appId }, OneSignalApiSW.downloadServerAppConfig); + const context = new ContextSW(appConfig); + await context.subscriptionManager.unsubscribe(UnsubscriptionStrategy.MarkUnsubscribed); + workerMessenger.broadcast(WorkerMessengerCommand.AmpUnsubscribe, null); + }, + [WorkerMessengerCommand.AreYouVisibleResponse]: async (payload: PageVisibilityResponse) => { + SWLog.debug('[Service Worker] Received response for AreYouVisible', payload); + if (!self.clientsStatus) { return; } + + const timestamp = payload.timestamp; + if (self.clientsStatus.timestamp !== timestamp) { return; } + + self.clientsStatus.receivedResponsesCount++; + if (payload.focused) { + self.clientsStatus.hasAnyActiveSessions = true; + } + }, + [WorkerMessengerCommand.SetLogging]: async (payload: {shouldLog: boolean}) => { + const message = !!payload.shouldLog + ? "enabled" + : "disabled"; + + // Ensure this message will be logged: + SWLog.resetConsole(SWLog.consoles.null); + SWLog.debug(`[Service Worker] Received SetLogging message. Logging ${message}.`, payload); + + // Now set it how it should be, newly: + const derivedConsole = (!!payload.shouldLog) + ? SWLog.consoles.env + : SWLog.consoles.null; + SWLog.resetConsole(derivedConsole); + }, + } +} + +export function applyWorkerMessagingCommandHandlers( + workerMessenger: WorkerMessenger, + workerMessengerCommandHandlers: WorkerMessengerCommandHandlers, +): void { + Object.keys(workerMessengerCommandHandlers).forEach((message: string) => { + workerMessenger.on( + message as WorkerMessengerCommand, + workerMessengerCommandHandlers[message as WorkerMessengerCommand] as WorkerMessengerCommandHandler, + ); + }); +} + + diff --git a/src/service-worker/types.ts b/src/service-worker/types.ts index 7ea8f35d4..1fcad3482 100644 --- a/src/service-worker/types.ts +++ b/src/service-worker/types.ts @@ -9,8 +9,7 @@ export interface ClientStatus { hasAnyActiveSessions: boolean; } -export interface OSServiceWorkerFields { - shouldLog?: boolean; +export interface OSServiceWorkerFields { debounceSessionTimerId?: number; finalizeSessionTimerId?: number; clientsStatus?: ClientStatus; diff --git a/src/utils/OneSignalStub.ts b/src/utils/OneSignalStub.ts index a15268858..d4969aef4 100644 --- a/src/utils/OneSignalStub.ts +++ b/src/utils/OneSignalStub.ts @@ -64,7 +64,8 @@ export abstract class OneSignalStub implements IndexableByString { "isOptedOut", "getEmailId", "getSMSId", - "sendOutcome" + "sendOutcome", + "setSWLogging", ]; public abstract isPushNotificationsSupported(): boolean; diff --git a/test/unit/libraries/SWLog.ts b/test/unit/libraries/SWLog.ts new file mode 100644 index 000000000..ab790b51a --- /dev/null +++ b/test/unit/libraries/SWLog.ts @@ -0,0 +1,117 @@ +import test, { afterEach } from 'ava' +import sinon, { SinonSpy, SinonSandbox } from 'sinon'; + +import SWLog, { SWLogConsole } from "../../../src/libraries/SWLog" + +const sinonSandbox: SinonSandbox = sinon.sandbox.create(); + +// This is the underlying console which won't log at all; necessary on account +// of not wanting to spam logged output during test run. +const spyOnConsole: (target: SWLogConsole) => SWLogConsole = (target) => { + let spiedUponConsole = target || SWLog.consoles.null; + + Object.keys(spiedUponConsole).forEach( + (m: string, _n: number, _a: string[]) => { + sinonSandbox.spy(spiedUponConsole, m as keyof SWLogConsole) + } + ); + + return spiedUponConsole; +} + +afterEach(() => { + sinonSandbox.restore(); +}) + +test('singletonConsole - behaves like a singleton', async t => { + SWLog.resetConsole(); + + const singletonConsoleA = SWLog.singletonConsole + const singletonConsoleB = SWLog.singletonConsole + t.is(singletonConsoleA, singletonConsoleB); // test is agnostic to what it is + + SWLog.resetConsole(SWLog.consoles.env); + t.not(singletonConsoleA, SWLog.singletonConsole) + t.not(singletonConsoleB, SWLog.singletonConsole) +}); + +test('it defaults to null console', async t => { + SWLog.resetConsole(undefined); // ensure (is global state) + + // NB no explicit reset + const console = spyOnConsole(SWLog.consoles.null); + + SWLog.singletonConsole.log('logging: log'); + t.true((console.log as SinonSpy).called); + + SWLog.singletonConsole.trace('logging: trace'); + t.true((console.trace as SinonSpy).called); + + SWLog.singletonConsole.debug('logging: debug'); + t.true((console.debug as SinonSpy).called); + + SWLog.singletonConsole.info('logging: info'); + t.true((console.info as SinonSpy).called); + + SWLog.singletonConsole.warn('logging: warn'); + t.true((console.warn as SinonSpy).called); + + SWLog.singletonConsole.error('logging: error'); + t.true((console.error as SinonSpy).called); +}); + + +test('it invokes the provided console', async t => { + const console = spyOnConsole(SWLog.consoles.null); + SWLog.resetConsole(console); + + SWLog.singletonConsole.log('logging: log'); + t.true((console.log as SinonSpy).called); + + SWLog.singletonConsole.trace('logging: trace'); + t.true((console.trace as SinonSpy).called); + + SWLog.singletonConsole.debug('logging: debug'); + t.true((console.debug as SinonSpy).called); + + SWLog.singletonConsole.info('logging: info'); + t.true((console.info as SinonSpy).called); + + SWLog.singletonConsole.warn('logging: warn'); + t.true((console.warn as SinonSpy).called); + + SWLog.singletonConsole.error('logging: error'); + t.true((console.error as SinonSpy).called); +}); + +test('it uses specified console; ignores others', async t => { + // disproving normal `console.log`: + const decoy = spyOnConsole(SWLog.consoles.env); + + const console = spyOnConsole(SWLog.consoles.null); + SWLog.resetConsole(console); + + SWLog.singletonConsole.log('logging: log'); + t.true((console.log as SinonSpy).called); + t.false((decoy.log as SinonSpy).called); + + SWLog.singletonConsole.trace('logging: trace'); + t.true((console.trace as SinonSpy).called); + t.false((decoy.trace as SinonSpy).called); + + SWLog.singletonConsole.debug('logging: debug'); + t.true((console.debug as SinonSpy).called); + t.false((decoy.debug as SinonSpy).called); + + SWLog.singletonConsole.info('logging: info'); + t.true((console.info as SinonSpy).called); + t.false((decoy.info as SinonSpy).called); + + SWLog.singletonConsole.warn('logging: warn'); + t.true((console.warn as SinonSpy).called); + t.false((decoy.warn as SinonSpy).called); + + SWLog.singletonConsole.error('logging: error'); + t.true((console.error as SinonSpy).called); + t.false((decoy.error as SinonSpy).called); +}) diff --git a/test/unit/modules/workerMessenger.ts b/test/unit/modules/workerMessenger.ts index dbeac4820..9dcd4a2a6 100644 --- a/test/unit/modules/workerMessenger.ts +++ b/test/unit/modules/workerMessenger.ts @@ -4,6 +4,8 @@ import { WorkerMessenger, WorkerMessengerCommand } from '../../../src/libraries/ import ContextSW from '../../../src/models/ContextSW'; import test from "ava"; import Random from "../../support/tester/Random"; +import SWLog from "../../../src/libraries/SWLog"; +import { buildWorkerMessagingCommandHandlers, applyWorkerMessagingCommandHandlers } from "../../../src/service-worker/WorkerMessengerCommandHandling"; test('service worker should gracefully handle unexpected page messages', async t => { @@ -83,3 +85,47 @@ test( t.pass(); } ); + +test( + 'service worker should accept SetLogging commands', + async t => { + await TestEnvironment.initializeForServiceWorker({ + url: new URL(`https://site.com/service-worker.js?a=1&b=2&appId=${Random.getRandomUuid()}&c=3`) + }); + + SWLog.resetConsole(); + const defaultConsole = SWLog.singletonConsole; + + t.false(SWLog.enabled); + t.deepEqual(defaultConsole, SWLog.consoles.null); + + const appConfig = TestEnvironment.getFakeAppConfig(); + const context = new ContextSW(appConfig); + const workerMessenger = new WorkerMessenger(context); + + /* We should be guaranteed a MessageEvent with at least an `event.data` property. */ + const data: any = { + data: { + command: WorkerMessengerCommand.SetLogging, + payload: { + shouldLog: true + } + } + }; + + const messageHandlers = buildWorkerMessagingCommandHandlers(workerMessenger); + applyWorkerMessagingCommandHandlers(workerMessenger, messageHandlers); + workerMessenger.listen(); + + try { + workerMessenger.onWorkerMessageReceivedFromPage(data); + } catch (e) { + t.fail("message function raised exception:" + e); + } + + t.true(SWLog.enabled); + t.notDeepEqual(SWLog.singletonConsole, defaultConsole); + t.pass(); + } +); +