@modernman00/shared-js-lib 1.2.89 → 1.6.2

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,308 @@
1
+ /**
2
+ * @modernman00/shared-js-lib — WebPushClient
3
+ *
4
+ * Enterprise Web Push & Service Worker Lifecycle Engine (RFC-0009):
5
+ * - Bounded Watchdog Promises (8s SW ready, 20s permission prompt, 10s PushManager subscribe, 8s backend sync)
6
+ * - Automatic VAPID Key Rotation Recovery (catches InvalidStateError, unsubscribes stale endpoint, retries once)
7
+ * - Smart 3-Day (72-Hour) Dismissal Cooldown (re-prompts unsubscribed users after 72h instead of permanent suppression)
8
+ * - Cross-Tab BroadcastChannel Deduplication ('fp_notification_sync')
9
+ */
10
+
11
+ export const DEFAULT_DISMISS_COOLDOWN_MS = 3 * 24 * 60 * 60 * 1000; // 72 hours (3 days)
12
+ export const DEFAULT_DISMISS_KEY = 'fp_push_banner_dismissed';
13
+ export const DEFAULT_SUBSCRIBED_KEY = 'fp_push_subscribed';
14
+
15
+ /**
16
+ * Wraps any Promise or thenable with a deterministic watchdog timer so UI spinners never hang indefinitely.
17
+ *
18
+ * @param {Promise<any>} promise
19
+ * @param {number} ms
20
+ * @param {string} [timeoutMessage]
21
+ * @returns {Promise<any>}
22
+ */
23
+ export function withPushTimeout(promise, ms, timeoutMessage = 'Push operation timed out') {
24
+ return new Promise((resolve, reject) => {
25
+ const timer = setTimeout(() => {
26
+ reject(new Error(timeoutMessage));
27
+ }, ms);
28
+ Promise.resolve(promise).then(
29
+ (val) => {
30
+ clearTimeout(timer);
31
+ resolve(val);
32
+ },
33
+ (err) => {
34
+ clearTimeout(timer);
35
+ reject(err);
36
+ }
37
+ );
38
+ });
39
+ }
40
+
41
+ /**
42
+ * Converts a URL-safe base64 VAPID public key string into a Uint8Array for PushManager.subscribe().
43
+ *
44
+ * @param {string} base64String
45
+ * @returns {Uint8Array}
46
+ */
47
+ export function urlBase64ToUint8Array(base64String) {
48
+ const clean = String(base64String || '').trim();
49
+ const padding = '='.repeat((4 - (clean.length % 4)) % 4);
50
+ const base64 = (clean + padding).replace(/-/g, '+').replace(/_/g, '/');
51
+ const rawData = typeof window !== 'undefined' && typeof window.atob === 'function'
52
+ ? window.atob(base64)
53
+ : Buffer.from(base64, 'base64').toString('binary');
54
+ const outputArray = new Uint8Array(rawData.length);
55
+ for (let i = 0; i < rawData.length; ++i) {
56
+ outputArray[i] = rawData.charCodeAt(i);
57
+ }
58
+ return outputArray;
59
+ }
60
+
61
+ /**
62
+ * Checks whether the user dismissed the push prompt within the 3-day (72-hour) cooldown window.
63
+ * Automatically migrates legacy boolean `'true'` values to a timestamp and clears expired timestamps.
64
+ *
65
+ * @param {string} [storageKey=DEFAULT_DISMISS_KEY]
66
+ * @param {number} [cooldownMs=DEFAULT_DISMISS_COOLDOWN_MS]
67
+ * @param {Storage} [storage]
68
+ * @returns {boolean} True if dismissed within the cooldown window; false if eligible to prompt again.
69
+ */
70
+ export function isPushDismissedWithinCooldown(
71
+ storageKey = DEFAULT_DISMISS_KEY,
72
+ cooldownMs = DEFAULT_DISMISS_COOLDOWN_MS,
73
+ storage = typeof localStorage !== 'undefined' ? localStorage : null
74
+ ) {
75
+ if (!storage) return false;
76
+ try {
77
+ const raw = storage.getItem(storageKey);
78
+ if (!raw) return false;
79
+
80
+ // Legacy migration: if 'true' was stored before RFC-0009, convert to current timestamp
81
+ if (raw === 'true') {
82
+ storage.setItem(storageKey, String(Date.now()));
83
+ return true;
84
+ }
85
+
86
+ const dismissedAt = parseInt(raw, 10);
87
+ if (Number.isNaN(dismissedAt) || dismissedAt <= 0) {
88
+ storage.removeItem(storageKey);
89
+ return false;
90
+ }
91
+
92
+ if (Date.now() - dismissedAt < cooldownMs) {
93
+ return true;
94
+ }
95
+
96
+ // Cooldown expired (>= 3 days): clear key so user is re-prompted
97
+ storage.removeItem(storageKey);
98
+ return false;
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Records the current timestamp when a user clicks "Not Now" or closes the push prompt.
106
+ *
107
+ * @param {string} [storageKey=DEFAULT_DISMISS_KEY]
108
+ * @param {Storage} [storage]
109
+ */
110
+ export function recordPushDismissal(
111
+ storageKey = DEFAULT_DISMISS_KEY,
112
+ storage = typeof localStorage !== 'undefined' ? localStorage : null
113
+ ) {
114
+ if (!storage) return;
115
+ try {
116
+ storage.setItem(storageKey, String(Date.now()));
117
+ } catch {
118
+ // Ignore storage quota errors
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Clears any recorded push dismissal timestamp (e.g., once the user enables notifications).
124
+ *
125
+ * @param {string} [storageKey=DEFAULT_DISMISS_KEY]
126
+ * @param {Storage} [storage]
127
+ */
128
+ export function clearPushDismissal(
129
+ storageKey = DEFAULT_DISMISS_KEY,
130
+ storage = typeof localStorage !== 'undefined' ? localStorage : null
131
+ ) {
132
+ if (!storage) return;
133
+ try {
134
+ storage.removeItem(storageKey);
135
+ } catch {
136
+ // Ignore storage errors
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Ensures the Service Worker is registered and active within a bounded timeout (default 8s),
142
+ * preventing `navigator.serviceWorker.ready` from hanging forever when registration fails.
143
+ *
144
+ * @param {object} [options]
145
+ * @param {string} [options.swUrl='/sw.js']
146
+ * @param {string} [options.scope='/']
147
+ * @param {number} [options.timeoutMs=8000]
148
+ * @returns {Promise<ServiceWorkerRegistration>}
149
+ */
150
+ export async function ensureServiceWorkerReady(options = {}) {
151
+ const swUrl = options.swUrl || '/sw.js';
152
+ const scope = options.scope || '/';
153
+ const timeoutMs = options.timeoutMs || 8000;
154
+
155
+ if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
156
+ throw new Error('Service Workers are not supported in this browser.');
157
+ }
158
+
159
+ let reg = await navigator.serviceWorker.getRegistration(scope);
160
+ if (!reg) {
161
+ reg = await navigator.serviceWorker.register(swUrl, { scope });
162
+ }
163
+ if (reg && reg.active) {
164
+ return reg;
165
+ }
166
+
167
+ return withPushTimeout(
168
+ navigator.serviceWorker.ready,
169
+ timeoutMs,
170
+ 'Service Worker activation timed out. Please reload the page and try again.'
171
+ );
172
+ }
173
+
174
+ /**
175
+ * Subscribes the browser to Web Push via PushManager with automatic InvalidStateError
176
+ * VAPID key rotation recovery and bounded timeouts.
177
+ *
178
+ * @param {object} options
179
+ * @param {string} options.vapidPublicKey
180
+ * @param {(subscription: PushSubscription) => Promise<any>} [options.syncToServer]
181
+ * @param {string} [options.swUrl='/sw.js']
182
+ * @param {string} [options.scope='/']
183
+ * @param {number} [options.swTimeoutMs=8000]
184
+ * @param {number} [options.subscribeTimeoutMs=10000]
185
+ * @param {number} [options.syncTimeoutMs=8000]
186
+ * @returns {Promise<PushSubscription>}
187
+ */
188
+ export async function subscribeWithKeyRotation(options = {}) {
189
+ const {
190
+ vapidPublicKey,
191
+ syncToServer,
192
+ swUrl = '/sw.js',
193
+ scope = '/',
194
+ swTimeoutMs = 8000,
195
+ subscribeTimeoutMs = 10000,
196
+ syncTimeoutMs = 8000,
197
+ } = options;
198
+
199
+ if (typeof navigator === 'undefined' || !('serviceWorker' in navigator) || typeof window === 'undefined' || !('PushManager' in window)) {
200
+ throw new Error('Push notifications are not supported in this browser.');
201
+ }
202
+
203
+ if (!vapidPublicKey || String(vapidPublicKey).trim() === '') {
204
+ throw new Error('VAPID public key is not configured.');
205
+ }
206
+
207
+ const registration = await ensureServiceWorkerReady({ swUrl, scope, timeoutMs: swTimeoutMs });
208
+ let subscription = await registration.pushManager.getSubscription();
209
+
210
+ if (!subscription) {
211
+ const appServerKey = urlBase64ToUint8Array(vapidPublicKey);
212
+ const attemptSubscribe = async () => {
213
+ try {
214
+ return await registration.pushManager.subscribe({
215
+ userVisibleOnly: true,
216
+ applicationServerKey: appServerKey,
217
+ });
218
+ } catch (subErr) {
219
+ if (subErr && subErr.name === 'InvalidStateError') {
220
+ const staleSub = await registration.pushManager.getSubscription();
221
+ if (staleSub) {
222
+ await staleSub.unsubscribe();
223
+ }
224
+ return await registration.pushManager.subscribe({
225
+ userVisibleOnly: true,
226
+ applicationServerKey: appServerKey,
227
+ });
228
+ }
229
+ throw subErr;
230
+ }
231
+ };
232
+
233
+ subscription = await withPushTimeout(
234
+ attemptSubscribe(),
235
+ subscribeTimeoutMs,
236
+ 'Push service connection timed out. Check your internet connection or browser push settings.'
237
+ );
238
+ }
239
+
240
+ if (typeof syncToServer === 'function') {
241
+ await withPushTimeout(
242
+ syncToServer(subscription),
243
+ syncTimeoutMs,
244
+ 'Server subscription sync timed out.'
245
+ );
246
+ }
247
+
248
+ return subscription;
249
+ }
250
+
251
+ /**
252
+ * Cross-tab BroadcastChannel coordinator so multiple open tabs share read-state and badge updates
253
+ * without duplicate network polling.
254
+ *
255
+ * @param {(payload: any) => void} onMessage
256
+ * @param {string} [channelName='fp_notification_sync']
257
+ * @returns {{ broadcast: (data: any) => void, close: () => void }}
258
+ */
259
+ export function createNotificationSyncChannel(onMessage, channelName = 'fp_notification_sync') {
260
+ if (typeof window === 'undefined' || typeof window.BroadcastChannel !== 'function') {
261
+ return {
262
+ broadcast: () => {},
263
+ close: () => {},
264
+ };
265
+ }
266
+
267
+ const channel = new window.BroadcastChannel(channelName);
268
+ if (typeof onMessage === 'function') {
269
+ channel.onmessage = (event) => {
270
+ if (event && event.data) {
271
+ onMessage(event.data);
272
+ }
273
+ };
274
+ }
275
+
276
+ return {
277
+ broadcast: (data) => {
278
+ try {
279
+ channel.postMessage(data);
280
+ } catch {
281
+ // Ignore closed channel errors
282
+ }
283
+ },
284
+ close: () => {
285
+ try {
286
+ channel.close();
287
+ } catch {
288
+ // Ignore
289
+ }
290
+ },
291
+ };
292
+ }
293
+
294
+ export const WebPushClient = {
295
+ DEFAULT_DISMISS_COOLDOWN_MS,
296
+ DEFAULT_DISMISS_KEY,
297
+ DEFAULT_SUBSCRIBED_KEY,
298
+ withPushTimeout,
299
+ urlBase64ToUint8Array,
300
+ isPushDismissedWithinCooldown,
301
+ recordPushDismissal,
302
+ clearPushDismissal,
303
+ ensureServiceWorkerReady,
304
+ subscribeWithKeyRotation,
305
+ createNotificationSyncChannel,
306
+ };
307
+
308
+ export default WebPushClient;
package/axiosWrapper.js CHANGED
@@ -18,4 +18,5 @@ axios.interceptors.request.use(config => {
18
18
  return config;
19
19
  });
20
20
 
21
+ export const axiosWrapper = axios;
21
22
  export default axios;
@@ -1,3 +1,3 @@
1
1
  <?php
2
- define('APP_VERSION', 'v1.2.89');
2
+ define('APP_VERSION', 'v1.2.90');
3
3
 
package/index.js CHANGED
@@ -32,4 +32,16 @@ export * from './ShareManager.js';
32
32
  export * from './WebAuthnClient.js';
33
33
  export * from './OtpInputManager.js';
34
34
  export * from './AddressAutocomplete.js';
35
- export * from './DigitalSignaturePad.js';
35
+ export * from './DigitalSignaturePad.js';
36
+ export * from './AiVoiceRecorder.js';
37
+ export * from './AiMediaGuard.js';
38
+ export * from './AiSemanticSearch.js';
39
+ export * from './AiFlyerStudio.js';
40
+ export * from './AiBillSplitter.js';
41
+ export * from './AiEntertainmentPrompt.js';
42
+ export * from './AiPrivacyGuard.js';
43
+ export * from './WebPushClient.js';
44
+ export * from './FileUpload.js';
45
+ export * from './CanvasPhysicsEngine.js';
46
+ export * from './SovereignBroadcasterClient.js';
47
+ export * from './HyperReality3DEngine.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modernman00/shared-js-lib",
3
- "version": "1.2.89",
3
+ "version": "1.6.2",
4
4
  "description": "Reusable JS utilities for numerous js problems",
5
5
  "homepage": "https://github.com/modernman00/shared-js-lib#readme",
6
6
  "keywords": [
@@ -48,6 +48,7 @@
48
48
  "InlineValidation.js",
49
49
  "Clipboard.js",
50
50
  "AuthInterceptor.js",
51
+ "WebAuthnClient.js",
51
52
  "ShowResponse.js",
52
53
  "Utility.js",
53
54
  "UtilityHtml.js",
@@ -65,6 +66,18 @@
65
66
  "OtpInputManager.js",
66
67
  "AddressAutocomplete.js",
67
68
  "DigitalSignaturePad.js",
69
+ "AiVoiceRecorder.js",
70
+ "AiMediaGuard.js",
71
+ "AiSemanticSearch.js",
72
+ "AiFlyerStudio.js",
73
+ "AiBillSplitter.js",
74
+ "AiEntertainmentPrompt.js",
75
+ "AiPrivacyGuard.js",
76
+ "WebPushClient.js",
77
+ "CanvasPhysicsEngine.js",
78
+ "HyperReality3DEngine.js",
79
+ "SovereignBroadcasterClient.js",
80
+ "VisualSupremacyRuntime.js",
68
81
  "templates/"
69
82
  ],
70
83
  "devDependencies": {
@@ -172,3 +172,9 @@
172
172
  - 🧹 Auto-commit before release _(by modernman00)_
173
173
  - 🔖 Release v1.2.88 _(by modernman00)_
174
174
  - 🧹 Auto-commit before release _(by modernman00)_
175
+
176
+ ## v1.2.90 — 2026-10-23 17:34
177
+ ### Changelog for v1.2.90
178
+
179
+ - fix(package): ship WebAuthnClient.js in the published files list _(by modernman00)_
180
+ - ci: drop npm publish step from release workflow _(by modernman00)_
@@ -138,7 +138,7 @@ if (typeof workbox !== 'undefined') {
138
138
  ]);
139
139
  }
140
140
 
141
- // Push Notifications & Badging Engine
141
+ // Push Notifications & Badging Engine — Meta/Facebook Tier-1 Standard
142
142
  self.addEventListener('push', (event) => {
143
143
  let data = {
144
144
  title: APP_NAME,
@@ -157,83 +157,131 @@ self.addEventListener('push', (event) => {
157
157
  }
158
158
  }
159
159
 
160
- const promises = [];
161
-
162
- // 1. Show System Notification Banner
163
- promises.push(
164
- self.registration.showNotification(data.title, {
165
- body: data.body,
166
- icon: data.icon,
167
- badge: data.badge,
168
- data: { url: data.url, id: data.targetNotificationId },
169
- tag: data.tag
170
- })
171
- );
172
-
173
- // 2. Web Badging API Sync (Phone App Icon Red Counter)
174
- if ('setAppBadge' in navigator) {
175
- let count = null;
176
- if (typeof data.badgeCount === 'number') {
177
- count = Math.max(0, Math.floor(data.badgeCount));
178
- } else if (data.badgeCount !== null && data.badgeCount !== undefined && !isNaN(parseInt(data.badgeCount, 10))) {
179
- count = Math.max(0, parseInt(data.badgeCount, 10));
160
+ event.waitUntil((async () => {
161
+ // 1. Web Badging API Sync (App Icon Counter on Home Screen / Dock)
162
+ let badgeCount = null;
163
+ if (typeof data.unread_count === 'number' || typeof data.unreadCount === 'number' || typeof data.badgeCount === 'number') {
164
+ badgeCount = Math.max(0, Math.floor(data.unread_count ?? data.unreadCount ?? data.badgeCount));
165
+ } else if (data.unread_count || data.unreadCount || data.badgeCount) {
166
+ const parsed = parseInt(data.unread_count || data.unreadCount || data.badgeCount, 10);
167
+ if (!isNaN(parsed)) badgeCount = Math.max(0, parsed);
180
168
  }
181
169
 
182
- if (count !== null) {
183
- promises.push(
184
- count > 0
185
- ? navigator.setAppBadge(count).catch(() => {})
186
- : navigator.clearAppBadge().catch(() => {})
187
- );
188
- } else {
189
- // Fallback: When an active push notification arrives, ensure at least 1 badge is set on the app icon
190
- promises.push(navigator.setAppBadge(1).catch(() => {}));
170
+ if ('setAppBadge' in navigator) {
171
+ try {
172
+ if (badgeCount !== null) {
173
+ if (badgeCount > 0) {
174
+ await navigator.setAppBadge(badgeCount);
175
+ } else {
176
+ await navigator.clearAppBadge();
177
+ }
178
+ } else {
179
+ await navigator.setAppBadge(1);
180
+ }
181
+ } catch (err) {}
191
182
  }
192
- }
193
183
 
194
- // 3. Notify any active client windows so in-app bell counter updates immediately
195
- promises.push(
196
- clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
184
+ // 2. Client-Focus Awareness (Meta Standard: Suppress system banner when app is actively focused)
185
+ const clientList = await clients.matchAll({ type: 'window', includeUncontrolled: true });
186
+ const activeFocusedClient = clientList.find(c => c.focused && 'postMessage' in c);
187
+
188
+ if (activeFocusedClient) {
189
+ // User is actively looking at the screen: broadcast to DOM toast, update tab badge, and SUPPRESS OS banner
197
190
  clientList.forEach((client) => {
198
191
  client.postMessage({
199
192
  type: 'ON_APP_NOTIFICATION',
200
- payload: data
193
+ payload: { ...data, badgeCount: badgeCount }
201
194
  });
202
195
  });
203
- }).catch(() => {})
204
- );
196
+ return; // Suppress OS lock-screen popup to eliminate double-alert noise
197
+ }
198
+
199
+ // Broadcast to any backgrounded open windows so their in-DOM tabs update
200
+ clientList.forEach((client) => {
201
+ client.postMessage({
202
+ type: 'ON_APP_NOTIFICATION',
203
+ payload: { ...data, badgeCount: badgeCount }
204
+ });
205
+ });
206
+
207
+ // 3. User is backgrounded or app is closed: Display Rich System Notification Banner
208
+ const options = {
209
+ body: data.body,
210
+ icon: data.icon || '/public/resources/assets/images/pwa/icons/icon-192x192.png',
211
+ badge: data.badge || '/public/resources/assets/images/pwa/icons/icon-72x72.png',
212
+ tag: data.tag || `${APP_NAME}-notification`,
213
+ vibrate: data.vibrate || [150, 80, 150],
214
+ renotify: data.renotify !== false,
215
+ data: {
216
+ url: data.url || '/',
217
+ id: data.targetNotificationId || null,
218
+ unreadCount: badgeCount
219
+ }
220
+ };
205
221
 
206
- event.waitUntil(Promise.all(promises));
222
+ if (data.image) {
223
+ options.image = data.image;
224
+ }
225
+ if (Array.isArray(data.actions) && data.actions.length > 0) {
226
+ options.actions = data.actions.slice(0, 2);
227
+ }
228
+
229
+ return self.registration.showNotification(data.title, options);
230
+ })());
207
231
  });
208
232
 
209
- // Notification Click & Deep-Linking
233
+ // Notification Click & Deep-Linking — Meta Standard
210
234
  self.addEventListener('notificationclick', (event) => {
211
235
  event.notification.close();
212
- const targetUrl = event.notification.data?.url || '/';
236
+ const notifData = event.notification.data || {};
237
+ let targetUrl = notifData.url || '/';
213
238
 
214
- const promises = [];
239
+ // Sanitize targetUrl: must be relative path or same origin (Red Team anti-open-redirect)
240
+ try {
241
+ const parsed = new URL(targetUrl, self.location.origin);
242
+ if (parsed.origin !== self.location.origin) {
243
+ targetUrl = '/';
244
+ } else {
245
+ targetUrl = parsed.pathname + parsed.search + parsed.hash;
246
+ }
247
+ } catch (e) {
248
+ targetUrl = '/';
249
+ }
215
250
 
216
- if ('clearAppBadge' in navigator) {
217
- promises.push(navigator.clearAppBadge().catch(() => {}));
251
+ // Handle action button clicks (e.g. "mark_read" or "dismiss")
252
+ if (event.action === 'mark_read' || event.action === 'read') {
253
+ if (notifData.id) {
254
+ event.waitUntil(
255
+ fetch('/api/notifications/read', {
256
+ method: 'PUT',
257
+ headers: { 'Content-Type': 'application/json' },
258
+ body: JSON.stringify({ id: notifData.id })
259
+ }).catch(() => {})
260
+ );
261
+ return;
262
+ }
218
263
  }
219
264
 
220
- promises.push(
221
- clients.matchAll({ type: 'window', includeUncontrolled: true }).then((list) => {
222
- for (const client of list) {
223
- if (client.url.includes(targetUrl) && 'focus' in client) {
224
- if ('navigate' in client) client.navigate(targetUrl);
225
- return client.focus();
226
- }
265
+ event.waitUntil((async () => {
266
+ const list = await clients.matchAll({ type: 'window', includeUncontrolled: true });
267
+ for (const client of list) {
268
+ if (client.url.includes(targetUrl) && 'focus' in client) {
269
+ if ('navigate' in client) await client.navigate(targetUrl);
270
+ return client.focus();
227
271
  }
228
- if (clients.openWindow) return clients.openWindow(targetUrl);
229
- })
230
- );
231
-
232
- event.waitUntil(Promise.all(promises));
272
+ }
273
+ if (clients.openWindow) {
274
+ return clients.openWindow(targetUrl);
275
+ }
276
+ })());
233
277
  });
234
278
 
235
279
  // Cross-Tab Client Messages
236
280
  self.addEventListener('message', (event) => {
237
- if (event.data?.action === 'skipWaiting') self.skipWaiting();
238
- if (event.data?.action === 'clearUserCache') event.waitUntil(caches.delete(API_CACHE));
281
+ if (event.data?.action === 'skipWaiting' || event.data?.type === 'SKIP_WAITING') {
282
+ self.skipWaiting();
283
+ }
284
+ if (event.data?.action === 'clearUserCache') {
285
+ event.waitUntil(caches.delete(API_CACHE));
286
+ }
239
287
  });