@markwharton/pwa-push 1.5.2 → 2.0.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.
Files changed (40) hide show
  1. package/dist/client.d.ts +181 -0
  2. package/dist/client.js +412 -0
  3. package/dist/index.d.ts +9 -0
  4. package/dist/index.js +9 -0
  5. package/dist/pwa-push-sw.js +61 -39
  6. package/dist/{server/send.d.ts → server.d.ts} +6 -2
  7. package/dist/{server/send.js → server.js} +9 -7
  8. package/dist/shared.d.ts +22 -2
  9. package/dist/shared.js +9 -6
  10. package/dist/sw.d.ts +1 -1
  11. package/dist/sw.js +2 -2
  12. package/package.json +11 -8
  13. package/dist/__tests__/client/deviceId.test.d.ts +0 -1
  14. package/dist/__tests__/client/deviceId.test.js +0 -134
  15. package/dist/__tests__/client/encoding.test.d.ts +0 -1
  16. package/dist/__tests__/client/encoding.test.js +0 -89
  17. package/dist/__tests__/client/indexedDb.test.d.ts +0 -1
  18. package/dist/__tests__/client/indexedDb.test.js +0 -195
  19. package/dist/__tests__/client/renewal.test.d.ts +0 -1
  20. package/dist/__tests__/client/renewal.test.js +0 -170
  21. package/dist/__tests__/client/subscribe.test.d.ts +0 -1
  22. package/dist/__tests__/client/subscribe.test.js +0 -299
  23. package/dist/__tests__/server/send.test.d.ts +0 -1
  24. package/dist/__tests__/server/send.test.js +0 -226
  25. package/dist/client/deviceId.d.ts +0 -23
  26. package/dist/client/deviceId.js +0 -49
  27. package/dist/client/encoding.d.ts +0 -17
  28. package/dist/client/encoding.js +0 -32
  29. package/dist/client/index.d.ts +0 -4
  30. package/dist/client/index.js +0 -20
  31. package/dist/client/indexedDb.d.ts +0 -38
  32. package/dist/client/indexedDb.js +0 -89
  33. package/dist/client/renewal.d.ts +0 -31
  34. package/dist/client/renewal.js +0 -80
  35. package/dist/client/subscribe.d.ts +0 -88
  36. package/dist/client/subscribe.js +0 -176
  37. package/dist/server/index.d.ts +0 -1
  38. package/dist/server/index.js +0 -11
  39. package/dist/types.d.ts +0 -39
  40. package/dist/types.js +0 -11
