-
Notifications
You must be signed in to change notification settings - Fork 116
/
ServiceWorkerManager.ts
453 lines (403 loc) · 19.2 KB
/
ServiceWorkerManager.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
import Environment from '../Environment';
import { InvalidStateError, InvalidStateReason } from '../errors/InvalidStateError';
import { WorkerMessengerCommand } from '../libraries/WorkerMessenger';
import Path from '../models/Path';
import SdkEnvironment from '../managers/SdkEnvironment';
import { Subscription } from '../models/Subscription';
import Database from '../services/Database';
import { IntegrationKind } from '../models/IntegrationKind';
import { WindowEnvironmentKind } from '../models/WindowEnvironmentKind';
import NotImplementedError from '../errors/NotImplementedError';
import ProxyFrameHost from '../modules/frames/ProxyFrameHost';
import Log from '../libraries/Log';
import Event from '../Event';
import ProxyFrame from '../modules/frames/ProxyFrame';
import ServiceWorkerRegistrationError from "../errors/ServiceWorkerRegistrationError"
import OneSignalUtils from "../utils/OneSignalUtils";
import ServiceWorkerHelper, { ServiceWorkerActiveState, ServiceWorkerManagerConfig }
from "../helpers/ServiceWorkerHelper";
import { ContextSWInterface } from '../models/ContextSW';
import { Utils } from "../context/shared/utils/Utils";
export class ServiceWorkerManager {
private context: ContextSWInterface;
private readonly config: ServiceWorkerManagerConfig;
constructor(context: ContextSWInterface, config: ServiceWorkerManagerConfig) {
this.context = context;
this.config = config;
}
// Gets details on the service-worker (if any) that controls the current page
public static async getRegistration(): Promise<ServiceWorkerRegistration | null | undefined> {
return await ServiceWorkerHelper.getRegistration();
}
public async getActiveState(): Promise<ServiceWorkerActiveState> {
/*
Note: This method can only be called on a secure origin. On an insecure
origin, it'll throw on getRegistration().
*/
/*
We want to find out if the *current* page is currently controlled by an
active service worker.
There are three ways (sort of) to do this:
- getRegistration()
- getRegistrations()
- navigator.serviceWorker.ready
We want to use getRegistration(), since it will not return a value if the
page is not currently controlled by an active service worker.
getRegistrations() returns all service worker registrations under the
origin (i.e. registrations in nested folders).
navigator.serviceWorker.ready will hang indefinitely and never resolve if
no registration is active.
*/
const integration = await SdkEnvironment.getIntegration();
if (integration === IntegrationKind.InsecureProxy) {
/* Service workers are not accessible on insecure origins */
return ServiceWorkerActiveState.Indeterminate;
} else if (integration === IntegrationKind.SecureProxy) {
/* If the site setup is secure proxy, we're either on the top frame without access to the
registration, or the child proxy frame that does have access to the registration. */
const env = SdkEnvironment.getWindowEnv();
switch (env) {
case WindowEnvironmentKind.Host:
case WindowEnvironmentKind.CustomIframe:
/* Both these top-ish frames will need to ask the proxy frame to access the service worker
registration */
const proxyFrameHost: ProxyFrameHost = OneSignal.proxyFrameHost;
if (!proxyFrameHost) {
/* On init, this function may be called. Return a null state for now */
return ServiceWorkerActiveState.Indeterminate;
} else {
return await proxyFrameHost.runCommand<ServiceWorkerActiveState>(
OneSignal.POSTMAM_COMMANDS.SERVICE_WORKER_STATE
);
}
case WindowEnvironmentKind.OneSignalSubscriptionPopup:
/* This is a top-level frame, so it can access the service worker registration */
break;
case WindowEnvironmentKind.OneSignalSubscriptionModal:
throw new NotImplementedError();
}
}
const workerRegistration = await ServiceWorkerManager.getRegistration();
if (!workerRegistration) {
/*
A site may have a service worker nested at /folder1/folder2/folder3, while the user is
currently on /folder1. The nested service worker does not control /folder1 though. Although
the nested service worker can receive push notifications without issue, it cannot perform
other SDK operations like checking whether existing tabs are optn eo the site on /folder1
(used to prevent opening unnecessary new tabs on notification click.)
Because we rely on being able to communicate with the service worker for SDK operations, we
only say we're active if the service worker directly controls this page.
*/
return ServiceWorkerActiveState.None;
}
else if (workerRegistration.installing) {
/*
Workers that are installing block for a while, since we can't use them until they're done
installing.
*/
return ServiceWorkerActiveState.Installing;
}
else if (!workerRegistration.active) {
/*
Workers that are waiting won't be our service workers, since we use clients.claim() and
skipWaiting() to bypass the install and waiting stages.
*/
return ServiceWorkerActiveState.ThirdParty;
}
// At this point, there is an active service worker registration controlling this page.
// We are now; 1. Getting the filename of the SW; 2. Checking if it is ours or a 3rd parties.
const swFileName = ServiceWorkerManager.activeSwFileName(workerRegistration);
const workerState = this.swActiveStateByFileName(swFileName);
/*
Our service worker registration can be both active and in the controlling scope of the current
page, but if the page was hard refreshed to bypass the cache (e.g. Ctrl + Shift + R), a
service worker will not control the page.
For a third-party service worker, if it does not call clients.claim(), even if its
registration is both active and in the controlling scope of the current page,
navigator.serviceWorker.controller will still be null on the first page visit. So we only
check if the controller is null for our worker, which we know uses clients.claim().
*/
if (!navigator.serviceWorker.controller && (
workerState === ServiceWorkerActiveState.WorkerA ||
workerState === ServiceWorkerActiveState.WorkerB
))
return ServiceWorkerActiveState.Bypassed;
return workerState;
}
// Get the file name of the active ServiceWorker
private static activeSwFileName(workerRegistration: ServiceWorkerRegistration): string | null {
if (!workerRegistration.active)
return null;
const workerScriptPath = new URL(workerRegistration.active.scriptURL).pathname;
const swFileName = new Path(workerScriptPath).getFileName();
// If the current service worker is Akamai's
if (swFileName == "akam-sw.js") {
// Check if its importing a ServiceWorker under it's "othersw" query param
const searchParams = new URLSearchParams(new URL(workerRegistration.active.scriptURL).search);
const importedSw = searchParams.get("othersw");
if (importedSw) {
Log.debug("Found a ServiceWorker under Akamai's akam-sw.js?othersw=", importedSw);
return new Path(new URL(importedSw).pathname).getFileName();
}
}
return swFileName;
}
// Check if the ServiceWorker file name is ours or a third party's
private swActiveStateByFileName(fileName: string | null): ServiceWorkerActiveState {
if (!fileName)
return ServiceWorkerActiveState.None;
if (fileName == this.config.workerAPath.getFileName())
return ServiceWorkerActiveState.WorkerA;
if (fileName == this.config.workerBPath.getFileName())
return ServiceWorkerActiveState.WorkerB;
return ServiceWorkerActiveState.ThirdParty;
}
public async getWorkerVersion(): Promise<number> {
return new Promise<number>(async resolve => {
if (OneSignalUtils.isUsingSubscriptionWorkaround()) {
const proxyFrameHost: ProxyFrameHost = OneSignal.proxyFrameHost;
if (!proxyFrameHost) {
/* On init, this function may be called. Return a null state for now */
resolve(NaN);
} else {
const proxyWorkerVersion =
await proxyFrameHost.runCommand<number>(OneSignal.POSTMAM_COMMANDS.GET_WORKER_VERSION);
resolve(proxyWorkerVersion);
}
} else {
this.context.workerMessenger.once(WorkerMessengerCommand.WorkerVersion, workerVersion => {
resolve(workerVersion);
});
this.context.workerMessenger.unicast(WorkerMessengerCommand.WorkerVersion);
}
});
}
private async shouldInstallWorker(): Promise<boolean> {
if (!Environment.supportsServiceWorkers())
return false;
if (!OneSignal.config)
return false;
// No, if configured to use our subdomain (AKA HTTP setup) AND this is on their page (HTTP or HTTPS).
if (OneSignal.config.subdomain && SdkEnvironment.getWindowEnv() == WindowEnvironmentKind.Host)
return false;
const workerState = await this.getActiveState();
// If there isn't a SW or it isn't OneSignal's only install our SW if notification permissions are enabled
// This prevents an unnessary install which saves bandwidth
if (workerState === ServiceWorkerActiveState.None || workerState === ServiceWorkerActiveState.ThirdParty) {
const permission = await OneSignal.context.permissionManager.getNotificationPermission(
OneSignal.config!.safariWebId
);
return permission === "granted";
}
return this.workerNeedsUpdate();
}
/**
* Performs a service worker update by swapping out the current service worker
* with a content-identical but differently named alternate service worker
* file.
*/
private async workerNeedsUpdate(): Promise<boolean> {
Log.info("[Service Worker Update] Checking service worker version...");
let workerVersion: number;
try {
workerVersion = await Utils.timeoutPromise(this.getWorkerVersion(), 2_000);
} catch (e) {
Log.info("[Service Worker Update] Worker did not reply to version query; assuming older version and updating.");
return true;
}
if (workerVersion !== Environment.version()) {
Log.info(`[Service Worker Update] Updating service worker from ${workerVersion} --> ${Environment.version()}.`);
return true;
}
Log.info(`[Service Worker Update] Service worker version is current at ${workerVersion} (no update required).`);
return false;
}
/**
* Installs a newer version of the OneSignal service worker.
*
* We have a couple different models of installing service workers:
*
* a) Originally, we provided users with two worker files:
* OneSignalSDKWorker.js and OneSignalSDKUpdaterWorker.js. Two workers were
* provided so each could be swapped with the other when the worker needed to
* update. The contents of both workers were identical; only the filenames
* were different, which is enough to update the worker.
*
* b) With AMP web push, users are to specify only the first worker file
* OneSignalSDKWorker.js, with an app ID parameter ?appId=12345. AMP web push
* is vendor agnostic and doesn't know about OneSignal, so all relevant
* information has to be passed to the service worker, which is the only
* vendor-specific file. So the service worker being installed is always
* OneSignalSDKWorker.js?appId=12345 and never OneSignalSDKUpdaterWorker.js.
* If AMP web push sees another worker like OneSignalSDKUpdaterWorker.js, or
* even the same OneSignalSDKWorker.js without the app ID query parameter, the
* user is considered unsubscribed.
*
* c) Due to b's restriction, we must always install
* OneSignalSDKWorker.js?appId=xxx. We also have to appropriately handle
* legacy cases:
*
* c-1) Where developers have OneSignalSDKWorker.js or
* OneSignalSDKUpdaterWorker.js alternatingly installed
*
* c-2) Where developers running progressive web apps force-register
* OneSignalSDKWorker.js
*
* Actually, users can customize the file names of Worker A / Worker B, but
* it's up to them to be consistent with their naming. For AMP web push, users
* can specify the full string to expect for the service worker. They can add
* additional query parameters, but this must then stay consistent.
*
* Installation Procedure
* ----------------------
*
* Worker A is always installed. If Worker A is already installed, Worker B is
* installed first, and then Worker A is installed again. This is necessary
* because AMP web push requires Worker A to be installed for the user to be
* considered subscribed.
*/
public async installWorker() {
if (!await this.shouldInstallWorker()) {
return;
}
const preInstallWorkerState = await this.getActiveState();
await this.installAlternatingWorker();
await new Promise(async resolve => {
const postInstallWorkerState = await this.getActiveState();
Log.debug(
"installWorker - Comparing pre and post states",
preInstallWorkerState,
postInstallWorkerState
);
if (preInstallWorkerState !== postInstallWorkerState &&
postInstallWorkerState !== ServiceWorkerActiveState.Installing) {
resolve();
}
else {
Log.debug("installWorker - Awaiting on navigator.serviceWorker's 'controllerchange' event");
navigator.serviceWorker.addEventListener('controllerchange', async e => {
const postInstallWorkerState = await this.getActiveState();
if (postInstallWorkerState !== preInstallWorkerState &&
postInstallWorkerState !== ServiceWorkerActiveState.Installing) {
resolve();
}
else {
Log.error("installWorker - SW's 'controllerchange' fired but no state change!");
}
});
}
});
if ((await this.getActiveState()) === ServiceWorkerActiveState.WorkerB) {
// If the worker is Worker B, reinstall Worker A
await this.installAlternatingWorker();
}
await this.establishServiceWorkerChannel();
}
public async establishServiceWorkerChannel() {
const workerMessenger = this.context.workerMessenger;
workerMessenger.off();
workerMessenger.on(WorkerMessengerCommand.NotificationDisplayed, data => {
Log.debug(location.origin, 'Received notification display event from service worker.');
Event.trigger(OneSignal.EVENTS.NOTIFICATION_DISPLAYED, data);
});
workerMessenger.on(WorkerMessengerCommand.NotificationClicked, async data => {
let clickedListenerCallbackCount: number;
if (SdkEnvironment.getWindowEnv() === WindowEnvironmentKind.OneSignalProxyFrame) {
clickedListenerCallbackCount = await new Promise<number>(resolve => {
const proxyFrame: ProxyFrame = OneSignal.proxyFrame;
if (proxyFrame) {
proxyFrame.messenger.message(
OneSignal.POSTMAM_COMMANDS.GET_EVENT_LISTENER_COUNT,
OneSignal.EVENTS.NOTIFICATION_CLICKED,
(reply: any) => {
let callbackCount: number = reply.data;
resolve(callbackCount);
}
);
}
});
}
else
clickedListenerCallbackCount = OneSignal.emitter.numberOfListeners(OneSignal.EVENTS.NOTIFICATION_CLICKED);
if (clickedListenerCallbackCount === 0) {
/*
A site's page can be open but not listening to the
notification.clicked event because it didn't call
addListenerForNotificationOpened(). In this case, if there are no
detected event listeners, we should save the event, instead of firing
it without anybody receiving it.
Or, since addListenerForNotificationOpened() only works once (you have
to call it again each time), maybe it was only called once and the
user isn't receiving the notification.clicked event for subsequent
notifications on the same browser tab.
Example: notificationClickHandlerMatch: 'origin', tab is clicked,
event fires without anybody listening, calling
addListenerForNotificationOpened() returns no results even
though a notification was just clicked.
*/
Log.debug(
'notification.clicked event received, but no event listeners; storing event in IndexedDb for later retrieval.'
);
/* For empty notifications without a URL, use the current document's URL */
let url = data.url;
if (!data.url) {
// Least likely to modify, since modifying this property changes the page's URL
url = location.href;
}
await Database.put('NotificationOpened', { url: url, data: data, timestamp: Date.now() });
}
else
Event.trigger(OneSignal.EVENTS.NOTIFICATION_CLICKED, data);
});
workerMessenger.on(WorkerMessengerCommand.RedirectPage, data => {
Log.debug(
`${SdkEnvironment.getWindowEnv().toString()} Picked up command.redirect to ${data}, forwarding to host page.`
);
const proxyFrame: ProxyFrame = OneSignal.proxyFrame;
if (proxyFrame) {
proxyFrame.messenger.message(OneSignal.POSTMAM_COMMANDS.SERVICEWORKER_COMMAND_REDIRECT, data);
}
});
workerMessenger.on(WorkerMessengerCommand.NotificationDismissed, data => {
Event.trigger(OneSignal.EVENTS.NOTIFICATION_DISMISSED, data);
});
}
/**
* Installs the OneSignal service worker.
*
* Depending on the existing worker, the alternate swap worker may be
* installed or, for 3rd party workers, the existing worker may be uninstalled
* before installing ours.
*/
private async installAlternatingWorker() {
const workerState = await this.getActiveState();
if (workerState === ServiceWorkerActiveState.ThirdParty) {
Log.info(`[Service Worker Installation] 3rd party service worker detected.`);
}
const workerFullPath = ServiceWorkerHelper.getServiceWorkerHref(workerState, this.config);
const installUrlQueryParams = Utils.encodeHashAsUriComponent({
appId: this.context.appConfig.appId
});
const fullWorkerPath = `${workerFullPath}?${installUrlQueryParams}`;
Log.info(`[Service Worker Installation] Installing service worker ${fullWorkerPath}.`);
try {
await navigator.serviceWorker.register(
fullWorkerPath,
{ scope: `${OneSignalUtils.getBaseUrl()}${this.config.registrationOptions.scope}` }
);
} catch (error) {
Log.error(`[Service Worker Installation] Installing service worker failed ${error}`);
// Try accessing the service worker path directly to find out what the problem is and report it to OneSignal api.
// If we are inside the popup and service worker fails to register, it's not developer's fault.
// No need to report it to the api then.
const env = SdkEnvironment.getWindowEnv();
if (env === WindowEnvironmentKind.OneSignalSubscriptionPopup)
throw error;
const response = await fetch(fullWorkerPath);
if (response.status === 403 || response.status === 404)
throw new ServiceWorkerRegistrationError(response.status, response.statusText);
throw error;
}
Log.debug(`[Service Worker Installation] Service worker installed.`);
}
}