@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.
- package/dist/client.d.ts +181 -0
- package/dist/client.js +412 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/pwa-push-sw.js +61 -39
- package/dist/{server/send.d.ts → server.d.ts} +6 -2
- package/dist/{server/send.js → server.js} +9 -7
- package/dist/shared.d.ts +22 -2
- package/dist/shared.js +9 -6
- package/dist/sw.d.ts +1 -1
- package/dist/sw.js +2 -2
- package/package.json +11 -8
- package/dist/__tests__/client/deviceId.test.d.ts +0 -1
- package/dist/__tests__/client/deviceId.test.js +0 -134
- package/dist/__tests__/client/encoding.test.d.ts +0 -1
- package/dist/__tests__/client/encoding.test.js +0 -89
- package/dist/__tests__/client/indexedDb.test.d.ts +0 -1
- package/dist/__tests__/client/indexedDb.test.js +0 -195
- package/dist/__tests__/client/renewal.test.d.ts +0 -1
- package/dist/__tests__/client/renewal.test.js +0 -170
- package/dist/__tests__/client/subscribe.test.d.ts +0 -1
- package/dist/__tests__/client/subscribe.test.js +0 -299
- package/dist/__tests__/server/send.test.d.ts +0 -1
- package/dist/__tests__/server/send.test.js +0 -226
- package/dist/client/deviceId.d.ts +0 -23
- package/dist/client/deviceId.js +0 -49
- package/dist/client/encoding.d.ts +0 -17
- package/dist/client/encoding.js +0 -32
- package/dist/client/index.d.ts +0 -4
- package/dist/client/index.js +0 -20
- package/dist/client/indexedDb.d.ts +0 -38
- package/dist/client/indexedDb.js +0 -89
- package/dist/client/renewal.d.ts +0 -31
- package/dist/client/renewal.js +0 -80
- package/dist/client/subscribe.d.ts +0 -88
- package/dist/client/subscribe.js +0 -176
- package/dist/server/index.d.ts +0 -1
- package/dist/server/index.js +0 -11
- package/dist/types.d.ts +0 -39
- package/dist/types.js +0 -11
package/dist/client.d.ts
ADDED
|
@@ -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);
|