/* eslint-disable @typescript-eslint/no-explicit-any */ /// /// /// const NOTIFLY_SERVICE_WORKER_VERSION = 'v1.4.0'; const NOTIFLY_SERVICE_WORKER_SEMVER = NOTIFLY_SERVICE_WORKER_VERSION.replace('v', ''); const NOTIFLY_LOG_EVENT_URL = 'https://e.notifly.tech/records'; const NOTIFLY_OBJECT_STORE_NAME = 'notiflyconfig'; // Constants for user ID generation const HASH_NAMESPACE_REGISTERED_USERID = 'ce7c62f9-e8ae-4009-8fd6-468e9581fa21'; const HASH_NAMESPACE_UNREGISTERED_USERID = 'a6446dcf-c057-4de7-a360-56af8659d52f'; const sw: ServiceWorkerGlobalScope & typeof globalThis = self as any; sw.addEventListener('install', () => { sw.skipWaiting(); }); sw.addEventListener('activate', (event) => { event.waitUntil(swActivate()); }); sw.addEventListener('push', (event) => { if (!event.data) return; const { notifly } = event.data.json(); if (!notifly) return; const options = { body: notifly.bd, icon: notifly.ic, badge: notifly.bg, image: notifly.im, vibrate: notifly.vb, sound: notifly.sd, tag: notifly.tg, renotify: notifly.rn || true, requireInteraction: notifly.ri, data: { ...notifly.data, url: notifly.u, campaign_id: notifly.cid, notifly_message_id: notifly.mid, }, actions: notifly.ac, }; event.waitUntil(sw.registration.showNotification(notifly.ti, options)); event.waitUntil( logNotiflyInternalEvent('push_delivered', { type: 'message_event', channel: 'web-push-notification', campaign_id: notifly.cid, notifly_message_id: notifly.mid, }) ); }); sw.addEventListener('notificationclick', function (event) { event.notification.close(); if (event.action === 'close') { return; } const messageData = event.notification?.data; if (!messageData) { return; } const { campaign_id, notifly_message_id } = messageData; event.waitUntil( logNotiflyInternalEvent('push_click', { type: 'message_event', channel: 'web-push-notification', campaign_id: campaign_id, notifly_message_id: notifly_message_id, }) ); event.waitUntil(action(messageData.url || null)); }); async function action(url: string | null) { // 1. If URL is specified, compare with current hostname. // 1-1. If URL is same with current hostname, focus the window if it exists. Otherwise open a new window. // 1-2. If URL is different with current hostname, open a new window. // 2. If URL is not specified, open a new window with current hostname if the window does not exist. // Otherwise focus the existing window. const swHostname = sw.location.hostname; const urlHostname = url ? new URL(url).hostname : null; const clientsList = await sw.clients.matchAll({ type: 'window' }); const existingClient = clientsList.find((client) => new URL(client.url).hostname === (urlHostname || swHostname)); if (url && urlHostname) { if (swHostname === urlHostname) { if (existingClient) { if (!existingClient.focused) { await existingClient.focus(); } existingClient.postMessage({ action: '__notifly_navigate_to_url', url: url, }); } else { await sw.clients.openWindow(url); } } else { await sw.clients.openWindow(url); } } else { if (existingClient) { if (!existingClient.focused) { await existingClient.focus(); } } else { await sw.clients.openWindow(sw.origin); } } } async function swActivate() { try { await setItemToIndexedDB('notifly', '__notiflySWVersion', NOTIFLY_SERVICE_WORKER_VERSION); } catch (error) { console.warn('[Notifly Service Worker] Failed to activate Service Worker: ', error); } } async function getItemFromIndexedDB(dbName: string, key: IDBValidKey) { try { const db = await openDB(dbName); const transaction = db.transaction(NOTIFLY_OBJECT_STORE_NAME); const store = transaction.objectStore(NOTIFLY_OBJECT_STORE_NAME); const value = await getValue(store, key); return value !== undefined ? value : null; // localForage returns null if key is not found } catch (error) { console.warn('[Notifly Service Worker] Failed to get item from IndexedDB: ', error); return null; } } function openDB(name: string): Promise { return new Promise((resolve, reject) => { const openReq = indexedDB.open(name); openReq.onerror = () => reject(openReq.error); openReq.onsuccess = () => resolve(openReq.result); openReq.onupgradeneeded = (event) => { if (!event.target) return; const request: IDBOpenDBRequest = event.target as IDBOpenDBRequest; const db = request.result; if (!db.objectStoreNames.contains(NOTIFLY_OBJECT_STORE_NAME)) { db.createObjectStore(NOTIFLY_OBJECT_STORE_NAME); } }; }); } function getValue(store: IDBObjectStore, key: IDBValidKey): Promise { return new Promise((resolve, reject) => { const getReq = store.get(key); getReq.onerror = () => reject(getReq.error); getReq.onsuccess = () => resolve(getReq.result); }); } async function setItemToIndexedDB(dbName: string, key: IDBValidKey, value: any) { try { const db = await openDB(dbName); const transaction = db.transaction(NOTIFLY_OBJECT_STORE_NAME, 'readwrite'); const store = transaction.objectStore(NOTIFLY_OBJECT_STORE_NAME); await setValue(store, key, value); } catch (error) { console.warn('[Notifly Service Worker] Failed to set item to IndexedDB: ', error); } } function setValue(store: IDBObjectStore, key: IDBValidKey, value: any) { return new Promise((resolve, reject) => { const putReq = store.put(value, key); putReq.onerror = () => reject(putReq.error); putReq.onsuccess = () => resolve(); }); } async function getCognitoIdTokenInSw(): Promise { const [userName, password] = [ await getItemFromIndexedDB('notifly', '__notiflyUserName'), await getItemFromIndexedDB('notifly', '__notiflyPassword'), ]; if (!userName || !password) { return null; } try { const response = await fetch('https://api.notifly.tech/authorize', { method: 'POST', body: JSON.stringify({ userName, password, }), headers: { 'X-Notifly-SDK-Version': `notifly/js-sw/${NOTIFLY_SERVICE_WORKER_SEMVER}`, }, }); const result = await response.json(); return result.AuthenticationResult?.IdToken || null; } catch (error) { console.warn('[Notifly Service Worker]: Failed to get authentication token ', error); return null; } } async function saveCognitoIdTokenInSW(cognitoIdToken: string): Promise { await setItemToIndexedDB('notifly', '__notiflyCognitoIDToken', cognitoIdToken); } async function logNotiflyInternalEvent( eventName: string, eventParams: Record | null = null, segmentationEventParamKeys: Array | null = null ) { try { const [cognitoToken, externalUserID, projectID, notiflyDeviceID] = await Promise.all([ getItemFromIndexedDB('notifly', '__notiflyCognitoIDToken'), getItemFromIndexedDB('notifly', '__notiflyExternalUserID'), getItemFromIndexedDB('notifly', '__notiflyProjectID'), getItemFromIndexedDB('notifly', '__notiflyDeviceID'), ]); if (!(projectID && notiflyDeviceID)) { console.warn('[Notifly Service Worker]: Fail to trackEvent because of invalid LocalForage setup.'); return; } const notiflyUserID = generateNotiflyUserId(projectID, externalUserID, notiflyDeviceID); let token: string | null = cognitoToken; if (!token) { token = await getCognitoIdTokenInSw(); if (!token) { console.warn('[Notifly Service Worker]: Fail to trackEvent'); return; } await saveCognitoIdTokenInSW(token); } const body = _getBodyForLogEvent( eventName, eventParams, segmentationEventParamKeys, projectID, notiflyUserID, externalUserID, notiflyDeviceID, true ); const requestOptions = _getRequestOptionsForLogEvent(token, body); const response = await fetch(NOTIFLY_LOG_EVENT_URL, requestOptions); // If the token is expired, get a new token and retry the logEvent. if (response.status === 401) { token = await getCognitoIdTokenInSw(); if (!token) { console.warn('[Notifly Service Worker] Failed to get authentication token.'); return; } await Promise.all([retryLogEvent(token, body), saveCognitoIdTokenInSW(token)]); } } catch (err) { console.warn('[Notifly Service Worker] Failed logging the event. ', err); } } async function retryLogEvent(token: string, body: string) { const requestOptions = _getRequestOptionsForLogEvent(token, body); const response = await fetch(NOTIFLY_LOG_EVENT_URL, requestOptions); if (!response.ok) { throw new Error('Retry failed.'); } } const _getTimestampMicroseconds = (): number => { if (sw.performance && 'now' in sw.performance && 'timeOrigin' in sw.performance) { return Math.floor((sw.performance.now() + sw.performance.timeOrigin) * 1000); } return Date.now() * 1000; }; function _getBodyForLogEvent( eventName: string, eventParams: Record | null, segmentationEventParamKeys: Array | null, projectID: string, notiflyUserID: string, externalUserID: string, notiflyDeviceID: string, isInternalEvent: boolean ) { const eventData = JSON.stringify({ id: generateRandomString(16), name: eventName, event_params: eventParams, segmentation_event_param_keys: segmentationEventParamKeys, project_id: projectID, notifly_user_id: notiflyUserID, external_user_id: externalUserID, notifly_device_id: notiflyDeviceID, is_internal_event: isInternalEvent, time: _getTimestampMicroseconds(), }); const body = JSON.stringify({ 'records': [ { 'data': eventData, 'partitionKey': notiflyUserID, }, ], }); return body; } function _getRequestOptionsForLogEvent(token: string, body: string) { const headers = new Headers(); headers.append('Authorization', token); headers.append('Content-Type', 'application/json'); headers.append('X-Notifly-SDK-Version', `notifly/js-sw/${NOTIFLY_SERVICE_WORKER_SEMVER}`); const requestOptions: RequestInit = { method: 'POST', headers: headers, body: body, redirect: 'follow', keepalive: true, }; return requestOptions; } function generateRandomString(size: number) { const epoch = Math.floor(size / 10) + (size % 10 > 0 ? 1 : 0); let randomString = ''; for (let i = 0; i < epoch; i++) { randomString += Math.random().toString(36).substring(2, 12); } return randomString.substring(0, size); } function generateNotiflyUserId(projectId: string, externalUserId: string | null, deviceId: string): string { const input = externalUserId ? `${projectId}${externalUserId}` : `${projectId}${deviceId}`; const namespace = externalUserId ? HASH_NAMESPACE_REGISTERED_USERID : HASH_NAMESPACE_UNREGISTERED_USERID; return uuidv5(input, namespace).replace(/-/g, ''); } // Simple UUID v5 implementation for service worker function uuidv5(name: string, namespace: string): string { // Convert namespace UUID to bytes const namespaceBytes = uuidStringToBytes(namespace); // Create hash input const input = new TextEncoder().encode(name); const hashInput = new Uint8Array(namespaceBytes.length + input.length); hashInput.set(namespaceBytes); hashInput.set(input, namespaceBytes.length); // Simple hash (not SHA-1, but sufficient for our needs) let hash = 0; for (let i = 0; i < hashInput.length; i++) { hash = ((hash << 5) - hash + hashInput[i]) & 0xffffffff; } // Generate deterministic UUID-like string from hash const bytes = new Uint8Array(16); for (let i = 0; i < 16; i++) { bytes[i] = (hash >> (i * 2)) & 0xff; hash = ((hash * 1103515245) + 12345) & 0xffffffff; // LCG for additional randomness } // Set version (5) and variant bits bytes[6] = (bytes[6] & 0x0f) | 0x50; // Version 5 bytes[8] = (bytes[8] & 0x3f) | 0x80; // Variant 10 return bytesToUuidString(bytes); } function uuidStringToBytes(uuid: string): Uint8Array { const hex = uuid.replace(/-/g, ''); const bytes = new Uint8Array(16); for (let i = 0; i < 32; i += 2) { bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); } return bytes; } function bytesToUuidString(bytes: Uint8Array): string { const hex = Array.from(bytes) .map(b => b.toString(16).padStart(2, '0')) .join(''); return [ hex.substring(0, 8), hex.substring(8, 12), hex.substring(12, 16), hex.substring(16, 20), hex.substring(20, 32) ].join('-'); }