@log-drop/web 1.0.0-alpha1

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.
@@ -0,0 +1,372 @@
1
+ // LogDrop Service Worker v1
2
+ // Handles: push events, notification clicks, subscription changes, ping/pong handshake
3
+
4
+ const LOGDROP_SW_VERSION = '1';
5
+
6
+ // install: skip waiting so new SW activates immediately
7
+ self.addEventListener('install', () => self.skipWaiting());
8
+
9
+ // activate: claim all clients immediately
10
+ self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));
11
+
12
+ // message: respond to ping/pong handshake from PushModule
13
+ self.addEventListener('message', (event) => {
14
+ if (event.data && event.data.type === 'logdrop:ping') {
15
+ // Reply to the specific client that sent the ping
16
+ if (event.source && typeof event.source.postMessage === 'function') {
17
+ event.source.postMessage({ type: 'logdrop:pong', version: LOGDROP_SW_VERSION });
18
+ }
19
+ } else if (event.data && event.data.type === 'logdrop:push-config') {
20
+ event.waitUntil(storePushConfig(event.data.config || {}));
21
+ } else if (event.data && event.data.type === 'logdrop:drain-pending') {
22
+ event.waitUntil(drainPendingPushes(event.source));
23
+ }
24
+ });
25
+
26
+ // push: show notification
27
+ self.addEventListener('push', (event) => {
28
+ event.waitUntil(handlePush(event).catch(err => {
29
+ console.error('[logdrop-sw] push handler error:', err);
30
+ }));
31
+ });
32
+
33
+ /**
34
+ * Display a notification derived from a push event payload, normalizing formats and handling actions.
35
+ *
36
+ * Parses the incoming PushEvent payload (attempting JSON, falling back to text), supports both
37
+ * Firebase-style `{ notification: { ... }, data: { ... } }` and flat `{ title, body, data }` shapes,
38
+ * normalizes `title`, `body`, `tag`, `requireInteraction`, `image`, and `silent`, maps provided
39
+ * action buttons into Notification actions and records an `actionUrls` lookup in `options.data`,
40
+ * then shows the notification via the ServiceWorker registration.
41
+ *
42
+ * @param {PushEvent} event - The push event containing an optional payload; if JSON parsing fails,
43
+ * the payload falls back to `{ title: 'LogDrop', body: <text> }`.
44
+ */
45
+ async function handlePush(event) {
46
+ let payload = {};
47
+ try {
48
+ if (event.data) {
49
+ payload = event.data.json();
50
+ }
51
+ } catch {
52
+ payload = { title: 'LogDrop', body: event.data ? event.data.text() : '' };
53
+ }
54
+
55
+ const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
56
+ for (const client of clients) {
57
+ try { client.postMessage({ type: 'logdrop:push-received', payload }); } catch { /* ignore */ }
58
+ }
59
+ if (clients.length === 0) {
60
+ await storePendingMessage('logdrop:push-received', payload);
61
+ }
62
+
63
+ // Data-only actions are delivered to open pages immediately or persisted for
64
+ // the next page load. Visible notifications continue through the code below.
65
+ if (payload.silent === true && !payload.notification && !payload.title && !payload.body) {
66
+ return;
67
+ }
68
+
69
+ // Support both FCM console format { notification: { title, body }, data: {} }
70
+ // and our flat format { title, body, data: {} }
71
+ const notification = payload.notification || payload;
72
+ const title = notification.title || payload.title || 'LogDrop Notification';
73
+ const options = {
74
+ body: notification.body || payload.body || '',
75
+ tag: notification.tag || payload.tag || 'logdrop',
76
+ data: payload.data || payload,
77
+ requireInteraction: notification.requireInteraction || payload.requireInteraction || false,
78
+ };
79
+
80
+ // Action buttons — store action URLs in data so notificationclick can route them
81
+ if (Array.isArray(notification.actions || payload.actions)) {
82
+ const actions = notification.actions || payload.actions;
83
+ options.actions = actions.map(a => ({ action: a.action, title: a.title, icon: a.icon })).filter(a => a.action && a.title);
84
+ // Store action URL map in data for click routing
85
+ const actionUrls = {};
86
+ actions.forEach(a => { if (a.action && a.url) actionUrls[a.action] = a.url; });
87
+ options.data = { ...(options.data || {}), actionUrls };
88
+ }
89
+
90
+ if (notification.image || payload.image) options.image = notification.image || payload.image;
91
+ if (notification.silent || payload.silent) options.silent = true;
92
+
93
+ await self.registration.showNotification(title, options);
94
+ }
95
+
96
+ // notificationclick: handle user clicking the notification
97
+ self.addEventListener('notificationclick', (event) => {
98
+ event.notification.close();
99
+ event.waitUntil(handleNotificationClick(event));
100
+ });
101
+
102
+ /**
103
+ * Handle a notification click by broadcasting the click to open window clients, then focusing or opening the appropriate page and performing best-effort click tracking.
104
+ *
105
+ * Broadcasts the click to open clients, honors doNothing, and routes deeplinks according to their browser mode.
106
+ * @param {NotificationEvent} event - The notification click event containing `notification.data` and an optional `action` identifier.
107
+ */
108
+ async function handleNotificationClick(event) {
109
+ const data = event.notification.data || {};
110
+ const actionUrls = data.actionUrls || {};
111
+ const action = data.action && typeof data.action === 'object' ? data.action : {};
112
+ const doNothing = data.click && data.click.doNothing === true;
113
+ const rawUrl = (event.action && actionUrls[event.action]) || action.target || data.url || null;
114
+
115
+ // 1. Post message to all open page clients so PushModule can invoke onRemoteMessageReceived
116
+ const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
117
+ for (const client of clients) {
118
+ try {
119
+ client.postMessage({ type: 'logdrop:notification-click', payload: data, action: event.action || null });
120
+ } catch { /* ignore */ }
121
+ }
122
+ if (clients.length === 0) {
123
+ try { await storePendingMessage('logdrop:notification-click', data); } catch { /* best effort */ }
124
+ }
125
+
126
+ // doNothing still reports the click, but must not focus or open a page.
127
+ if (doNothing) return;
128
+
129
+ // 2. Only navigate if a URL was explicitly provided
130
+ if (!rawUrl) {
131
+ if (clients.length > 0) await clients[0].focus();
132
+ else await self.clients.openWindow(self.location.origin);
133
+ return;
134
+ }
135
+
136
+ const url = rawUrl.startsWith('/') || /^[a-z][a-z0-9+.-]*:/i.test(rawUrl)
137
+ ? rawUrl
138
+ : 'https://' + rawUrl;
139
+ const externalBrowser = String(action.deeplinkMode || '').toUpperCase() === 'EXTERNAL_BROWSER';
140
+
141
+ // 3. EXTERNAL_BROWSER opens a new page. INLINE/INTERNAL_BROWSER reuse an
142
+ // existing page when possible, matching mobile's single-app navigation.
143
+ try {
144
+ if (externalBrowser || clients.length === 0) {
145
+ await self.clients.openWindow(url);
146
+ } else {
147
+ const client = clients[0];
148
+ await client.focus();
149
+ if (typeof client.navigate === 'function') await client.navigate(url);
150
+ }
151
+ } catch (err) {
152
+ const fallback = action.fallbackTarget;
153
+ if (fallback) {
154
+ try { await self.clients.openWindow(fallback); } catch { /* ignore */ }
155
+ } else {
156
+ console.error('[logdrop-sw] navigation failed:', err);
157
+ }
158
+ }
159
+
160
+ // 4. Best-effort click tracking (fire-and-forget)
161
+ if (data.clickTrackingUrl) {
162
+ try {
163
+ await fetch(data.clickTrackingUrl, { method: 'POST', keepalive: true });
164
+ } catch { /* ignore — tracking is non-critical */ }
165
+ }
166
+ }
167
+
168
+ // pushsubscriptionchange: re-subscribe when browser rotates subscription
169
+ self.addEventListener('pushsubscriptionchange', (event) => {
170
+ event.waitUntil(handleSubscriptionChange(event));
171
+ });
172
+
173
+ /**
174
+ * Re-subscribes after browser rotation and persists the new subscription through the same protobuf users/update flow used by the SDKs.
175
+ *
176
+ * Attempts to load stored push configuration from IndexedDB; if required auth or VAPID data is missing, the next page init performs reconciliation.
177
+ * @param {PushSubscriptionChangeEvent} event - The subscription change event from the Service Worker.
178
+ */
179
+ async function handleSubscriptionChange(event) {
180
+ // Retrieve stored config from IndexedDB (set by PushModule via postMessage on init)
181
+ // If unavailable, we cannot re-subscribe automatically without the VAPID key.
182
+ let config = null;
183
+ try {
184
+ config = await getStoredPushConfig();
185
+ } catch { /* ignore */ }
186
+
187
+ if (!config || !config.vapidPublicKey || !config.apiBase || !config.apiKey ||
188
+ !config.sessionToken || !config.deviceUuid) {
189
+ // Cannot re-subscribe without config — the next page load will handle it
190
+ return;
191
+ }
192
+
193
+ const applicationServerKey = urlBase64ToUint8Array(config.vapidPublicKey);
194
+ const subscription = await self.registration.pushManager.subscribe({
195
+ userVisibleOnly: true,
196
+ applicationServerKey,
197
+ });
198
+
199
+ const subscriptionPayload = subscription.toJSON();
200
+ const body = encodeWebPushUserUpdate(config.deviceUuid, subscriptionPayload);
201
+ const response = await fetch(`${config.apiBase}/users/update`, {
202
+ method: 'POST',
203
+ headers: {
204
+ 'Content-Type': 'application/x-protobuf',
205
+ 'Accept': 'application/x-protobuf',
206
+ 'x-api-key': config.apiKey,
207
+ 'x-session-token': config.sessionToken,
208
+ },
209
+ body,
210
+ keepalive: true,
211
+ });
212
+ if (!response.ok) throw new Error(`Web Push subscription sync failed: HTTP ${response.status}`);
213
+ }
214
+
215
+ function encodeWebPushUserUpdate(deviceUuid, subscription) {
216
+ const writeVarint = (target, value) => {
217
+ let remaining = Math.max(0, Math.floor(Number(value) || 0));
218
+ while (remaining > 0x7f) {
219
+ target.push((remaining % 128) | 0x80);
220
+ remaining = Math.floor(remaining / 128);
221
+ }
222
+ target.push(remaining);
223
+ };
224
+ const writeTag = (target, field, wireType) => writeVarint(target, (field << 3) | wireType);
225
+ const writeString = (target, field, value) => {
226
+ if (!value) return;
227
+ const bytes = new TextEncoder().encode(String(value));
228
+ writeTag(target, field, 2);
229
+ writeVarint(target, bytes.length);
230
+ target.push(...bytes);
231
+ };
232
+ const writeInt64 = (target, field, value) => {
233
+ if (!Number.isSafeInteger(value) || value <= 0) return;
234
+ writeTag(target, field, 0);
235
+ writeVarint(target, value);
236
+ };
237
+ const writeMessage = (target, field, bytes) => {
238
+ if (!bytes.length) return;
239
+ writeTag(target, field, 2);
240
+ writeVarint(target, bytes.length);
241
+ target.push(...bytes);
242
+ };
243
+
244
+ const keys = subscription.keys || {};
245
+ const webPush = [];
246
+ writeString(webPush, 1, subscription.endpoint);
247
+ writeString(webPush, 2, keys.p256dh);
248
+ writeString(webPush, 3, keys.auth);
249
+ writeInt64(webPush, 4, Number(subscription.expirationTime));
250
+
251
+ const device = [];
252
+ writeString(device, 1, deviceUuid);
253
+ writeString(device, 12, 'WEB');
254
+ writeMessage(device, 16, webPush);
255
+
256
+ const request = [];
257
+ writeMessage(request, 2, device);
258
+ return new Uint8Array(request);
259
+ }
260
+
261
+ // ---------------------------------------------------------------------------
262
+ // Config storage via IndexedDB
263
+ // Used by pushsubscriptionchange to re-subscribe without page context
264
+ // ---------------------------------------------------------------------------
265
+
266
+ const IDB_NAME = 'logdrop-push';
267
+ const IDB_STORE = 'config';
268
+ const IDB_KEY = 'push-config';
269
+ const IDB_PENDING_STORE = 'pending';
270
+
271
+ /**
272
+ * Open (or create) the IndexedDB database used for push configuration.
273
+ *
274
+ * Creates the required object store on database upgrade and resolves with the open IDBDatabase instance.
275
+ * @returns {Promise<IDBDatabase>} A promise that resolves with the opened IndexedDB database, or rejects with the underlying error.
276
+ */
277
+ function openConfigDB() {
278
+ return new Promise((resolve, reject) => {
279
+ const req = self.indexedDB.open(IDB_NAME, 2);
280
+ req.onupgradeneeded = (e) => {
281
+ const db = e.target.result;
282
+ if (!db.objectStoreNames.contains(IDB_STORE)) db.createObjectStore(IDB_STORE);
283
+ if (!db.objectStoreNames.contains(IDB_PENDING_STORE)) {
284
+ db.createObjectStore(IDB_PENDING_STORE, { autoIncrement: true });
285
+ }
286
+ };
287
+ req.onsuccess = (e) => resolve(e.target.result);
288
+ req.onerror = (e) => reject(e.target.error);
289
+ });
290
+ }
291
+
292
+ /**
293
+ * Retrieve the stored push configuration from IndexedDB.
294
+ *
295
+ * Reads the value stored under IDB_KEY in the IDB_STORE object store and resolves
296
+ * with that object or `null` if no value is present.
297
+ *
298
+ * @returns {Promise<Object|null>} The stored push configuration object, or `null` if not found.
299
+ * @throws {DOMException|Error} Rejects with the underlying IndexedDB error if the read operation fails.
300
+ */
301
+ async function getStoredPushConfig() {
302
+ const db = await openConfigDB();
303
+ return new Promise((resolve, reject) => {
304
+ const tx = db.transaction(IDB_STORE, 'readonly');
305
+ const req = tx.objectStore(IDB_STORE).get(IDB_KEY);
306
+ req.onsuccess = (e) => resolve(e.target.result ?? null);
307
+ req.onerror = (e) => reject(e.target.error);
308
+ });
309
+ }
310
+
311
+ async function storePushConfig(config) {
312
+ const db = await openConfigDB();
313
+ return new Promise((resolve, reject) => {
314
+ const tx = db.transaction(IDB_STORE, 'readwrite');
315
+ tx.objectStore(IDB_STORE).put(config, IDB_KEY);
316
+ tx.oncomplete = () => resolve();
317
+ tx.onerror = () => reject(tx.error);
318
+ });
319
+ }
320
+
321
+ async function storePendingMessage(type, payload) {
322
+ const db = await openConfigDB();
323
+ return new Promise((resolve, reject) => {
324
+ const tx = db.transaction(IDB_PENDING_STORE, 'readwrite');
325
+ tx.objectStore(IDB_PENDING_STORE).add({ type, payload });
326
+ tx.oncomplete = () => resolve();
327
+ tx.onerror = () => reject(tx.error);
328
+ });
329
+ }
330
+
331
+ async function drainPendingPushes(client) {
332
+ if (!client || typeof client.postMessage !== 'function') return;
333
+ const db = await openConfigDB();
334
+ const payloads = await new Promise((resolve, reject) => {
335
+ const tx = db.transaction(IDB_PENDING_STORE, 'readwrite');
336
+ const store = tx.objectStore(IDB_PENDING_STORE);
337
+ const req = store.getAll();
338
+ req.onsuccess = () => {
339
+ const values = req.result || [];
340
+ store.clear();
341
+ resolve(values);
342
+ };
343
+ req.onerror = () => reject(req.error);
344
+ });
345
+ for (const message of payloads) {
346
+ client.postMessage({
347
+ type: message && message.type ? message.type : 'logdrop:push-received',
348
+ payload: message && Object.prototype.hasOwnProperty.call(message, 'payload')
349
+ ? message.payload
350
+ : message,
351
+ });
352
+ }
353
+ }
354
+
355
+ // ---------------------------------------------------------------------------
356
+ // Helpers
357
+ /**
358
+ * Convert a URL-safe Base64 string into a Uint8Array of decoded bytes.
359
+ * @param {string} base64String - URL-safe Base64 (may use '-' and '_' and may be missing padding).
360
+ * @returns {Uint8Array} The decoded bytes.
361
+ */
362
+
363
+ function urlBase64ToUint8Array(base64String) {
364
+ const padding = '='.repeat((4 - base64String.length % 4) % 4);
365
+ const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
366
+ const rawData = self.atob(base64);
367
+ const output = new Uint8Array(rawData.length);
368
+ for (let i = 0; i < rawData.length; i++) {
369
+ output[i] = rawData.charCodeAt(i);
370
+ }
371
+ return output;
372
+ }