@modernman00/shared-js-lib 1.2.82 → 1.2.87

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/PwaManager.js ADDED
@@ -0,0 +1,342 @@
1
+ /**
2
+ * 📱 PwaManager — Universal Cross-App Service Worker & PWA Controller
3
+ * Part of @modernman00/shared-js-lib
4
+ *
5
+ * Implements:
6
+ * 1. Clean root /sw.js registration with scope '/'
7
+ * 2. Purging of legacy orphaned workers (/public/sw.js, /service-worker.js)
8
+ * 3. User-prompted updates via 'postMessage' with zero jarring auto-reloads
9
+ * 4. Safe cache purging on user logout (clears API & session caches)
10
+ * 5. Custom event dispatching for seamless Blade UI integration
11
+ */
12
+
13
+ export class PwaManager {
14
+ static registration = null;
15
+ static options = {
16
+ swUrl: '/sw.js',
17
+ scope: '/',
18
+ appName: 'App',
19
+ enableUpdateToast: true,
20
+ enableBadgingSync: true,
21
+ badgeEndpoint: '/api/notifications/unread',
22
+ onUpdateAvailable: null
23
+ };
24
+
25
+ /**
26
+ * Initializes Service Worker registration and lifecycle monitoring
27
+ */
28
+ static async init(userOptions = {}) {
29
+ if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
30
+ return null;
31
+ }
32
+
33
+ this.options = { ...this.options, ...userOptions };
34
+
35
+ window.addEventListener('load', async () => {
36
+ try {
37
+ await this.purgeLegacyWorkers();
38
+ this.registration = await navigator.serviceWorker.register(this.options.swUrl, {
39
+ scope: this.options.scope
40
+ });
41
+
42
+ console.log(`[PwaManager] ${this.options.appName} ServiceWorker active with scope:`, this.registration.scope);
43
+
44
+ // Listen for new workers entering 'installing' or 'waiting' states
45
+ this.registration.addEventListener('updatefound', () => {
46
+ const installingWorker = this.registration.installing;
47
+ if (!installingWorker) return;
48
+
49
+ installingWorker.addEventListener('statechange', () => {
50
+ if (installingWorker.state === 'installed' && navigator.serviceWorker.controller) {
51
+ console.log(`[PwaManager] New version of ${this.options.appName} installed and waiting.`);
52
+ this.handleUpdateAvailable(installingWorker);
53
+ }
54
+ });
55
+ });
56
+
57
+ // Detect if a worker was already waiting prior to page load
58
+ if (this.registration.waiting && navigator.serviceWorker.controller) {
59
+ this.handleUpdateAvailable(this.registration.waiting);
60
+ }
61
+ } catch (error) {
62
+ console.error(`[PwaManager] ServiceWorker registration failed:`, error);
63
+ }
64
+ });
65
+
66
+ // Listen for controller changes (when skipWaiting activates new worker)
67
+ navigator.serviceWorker.addEventListener('controllerchange', () => {
68
+ console.log('[PwaManager] ServiceWorker controller changed. Page ready for reload.');
69
+ });
70
+
71
+ // Automatic Home Screen Badging Synchronization
72
+ if (this.options.enableBadgingSync && typeof window !== 'undefined') {
73
+ this.syncAppBadge();
74
+
75
+ window.addEventListener('focus', () => this.syncAppBadge());
76
+ document.addEventListener('visibilitychange', () => {
77
+ if (document.visibilityState === 'visible') {
78
+ this.syncAppBadge();
79
+ }
80
+ });
81
+
82
+ navigator.serviceWorker.addEventListener('message', (event) => {
83
+ if (event.data?.type === 'ON_APP_NOTIFICATION') {
84
+ this.syncAppBadge();
85
+ if (typeof window !== 'undefined' && window.dispatchEvent) {
86
+ window.dispatchEvent(new CustomEvent('pwa:notification-received', { detail: event.data.payload }));
87
+ }
88
+ }
89
+ });
90
+ }
91
+
92
+ return this.registration;
93
+ }
94
+
95
+ /**
96
+ * Drops legacy or misconfigured workers from older architecture iterations
97
+ */
98
+ static async purgeLegacyWorkers() {
99
+ try {
100
+ const registrations = await navigator.serviceWorker.getRegistrations();
101
+ for (const reg of registrations) {
102
+ const scriptURL = (reg.active || reg.waiting || reg.installing || {}).scriptURL || '';
103
+ // If worker is scoped to /public/ or named /service-worker.js, unregister it
104
+ if (scriptURL.endsWith('/public/sw.js') || scriptURL.endsWith('/service-worker.js')) {
105
+ console.warn('[PwaManager] Unregistering legacy orphaned worker:', scriptURL);
106
+ await reg.unregister();
107
+ }
108
+ }
109
+ } catch (err) {
110
+ console.warn('[PwaManager] Legacy cleanup check error:', err);
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Handles update notification
116
+ */
117
+ static handleUpdateAvailable(worker) {
118
+ if (typeof window !== 'undefined' && window.dispatchEvent) {
119
+ window.dispatchEvent(new CustomEvent('pwa:update-available', {
120
+ detail: { worker, appName: this.options.appName }
121
+ }));
122
+ }
123
+
124
+ if (this.options.enableUpdateToast) {
125
+ this.showUpdateToast(worker);
126
+ }
127
+
128
+ if (typeof this.options.onUpdateAvailable === 'function') {
129
+ this.options.onUpdateAvailable(worker);
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Renders a discreet, glassmorphic floating update pill to prompt user refresh
135
+ */
136
+ static showUpdateToast(worker) {
137
+ if (typeof document === 'undefined' || document.getElementById('pwaUniversalUpdateToast')) {
138
+ return;
139
+ }
140
+
141
+ const toast = document.createElement('div');
142
+ toast.id = 'pwaUniversalUpdateToast';
143
+ toast.setAttribute('role', 'status');
144
+ toast.setAttribute('aria-live', 'polite');
145
+ toast.setAttribute('aria-label', `${this.options.appName || 'App'} update available`);
146
+
147
+ toast.style.position = 'fixed';
148
+ toast.style.bottom = 'max(16px, env(safe-area-inset-bottom, 16px))';
149
+ toast.style.left = '50%';
150
+ toast.style.transform = 'translateX(-50%)';
151
+ toast.style.zIndex = '99999';
152
+ toast.style.display = 'flex';
153
+ toast.style.alignItems = 'center';
154
+ toast.style.gap = '12px';
155
+ toast.style.padding = '8px 14px 8px 16px';
156
+ toast.style.backgroundColor = 'rgba(15, 23, 42, 0.94)';
157
+ toast.style.backdropFilter = 'blur(12px)';
158
+ toast.style.webkitBackdropFilter = 'blur(12px)';
159
+ toast.style.border = '1px solid rgba(255, 255, 255, 0.12)';
160
+ toast.style.borderRadius = '9999px';
161
+ toast.style.boxShadow = '0 12px 32px rgba(0, 0, 0, 0.45)';
162
+ toast.style.color = '#ffffff';
163
+ toast.style.fontFamily = 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
164
+ toast.style.fontSize = '14px';
165
+ toast.style.lineHeight = '1.4';
166
+ toast.style.maxWidth = 'calc(100vw - 32px)';
167
+ toast.style.boxSizing = 'border-box';
168
+ toast.style.transition = 'all 0.25s cubic-bezier(0.16, 1, 0.3, 1)';
169
+
170
+ const appTitle = this.options.appName || 'App';
171
+
172
+ toast.innerHTML = `
173
+ <div style="display: flex; align-items: center; gap: 8px; white-space: nowrap;">
174
+ <span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background-color: #10b981; box-shadow: 0 0 8px #10b981;"></span>
175
+ <span style="font-weight: 500;">${appTitle} updated</span>
176
+ </div>
177
+ <button id="pwaUpdateActionBtn" type="button" style="
178
+ background: #2563eb;
179
+ color: #ffffff;
180
+ border: none;
181
+ padding: 6px 14px;
182
+ border-radius: 9999px;
183
+ font-size: 13px;
184
+ font-weight: 600;
185
+ cursor: pointer;
186
+ display: inline-flex;
187
+ align-items: center;
188
+ justify-content: center;
189
+ min-height: 36px;
190
+ transition: background 0.2s;
191
+ white-space: nowrap;
192
+ ">Refresh</button>
193
+ <button id="pwaUpdateDismissBtn" type="button" style="
194
+ background: transparent;
195
+ color: #94a3b8;
196
+ border: none;
197
+ font-size: 16px;
198
+ cursor: pointer;
199
+ padding: 6px;
200
+ min-width: 36px;
201
+ min-height: 36px;
202
+ display: inline-flex;
203
+ align-items: center;
204
+ justify-content: center;
205
+ line-height: 1;
206
+ " aria-label="Dismiss">✕</button>
207
+ `;
208
+
209
+ document.body.appendChild(toast);
210
+
211
+ const updateBtn = document.getElementById('pwaUpdateActionBtn');
212
+ if (updateBtn) {
213
+ updateBtn.addEventListener('click', () => {
214
+ toast.remove();
215
+ this.applyUpdate(worker);
216
+ });
217
+ }
218
+
219
+ const dismissBtn = document.getElementById('pwaUpdateDismissBtn');
220
+ if (dismissBtn) {
221
+ dismissBtn.addEventListener('click', () => {
222
+ toast.remove();
223
+ });
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Sends skipWaiting postMessage to the waiting worker and triggers clean reload
229
+ */
230
+ static applyUpdate(worker = null) {
231
+ const targetWorker = worker || (this.registration ? this.registration.waiting : null);
232
+ if (targetWorker) {
233
+ targetWorker.postMessage({ action: 'skipWaiting' });
234
+ } else if (navigator.serviceWorker.controller) {
235
+ navigator.serviceWorker.controller.postMessage({ action: 'skipWaiting' });
236
+ }
237
+
238
+ navigator.serviceWorker.addEventListener('controllerchange', () => {
239
+ window.location.reload();
240
+ }, { once: true });
241
+ }
242
+
243
+ /**
244
+ * Purges sensitive API and user caches from the Service Worker upon user logout
245
+ */
246
+ static async clearUserCacheOnLogout() {
247
+ if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
248
+ navigator.serviceWorker.controller.postMessage({ action: 'clearUserCache' });
249
+ }
250
+
251
+ if (typeof caches !== 'undefined') {
252
+ try {
253
+ const keys = await caches.keys();
254
+ await Promise.all(
255
+ keys.filter(k => k.includes('api-cache') || k.includes('user-cache')).map(k => caches.delete(k))
256
+ );
257
+ console.log('[PwaManager] Client-side sensitive user caches purged.');
258
+ } catch (e) {
259
+ console.warn('[PwaManager] Failed to purge user caches:', e);
260
+ }
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Sets app badge counter on the phone's home screen icon (Web Badging API)
266
+ *
267
+ * @param {number|string} count
268
+ * @returns {Promise<void>}
269
+ */
270
+ static setAppBadge(count) {
271
+ const num = Math.max(0, parseInt(count, 10) || 0);
272
+ if (typeof window !== 'undefined' && window.dispatchEvent) {
273
+ window.dispatchEvent(new CustomEvent('pwa:badge-updated', { detail: { count: num } }));
274
+ }
275
+ if (typeof navigator !== 'undefined' && 'setAppBadge' in navigator) {
276
+ return (num > 0 ? navigator.setAppBadge(num) : navigator.clearAppBadge()).catch(() => {});
277
+ }
278
+ return Promise.resolve();
279
+ }
280
+
281
+ /**
282
+ * Clears app badge counter from the phone's home screen icon
283
+ *
284
+ * @returns {Promise<void>}
285
+ */
286
+ static clearAppBadge() {
287
+ if (typeof window !== 'undefined' && window.dispatchEvent) {
288
+ window.dispatchEvent(new CustomEvent('pwa:badge-updated', { detail: { count: 0 } }));
289
+ }
290
+ if (typeof navigator !== 'undefined' && 'clearAppBadge' in navigator) {
291
+ return navigator.clearAppBadge().catch(() => {});
292
+ }
293
+ return Promise.resolve();
294
+ }
295
+
296
+ /**
297
+ * Synchronizes app badge with the server's unread notifications endpoint
298
+ *
299
+ * @param {string|null} endpoint
300
+ * @returns {Promise<void>}
301
+ */
302
+ static async syncAppBadge(endpoint = null) {
303
+ const url = endpoint || this.options.badgeEndpoint || '/api/notifications/unread';
304
+
305
+ try {
306
+ const res = await fetch(url, { headers: { Accept: 'application/json' } });
307
+ if (!res.ok) {
308
+ // Release the stream before bailing. An abandoned response body stays
309
+ // "in flight" in Chromium indefinitely, which keeps the tab from ever
310
+ // reaching network idle — it silently wedges Playwright's
311
+ // `networkidle` waits and any load-state detection for the whole page.
312
+ await res.body?.cancel();
313
+ return;
314
+ }
315
+ const data = await res.json();
316
+ let count = 0;
317
+ if (data) {
318
+ if (typeof data.unread_count === 'number') {
319
+ count = data.unread_count;
320
+ } else if (typeof data.count === 'number') {
321
+ count = data.count;
322
+ } else if (typeof data.unreadCount === 'number') {
323
+ count = data.unreadCount;
324
+ } else if (data.status === 'success' && Array.isArray(data.message)) {
325
+ count = data.message.length;
326
+ } else if (Array.isArray(data.data)) {
327
+ count = data.data.length;
328
+ } else if (Array.isArray(data.notifications)) {
329
+ count = data.notifications.length;
330
+ }
331
+ }
332
+ const num = Math.max(0, parseInt(count, 10) || 0);
333
+ if (num > 0) {
334
+ await this.setAppBadge(num);
335
+ } else {
336
+ await this.clearAppBadge();
337
+ }
338
+ } catch (e) {
339
+ // Harmless no-op on network failure or offline mode
340
+ }
341
+ }
342
+ }