@markwharton/pwa-push 1.0.0 → 1.1.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.
@@ -8,14 +8,14 @@ exports.getDeviceId = getDeviceId;
8
8
  exports.clearDeviceId = clearDeviceId;
9
9
  const DEVICE_ID_KEY = 'push_device_id';
10
10
  /**
11
- * Generate a UUID v4
11
+ * Generate a cryptographically secure UUID v4
12
12
  */
13
13
  function generateUUID() {
14
- return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
15
- const r = (Math.random() * 16) | 0;
16
- const v = c === 'x' ? r : (r & 0x3) | 0x8;
17
- return v.toString(16);
18
- });
14
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
15
+ bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
16
+ bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
17
+ const hex = [...bytes].map(b => b.toString(16).padStart(2, '0')).join('');
18
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
19
19
  }
20
20
  /**
21
21
  * Get or create a persistent device ID
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Encoding utilities for push notification client
3
+ */
4
+ /**
5
+ * Convert base64 URL-encoded string to Uint8Array
6
+ * Used to convert VAPID public keys for the PushManager API
7
+ * Works in both main thread and service worker contexts
8
+ */
9
+ export declare function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer>;
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ /**
3
+ * Encoding utilities for push notification client
4
+ */
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.urlBase64ToUint8Array = urlBase64ToUint8Array;
7
+ /**
8
+ * Convert base64 URL-encoded string to Uint8Array
9
+ * Used to convert VAPID public keys for the PushManager API
10
+ * Works in both main thread and service worker contexts
11
+ */
12
+ function urlBase64ToUint8Array(base64String) {
13
+ const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
14
+ const base64 = (base64String + padding)
15
+ .replace(/-/g, '+')
16
+ .replace(/_/g, '/');
17
+ const rawData = atob(base64);
18
+ const buffer = new ArrayBuffer(rawData.length);
19
+ const outputArray = new Uint8Array(buffer);
20
+ for (let i = 0; i < rawData.length; ++i) {
21
+ outputArray[i] = rawData.charCodeAt(i);
22
+ }
23
+ return outputArray;
24
+ }
@@ -1,4 +1,4 @@
1
1
  export { getDeviceId, clearDeviceId } from './deviceId';
2
2
  export { saveSubscriptionData, getSubscriptionData, clearSubscriptionData } from './indexedDb';
3
- export { getPermissionState, requestPermission, isPushSupported, subscribeToPush, unsubscribeFromPush, getCurrentSubscription } from './subscribe';
3
+ export { getPermissionState, requestPermission, isPushSupported, toPushSubscription, subscribeToPush, unsubscribeFromPush, getCurrentSubscription } from './subscribe';
4
4
  export { handleSubscriptionChange } from './renewal';
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.handleSubscriptionChange = exports.getCurrentSubscription = exports.unsubscribeFromPush = exports.subscribeToPush = exports.isPushSupported = exports.requestPermission = exports.getPermissionState = exports.clearSubscriptionData = exports.getSubscriptionData = exports.saveSubscriptionData = exports.clearDeviceId = exports.getDeviceId = void 0;
3
+ exports.handleSubscriptionChange = exports.getCurrentSubscription = exports.unsubscribeFromPush = exports.subscribeToPush = exports.toPushSubscription = exports.isPushSupported = exports.requestPermission = exports.getPermissionState = exports.clearSubscriptionData = exports.getSubscriptionData = exports.saveSubscriptionData = exports.clearDeviceId = exports.getDeviceId = void 0;
4
4
  var deviceId_1 = require("./deviceId");
5
5
  Object.defineProperty(exports, "getDeviceId", { enumerable: true, get: function () { return deviceId_1.getDeviceId; } });
6
6
  Object.defineProperty(exports, "clearDeviceId", { enumerable: true, get: function () { return deviceId_1.clearDeviceId; } });
@@ -12,6 +12,7 @@ var subscribe_1 = require("./subscribe");
12
12
  Object.defineProperty(exports, "getPermissionState", { enumerable: true, get: function () { return subscribe_1.getPermissionState; } });
13
13
  Object.defineProperty(exports, "requestPermission", { enumerable: true, get: function () { return subscribe_1.requestPermission; } });
14
14
  Object.defineProperty(exports, "isPushSupported", { enumerable: true, get: function () { return subscribe_1.isPushSupported; } });
15
+ Object.defineProperty(exports, "toPushSubscription", { enumerable: true, get: function () { return subscribe_1.toPushSubscription; } });
15
16
  Object.defineProperty(exports, "subscribeToPush", { enumerable: true, get: function () { return subscribe_1.subscribeToPush; } });
