@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/LICENSE +201 -0
- package/README.md +113 -0
- package/dist/index.cjs +433 -0
- package/dist/index.d.cts +118 -0
- package/dist/index.d.ts +118 -0
- package/dist/index.js +421 -0
- package/dist/sw-template.cjs +67 -0
- package/dist/sw-template.d.cts +3 -0
- package/dist/sw-template.d.ts +3 -0
- package/dist/sw-template.js +32 -0
- package/package.json +44 -0
- package/public/firebase-messaging-sw.js +124 -0
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,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
|
+
});
|