@@ -0,0 +1,181 @@
1
+ /**
2
+ * pwa-push/client - Browser-side push notification utilities
3
+ *
4
+ * Includes: subscription management, device ID, IndexedDB storage, service worker renewal
5
+ */
6
+ import { Result } from '@markwharton/pwa-core/shared';
7
+ import { PushSubscription, SubscriptionRequest, SubscriptionData, PushPermissionState } from './shared';
8
+ /**
9
+ * Converts a base64 URL-encoded string to a Uint8Array.
10
+ * Used to convert VAPID public keys for the PushManager API.
11
+ * Works in both main thread and service worker contexts.
12
+ * @param base64String - The base64 URL-encoded string to convert
13
+ * @returns A Uint8Array containing the decoded bytes
14
+ * @example
15
+ * const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey);
16
+ * await registration.pushManager.subscribe({
17
+ * userVisibleOnly: true,
18
+ * applicationServerKey
19
+ * });
20
+ */
21
+ export declare function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer>;
22
+ /**
23
+ * Gets or creates a persistent device ID for subscription deduplication.
24
+ * The ID is stored in localStorage and persists across sessions.
25
+ * Uses localStorage (main thread only - not available in service workers).
26
+ * @returns The device ID string (UUID v4 format)
27
+ * @example
28
+ * const deviceId = getDeviceId();
29
+ * // Use deviceId in subscription requests to identify this device
30
+ */
31
+ export declare function getDeviceId(): string;
32
+ /**
33
+ * Clears the device ID from localStorage.
34
+ * Use for testing or when user logs out and wants a fresh device identity.
35
+ * @example
36
+ * // On user logout
37
+ * await unsubscribeFromPush();
38
+ * clearDeviceId();
39
+ */
40
+ export declare function clearDeviceId(): void;
41
+ /**
42
+ * Saves subscription data to IndexedDB for service worker access.
43
+ * This data is used for subscription renewal when the browser rotates keys.
44
+ * @param data - The subscription data to persist (VAPID key, deviceId, basePath)
45
+ * @returns Promise that resolves when data is saved
46
+ * @example
47
+ * await saveSubscriptionData({
48
+ * publicKey: vapidPublicKey,
49
+ * deviceId: getDeviceId(),
50
+ * basePath: '/app'
51
+ * });
52
+ */
53
+ export declare function saveSubscriptionData(data: SubscriptionData): Promise<void>;
54
+ /**
55
+ * Retrieves subscription data from IndexedDB.
56
+ * Used by service workers during subscription renewal.
57
+ * @returns The stored subscription data, or null if not found
58
+ * @example
59
+ * const data = await getSubscriptionData();
60
+ * if (data) {
61
+ * console.log('Device ID:', data.deviceId);
62
+ * }
63
+ */
64
+ export declare function getSubscriptionData(): Promise<SubscriptionData | null>;
65
+ /**
66
+ * Clears subscription data from IndexedDB.
67
+ * Call when unsubscribing or during cleanup.
68
+ * @returns Promise that resolves when data is cleared
69
+ * @example
70
+ * await unsubscribeFromPush();
71
+ * await clearSubscriptionData();
72
+ */
73
+ export declare function clearSubscriptionData(): Promise<void>;
74
+ /**
75
+ * Gets the current notification permission state.
76
+ * @returns The current permission state ('granted', 'denied', or 'default')
77
+ * @example
78
+ * const state = getPermissionState();
79
+ * if (state === 'granted') {
80
+ * // Can show notifications
81
+ * }
82
+ */
83
+ export declare function getPermissionState(): PushPermissionState;
84
+ /**
85
+ * Requests notification permission from the user.
86
+ * Shows browser permission dialog if not already granted/denied.
87
+ * @returns The resulting permission state
88
+ * @example
89
+ * const permission = await requestPermission();
90
+ * if (permission === 'granted') {
91
+ * await subscribeToPush(vapidKey);
92
+ * }
93
+ */
94
+ export declare function requestPermission(): Promise<PushPermissionState>;
95
+ /**
96
+ * Checks if push notifications are supported in the current browser.
97
+ * Requires both Service Worker and Push API support.
98
+ * @returns True if push notifications are supported
99
+ * @example
100
+ * if (!isPushSupported()) {
101
+ * console.log('Push not supported on this browser');
102
+ * return;
103
+ * }
104
+ */
105
+ export declare function isPushSupported(): boolean;
106
+ /**
107
+ * Converts a browser PushSubscription to the simplified format for backend storage.
108
+ * Works in both main thread and service worker contexts.
109
+ * @param browserSub - The native browser PushSubscription object
110
+ * @returns A simplified PushSubscription with endpoint and keys
111
+ * @example
112
+ * const browserSub = await registration.pushManager.subscribe({ ... });
113
+ * const subscription = toPushSubscription(browserSub);
114
+ * await fetch('/api/subscribe', { body: JSON.stringify(subscription) });
115
+ */
116
+ export declare function toPushSubscription(browserSub: globalThis.PushSubscription): PushSubscription;
117
+ /**
118
+ * Subscribes to push notifications and prepares a request for backend registration.
119
+ * Handles permission request, service worker subscription, and data persistence.
120
+ * @param vapidPublicKey - The VAPID public key from your server
121
+ * @param basePath - Optional base path for notification click handling (default: '/')
122
+ * @returns Result with subscription request on success, or error message on failure
123
+ * @example
124
+ * const result = await subscribeToPush(vapidPublicKey);
125
+ * if (result.ok) {
126
+ * await fetch('/api/push/subscribe', {
127
+ * method: 'POST',
128
+ * body: JSON.stringify(result.data)
129
+ * });
130
+ * }
131
+ */
132
+ export declare function subscribeToPush(vapidPublicKey: string, basePath?: string): Promise<Result<SubscriptionRequest>>;
133
+ /**
134
+ * Unsubscribes from push notifications.
135
+ * Removes the browser push subscription and clears stored subscription data.
136
+ * @returns Result with ok=true on success, or error message on failure
137
+ * @example
138
+ * const result = await unsubscribeFromPush();
139
+ * if (result.ok) {
140
+ * await fetch('/api/push/unsubscribe', { method: 'POST' });
141
+ * }
142
+ */
143
+ export declare function unsubscribeFromPush(): Promise<Result<void>>;
144
+ /**
145
+ * Gets the current push subscription if one exists.
146
+ * Useful for checking subscription status or syncing with backend.
147
+ * @returns Result with the current subscription, or error if not subscribed
148
+ * @example
149
+ * const result = await getCurrentSubscription();
150
+ * if (result.ok) {
151
+ * console.log('Endpoint:', result.data.endpoint);
152
+ * } else {
153
+ * console.log('Not subscribed:', result.error);
154
+ * }
155
+ */
156
+ export declare function getCurrentSubscription(): Promise<Result<PushSubscription>>;
157
+ /**
158
+ * Type definition for the pushsubscriptionchange event.
159
+ * Fired when the browser rotates push subscriptions.
160
+ */
161
+ interface PushSubscriptionChangeEvent extends ExtendableEvent {
162
+ /** The old subscription that is being replaced (may be null) */
163
+ oldSubscription?: PushSubscription;
164
+ /** The new subscription (may be null if unsubscribed) */
165
+ newSubscription?: PushSubscription;
166
+ }
167
+ /**
168
+ * Handles the pushsubscriptionchange event in a service worker.
169
+ * Call this from your service worker's event listener when the browser
170
+ * rotates push subscriptions (typically for security reasons).
171
+ * @param event - The pushsubscriptionchange event from the service worker
172
+ * @param renewEndpoint - The backend endpoint to send the renewal request to
173
+ * @returns Result with ok=true on success, or error message on failure
174
+ * @example
175
+ * // In your service worker (sw.js):
176
+ * self.addEventListener('pushsubscriptionchange', (event) => {
177
+ * event.waitUntil(handleSubscriptionChange(event, '/api/push/renew'));
178
+ * });
179
+ */
180
+ export declare function handleSubscriptionChange(event: PushSubscriptionChangeEvent, renewEndpoint: string): Promise<Result<void>>;
181
+ export type { PushSubscription, SubscriptionRequest, SubscriptionData, PushPermissionState } from './shared';
package/dist/client.js ADDED
@@ -0,0 +1,412 @@
1
+ "use strict";
2
+ /**
3
+ * pwa-push/client - Browser-side push notification utilities
4
+ *
5
+ * Includes: subscription management, device ID, IndexedDB storage, service worker renewal
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.urlBase64ToUint8Array = urlBase64ToUint8Array;
9
+ exports.getDeviceId = getDeviceId;
10
+ exports.clearDeviceId = clearDeviceId;
11
+ exports.saveSubscriptionData = saveSubscriptionData;
12
+ exports.getSubscriptionData = getSubscriptionData;
13
+ exports.clearSubscriptionData = clearSubscriptionData;
14
+ exports.getPermissionState = getPermissionState;
15
+ exports.requestPermission = requestPermission;
16
+ exports.isPushSupported = isPushSupported;
17
+ exports.toPushSubscription = toPushSubscription;
18
+ exports.subscribeToPush = subscribeToPush;
19
+ exports.unsubscribeFromPush = unsubscribeFromPush;
20
+ exports.getCurrentSubscription = getCurrentSubscription;
21
+ exports.handleSubscriptionChange = handleSubscriptionChange;
22
+ const shared_1 = require("@markwharton/pwa-core/shared");
23
+ // =============================================================================
24
+ // Base64 Encoding
25
+ // =============================================================================
26
+ /**
27
+ * Converts a base64 URL-encoded string to a Uint8Array.
28
+ * Used to convert VAPID public keys for the PushManager API.
29
+ * Works in both main thread and service worker contexts.
30
+ * @param base64String - The base64 URL-encoded string to convert
31
+ * @returns A Uint8Array containing the decoded bytes
32
+ * @example
33
+ * const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey);
34
+ * await registration.pushManager.subscribe({
35
+ * userVisibleOnly: true,
36
+ * applicationServerKey
37
+ * });
38
+ */
39
+ function urlBase64ToUint8Array(base64String) {
40
+ const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
41
+ const base64 = (base64String + padding)
42
+ .replace(/-/g, '+')
43
+ .replace(/_/g, '/');
44
+ const rawData = atob(base64);
45
+ const buffer = new ArrayBuffer(rawData.length);
46
+ const outputArray = new Uint8Array(buffer);
47
+ for (let i = 0; i < rawData.length; ++i) {
48
+ outputArray[i] = rawData.charCodeAt(i);
49
+ }
50
+ return outputArray;
51
+ }
52
+ // =============================================================================
53
+ // Device ID Management
54
+ // =============================================================================
55
+ const DEVICE_ID_KEY = 'push_device_id';
56
+ /**
57
+ * Generates a cryptographically secure UUID v4.
58
+ * Uses Web Crypto API for secure random number generation.
59
+ * @returns A UUID v4 string (e.g., '550e8400-e29b-41d4-a716-446655440000')
60
+ */
61
+ function generateUUID() {
62
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
63
+ bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
64
+ bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
65
+ const hex = [...bytes].map(b => b.toString(16).padStart(2, '0')).join('');
66
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
67
+ }
68
+ /**
69
+ * Gets or creates a persistent device ID for subscription deduplication.
70
+ * The ID is stored in localStorage and persists across sessions.
71
+ * Uses localStorage (main thread only - not available in service workers).
72
+ * @returns The device ID string (UUID v4 format)
73
+ * @example
74
+ * const deviceId = getDeviceId();
75
+ * // Use deviceId in subscription requests to identify this device
76
+ */
77
+ function getDeviceId() {
78
+ let deviceId = localStorage.getItem(DEVICE_ID_KEY);
79
+ if (!deviceId) {
80
+ deviceId = generateUUID();
81
+ localStorage.setItem(DEVICE_ID_KEY, deviceId);
82
+ }
83
+ return deviceId;
84
+ }
85
+ /**
86
+ * Clears the device ID from localStorage.
87
+ * Use for testing or when user logs out and wants a fresh device identity.
88
+ * @example
89
+ * // On user logout
90
+ * await unsubscribeFromPush();
91
+ * clearDeviceId();
92
+ */
93
+ function clearDeviceId() {
94
+ localStorage.removeItem(DEVICE_ID_KEY);
95
+ }
96
+ // =============================================================================
97
+ // IndexedDB Storage
98
+ // =============================================================================
99
+ const DB_NAME = 'PushSubscriptionDB';
100
+ const STORE_NAME = 'subscriptionData';
101
+ const DATA_KEY = 'current';
102
+ /**
103
+ * Opens the IndexedDB database, creating the object store if needed.
104
+ * Internal helper for subscription data persistence.
105
+ * @returns Promise resolving to the opened database
106
+ */
107
+ function openDatabase() {
108
+ return new Promise((resolve, reject) => {
109
+ const request = indexedDB.open(DB_NAME, 1);
110
+ request.onerror = () => reject(request.error);
111
+ request.onsuccess = () => resolve(request.result);
112
+ request.onupgradeneeded = (event) => {
113
+ const db = event.target.result;
114
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
115
+ db.createObjectStore(STORE_NAME);
116
+ }
117
+ };
118
+ });
119
+ }
120
+ /**
121
+ * Executes an IndexedDB operation within a transaction.
122
+ * Handles database opening, transaction management, and cleanup.
123
+ * @typeParam T - The expected result type from the operation
124
+ * @param mode - Transaction mode ('readonly' or 'readwrite')
125
+ * @param operation - Function that performs the IndexedDB operation
126
+ * @returns Promise resolving to the operation result
127
+ */
128
+ async function withTransaction(mode, operation) {
129
+ const db = await openDatabase();
130
+ return new Promise((resolve, reject) => {
131
+ const transaction = db.transaction(STORE_NAME, mode);
132
+ const store = transaction.objectStore(STORE_NAME);
133
+ const request = operation(store);
134
+ request.onerror = () => reject(request.error);
135
+ request.onsuccess = () => resolve(request.result);
136
+ transaction.oncomplete = () => db.close();
137
+ });
138
+ }
139
+ /**
140
+ * Saves subscription data to IndexedDB for service worker access.
141
+ * This data is used for subscription renewal when the browser rotates keys.
142
+ * @param data - The subscription data to persist (VAPID key, deviceId, basePath)
143
+ * @returns Promise that resolves when data is saved
144
+ * @example
145
+ * await saveSubscriptionData({
146
+ * publicKey: vapidPublicKey,
147
+ * deviceId: getDeviceId(),
148
+ * basePath: '/app'
149
+ * });
150
+ */
151
+ function saveSubscriptionData(data) {
152
+ return withTransaction('readwrite', (store) => store.put(data, DATA_KEY));
153
+ }
154
+ /**
155
+ * Retrieves subscription data from IndexedDB.
156
+ * Used by service workers during subscription renewal.
157
+ * @returns The stored subscription data, or null if not found
158
+ * @example
159
+ * const data = await getSubscriptionData();
160
+ * if (data) {
161
+ * console.log('Device ID:', data.deviceId);
162
+ * }
163
+ */
164
+ async function getSubscriptionData() {
165
+ const result = await withTransaction('readonly', (store) => store.get(DATA_KEY));
166
+ return result || null;
167
+ }
168
+ /**
169
+ * Clears subscription data from IndexedDB.
170
+ * Call when unsubscribing or during cleanup.
171
+ * @returns Promise that resolves when data is cleared
172
+ * @example
173
+ * await unsubscribeFromPush();
174
+ * await clearSubscriptionData();
175
+ */
176
+ function clearSubscriptionData() {
177
+ return withTransaction('readwrite', (store) => store.delete(DATA_KEY));
178
+ }
179
+ // =============================================================================
180
+ // Push Support Detection
181
+ // =============================================================================
182
+ /**
183
+ * Gets the current notification permission state.
184
+ * @returns The current permission state ('granted', 'denied', or 'default')
185
+ * @example
186
+ * const state = getPermissionState();
187
+ * if (state === 'granted') {
188
+ * // Can show notifications
189
+ * }
190
+ */
191
+ function getPermissionState() {
192
+ return Notification.permission;
193
+ }
194
+ /**
195
+ * Requests notification permission from the user.
196
+ * Shows browser permission dialog if not already granted/denied.
197
+ * @returns The resulting permission state
198
+ * @example
199
+ * const permission = await requestPermission();
200
+ * if (permission === 'granted') {
201
+ * await subscribeToPush(vapidKey);
202
+ * }
203
+ */
204
+ async function requestPermission() {
205
+ const result = await Notification.requestPermission();
206
+ return result;
207
+ }
208
+ /**
209
+ * Checks if push notifications are supported in the current browser.
210
+ * Requires both Service Worker and Push API support.
211
+ * @returns True if push notifications are supported
212
+ * @example
213
+ * if (!isPushSupported()) {
214
+ * console.log('Push not supported on this browser');
215
+ * return;
216
+ * }
217
+ */
218
+ function isPushSupported() {
219
+ return 'serviceWorker' in navigator && 'PushManager' in window;
220
+ }
221
+ // =============================================================================
222
+ // Subscription Conversion
223
+ // =============================================================================
224
+ /**
225
+ * Converts a browser PushSubscription to the simplified format for backend storage.
226
+ * Works in both main thread and service worker contexts.
227
+ * @param browserSub - The native browser PushSubscription object
228
+ * @returns A simplified PushSubscription with endpoint and keys
229
+ * @example
230
+ * const browserSub = await registration.pushManager.subscribe({ ... });
231
+ * const subscription = toPushSubscription(browserSub);
232
+ * await fetch('/api/subscribe', { body: JSON.stringify(subscription) });
233
+ */
234
+ function toPushSubscription(browserSub) {
235
+ const json = browserSub.toJSON();
236
+ return {
237
+ endpoint: browserSub.endpoint,
238
+ keys: {
239
+ p256dh: json.keys?.p256dh || '',
240
+ auth: json.keys?.auth || ''
241
+ }
242
+ };
243
+ }
244
+ // =============================================================================
245
+ // Subscription Lifecycle
246
+ // =============================================================================
247
+ /**
248
+ * Subscribes to push notifications and prepares a request for backend registration.
249
+ * Handles permission request, service worker subscription, and data persistence.
250
+ * @param vapidPublicKey - The VAPID public key from your server
251
+ * @param basePath - Optional base path for notification click handling (default: '/')
252
+ * @returns Result with subscription request on success, or error message on failure
253
+ * @example
254
+ * const result = await subscribeToPush(vapidPublicKey);
255
+ * if (result.ok) {
256
+ * await fetch('/api/push/subscribe', {
257
+ * method: 'POST',
258
+ * body: JSON.stringify(result.data)
259
+ * });
260
+ * }
261
+ */
262
+ async function subscribeToPush(vapidPublicKey, basePath = '/') {
263
+ if (!isPushSupported()) {
264
+ return (0, shared_1.err)('Push notifications not supported');
265
+ }
266
+ const permission = await requestPermission();
267
+ if (permission !== 'granted') {
268
+ return (0, shared_1.err)('Push permission denied');
269
+ }
270
+ try {
271
+ const registration = await navigator.serviceWorker.ready;
272
+ const deviceId = getDeviceId();
273
+ // Convert VAPID key to Uint8Array
274
+ const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey);
275
+ const browserSub = await registration.pushManager.subscribe({
276
+ userVisibleOnly: true,
277
+ applicationServerKey
278
+ });
279
+ // Save data for service worker renewal
280
+ await saveSubscriptionData({
281
+ publicKey: vapidPublicKey,
282
+ deviceId,
283
+ basePath
284
+ });
285
+ return (0, shared_1.ok)({
286
+ subscription: toPushSubscription(browserSub),
287
+ deviceId,
288
+ basePath
289
+ });
290
+ }
291
+ catch (error) {
292
+ return (0, shared_1.err)((0, shared_1.getErrorMessage)(error, 'Subscription failed'));
293
+ }
294
+ }
295
+ /**
296
+ * Unsubscribes from push notifications.
297
+ * Removes the browser push subscription and clears stored subscription data.
298
+ * @returns Result with ok=true on success, or error message on failure
299
+ * @example
300
+ * const result = await unsubscribeFromPush();
301
+ * if (result.ok) {
302
+ * await fetch('/api/push/unsubscribe', { method: 'POST' });
303
+ * }
304
+ */
305
+ async function unsubscribeFromPush() {
306
+ try {
307
+ const registration = await navigator.serviceWorker.ready;
308
+ const subscription = await registration.pushManager.getSubscription();
309
+ if (subscription) {
310
+ await subscription.unsubscribe();
311
+ }
312
+ // Clear stored subscription data from IndexedDB
313
+ await clearSubscriptionData();
314
+ return (0, shared_1.okVoid)();
315
+ }
316
+ catch (error) {
317
+ return (0, shared_1.err)((0, shared_1.getErrorMessage)(error, 'Unsubscribe failed'));
318
+ }
319
+ }
320
+ /**
321
+ * Gets the current push subscription if one exists.
322
+ * Useful for checking subscription status or syncing with backend.
323
+ * @returns Result with the current subscription, or error if not subscribed
324
+ * @example
325
+ * const result = await getCurrentSubscription();
326
+ * if (result.ok) {
327
+ * console.log('Endpoint:', result.data.endpoint);
328
+ * } else {
329
+ * console.log('Not subscribed:', result.error);
330
+ * }
331
+ */
332
+ async function getCurrentSubscription() {
333
+ try {
334
+ const registration = await navigator.serviceWorker.ready;
335
+ const subscription = await registration.pushManager.getSubscription();
336
+ if (!subscription) {
337
+ return (0, shared_1.err)('No active subscription');
338
+ }
339
+ return (0, shared_1.ok)(toPushSubscription(subscription));
340
+ }
341
+ catch (error) {
342
+ return (0, shared_1.err)((0, shared_1.getErrorMessage)(error, 'Failed to get subscription'));
343
+ }
344
+ }
345
+ /**
346
+ * Resubscribes to push notifications using stored VAPID key.
347
+ * Internal helper for subscription renewal in service worker context.
348
+ * @param data - The stored subscription data containing VAPID key
349
+ * @returns The new PushSubscription, or null on failure
350
+ */
351
+ async function resubscribe(data) {
352
+ try {
353
+ const self = globalThis;
354
+ const registration = self.registration;
355
+ const applicationServerKey = urlBase64ToUint8Array(data.publicKey);
356
+ const subscription = await registration.pushManager.subscribe({
357
+ userVisibleOnly: true,
358
+ applicationServerKey
359
+ });
360
+ return toPushSubscription(subscription);
361
+ }
362
+ catch (error) {
363
+ console.error('Resubscribe failed:', error);
364
+ return null;
365
+ }
366
+ }
367
+ /**
368
+ * Handles the pushsubscriptionchange event in a service worker.
369
+ * Call this from your service worker's event listener when the browser
370
+ * rotates push subscriptions (typically for security reasons).
371
+ * @param event - The pushsubscriptionchange event from the service worker
372
+ * @param renewEndpoint - The backend endpoint to send the renewal request to
373
+ * @returns Result with ok=true on success, or error message on failure
374
+ * @example
375
+ * // In your service worker (sw.js):
376
+ * self.addEventListener('pushsubscriptionchange', (event) => {
377
+ * event.waitUntil(handleSubscriptionChange(event, '/api/push/renew'));
378
+ * });
379
+ */
380
+ async function handleSubscriptionChange(event, renewEndpoint) {
381
+ try {
382
+ // Get stored data from IndexedDB
383
+ const data = await getSubscriptionData();
384
+ if (!data) {
385
+ return (0, shared_1.err)('No subscription data found for renewal');
386
+ }
387
+ // Resubscribe with same VAPID key
388
+ const newSubscription = await resubscribe(data);
389
+ if (!newSubscription) {
390
+ return (0, shared_1.err)('Failed to create new subscription');
391
+ }
392
+ // Build renewal request (uses SubscriptionRequest type)
393
+ const renewalRequest = {
394
+ subscription: newSubscription,
395
+ deviceId: data.deviceId,
396
+ basePath: data.basePath
397
+ };
398
+ // Send to backend
399
+ const response = await fetch(renewEndpoint, {
400
+ method: 'POST',
401
+ headers: { 'Content-Type': 'application/json' },
402
+ body: JSON.stringify(renewalRequest)
403
+ });
404
+ if (!response.ok) {
405
+ return (0, shared_1.err)(`Renewal request failed: ${response.status}`);
406
+ }
407
+ return (0, shared_1.okVoid)();
408
+ }
409
+ catch (error) {
410
+ return (0, shared_1.err)((0, shared_1.getErrorMessage)(error, 'Subscription renewal failed'));
411
+ }
412
+ }
package/dist/index.d.ts CHANGED
@@ -1,2 +1,11 @@
1
+ /**
2
+ * pwa-push - Web push notifications for Azure PWA projects
3
+ *
4
+ * Import paths:
5
+ * - '@markwharton/pwa-push/shared' - Types and Result pattern (browser-safe)
6
+ * - '@markwharton/pwa-push/server' - VAPID config, send notifications (Node.js only)
7
+ * - '@markwharton/pwa-push/client' - Subscription lifecycle, device ID (browser only)
8
+ * - '@markwharton/pwa-push' - Server + shared (for convenience in Node.js)
9
+ */
1
10
  export * from './shared';
2
11
  export * from './server';
package/dist/index.js CHANGED
@@ -1,4 +1,13 @@
1
1
  "use strict";
2
+ /**
3
+ * pwa-push - Web push notifications for Azure PWA projects
4
+ *
5
+ * Import paths:
6
+ * - '@markwharton/pwa-push/shared' - Types and Result pattern (browser-safe)
7
+ * - '@markwharton/pwa-push/server' - VAPID config, send notifications (Node.js only)
8
+ * - '@markwharton/pwa-push/client' - Subscription lifecycle, device ID (browser only)
9
+ * - '@markwharton/pwa-push' - Server + shared (for convenience in Node.js)
10
+ */
2
11
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
12
  if (k2 === undefined) k2 = k;
4
13
  var desc = Object.getOwnPropertyDescriptor(m, k);