16
17
  Object.defineProperty(exports, "unsubscribeFromPush", { enumerable: true, get: function () { return subscribe_1.unsubscribeFromPush; } });
17
18
  Object.defineProperty(exports, "getCurrentSubscription", { enumerable: true, get: function () { return subscribe_1.getCurrentSubscription; } });
@@ -27,44 +27,35 @@ function openDatabase() {
27
27
  });
28
28
  }
29
29
  /**
30
- * Save subscription data to IndexedDB (for service worker access)
30
+ * Execute an IndexedDB operation within a transaction
31
31
  */
32
- async function saveSubscriptionData(data) {
32
+ async function withTransaction(mode, operation) {
33
33
  const db = await openDatabase();
34
34
  return new Promise((resolve, reject) => {
35
- const transaction = db.transaction(STORE_NAME, 'readwrite');
35
+ const transaction = db.transaction(STORE_NAME, mode);
36
36
  const store = transaction.objectStore(STORE_NAME);
37
- const request = store.put(data, DATA_KEY);
37
+ const request = operation(store);
38
38
  request.onerror = () => reject(request.error);
39
- request.onsuccess = () => resolve();
39
+ request.onsuccess = () => resolve(request.result);
40
40
  transaction.oncomplete = () => db.close();
41
41
  });
42
42
  }
43
+ /**
44
+ * Save subscription data to IndexedDB (for service worker access)
45
+ */
46
+ function saveSubscriptionData(data) {
47
+ return withTransaction('readwrite', (store) => store.put(data, DATA_KEY));
48
+ }
43
49
  /**
44
50
  * Get subscription data from IndexedDB
45
51
  */
46
52
  async function getSubscriptionData() {
47
- const db = await openDatabase();
48
- return new Promise((resolve, reject) => {
49
- const transaction = db.transaction(STORE_NAME, 'readonly');
50
- const store = transaction.objectStore(STORE_NAME);
51
- const request = store.get(DATA_KEY);
52
- request.onerror = () => reject(request.error);
53
- request.onsuccess = () => resolve(request.result || null);
54
- transaction.oncomplete = () => db.close();
55
- });
53
+ const result = await withTransaction('readonly', (store) => store.get(DATA_KEY));
54
+ return result || null;
56
55
  }
57
56
  /**
58
57
  * Clear subscription data from IndexedDB
59
58
  */
60
- async function clearSubscriptionData() {
61
- const db = await openDatabase();
62
- return new Promise((resolve, reject) => {
63
- const transaction = db.transaction(STORE_NAME, 'readwrite');
64
- const store = transaction.objectStore(STORE_NAME);
65
- const request = store.delete(DATA_KEY);
66
- request.onerror = () => reject(request.error);
67
- request.onsuccess = () => resolve();
68
- transaction.oncomplete = () => db.close();
69
- });
59
+ function clearSubscriptionData() {
60
+ return withTransaction('readwrite', (store) => store.delete(DATA_KEY));
70
61
  }
@@ -2,6 +2,7 @@
2
2
  * Push subscription renewal handler for service worker
3
3
  * Handles the 'pushsubscriptionchange' event when browser rotates subscriptions
4
4
  */
5
+ import { PushSubscription } from '../types';
5
6
  /**
6
7
  * Handle pushsubscriptionchange event in service worker
7
8
  * Call this from your service worker's event listener
@@ -5,7 +5,9 @@
5
5
  */
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
7
  exports.handleSubscriptionChange = handleSubscriptionChange;
8
+ const encoding_1 = require("./encoding");
8
9
  const indexedDb_1 = require("./indexedDb");
10
+ const subscribe_1 = require("./subscribe");
9
11
  /**
10
12
  * Handle pushsubscriptionchange event in service worker
11
13
  * Call this from your service worker's event listener
@@ -31,7 +33,7 @@ async function handleSubscriptionChange(event, renewEndpoint) {
31
33
  console.error('Failed to create new subscription');
32
34
  return false;
33
35
  }
34
- // Build renewal request
36
+ // Build renewal request (uses SubscriptionRequest type)
35
37
  const renewalRequest = {
36
38
  subscription: newSubscription,
37
39
  deviceId: data.deviceId,
@@ -62,38 +64,15 @@ async function resubscribe(data) {
62
64
  try {
63
65
  const self = globalThis;
64
66
  const registration = self.registration;
65
- const applicationServerKey = urlBase64ToUint8Array(data.publicKey);
67
+ const applicationServerKey = (0, encoding_1.urlBase64ToUint8Array)(data.publicKey);
66
68
  const subscription = await registration.pushManager.subscribe({
67
69
  userVisibleOnly: true,
68
70
  applicationServerKey
69
71
  });
70
- const json = subscription.toJSON();
71
- return {
72
- endpoint: subscription.endpoint,
73
- keys: {
74
- p256dh: json.keys?.p256dh || '',
75
- auth: json.keys?.auth || ''
76
- }
77
- };
72
+ return (0, subscribe_1.toPushSubscription)(subscription);
78
73
  }
79
74
  catch (error) {
80
75
  console.error('Resubscribe failed:', error);
81
76
  return null;
82
77
  }
83
78
  }
84
- /**
85
- * Convert base64 URL-encoded string to Uint8Array (service worker version)
86
- */
87
- function urlBase64ToUint8Array(base64String) {
88
- const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
89
- const base64 = (base64String + padding)
90
- .replace(/-/g, '+')
91
- .replace(/_/g, '/');
92
- const rawData = atob(base64);
93
- const buffer = new ArrayBuffer(rawData.length);
94
- const outputArray = new Uint8Array(buffer);
95
- for (let i = 0; i < rawData.length; ++i) {
96
- outputArray[i] = rawData.charCodeAt(i);
97
- }
98
- return outputArray;
99
- }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Push subscription helpers for main thread
3
3
  */
4
- import { PushSubscription, SubscriptionRequest, PushPermissionState } from '../types';
4
+ import { PushSubscription, SubscriptionRequest, PushPermissionState, ClientResult } from '../types';
5
5
  /**
6
6
  * Check current notification permission state
7
7
  */
@@ -14,16 +14,26 @@ export declare function requestPermission(): Promise<PushPermissionState>;
14
14
  * Check if push notifications are supported
15
15
  */
16
16
  export declare function isPushSupported(): boolean;
17
+ /**
18
+ * Convert browser PushSubscription to our format
19
+ * Works in both main thread and service worker contexts
20
+ */
21
+ export declare function toPushSubscription(browserSub: globalThis.PushSubscription): PushSubscription;
17
22
  /**
18
23
  * Subscribe to push notifications
19
- * Returns subscription request ready to send to backend
24
+ *
25
+ * @returns Result with subscription request ready to send to backend
20
26
  */
21
- export declare function subscribeToPush(vapidPublicKey: string, basePath?: string): Promise<SubscriptionRequest | null>;
27
+ export declare function subscribeToPush(vapidPublicKey: string, basePath?: string): Promise<ClientResult<SubscriptionRequest>>;
22
28
  /**
23
29
  * Unsubscribe from push notifications
30
+ *
31
+ * @returns Result indicating success or failure
24
32
  */
25
- export declare function unsubscribeFromPush(): Promise<boolean>;
33
+ export declare function unsubscribeFromPush(): Promise<ClientResult<void>>;
26
34
  /**
27
35
  * Get current push subscription if exists
36
+ *
37
+ * @returns Result with current subscription, or error if not subscribed
28
38
  */
29
- export declare function getCurrentSubscription(): Promise<PushSubscription | null>;
39
+ export declare function getCurrentSubscription(): Promise<ClientResult<PushSubscription>>;
@@ -6,11 +6,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.getPermissionState = getPermissionState;
7
7
  exports.requestPermission = requestPermission;
8
8
  exports.isPushSupported = isPushSupported;
9
+ exports.toPushSubscription = toPushSubscription;
9
10
  exports.subscribeToPush = subscribeToPush;
10
11
  exports.unsubscribeFromPush = unsubscribeFromPush;
11
12
  exports.getCurrentSubscription = getCurrentSubscription;
12
13
  const deviceId_1 = require("./deviceId");
13
14
  const indexedDb_1 = require("./indexedDb");
15
+ const encoding_1 = require("./encoding");
14
16
  /**
15
17
  * Check current notification permission state
16
18
  */
@@ -32,6 +34,7 @@ function isPushSupported() {
32
34
  }
33
35
  /**
34
36
  * Convert browser PushSubscription to our format
37
+ * Works in both main thread and service worker contexts
35
38
  */
36
39
  function toPushSubscription(browserSub) {
37
40
  const json = browserSub.toJSON();
@@ -45,40 +48,50 @@ function toPushSubscription(browserSub) {
45
48
  }
46
49
  /**
47
50
  * Subscribe to push notifications
48
- * Returns subscription request ready to send to backend
51
+ *
52
+ * @returns Result with subscription request ready to send to backend
49
53
  */
50
54
  async function subscribeToPush(vapidPublicKey, basePath = '/') {
51
55
  if (!isPushSupported()) {
52
- console.error('Push notifications not supported');
53
- return null;
56
+ return { ok: false, error: 'Push notifications not supported' };
54
57
  }
55
58
  const permission = await requestPermission();
56
59
  if (permission !== 'granted') {
57
- console.warn('Push permission denied');
58
- return null;
60
+ return { ok: false, error: 'Push permission denied' };
61
+ }
62
+ try {
63
+ const registration = await navigator.serviceWorker.ready;
64
+ const deviceId = (0, deviceId_1.getDeviceId)();
65
+ // Convert VAPID key to Uint8Array
66
+ const applicationServerKey = (0, encoding_1.urlBase64ToUint8Array)(vapidPublicKey);
67
+ const browserSub = await registration.pushManager.subscribe({
68
+ userVisibleOnly: true,
69
+ applicationServerKey
70
+ });
71
+ // Save data for service worker renewal
72
+ await (0, indexedDb_1.saveSubscriptionData)({
73
+ publicKey: vapidPublicKey,
74
+ deviceId,
75
+ basePath
76
+ });
77
+ return {
78
+ ok: true,
79
+ data: {
80
+ subscription: toPushSubscription(browserSub),
81
+ deviceId,
82
+ basePath
83
+ }
84
+ };
85
+ }
86
+ catch (error) {
87
+ const message = error instanceof Error ? error.message : 'Subscription failed';
88
+ return { ok: false, error: message };
59
89
  }
60
- const registration = await navigator.serviceWorker.ready;
61
- const deviceId = (0, deviceId_1.getDeviceId)();
62
- // Convert VAPID key to Uint8Array
63
- const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey);
64
- const browserSub = await registration.pushManager.subscribe({
65
- userVisibleOnly: true,
66
- applicationServerKey
67
- });
68
- // Save data for service worker renewal
69
- await (0, indexedDb_1.saveSubscriptionData)({
70
- publicKey: vapidPublicKey,
71
- deviceId,
72
- basePath
73
- });
74
- return {
75
- subscription: toPushSubscription(browserSub),
76
- deviceId,
77
- basePath
78
- };
79
90
  }
80
91
  /**
81
92
  * Unsubscribe from push notifications
93
+ *
94
+ * @returns Result indicating success or failure
82
95
  */
83
96
  async function unsubscribeFromPush() {
84
97
  try {
@@ -87,42 +100,29 @@ async function unsubscribeFromPush() {
87
100
  if (subscription) {
88
101
  await subscription.unsubscribe();
89
102
  }
90
- return true;
103
+ return { ok: true };
91
104
  }
92
105
  catch (error) {
93
- console.error('Unsubscribe failed:', error);
94
- return false;
106
+ const message = error instanceof Error ? error.message : 'Unsubscribe failed';
107
+ return { ok: false, error: message };
95
108
  }
96
109
  }
97
110
  /**
98
111
  * Get current push subscription if exists
112
+ *
113
+ * @returns Result with current subscription, or error if not subscribed
99
114
  */
100
115
  async function getCurrentSubscription() {
101
116
  try {
102
117
  const registration = await navigator.serviceWorker.ready;
103
118
  const subscription = await registration.pushManager.getSubscription();
104
119
  if (!subscription) {
105
- return null;
120
+ return { ok: false, error: 'No active subscription' };
106
121
  }
107
- return toPushSubscription(subscription);
108
- }
109
- catch {
110
- return null;
122
+ return { ok: true, data: toPushSubscription(subscription) };
111
123
  }
112
- }
113
- /**
114
- * Convert base64 URL-encoded string to Uint8Array
115
- */
116
- function urlBase64ToUint8Array(base64String) {
117
- const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
118
- const base64 = (base64String + padding)
119
- .replace(/-/g, '+')
120
- .replace(/_/g, '/');
121
- const rawData = window.atob(base64);
122
- const buffer = new ArrayBuffer(rawData.length);
123
- const outputArray = new Uint8Array(buffer);
124
- for (let i = 0; i < rawData.length; ++i) {
125
- outputArray[i] = rawData.charCodeAt(i);
124
+ catch (error) {
125
+ const message = error instanceof Error ? error.message : 'Failed to get subscription';
126
+ return { ok: false, error: message };
126
127
  }
127
- return outputArray;
128
128
  }
@@ -18,15 +18,18 @@ export declare function initPushFromEnv(): void;
18
18
  export declare function getVapidPublicKey(): string;
19
19
  /**
20
20
  * Send a push notification to a single subscription
21
+ *
22
+ * @returns Result with ok status, or error details on failure
21
23
  */
22
- export declare function sendPushNotification(subscription: PushSubscription, payload: NotificationPayload): Promise<boolean>;
24
+ export declare function sendPushNotification(subscription: PushSubscription, payload: NotificationPayload): Promise<SendResult>;
23
25
  /**
24
26
  * Send a push notification to multiple subscriptions
25
- * Returns array of results
27
+ * Returns array of results, one per subscription
26
28
  */
27
29
  export declare function sendPushToAll(subscriptions: PushSubscription[], payload: NotificationPayload): Promise<SendResult[]>;
28
30
  /**
29
31
  * Send push notification with detailed error information
32
+ * Auto-injects basePath from subscription to payload if not already set
30
33
  */
31
34
  export declare function sendPushWithDetails(subscription: PushSubscription, payload: NotificationPayload): Promise<SendResult>;
32
35
  /**
@@ -51,14 +51,15 @@ function getVapidPublicKey() {
51
51
  }
52
52
  /**
53
53
  * Send a push notification to a single subscription
54
+ *
55
+ * @returns Result with ok status, or error details on failure
54
56
  */
55
57
  async function sendPushNotification(subscription, payload) {
56
- const result = await sendPushWithDetails(subscription, payload);
57
- return result.success;
58
+ return sendPushWithDetails(subscription, payload);
58
59
  }
59
60
  /**
60
61
  * Send a push notification to multiple subscriptions
61
- * Returns array of results
62
+ * Returns array of results, one per subscription
62
63
  */
63
64
  async function sendPushToAll(subscriptions, payload) {
64
65
  const results = await Promise.all(subscriptions.map(sub => sendPushWithDetails(sub, payload)));
@@ -66,19 +67,25 @@ async function sendPushToAll(subscriptions, payload) {
66
67
  }
67
68
  /**
68
69
  * Send push notification with detailed error information
70
+ * Auto-injects basePath from subscription to payload if not already set
69
71
  */
70
72
  async function sendPushWithDetails(subscription, payload) {
71
73
  if (!vapidConfig) {
72
- return { success: false, error: 'Push not initialized' };
74
+ return { ok: false, error: 'Push not initialized' };
73
75
  }
76
+ // Auto-inject basePath from subscription if not set in payload
77
+ const payloadWithBasePath = {
78
+ ...payload,
79
+ basePath: payload.basePath ?? subscription.basePath ?? '/'
80
+ };
74
81
  try {
75
- await web_push_1.default.sendNotification(subscription, JSON.stringify(payload));
76
- return { success: true };
82
+ await web_push_1.default.sendNotification(subscription, JSON.stringify(payloadWithBasePath));
83
+ return { ok: true };
77
84
  }
78
85
  catch (error) {
79
86
  const webPushError = error;
80
87
  return {
81
- success: false,
88
+ ok: false,
82
89
  error: webPushError.message || 'Unknown error',
83
90
  statusCode: webPushError.statusCode
84
91
  };
package/dist/types.d.ts CHANGED
@@ -7,6 +7,7 @@ export interface PushSubscription {
7
7
  p256dh: string;
8
8
  auth: string;
9
9
  };
10
+ basePath?: string;
10
11
  }
11
12
  export interface NotificationPayload {
12
13
  title: string;
@@ -15,23 +16,34 @@ export interface NotificationPayload {
15
16
  url?: string;
16
17
  tag?: string;
17
18
  data?: Record<string, unknown>;
19
+ id?: string;
20
+ timestamp?: string;
21
+ basePath?: string;
18
22
  }
19
23
  export interface VapidConfig {
20
24
  publicKey: string;
21
25
  privateKey: string;
22
26
  subject: string;
23
27
  }
28
+ /**
29
+ * Result type for push operations.
30
+ * Aligned with Result<T> pattern from @markwharton/pwa-core
31
+ */
24
32
  export interface SendResult {
25
- success: boolean;
33
+ ok: boolean;
26
34
  error?: string;
27
35
  statusCode?: number;
28
36
  }
29
- export interface SubscriptionRequest {
30
- subscription: PushSubscription;
31
- deviceId: string;
32
- basePath?: string;
37
+ /**
38
+ * Generic result type for client operations.
39
+ * Aligned with Result<T> pattern from @markwharton/pwa-core
40
+ */
41
+ export interface ClientResult<T> {
42
+ ok: boolean;
43
+ data?: T;
44
+ error?: string;
33
45
  }
34
- export interface RenewalRequest {
46
+ export interface SubscriptionRequest {
35
47
  subscription: PushSubscription;
36
48
  deviceId: string;
37
49
  basePath?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markwharton/pwa-push",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Web push notifications for Azure PWA projects",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",