@synergonai/push-web 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,421 @@
1
+ // src/index.ts
2
+ import {
3
+ ApiClient,
4
+ EventQueue,
5
+ STORAGE_KEYS
6
+ } from "@synergonai/push-core";
7
+
8
+ // src/firebase-bootstrap.ts
9
+ import { initializeApp } from "firebase/app";
10
+ import {
11
+ getMessaging,
12
+ getToken,
13
+ onMessage,
14
+ isSupported
15
+ } from "firebase/messaging";
16
+
17
+ // src/fcm-fetch-patch.ts
18
+ var FCM_REGISTRATIONS_HOST = "fcmregistrations.googleapis.com";
19
+ var PATCH_FLAG = "__synergonFcmFetchPatched";
20
+ function installFcmFetchPatch() {
21
+ if (typeof globalThis === "undefined" || typeof globalThis.fetch !== "function") return;
22
+ const g = globalThis;
23
+ if (g[PATCH_FLAG]) return;
24
+ const originalFetch = globalThis.fetch.bind(globalThis);
25
+ globalThis.fetch = async function patchedFetch(input, init) {
26
+ try {
27
+ const url = resolveUrl(input);
28
+ if (!url || !url.includes(FCM_REGISTRATIONS_HOST)) {
29
+ return originalFetch(input, init);
30
+ }
31
+ const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
32
+ if (method !== "POST" && method !== "PATCH") {
33
+ return originalFetch(input, init);
34
+ }
35
+ const sourceBody = init?.body ?? (input instanceof Request ? await readRequestBody(input) : void 0);
36
+ if (typeof sourceBody !== "string") {
37
+ return originalFetch(input, init);
38
+ }
39
+ let parsed;
40
+ try {
41
+ parsed = JSON.parse(sourceBody);
42
+ } catch {
43
+ return originalFetch(input, init);
44
+ }
45
+ if (!parsed?.web || !("applicationPubKey" in parsed.web)) {
46
+ return originalFetch(input, init);
47
+ }
48
+ delete parsed.web.applicationPubKey;
49
+ const patchedBody = JSON.stringify(parsed);
50
+ if (input instanceof Request) {
51
+ const clonedInit = {
52
+ method,
53
+ headers: cloneHeaders(input.headers),
54
+ body: patchedBody,
55
+ credentials: input.credentials,
56
+ mode: input.mode,
57
+ referrer: input.referrer
58
+ };
59
+ return originalFetch(input.url, clonedInit);
60
+ }
61
+ return originalFetch(input, { ...init, body: patchedBody });
62
+ } catch {
63
+ return originalFetch(input, init);
64
+ }
65
+ };
66
+ g[PATCH_FLAG] = true;
67
+ }
68
+ function resolveUrl(input) {
69
+ if (typeof input === "string") return input;
70
+ if (input instanceof URL) return input.href;
71
+ if (input instanceof Request) return input.url;
72
+ return null;
73
+ }
74
+ async function readRequestBody(req) {
75
+ try {
76
+ return await req.clone().text();
77
+ } catch {
78
+ return void 0;
79
+ }
80
+ }
81
+ function cloneHeaders(h) {
82
+ const out = new Headers();
83
+ h.forEach((v, k) => out.append(k, v));
84
+ return out;
85
+ }
86
+
87
+ // src/firebase-bootstrap.ts
88
+ var FirebaseBootstrap = class _FirebaseBootstrap {
89
+ constructor(app, messaging, opts) {
90
+ this.app = app;
91
+ this.messaging = messaging;
92
+ this.vapidKey = opts.vapidKey;
93
+ this.swReg = opts.serviceWorkerRegistration;
94
+ }
95
+ /**
96
+ * Returns null if the runtime can't support FCM (no service worker
97
+ * support, http://, etc). Callers should treat null as a hard "no push
98
+ * here" and surface that to the user.
99
+ */
100
+ static async create(opts) {
101
+ if (!await isSupported()) return null;
102
+ installFcmFetchPatch();
103
+ const app = initializeApp(opts.firebaseConfig);
104
+ const messaging = getMessaging(app);
105
+ return new _FirebaseBootstrap(app, messaging, opts);
106
+ }
107
+ /**
108
+ * Fetch the current FCM registration token. Triggers the browser's
109
+ * notification permission prompt if not yet granted.
110
+ */
111
+ async getToken() {
112
+ const token = await getToken(this.messaging, {
113
+ vapidKey: this.vapidKey,
114
+ serviceWorkerRegistration: this.swReg
115
+ });
116
+ if (!token) {
117
+ throw new Error("FCM returned an empty token. User may have denied notifications.");
118
+ }
119
+ return token;
120
+ }
121
+ /** Subscribe to foreground messages (page visible). Returns an unsubscribe. */
122
+ onMessage(handler) {
123
+ return onMessage(this.messaging, handler);
124
+ }
125
+ };
126
+
127
+ // src/storage-localstorage.ts
128
+ var LocalStorageAdapter = class _LocalStorageAdapter {
129
+ constructor() {
130
+ this.available = _LocalStorageAdapter.isAvailable();
131
+ }
132
+ async get(key) {
133
+ if (!this.available) return null;
134
+ try {
135
+ const raw = window.localStorage.getItem(key);
136
+ if (raw === null) return null;
137
+ return JSON.parse(raw);
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+ async set(key, value) {
143
+ if (!this.available) return;
144
+ try {
145
+ window.localStorage.setItem(key, JSON.stringify(value));
146
+ } catch {
147
+ }
148
+ }
149
+ async remove(key) {
150
+ if (!this.available) return;
151
+ try {
152
+ window.localStorage.removeItem(key);
153
+ } catch {
154
+ }
155
+ }
156
+ /**
157
+ * Some browsers (Safari private mode) expose localStorage but throw on
158
+ * write. Probe with a no-op write and clean up.
159
+ */
160
+ static isAvailable() {
161
+ try {
162
+ if (typeof window === "undefined" || !window.localStorage) return false;
163
+ const probe = "__synergon_probe__";
164
+ window.localStorage.setItem(probe, "1");
165
+ window.localStorage.removeItem(probe);
166
+ return true;
167
+ } catch {
168
+ return false;
169
+ }
170
+ }
171
+ };
172
+
173
+ // src/payload.ts
174
+ import {
175
+ PLACEHOLDER_MESSAGE_ID,
176
+ SYN_CAMPAIGN_ID_KEY,
177
+ SYN_MESSAGE_ID_KEY
178
+ } from "@synergonai/push-core";
179
+ function normalizePayload(raw) {
180
+ const data = raw.data ?? {};
181
+ const notification = raw.notification ?? {};
182
+ const rawMessageId = data[SYN_MESSAGE_ID_KEY];
183
+ return {
184
+ notification: {
185
+ title: notification.title,
186
+ body: notification.body,
187
+ image: notification.image
188
+ },
189
+ data,
190
+ messageId: rawMessageId && rawMessageId !== PLACEHOLDER_MESSAGE_ID ? rawMessageId : void 0,
191
+ campaignId: data[SYN_CAMPAIGN_ID_KEY]
192
+ };
193
+ }
194
+
195
+ // src/index.ts
196
+ var SynergonPushClass = class {
197
+ constructor() {
198
+ this.apiClient = null;
199
+ this.eventQueue = null;
200
+ this.firebase = null;
201
+ this.storage = null;
202
+ this.logger = {};
203
+ this.initOpts = null;
204
+ this.messageHandler = null;
205
+ this.unsubMessage = null;
206
+ this.deviceId = null;
207
+ this.fcmToken = null;
208
+ }
209
+ /**
210
+ * Bootstrap the SDK. Idempotent — safe to call from React StrictMode
211
+ * double-render, etc. Returns the SDK status snapshot.
212
+ */
213
+ async init(opts) {
214
+ if (this.initOpts) {
215
+ this.logger.warn?.("[push-web] SynergonPush.init called twice; ignoring second call.");
216
+ return this.getStatus();
217
+ }
218
+ this.initOpts = opts;
219
+ this.logger = opts.logger ?? {};
220
+ this.storage = new LocalStorageAdapter();
221
+ this.apiClient = new ApiClient({ config: opts });
222
+ this.eventQueue = new EventQueue({
223
+ api: this.apiClient,
224
+ storage: this.storage,
225
+ logger: this.logger
226
+ });
227
+ let bootstrap;
228
+ try {
229
+ bootstrap = await this.apiClient.bootstrap();
230
+ } catch (err) {
231
+ this.logger.error?.("[push-web] bootstrap failed", { error: err.message });
232
+ throw new Error(
233
+ "Synergon bootstrap failed. Either the publishable key is invalid or the account is missing its Firebase Web config. Configure it in Synergon admin \u2192 Campaigns \u2192 Push \u2192 Firebase Account."
234
+ );
235
+ }
236
+ let swReg;
237
+ if (opts.serviceWorkerPath) {
238
+ try {
239
+ swReg = await navigator.serviceWorker.register(opts.serviceWorkerPath);
240
+ } catch (err) {
241
+ this.logger.warn?.("[push-web] service worker registration failed", {
242
+ path: opts.serviceWorkerPath,
243
+ error: err.message
244
+ });
245
+ }
246
+ }
247
+ this.firebase = await FirebaseBootstrap.create({
248
+ firebaseConfig: bootstrap.firebaseConfig,
249
+ vapidKey: bootstrap.vapidKey,
250
+ serviceWorkerRegistration: swReg
251
+ });
252
+ this.deviceId = await this.storage.get(STORAGE_KEYS.deviceId);
253
+ this.fcmToken = await this.storage.get(STORAGE_KEYS.lastFcmToken);
254
+ this.eventQueue.flush().catch((err) => this.logger.warn?.("[push-web] flush at boot failed", { error: err.message }));
255
+ if (typeof window !== "undefined") {
256
+ window.addEventListener("online", () => {
257
+ this.eventQueue?.flush().catch(() => void 0);
258
+ });
259
+ }
260
+ this.subscribeForegroundMessages();
261
+ return this.getStatus();
262
+ }
263
+ /**
264
+ * Prompt the user for notification permission, fetch a token from
265
+ * Firebase, then register with Synergon. Throws if the user denies or
266
+ * if Firebase is unavailable.
267
+ */
268
+ async requestPermissionAndRegister(opts = {}) {
269
+ this.assertInitialized();
270
+ if (typeof Notification === "undefined") {
271
+ throw new Error("Notification API unavailable in this environment.");
272
+ }
273
+ const permission = await Notification.requestPermission();
274
+ if (permission !== "granted") {
275
+ throw new Error(`Notification permission ${permission}.`);
276
+ }
277
+ return this.register(opts);
278
+ }
279
+ /**
280
+ * Register only if the user has already granted permission. Returns
281
+ * null if not granted (no prompt). Useful when the integrator wants to
282
+ * call this on every app load (idempotent) without nagging the user.
283
+ */
284
+ async registerIfPermitted(opts = {}) {
285
+ this.assertInitialized();
286
+ if (typeof Notification === "undefined") return null;
287
+ if (Notification.permission !== "granted") return null;
288
+ return this.register(opts);
289
+ }
290
+ /**
291
+ * Fetch (or refresh) the FCM token and POST it to /v1/campaigns/push/devices.
292
+ * Persists `deviceId` and `fcmToken` locally so subsequent calls are no-ops
293
+ * if the token hasn't changed.
294
+ */
295
+ async register(opts = {}) {
296
+ this.assertInitialized();
297
+ const fb = this.firebase;
298
+ if (!fb) throw new Error("Firebase Messaging not supported in this browser.");
299
+ const token = await fb.getToken();
300
+ if (this.fcmToken && this.fcmToken === token && this.deviceId) {
301
+ this.logger.debug?.("[push-web] token unchanged; skipping re-register", {
302
+ deviceId: this.deviceId
303
+ });
304
+ return this.cachedDeviceResponse();
305
+ }
306
+ const response = await this.apiClient.registerDevice({
307
+ fcmToken: token,
308
+ platform: "web",
309
+ appVersion: opts.appVersion,
310
+ locale: typeof navigator !== "undefined" ? navigator.language : void 0,
311
+ identifierValue: opts.identifierValue,
312
+ leadId: opts.leadId,
313
+ marketingOptIn: opts.marketingOptIn
314
+ });
315
+ this.deviceId = response.id;
316
+ this.fcmToken = token;
317
+ await this.storage.set(STORAGE_KEYS.deviceId, response.id);
318
+ await this.storage.set(STORAGE_KEYS.lastFcmToken, token);
319
+ if (opts.identifierValue !== void 0) {
320
+ await this.storage.set(STORAGE_KEYS.identifierValue, opts.identifierValue);
321
+ }
322
+ if (opts.marketingOptIn !== void 0) {
323
+ await this.storage.set(STORAGE_KEYS.marketingOptIn, opts.marketingOptIn);
324
+ }
325
+ this.logger.info?.("[push-web] registered with Synergon", { deviceId: response.id });
326
+ return response;
327
+ }
328
+ /** Foreground message subscription. Returns an unsubscribe callable. */
329
+ onMessage(handler) {
330
+ this.messageHandler = handler;
331
+ return () => {
332
+ this.messageHandler = null;
333
+ };
334
+ }
335
+ /**
336
+ * Manually fire a delivered/opened/clicked event. The SDK's own
337
+ * onMessage path fires `delivered` automatically — call this only when
338
+ * you want to attribute custom events (e.g. a button click in a
339
+ * notification-triggered modal).
340
+ */
341
+ async reportEvent(idOrPayload, eventType) {
342
+ this.assertInitialized();
343
+ const ids = typeof idOrPayload === "string" ? { messageId: idOrPayload } : idOrPayload;
344
+ await this.eventQueue.enqueue({
345
+ ...ids,
346
+ eventType,
347
+ platform: "web",
348
+ occurredAt: (/* @__PURE__ */ new Date()).toISOString()
349
+ });
350
+ }
351
+ /**
352
+ * Clear local state. Note: this does NOT delete the device on the
353
+ * Synergon side — the public endpoint doesn't permit publishable-key
354
+ * authenticated deletes. Have your backend call DELETE
355
+ * /v1/campaigns/push/devices/:id if you want full removal.
356
+ */
357
+ async unregister() {
358
+ this.assertInitialized();
359
+ await this.storage.remove(STORAGE_KEYS.deviceId);
360
+ await this.storage.remove(STORAGE_KEYS.lastFcmToken);
361
+ await this.storage.remove(STORAGE_KEYS.identifierValue);
362
+ await this.storage.remove(STORAGE_KEYS.marketingOptIn);
363
+ this.deviceId = null;
364
+ this.fcmToken = null;
365
+ this.unsubMessage?.();
366
+ this.unsubMessage = null;
367
+ }
368
+ getStatus() {
369
+ const permission = typeof Notification === "undefined" ? "unsupported" : Notification.permission;
370
+ return {
371
+ initialized: this.initOpts !== null,
372
+ permission,
373
+ registered: this.deviceId !== null,
374
+ deviceId: this.deviceId,
375
+ fcmToken: this.fcmToken
376
+ };
377
+ }
378
+ // ─── internals ───────────────────────────────────────────────────────
379
+ subscribeForegroundMessages() {
380
+ if (!this.firebase) return;
381
+ this.unsubMessage = this.firebase.onMessage(async (raw) => {
382
+ const payload = normalizePayload(raw);
383
+ const canIdentify = !!payload.messageId || !!(this.fcmToken && payload.campaignId);
384
+ if (canIdentify) {
385
+ try {
386
+ await this.eventQueue?.enqueue({
387
+ messageId: payload.messageId,
388
+ fcmToken: payload.messageId ? void 0 : this.fcmToken,
389
+ campaignId: payload.messageId ? void 0 : payload.campaignId,
390
+ eventType: "delivered",
391
+ platform: "web",
392
+ occurredAt: (/* @__PURE__ */ new Date()).toISOString()
393
+ });
394
+ } catch (err) {
395
+ this.logger.warn?.("[push-web] failed to report delivered", {
396
+ error: err.message
397
+ });
398
+ }
399
+ }
400
+ this.messageHandler?.(payload);
401
+ });
402
+ }
403
+ cachedDeviceResponse() {
404
+ return {
405
+ id: this.deviceId,
406
+ status: "active",
407
+ platform: "web",
408
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
409
+ registeredAt: (/* @__PURE__ */ new Date()).toISOString()
410
+ };
411
+ }
412
+ assertInitialized() {
413
+ if (!this.initOpts) {
414
+ throw new Error("SynergonPush.init() must be called before any other SDK method.");
415
+ }
416
+ }
417
+ };
418
+ var SynergonPush = new SynergonPushClass();
419
+ export {
420
+ SynergonPush
421
+ };
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/sw-template.ts
31
+ var sw_template_exports = {};
32
+ __export(sw_template_exports, {
33
+ readServiceWorkerTemplate: () => readServiceWorkerTemplate
34
+ });
35
+ module.exports = __toCommonJS(sw_template_exports);
36
+ var import_node_fs = __toESM(require("fs"), 1);
37
+ var import_node_path = __toESM(require("path"), 1);
38
+ var import_node_url = require("url");
39
+ function getModuleDir() {
40
+ try {
41
+ const dirname = (0, eval)('typeof __dirname !== "undefined" ? __dirname : undefined');
42
+ if (dirname) return dirname;
43
+ } catch {
44
+ }
45
+ const url = (0, eval)('typeof import.meta !== "undefined" ? import.meta.url : undefined');
46
+ if (url) return import_node_path.default.dirname((0, import_node_url.fileURLToPath)(url));
47
+ throw new Error("[push-web/sw-template] Unable to resolve module directory.");
48
+ }
49
+ var here = getModuleDir();
50
+ function readServiceWorkerTemplate() {
51
+ const candidates = [
52
+ import_node_path.default.resolve(here, "../public/firebase-messaging-sw.js"),
53
+ import_node_path.default.resolve(here, "../../public/firebase-messaging-sw.js")
54
+ ];
55
+ for (const candidate of candidates) {
56
+ if (import_node_fs.default.existsSync(candidate)) {
57
+ return import_node_fs.default.readFileSync(candidate, "utf8");
58
+ }
59
+ }
60
+ throw new Error(
61
+ "[push-web/sw-template] firebase-messaging-sw.js not found. Looked at:\n " + candidates.join("\n ")
62
+ );
63
+ }
64
+ // Annotate the CommonJS export names for ESM import in node:
65
+ 0 && (module.exports = {
66
+ readServiceWorkerTemplate
67
+ });
@@ -0,0 +1,3 @@
1
+ declare function readServiceWorkerTemplate(): string;
2
+
3
+ export { readServiceWorkerTemplate };
@@ -0,0 +1,3 @@
1
+ declare function readServiceWorkerTemplate(): string;
2
+
3
+ export { readServiceWorkerTemplate };
@@ -0,0 +1,32 @@
1
+ // src/sw-template.ts
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { fileURLToPath } from "url";
5
+ function getModuleDir() {
6
+ try {
7
+ const dirname = (0, eval)('typeof __dirname !== "undefined" ? __dirname : undefined');
8
+ if (dirname) return dirname;
9
+ } catch {
10
+ }
11
+ const url = (0, eval)('typeof import.meta !== "undefined" ? import.meta.url : undefined');
12
+ if (url) return path.dirname(fileURLToPath(url));
13
+ throw new Error("[push-web/sw-template] Unable to resolve module directory.");
14
+ }
15
+ var here = getModuleDir();
16
+ function readServiceWorkerTemplate() {
17
+ const candidates = [
18
+ path.resolve(here, "../public/firebase-messaging-sw.js"),
19
+ path.resolve(here, "../../public/firebase-messaging-sw.js")
20
+ ];
21
+ for (const candidate of candidates) {
22
+ if (fs.existsSync(candidate)) {
23
+ return fs.readFileSync(candidate, "utf8");
24
+ }
25
+ }
26
+ throw new Error(
27
+ "[push-web/sw-template] firebase-messaging-sw.js not found. Looked at:\n " + candidates.join("\n ")
28
+ );
29
+ }
30
+ export {
31
+ readServiceWorkerTemplate
32
+ };
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@synergonai/push-web",
3
+ "version": "0.1.0",
4
+ "description": "Synergon Push SDK for browsers. Wraps firebase/messaging and handles registration + event reporting.",
5
+ "license": "Apache-2.0",
6
+ "homepage": "https://synergon.ai",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "type": "module",
11
+ "main": "./dist/index.cjs",
12
+ "module": "./dist/index.js",
13
+ "types": "./dist/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "import": "./dist/index.js",
18
+ "require": "./dist/index.cjs"
19
+ },
20
+ "./sw-template": {
21
+ "types": "./dist/sw-template.d.ts",
22
+ "import": "./dist/sw-template.js",
23
+ "require": "./dist/sw-template.cjs"
24
+ },
25
+ "./service-worker.js": "./public/firebase-messaging-sw.js"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "public/firebase-messaging-sw.js"
30
+ ],
31
+ "dependencies": {
32
+ "firebase": "^12.13.0",
33
+ "@synergonai/push-core": "0.1.0"
34
+ },
35
+ "devDependencies": {
36
+ "tsup": "^8.3.0",
37
+ "typescript": "^5.6.0"
38
+ },
39
+ "scripts": {
40
+ "build": "tsup",
41
+ "typecheck": "tsc --noEmit",
42
+ "test": "echo 'web: no tests yet' && exit 0"
43
+ }
44
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Synergon Push — service worker template.
3
+ *
4
+ * The integrator copies (or symlinks) this file to the root of their static
5
+ * assets so it's served from `/firebase-messaging-sw.js` (the path
6
+ * firebase/messaging looks for by default).
7
+ *
8
+ * Before deploying:
9
+ * 1. Replace the placeholders in `firebaseConfig` with your project's
10
+ * Web config from Firebase Console → Project Settings → General →
11
+ * Your apps → Web → SDK setup and configuration.
12
+ * 2. Replace the SYNERGON_API_BASE_URL / SYNERGON_TENANT_ID /
13
+ * SYNERGON_PUBLISHABLE_KEY placeholders so background message clicks
14
+ * can fire /events/opened.
15
+ *
16
+ * Why this lives outside the SDK bundle: browser security requires the
17
+ * service worker to be served from a same-origin URL, and firebase auto
18
+ * looks at `/firebase-messaging-sw.js`. Bundlers can't relocate it for the
19
+ * integrator — they have to host it themselves.
20
+ *
21
+ * Background message rendering: Firebase auto-renders any payload with a
22
+ * `notification` block. We only intervene to fire /events/opened when the
23
+ * user taps the notification, since FCM doesn't ping us back on its own.
24
+ */
25
+
26
+ // Compat scripts MUST match the firebase version used in the page bundle —
27
+ // version skew silently drops pushes (the SW won't recognise the FCM envelope).
28
+ importScripts('https://www.gstatic.com/firebasejs/12.13.0/firebase-app-compat.js');
29
+ importScripts('https://www.gstatic.com/firebasejs/12.13.0/firebase-messaging-compat.js');
30
+
31
+ self.addEventListener('install', () => self.skipWaiting());
32
+ self.addEventListener('activate', event => event.waitUntil(self.clients.claim()));
33
+
34
+ // REPLACE these placeholders with your Firebase Web config + Synergon credentials.
35
+ const firebaseConfig = {
36
+ apiKey: 'REPLACE_ME',
37
+ authDomain: 'REPLACE_ME.firebaseapp.com',
38
+ projectId: 'REPLACE_ME',
39
+ messagingSenderId: 'REPLACE_ME',
40
+ appId: 'REPLACE_ME',
41
+ };
42
+
43
+ const SYNERGON_API_BASE_URL = 'REPLACE_ME'; // e.g. https://api.synergon.ai
44
+ const SYNERGON_TENANT_ID = 'REPLACE_ME';
45
+ const SYNERGON_PUBLISHABLE_KEY = 'REPLACE_ME'; // pk_live_...
46
+
47
+ firebase.initializeApp(firebaseConfig);
48
+ const messaging = firebase.messaging();
49
+
50
+ /**
51
+ * Fire-and-forget POST. We never await this inside a service-worker handler
52
+ * because that would delay notification rendering / tab focus.
53
+ */
54
+ function reportEvent(messageId, eventType) {
55
+ if (!messageId) return;
56
+ try {
57
+ fetch(`${SYNERGON_API_BASE_URL}/v1/campaigns/push/events/${eventType}`, {
58
+ method: 'POST',
59
+ mode: 'cors',
60
+ keepalive: true,
61
+ headers: {
62
+ 'Content-Type': 'application/json',
63
+ 'X-Tenant-Id': SYNERGON_TENANT_ID,
64
+ 'Authorization': `Bearer ${SYNERGON_PUBLISHABLE_KEY}`,
65
+ },
66
+ body: JSON.stringify({
67
+ messageId,
68
+ eventType,
69
+ platform: 'web',
70
+ occurredAt: new Date().toISOString(),
71
+ }),
72
+ }).catch(() => undefined);
73
+ } catch {
74
+ // ignore — events are best-effort from the SW
75
+ }
76
+ }
77
+
78
+ // Click handler — runs when the user taps a notification rendered by FCM.
79
+ self.addEventListener('notificationclick', event => {
80
+ const data = event.notification.data ?? {};
81
+ const messageId = data.syn_msg_id ?? data.FCM_MSG?.data?.syn_msg_id;
82
+ const clickUrl = data.click_action ?? data.FCM_MSG?.notification?.click_action ?? '/';
83
+
84
+ event.notification.close();
85
+ reportEvent(messageId, 'opened');
86
+
87
+ // Focus an existing tab open at clickUrl, or open a new one.
88
+ event.waitUntil((async () => {
89
+ const allClients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
90
+ for (const client of allClients) {
91
+ if (client.url.includes(clickUrl) && 'focus' in client) {
92
+ return client.focus();
93
+ }
94
+ }
95
+ if (self.clients.openWindow) {
96
+ return self.clients.openWindow(clickUrl);
97
+ }
98
+ })());
99
+ });
100
+
101
+ /**
102
+ * Background message handler. Firebase auto-renders `notification` payloads,
103
+ * but we still get this callback for data-only messages or when the page is
104
+ * hidden. We use it to fire /events/delivered.
105
+ *
106
+ * For data-only messages, we additionally show our own notification so the
107
+ * user sees something — otherwise the message would be invisible.
108
+ */
109
+ messaging.onBackgroundMessage(payload => {
110
+ const data = payload.data ?? {};
111
+ const messageId = data.syn_msg_id;
112
+ reportEvent(messageId, 'delivered');
113
+
114
+ // Render a fallback notification only if FCM didn't supply one.
115
+ if (!payload.notification) {
116
+ const title = data.title ?? 'New notification';
117
+ const body = data.body ?? '';
118
+ self.registration.showNotification(title, {
119
+ body,
120
+ icon: data.icon ?? undefined,
121
+ data,
122
+ });
123
+ }
124
+ });