PageSourceSearch

https://kombo.so/firebase-messaging-sw.js

js kombo.so collected 2026-10-08 23:43:04 UTC 13,669 bytes, 247 lines download raw bytes

1/* eslint-disable no-undef */
2// Dedicated Firebase Cloud Messaging service worker for DESKTOP Web Push.
3//
4// This is a SEPARATE worker from service-worker.js (the Workbox runtime-caching worker
5// registered by src/registerServiceWorker.js) — do NOT merge them. FCM's own
6// firebase.messaging().onBackgroundMessage() wiring needs to own this worker's 'push' event,
7// and the two workers are registered with different scopes (see NotificationsMixin.js
8// registerWebPush) so neither one's lifecycle (update/skipWaiting, cache versioning) interferes
9// with the other's.
10//
11// Plain (non-module) worker script — Firebase serves a "compat" build for exactly this, loaded
12// via importScripts since service workers can't use ES module imports in every browser we
13// support yet.
14importScripts('https://www.gstatic.com/firebasejs/10.14.1/firebase-app-compat.js')
15importScripts('https://www.gstatic.com/firebasejs/10.14.1/firebase-messaging-compat.js')
16
17// Kept in sync BY HAND with FIREBASE_WEB_CONFIG in src/constants.js — this file is served
18// statically (public/) and is not run through the webpack build, so it can't import that
19// module. appId is the Web app registered on 2026-09-02 in the gethello-firebase-auth project.
20firebase.initializeApp({
21    apiKey: 'AIzaSyCh2DaT5NkHApW3f4rqKTc64Hs0Q6jzWrY',
22    authDomain: 'gethello-firebase-auth.firebaseapp.com',
23    projectId: 'gethello-firebase-auth',
24    storageBucket: 'gethello-firebase-auth.appspot.com',
25    messagingSenderId: '255570553355',
26    appId: '1:255570553355:web:98cc58e92589fba133f50e',
27})
28
29// ORDER MATTERS. firebase.messaging() registers the SDK's own 'notificationclick' listener, which
30// calls event.stopImmediatePropagation() for notifications IT displayed — any listener added after
31// it never runs for those. Ours are registered first, so they always run, and they stop propagation
32// themselves once they have handled the click (else the SDK would open the link a second time).
33self.addEventListener('install', (event) => { self.skipWaiting(); event.waitUntil(diag('installed')) })
34self.addEventListener('activate', (event) => { event.waitUntil(Promise.all([clients.claim(), diag('activated'), requestConfigFromClients()])) })
35
36/** A new worker version may activate AFTER the page handed its config to the previous version;
37 *  if that version predates config storage, Cache Storage holds nothing. Ask every open Kombo tab
38 *  to hand it over again (page side: webPush.ts ensureConfigResponder). */
39async function requestConfigFromClients() {
40    try {
41        const list = await clients.matchAll({ type: 'window', includeUncontrolled: true })
42        list.forEach((c) => c.postMessage({ type: 'kombo-webpush-config-request' }))
43        await diag('config requested from clients', { clients: list.length })
44    } catch (e) {
45        console.warn('[webpush-sw] config request failed', e)
46    }
47}
48self.addEventListener('push', (event) => event.waitUntil(diag('raw push event', event.data ? event.data.text().slice(0, 300) : '(no data)')))
49
50// ---------------------------------------------------------------------------------------------
51// Delivery funnel callbacks (received / opened / dismissed). The worker has no session, so it
52// reports through the public ack endpoint whose capability is the (trackingId, deviceKey) pair
53// carried in the push payload — see NotificationController.publicAckWebDelivery. Single-device
54// sends put deviceKey in the payload; audience broadcasts don't, so the page hands the worker its
55// own deviceKey + API base once per load (message below), persisted in Cache Storage because a
56// worker's memory does not survive being killed between events.
57const CONFIG_CACHE = 'kombo-webpush-config'
58const CONFIG_URL = '/__kombo-webpush-config'
59// Worker-side diagnostics ring buffer, same cache. The worker's console is invisible to the page
60// (and to the /ifl log relay), so every step it takes is also appended here and the test page
61// (/webtools/webpush) reads it back via a 'kombo-webpush-diag-get' MessageChannel request.
62const DIAG_URL = '/__kombo-webpush-diag'
63const DIAG_MAX = 40
64const SW_VERSION = '2026-09-02.5'
65
66async function saveConfig(cfg) {
67    const cache = await caches.open(CONFIG_CACHE)
68    await cache.put(CONFIG_URL, new Response(JSON.stringify(cfg), { headers: { 'Content-Type': 'application/json' } }))
69}
70async function loadConfig() {
71    try {
72        const cache = await caches.open(CONFIG_CACHE)
73        const res = await cache.match(CONFIG_URL)
74        return res ? await res.json() : null
75    } catch (e) {
76        return null
77    }
78}
79async function loadDiag() {
80    try {
81        const cache = await caches.open(CONFIG_CACHE)
82        const res = await cache.match(DIAG_URL)
83        return res ? await res.json() : []
84    } catch (e) {
85        return []
86    }
87}
88/** Append one diagnostic line (also console-logged); never throws. */
89async function diag(step, detail) {
90    const line = { at: new Date().toISOString(), v: SW_VERSION, step, detail: detail === undefined ? null : detail }
91    console.log('[webpush-sw]', step, detail === undefined ? '' : detail)
92    try {
93        const list = (await loadDiag()).concat([line]).slice(-DIAG_MAX)
94        const cache = await caches.open(CONFIG_CACHE)
95        await cache.put(DIAG_URL, new Response(JSON.stringify(list), { headers: { 'Content-Type': 'application/json' } }))
96    } catch (e) {
97        console.warn('[webpush-sw] diag store failed', e)
98    }
99}
100
101self.addEventListener('message', (event) => {
102    const msg = event.data || {}
103    if (msg.type === 'kombo-webpush-config') {
104        event.waitUntil(saveConfig({ apiBaseUrl: msg.apiBaseUrl, deviceKey: msg.deviceKey }).then(
105            () => diag('config stored', { apiBaseUrl: msg.apiBaseUrl, deviceKey: msg.deviceKey }),
106            (e) => diag('config store FAILED', String(e)),
107        ))
108        return
109    }
110    if (msg.type === 'kombo-webpush-diag-get') {
111        // Reply on the MessageChannel port the page handed us (event.ports[0]).
112        const port = event.ports && event.ports[0]
113        if (!port) return
114        event.waitUntil(Promise.all([loadConfig(), loadDiag()]).then(
115            ([config, lines]) => port.postMessage({ version: SW_VERSION, config, diag: lines }),
116            (e) => port.postMessage({ version: SW_VERSION, error: String(e) }),
117        ))
118    }
119})
120
121/** Best-effort funnel report; never throws (a failed ack must not break click navigation).
122 *  `data.revision` — present on an in-place-update push, see FirebaseService.kt /
123 *  NotificationService.dataWithTracking — rides along as a number so the backend can tell an
124 *  update's receive apart from the original send's (NotificationController.WebDeliveryAckDTO). */
125async function ack(eventName, data) {
126    try {
127        const cfg = (await loadConfig()) || {}
128        const trackingId = data && data.trackingId
129        const deviceKey = (data && data.deviceKey) || cfg.deviceKey
130        if (!trackingId || !deviceKey || !cfg.apiBaseUrl) {
131            await diag('ack SKIPPED ' + eventName, { trackingId: !!trackingId, deviceKey: !!deviceKey, apiBaseUrl: cfg.apiBaseUrl || null })
132            return
133        }
134        const revision = data && data.revision != null ? Number(data.revision) : undefined
135        // No `keepalive`: the callers wrap this in event.waitUntil(), which is the service-worker
136        // way of outliving the event (Chrome 152 does accept keepalive in a worker — verified
137        // headless on 2026-09-02 — it is simply redundant here).
138        const res = await fetch(cfg.apiBaseUrl + '/public/notifications/ack', {
139            method: 'POST',
140            headers: { 'Content-Type': 'application/json' },
141            body: JSON.stringify({ trackingId, deviceKey, event: eventName, revision }),
142        })
143        await diag('ack ' + eventName, { trackingId, revision: revision ?? null, status: res.status, apiBaseUrl: cfg.apiBaseUrl })
144    } catch (e) {
145        await diag('ack FAILED ' + eventName, String(e && e.message || e))
146    }
147}
148
149/**
150 * The Kombo data map of a displayed notification, whichever path displayed it: the SDK wraps the
151 * whole FCM payload under data.FCM_MSG (background, SDK-displayed); the page's foreground
152 * onMessage handler stores payload.data directly (see webPush.ts).
153 */
154function komboData(notification) {
155    const d = (notification && notification.data) || {}
156    if (d.FCM_MSG) return (d.FCM_MSG.data) || {}
157    return d
158}
159
160/** Where a tap goes: explicit URL from the payload, else the link FCM resolved server-side
161 *  (WebpushFcmOptions.link), else the app root. (redirectRoute is a ROUTER NAME the SPA resolves
162 *  in NotificationsMixin — not resolvable here; those land on root.) */
163function clickTarget(notification) {
164    const data = komboData(notification)
165    if (data.redirectURL) return data.redirectURL
166    const fcm = notification.data && notification.data.FCM_MSG
167    const link = fcm && fcm.fcmOptions && fcm.fcmOptions.link
168    if (link) return link
169    if (data.redirectRoute) console.warn('[webpush-sw] redirectRoute not resolvable in the worker, opening root:', data.redirectRoute)
170    return self.location.origin + '/'
171}
172
173/**
174 * Closes every currently-displayed notification matching a cancel push's target — used for the
175 * worker's own background handling below AND mirrored page-side (webPush.ts
176 * attachForegroundHandler) for the case the SDK forwards a data-only message straight to a
177 * visible tab instead of here. Matches by TAG first (tag IS collapseKey||trackingId at send time,
178 * see FirebaseService.kt WebpushNotification.setTag — cheap, no need to unwrap each
179 * notification's data), falling back to each shown notification's own komboData().trackingId for
180 * anything displayed with a different/no tag (e.g. an older SW version, or a send predating the
181 * collapseKey-in-data change).
182 */
183async function closeCancelledNotifications(data) {
184    const tag = data && (data.collapseKey || data.trackingId)
185    const trackingId = data && data.trackingId
186    const shown = await self.registration.getNotifications()
187    const matches = shown.filter((n) => (tag && n.tag === tag) || (trackingId && komboData(n).trackingId === trackingId))
188    matches.forEach((n) => n.close())
189    await diag('cancel: closed notifications', { tag: tag || null, trackingId: trackingId || null, closed: matches.length, shown: shown.length })
190    return matches.length
191}
192
193// Click: report the open, then focus a Kombo tab (navigating it to the target) or open one.
194self.addEventListener('notificationclick', (event) => {
195    if (event.action) return // action buttons are not ours
196    const data = komboData(event.notification)
197    const target = clickTarget(event.notification)
198    event.stopImmediatePropagation() // the SDK's handler must not also act on this click
199    event.notification.close()
200    event.waitUntil((async () => {
201        await diag('click', { trackingId: data.trackingId || null, target, tag: event.notification.tag || null })
202        await ack('opened', data)
203        const clientList = await clients.matchAll({ type: 'window', includeUncontrolled: true })
204        const sameOrigin = clientList.find((c) => c.url && c.url.startsWith(self.location.origin))
205        const internal = target.startsWith(self.location.origin)
206        if (sameOrigin && 'focus' in sameOrigin) {
207            await sameOrigin.focus()
208            if (!internal) return clients.openWindow(target)
209            if ('navigate' in sameOrigin) {
210                try { return await sameOrigin.navigate(target) } catch (e) { console.warn('[webpush-sw] navigate failed, opening window', e) }
211            }
212            return clients.openWindow(target)
213        }
214        if (clients.openWindow) return clients.openWindow(target)
215    })())
216})
217
218// Dismiss (swipe-away / close button): natively supported by the Web Notifications API — the one
219// platform where dismissals are observable without a foreground app.
220self.addEventListener('notificationclose', (event) => {
221    const data = komboData(event.notification)
222    event.waitUntil(diag('close', { trackingId: data.trackingId || null, tag: event.notification.tag || null }).then(() => ack('dismissed', data)))
223})
224
225const messaging = firebase.messaging()
226
227// Background delivery. The SDK displays the notification ITSELF whenever the payload carries a
228// notification block (FirebaseService always sets one) — calling showNotification here again is
229// what produced duplicate banners. This handler is only the receipt: unlike the native shells
230// (foreground-only), the worker sees EVERY delivery.
231//
232// Cancel pushes (data.kombo === 'cancel', see NotificationService.cancelNotification) are
233// DATA-ONLY — no notification block, so the SDK never auto-displays anything for them — and are
234// handled here instead of the receipt path: close whatever's already shown for that
235// trackingId/collapseKey, then ack 'cancelled' instead of 'received'. NOTE: this branch only runs
236// when the worker actually gets the message — the SDK forwards data-only messages to any VISIBLE
237// tab instead of here, which is why webPush.ts's attachForegroundHandler mirrors this same logic.
238messaging.onBackgroundMessage((payload) => {
239    const data = payload && payload.data
240    if (data && data.kombo === 'cancel') {
241        return diag('onBackgroundMessage: cancel', { trackingId: data.trackingId || null })
242            .then(() => closeCancelledNotifications(data))
243            .then(() => ack('cancelled', data))
244    }
245    return diag('onBackgroundMessage', { trackingId: data && data.trackingId || null, hasNotification: !!(payload && payload.notification) })
246        .then(() => ack('received', data))
247})

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.