@pushforge/builder 1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 David Raphi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,198 @@
1
+ # PushForge Builder
2
+
3
+ A robust, cross-platform Web Push notification library that handles VAPID authentication and payload encryption following the Web Push Protocol standard.
4
+
5
+ ## Installation
6
+
7
+ Choose your preferred package manager:
8
+
9
+ ```bash
10
+ # NPM
11
+ npm install @pushforge/builder
12
+
13
+ # Yarn
14
+ yarn add @pushforge/builder
15
+
16
+ # pnpm
17
+ pnpm add @pushforge/builder
18
+ ```
19
+
20
+ ## Features
21
+
22
+ - 🔑 Compliant VAPID authentication
23
+ - 🔒 Web Push Protocol encryption
24
+ - 🌐 Cross-platform compatibility (Node.js 16+, Browsers, Deno, Bun, Cloudflare Workers)
25
+ - 🧩 TypeScript definitions included
26
+ - 🛠️ Zero dependencies
27
+
28
+ ## Getting Started
29
+
30
+ ### Step 1: Generate VAPID Keys
31
+
32
+ PushForge includes a built-in CLI tool to generate VAPID keys for Web Push Authentication:
33
+
34
+ ```bash
35
+ # Generate VAPID keys using npx
36
+ npx @pushforge/builder generate-vapid-keys
37
+
38
+ # Using yarn
39
+ yarn dlx @pushforge/builder generate-vapid-keys
40
+
41
+ # Using pnpm
42
+ pnpm dlx @pushforge/builder generate-vapid-keys
43
+ ```
44
+
45
+ This will output a public key and private key that you can use for VAPID authentication:
46
+
47
+ ```
48
+ ┌────────────────────────────────────────────────────────────┐
49
+ │ │
50
+ │ VAPID Keys Generated Successfully │
51
+ │ │
52
+ │ Public Key: │
53
+ │ BDd0DtL3qQmnI7-JPwKMuGuFBC7VW9GjKP0qR-4C9Y9lJ2LLWR0pSI... │
54
+ │ │
55
+ │ Private Key (JWK): │
56
+ │ { │
57
+ │ "alg": "ES256", │
58
+ │ "kty": "EC", │
59
+ │ "crv": "P-256", │
60
+ │ "x": "N3QO0vepCacjv4k_AoyYa4UELtVb0aMo_SpH7gL1j2U", │
61
+ │ "y": "ZSdiy1kdKUiOGjuoVgMbp4HwmQDz0nhHxPJLbFYh1j8", │
62
+ │ "d": "8M9F5JCaEsXdTU1OpD4ODq-o5qZQcDmCYS6EHrC1o8E" │
63
+ │ } │
64
+ │ │
65
+ │ Store these keys securely. Never expose your private key. │
66
+ │ │
67
+ └────────────────────────────────────────────────────────────┘
68
+ ```
69
+
70
+ **Requirements:**
71
+
72
+ - Node.js 16.0.0 or later
73
+ - The command uses the WebCrypto API which is built-in to Node.js 16+
74
+
75
+ ### Step 2: Set Up Push Notifications in Your Web Application
76
+
77
+ To implement push notifications in your web application, you'll need to:
78
+
79
+ 1. Use the VAPID public key generated in Step 1
80
+ 2. Request notification permission from the user
81
+ 3. Subscribe to push notifications using the Push API
82
+ 4. Save the subscription information in your backend
83
+
84
+ When implementing your service worker, handle push events to display notifications when they arrive:
85
+
86
+ 1. Listen for `push` events
87
+ 2. Parse the notification data
88
+ 3. Display the notification to the user
89
+ 4. Handle notification click events
90
+
91
+ Refer to the [Push API documentation](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) and [Notifications API documentation](https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API) for detailed implementation.
92
+
93
+ ### Step 3: Send Push Notifications from Your Server
94
+
95
+ On your backend server, use the VAPID private key to send push notifications:
96
+
97
+ ```typescript
98
+ import { buildPushHTTPRequest } from "@pushforge/builder";
99
+
100
+ // Load the private key from your secure environment
101
+ // This should be the private key from your VAPID key generation
102
+ const privateJWK = process.env.VAPID_PRIVATE_KEY;
103
+
104
+ // User subscription from browser push API
105
+ const subscription = {
106
+ endpoint: "https://fcm.googleapis.com/fcm/send/DEVICE_TOKEN",
107
+ keys: {
108
+ p256dh: "USER_PUBLIC_KEY",
109
+ auth: "USER_AUTH_SECRET",
110
+ },
111
+ };
112
+
113
+ // Create message with payload
114
+ const message = {
115
+ payload: {
116
+ title: "New Message",
117
+ body: "You have a new message!",
118
+ icon: "/images/icon.png",
119
+ },
120
+ options: {
121
+ //Default value is 24 * 60 * 60 (24 hours).
122
+ //The VAPID JWT expiration claim (`exp`) must not exceed 24 hours from the time of the request.
123
+ ttl: 3600, // Time-to-live in seconds
124
+ urgency: "normal", // Options: "very-low", "low", "normal", "high"
125
+ topic: "updates", // Optional topic for replacing notifications
126
+ },
127
+ adminContact: "mailto:admin@example.com", //The contact information of the administrator
128
+ };
129
+
130
+ // Build the push notification request
131
+ const {endpoint, headers, body} = await buildPushHTTPRequest({
132
+ privateJWK,
133
+ message,
134
+ subscription,
135
+ });
136
+
137
+ // Send the push notification
138
+ const response = await fetch(endpoint, {
139
+ method: "POST",
140
+ headers,
141
+ body,
142
+ });
143
+
144
+ if (response.status === 201) {
145
+ console.log("Push notification sent successfully");
146
+ } else {
147
+ console.error("Failed to send push notification", await response.text());
148
+ }
149
+ ```
150
+
151
+ ## Cross-Platform Support
152
+
153
+ PushForge works in all major JavaScript environments:
154
+
155
+ ### Node.js 16+
156
+
157
+ ```js
158
+ import { buildPushHTTPRequest } from "@pushforge/builder";
159
+ // OR
160
+ const { buildPushHTTPRequest } = require("@pushforge/builder");
161
+
162
+ // Use normally - Node.js 16+ has Web Crypto API built-in
163
+ ```
164
+
165
+ ### Browsers
166
+
167
+ ```js
168
+ import { buildPushHTTPRequest } from "@pushforge/builder";
169
+
170
+ // Use in a service worker for push notification handling
171
+ ```
172
+
173
+ ### Deno
174
+
175
+ ```js
176
+ // Import from npm CDN
177
+ import { buildPushHTTPRequest } from "https://cdn.jsdelivr.net/npm/@pushforge/builder/dist/lib/main.js";
178
+
179
+ // Run with --allow-net permissions
180
+ ```
181
+
182
+ ### Bun
183
+
184
+ ```js
185
+ import { buildPushHTTPRequest } from "@pushforge/builder";
186
+
187
+ // Works natively in Bun with no special configuration
188
+ ```
189
+
190
+ ### Cloudflare Workers
191
+
192
+ ```js
193
+ import { buildPushHTTPRequest } from "@pushforge/builder";
194
+ ```
195
+
196
+ ## License
197
+
198
+ MIT
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Encodes a string, ArrayBuffer, or Uint8Array into a Base64 URL format.
3
+ *
4
+ * This function handles both browser and Node.js environments by using
5
+ * the appropriate encoding method based on the environment.
6
+ *
7
+ * @param {string | ArrayBuffer | Uint8Array} input - The input to encode.
8
+ * @returns {string} The Base64 URL encoded string.
9
+ */
10
+ export declare const base64UrlEncode: (input: string | ArrayBuffer | Uint8Array) => string;
11
+ /**
12
+ * Decodes a Base64 URL encoded string back to its original format.
13
+ *
14
+ * This function handles both browser and Node.js environments by using
15
+ * the appropriate decoding method based on the environment.
16
+ *
17
+ * @param {string | undefined} s - The Base64 URL encoded string to decode.
18
+ * @returns {string} The decoded data as a string.
19
+ * @throws {Error} Throws an error if the input string is invalid.
20
+ */
21
+ export declare const base64UrlDecodeString: (s: string | undefined) => string;
22
+ /**
23
+ * Decodes a Base64 URL encoded string into an ArrayBuffer.
24
+ *
25
+ * This function handles both browser and Node.js environments by using
26
+ * the appropriate decoding method based on the environment.
27
+ *
28
+ * @param {string} input - The Base64 URL encoded string to decode.
29
+ * @returns {ArrayBuffer} The decoded data as an ArrayBuffer.
30
+ */
31
+ export declare const base64UrlDecode: (input: string) => ArrayBuffer;
@@ -0,0 +1,67 @@
1
+ import { stringFromArrayBuffer } from './utils.js';
2
+ /**
3
+ * Encodes a string, ArrayBuffer, or Uint8Array into a Base64 URL format.
4
+ *
5
+ * This function handles both browser and Node.js environments by using
6
+ * the appropriate encoding method based on the environment.
7
+ *
8
+ * @param {string | ArrayBuffer | Uint8Array} input - The input to encode.
9
+ * @returns {string} The Base64 URL encoded string.
10
+ */
11
+ export const base64UrlEncode = (input) => {
12
+ // Convert input to string if it's binary
13
+ const text = typeof input === 'string' ? input : stringFromArrayBuffer(input);
14
+ // Use environment-specific encoding
15
+ let base64;
16
+ if (typeof globalThis !== 'undefined' && 'btoa' in globalThis) {
17
+ // Browser environment
18
+ base64 = globalThis.btoa(text);
19
+ }
20
+ else {
21
+ // Node.js environment
22
+ base64 = Buffer.from(text, 'binary').toString('base64');
23
+ }
24
+ // Convert base64 to base64url format
25
+ return base64.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
26
+ };
27
+ /**
28
+ * Decodes a Base64 URL encoded string back to its original format.
29
+ *
30
+ * This function handles both browser and Node.js environments by using
31
+ * the appropriate decoding method based on the environment.
32
+ *
33
+ * @param {string | undefined} s - The Base64 URL encoded string to decode.
34
+ * @returns {string} The decoded data as a string.
35
+ * @throws {Error} Throws an error if the input string is invalid.
36
+ */
37
+ export const base64UrlDecodeString = (s) => {
38
+ if (!s)
39
+ throw new Error('Invalid input');
40
+ return (s.replace(/-/g, '+').replace(/_/g, '/') +
41
+ '='.repeat((4 - (s.length % 4)) % 4));
42
+ };
43
+ /**
44
+ * Decodes a Base64 URL encoded string into an ArrayBuffer.
45
+ *
46
+ * This function handles both browser and Node.js environments by using
47
+ * the appropriate decoding method based on the environment.
48
+ *
49
+ * @param {string} input - The Base64 URL encoded string to decode.
50
+ * @returns {ArrayBuffer} The decoded data as an ArrayBuffer.
51
+ */
52
+ export const base64UrlDecode = (input) => {
53
+ // Convert from base64url to base64
54
+ const base64 = base64UrlDecodeString(input);
55
+ // Decode based on environment
56
+ if (typeof globalThis !== 'undefined' && 'atob' in globalThis) {
57
+ // Browser environment
58
+ const binaryString = globalThis.atob(base64);
59
+ const bytes = new Uint8Array(binaryString.length);
60
+ for (let i = 0; i < binaryString.length; i++) {
61
+ bytes[i] = binaryString.charCodeAt(i);
62
+ }
63
+ return bytes.buffer;
64
+ }
65
+ // Node.js environment
66
+ return Buffer.from(base64, 'base64').buffer;
67
+ };
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ import { getPublicKeyFromJwk } from '../utils.js';
3
+ let webcrypto;
4
+ try {
5
+ const nodeCrypto = await import('node:crypto');
6
+ webcrypto = nodeCrypto.webcrypto;
7
+ }
8
+ catch {
9
+ console.error('Error: This command requires Node.js environment.');
10
+ console.error("Please ensure you're running Node.js 16.0.0 or later.");
11
+ process.exit(1);
12
+ }
13
+ async function generateVapidKeys() {
14
+ try {
15
+ console.log('Generating VAPID keys...');
16
+ const keypair = await webcrypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
17
+ const privateJWK = await webcrypto.subtle.exportKey('jwk', keypair.privateKey);
18
+ const privateJWKWithAlg = { alg: 'ES256', ...privateJWK };
19
+ const publicKey = getPublicKeyFromJwk(privateJWKWithAlg);
20
+ // Display in a nice formatted output
21
+ const resultText = `
22
+ VAPID Keys Generated Successfully
23
+
24
+ Public Key:
25
+ ${publicKey}
26
+
27
+ Private Key (JWK):
28
+ ${JSON.stringify(privateJWKWithAlg, null, 2)}
29
+
30
+ Store these keys securely. Never expose your private key.
31
+ `;
32
+ console.log(resultText);
33
+ }
34
+ catch (error) {
35
+ console.error('Error generating VAPID keys:');
36
+ if (error instanceof Error) {
37
+ console.error(error.message);
38
+ }
39
+ else {
40
+ console.error('An unknown error occurred.');
41
+ }
42
+ console.error('\nThis tool requires Node.js v16.0.0 or later with WebCrypto API support.');
43
+ process.exit(1);
44
+ }
45
+ }
46
+ // Simple command parsing
47
+ const args = process.argv.slice(2);
48
+ const command = args[0];
49
+ if (command === 'generate-vapid-keys') {
50
+ generateVapidKeys();
51
+ }
52
+ else {
53
+ console.log(`
54
+ PushForge CLI Tools
55
+
56
+ Usage:
57
+ npx @pushforge/builder generate-vapid-keys Generate VAPID key pair for Web Push Authentication
58
+
59
+ For more information, visit: https://github.com/draphy/pushforge
60
+ `);
61
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * A cryptographic interface that provides methods for generating random values
3
+ * and accessing subtle cryptographic operations.
4
+ */
5
+ export declare const crypto: {
6
+ /**
7
+ * Fills the given typed array with cryptographically secure random values.
8
+ *
9
+ * @param {T} array - The typed array to fill with random values.
10
+ * @returns {T} The filled typed array.
11
+ * @template T - The type of the typed array (e.g., Uint8Array).
12
+ */
13
+ getRandomValues<T extends Uint8Array>(array: T): T;
14
+ /**
15
+ * Provides access to subtle cryptographic operations.
16
+ *
17
+ * @type {SubtleCrypto} The subtle cryptographic interface.
18
+ */
19
+ subtle: SubtleCrypto;
20
+ };
@@ -0,0 +1,48 @@
1
+ /// <reference lib="webworker" />
2
+ /// <reference types="node" />
3
+ /**
4
+ * A module that provides a cross-platform cryptographic interface.
5
+ *
6
+ * @module crypto
7
+ */
8
+ let isomorphicCrypto;
9
+ // Cloudflare Worker, Deno, Bun and Browser environments have crypto globally available
10
+ if (globalThis.crypto?.subtle) {
11
+ isomorphicCrypto = globalThis.crypto;
12
+ }
13
+ // Node.js requires importing the webcrypto module
14
+ else if (typeof process !== 'undefined' && process.versions?.node) {
15
+ try {
16
+ const { webcrypto } = await import('node:crypto');
17
+ isomorphicCrypto = webcrypto;
18
+ }
19
+ catch {
20
+ throw new Error('Crypto API not available in this Node.js environment. Please use Node.js 16+ which supports the Web Crypto API.');
21
+ }
22
+ }
23
+ // Fallback error for unsupported environments
24
+ else {
25
+ throw new Error('No Web Crypto API implementation available in this environment.');
26
+ }
27
+ /**
28
+ * A cryptographic interface that provides methods for generating random values
29
+ * and accessing subtle cryptographic operations.
30
+ */
31
+ export const crypto = {
32
+ /**
33
+ * Fills the given typed array with cryptographically secure random values.
34
+ *
35
+ * @param {T} array - The typed array to fill with random values.
36
+ * @returns {T} The filled typed array.
37
+ * @template T - The type of the typed array (e.g., Uint8Array).
38
+ */
39
+ getRandomValues(array) {
40
+ return isomorphicCrypto.getRandomValues(array);
41
+ },
42
+ /**
43
+ * Provides access to subtle cryptographic operations.
44
+ *
45
+ * @type {SubtleCrypto} The subtle cryptographic interface.
46
+ */
47
+ subtle: isomorphicCrypto.subtle,
48
+ };
@@ -0,0 +1,19 @@
1
+ import type { JwtData } from './types.js';
2
+ /**
3
+ * Creates a JSON Web Token (JWT) using the ECDSA algorithm.
4
+ *
5
+ * This function takes a JSON Web Key (JWK) and JWT data, encodes them,
6
+ * and signs the token using the specified algorithm and hash function.
7
+ *
8
+ * In the Web Push protocol, the VAPID JWT includes an `exp` (expiration) claim
9
+ * that specifies the token's validity period. According to the VAPID specification,
10
+ * the `exp` value must not exceed 24 hours from the time of the request. If it does,
11
+ * the push service (like FCM) will reject the request with a 403 Forbidden error.
12
+ *
13
+ * @param {JsonWebKey} jwk - The JSON Web Key used for signing the JWT.
14
+ * @param {JwtData} jwtData - The data to be included in the JWT payload.
15
+ * @returns {Promise<string>} A promise that resolves to the signed JWT as a string.
16
+ *
17
+ * @throws {Error} Throws an error if the key import or signing process fails.
18
+ */
19
+ export declare const createJwt: (jwk: JsonWebKey, jwtData: JwtData) => Promise<string>;
@@ -0,0 +1,38 @@
1
+ import { base64UrlEncode } from './base64.js';
2
+ import { crypto } from './crypto.js';
3
+ /**
4
+ * Creates a JSON Web Token (JWT) using the ECDSA algorithm.
5
+ *
6
+ * This function takes a JSON Web Key (JWK) and JWT data, encodes them,
7
+ * and signs the token using the specified algorithm and hash function.
8
+ *
9
+ * In the Web Push protocol, the VAPID JWT includes an `exp` (expiration) claim
10
+ * that specifies the token's validity period. According to the VAPID specification,
11
+ * the `exp` value must not exceed 24 hours from the time of the request. If it does,
12
+ * the push service (like FCM) will reject the request with a 403 Forbidden error.
13
+ *
14
+ * @param {JsonWebKey} jwk - The JSON Web Key used for signing the JWT.
15
+ * @param {JwtData} jwtData - The data to be included in the JWT payload.
16
+ * @returns {Promise<string>} A promise that resolves to the signed JWT as a string.
17
+ *
18
+ * @throws {Error} Throws an error if the key import or signing process fails.
19
+ */
20
+ export const createJwt = async (jwk, jwtData) => {
21
+ // JWT header information
22
+ const jwtInfo = {
23
+ typ: 'JWT', // Type of the token
24
+ alg: 'ES256', // Algorithm used for signing
25
+ };
26
+ // Encode the JWT header and payload
27
+ const base64JwtInfo = base64UrlEncode(JSON.stringify(jwtInfo));
28
+ const base64JwtData = base64UrlEncode(JSON.stringify(jwtData));
29
+ const unsignedToken = `${base64JwtInfo}.${base64JwtData}`;
30
+ // Import the private key from the JWK
31
+ const privateKey = await crypto.subtle.importKey('jwk', jwk, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign']);
32
+ // Sign the token using the ECDSA algorithm and SHA-256 hash
33
+ const signature = await crypto.subtle
34
+ .sign({ name: 'ECDSA', hash: { name: 'SHA-256' } }, privateKey, new TextEncoder().encode(unsignedToken))
35
+ .then((token) => base64UrlEncode(token));
36
+ // Return the complete JWT
37
+ return `${base64JwtInfo}.${base64JwtData}.${signature}`;
38
+ };
@@ -0,0 +1,2 @@
1
+ export type { PushMessage, PushSubscription, BuilderOptions, PushOptions, } from './types.js';
2
+ export { buildPushHTTPRequest } from './request.js';
@@ -0,0 +1 @@
1
+ export { buildPushHTTPRequest } from './request.js';
@@ -0,0 +1,11 @@
1
+ import type { PushSubscription } from './types.js';
2
+ /**
3
+ * Encrypts the payload for a push notification using the provided keys and context.
4
+ *
5
+ * @param {CryptoKeyPair} localKeys - The local key pair used for encryption.
6
+ * @param {Uint8Array} salt - The salt used in the encryption process.
7
+ * @param {string} payload - The original payload to encrypt.
8
+ * @param {PushSubscription} target - The target push subscription containing client keys.
9
+ * @returns {Promise<ArrayBuffer>} A promise that resolves to the encrypted payload.
10
+ */
11
+ export declare const encryptPayload: (localKeys: CryptoKeyPair, salt: Uint8Array, payload: string, target: PushSubscription) => Promise<ArrayBuffer>;
@@ -0,0 +1,151 @@
1
+ import { base64UrlDecode, base64UrlDecodeString, base64UrlEncode, } from './base64.js';
2
+ import { crypto } from './crypto.js';
3
+ import { deriveSharedSecret } from './shared-secret.js';
4
+ import { concatTypedArrays } from './utils.js';
5
+ /**
6
+ * Imports and validates the client's public and authentication keys from a PushSubscriptionKey.
7
+ *
8
+ * @param {PushSubscriptionKey} keys - The keys associated with the push subscription.
9
+ * @returns {Promise<{ auth: ArrayBuffer, p256: CryptoKey }>} A promise that resolves to an object containing the authentication key and the imported public key.
10
+ * @throws {Error} Throws an error if the authentication key length is incorrect.
11
+ */
12
+ const importClientKeys = async (keys) => {
13
+ const auth = base64UrlDecode(keys.auth);
14
+ if (auth.byteLength !== 16) {
15
+ throw new Error(`Incorrect auth length, expected 16 bytes but got ${auth.byteLength}`);
16
+ }
17
+ let decodedKey;
18
+ const base64Key = base64UrlDecodeString(keys.p256dh);
19
+ if (typeof globalThis !== 'undefined' && 'atob' in globalThis) {
20
+ // Browser environment
21
+ const binaryStr = globalThis.atob(base64Key);
22
+ decodedKey = new Uint8Array(binaryStr.length);
23
+ for (let i = 0; i < binaryStr.length; i++) {
24
+ decodedKey[i] = binaryStr.charCodeAt(i);
25
+ }
26
+ }
27
+ else {
28
+ // Node.js environment
29
+ decodedKey = new Uint8Array(Buffer.from(base64Key, 'base64'));
30
+ }
31
+ const p256 = await crypto.subtle.importKey('jwk', {
32
+ kty: 'EC',
33
+ crv: 'P-256',
34
+ x: base64UrlEncode(decodedKey.slice(1, 33)),
35
+ y: base64UrlEncode(decodedKey.slice(33, 65)),
36
+ ext: true,
37
+ }, { name: 'ECDH', namedCurve: 'P-256' }, true, []);
38
+ return { auth, p256 };
39
+ };
40
+ /**
41
+ * Derives a pseudo-random key using HKDF from the shared secret and authentication key.
42
+ *
43
+ * @param {ArrayBuffer} auth - The authentication key used as salt in the derivation process.
44
+ * @param {CryptoKey} sharedSecret - The shared secret derived from the client's public key and local private key.
45
+ * @returns {Promise<CryptoKey>} A promise that resolves to the derived pseudo-random key.
46
+ */
47
+ const derivePseudoRandomKey = async (auth, sharedSecret) => {
48
+ const pseudoRandomKeyBytes = await crypto.subtle.deriveBits({
49
+ name: 'HKDF',
50
+ hash: 'SHA-256',
51
+ salt: auth,
52
+ // Adding Content-Encoding data info here is required by the Web Push API
53
+ info: new TextEncoder().encode('Content-Encoding: auth\0'),
54
+ }, sharedSecret, 256);
55
+ return crypto.subtle.importKey('raw', pseudoRandomKeyBytes, 'HKDF', false, [
56
+ 'deriveBits',
57
+ ]);
58
+ };
59
+ /**
60
+ * Creates a context for the ECDH key exchange using the client's and local public keys.
61
+ *
62
+ * @param {CryptoKey} clientPublicKey - The client's public key.
63
+ * @param {CryptoKey} localPublicKey - The local public key.
64
+ * @returns {Promise<Uint8Array>} A promise that resolves to a concatenated context array.
65
+ */
66
+ const createContext = async (clientPublicKey, localPublicKey) => {
67
+ const [clientKeyBytes, localKeyBytes] = await Promise.all([
68
+ crypto.subtle.exportKey('raw', clientPublicKey),
69
+ crypto.subtle.exportKey('raw', localPublicKey),
70
+ ]);
71
+ return concatTypedArrays([
72
+ new TextEncoder().encode('P-256\0'),
73
+ new Uint8Array([0, clientKeyBytes.byteLength]),
74
+ new Uint8Array(clientKeyBytes),
75
+ new Uint8Array([0, localKeyBytes.byteLength]),
76
+ new Uint8Array(localKeyBytes),
77
+ ]);
78
+ };
79
+ /**
80
+ * Derives a nonce for encryption using HKDF from the pseudo-random key, salt, and context.
81
+ *
82
+ * @param {CryptoKey} pseudoRandomKey - The pseudo-random key derived from the shared secret.
83
+ * @param {Uint8Array} salt - The salt used in the derivation process.
84
+ * @param {Uint8Array} context - The context for the nonce derivation.
85
+ * @returns {Promise<ArrayBuffer>} A promise that resolves to the derived nonce.
86
+ */
87
+ const deriveNonce = async (pseudoRandomKey, salt, context) => {
88
+ const nonceInfo = concatTypedArrays([
89
+ new TextEncoder().encode('Content-Encoding: nonce\0'),
90
+ context,
91
+ ]);
92
+ return crypto.subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt, info: nonceInfo }, pseudoRandomKey, 12 * 8);
93
+ };
94
+ /**
95
+ * Derives a content encryption key using HKDF from the pseudo-random key, salt, and context.
96
+ *
97
+ * @param {CryptoKey} pseudoRandomKey - The pseudo-random key derived from the shared secret.
98
+ * @param {Uint8Array} salt - The salt used in the derivation process.
99
+ * @param {Uint8Array} context - The context for the key derivation.
100
+ * @returns {Promise<CryptoKey>} A promise that resolves to the derived content encryption key.
101
+ */
102
+ const deriveContentEncryptionKey = async (pseudoRandomKey, salt, context) => {
103
+ const info = concatTypedArrays([
104
+ new TextEncoder().encode('Content-Encoding: aesgcm\0'),
105
+ context,
106
+ ]);
107
+ const bits = await crypto.subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt, info }, pseudoRandomKey, 16 * 8);
108
+ return crypto.subtle.importKey('raw', bits, 'AES-GCM', false, ['encrypt']);
109
+ };
110
+ /**
111
+ * Pads the payload to ensure it fits within the maximum allowed size for web push notifications.
112
+ *
113
+ * Web push payloads have an overall max size of 4KB (4096 bytes). With the
114
+ * required overhead for encryption, the actual max payload size is 4078 bytes.
115
+ *
116
+ * @param {Uint8Array} payload - The original payload to be padded.
117
+ * @returns {Uint8Array} The padded payload, including length information.
118
+ */
119
+ const padPayload = (payload) => {
120
+ const MAX_PAYLOAD_SIZE = 4078; // Maximum payload size after encryption overhead
121
+ let paddingSize = Math.round(Math.random() * 100); // Random padding size
122
+ const payloadSizeWithPadding = payload.byteLength + 2 + paddingSize;
123
+ if (payloadSizeWithPadding > MAX_PAYLOAD_SIZE) {
124
+ // Adjust padding size if the total exceeds the maximum allowed size
125
+ paddingSize -= payloadSizeWithPadding - MAX_PAYLOAD_SIZE;
126
+ }
127
+ const paddingArray = new ArrayBuffer(2 + paddingSize);
128
+ new DataView(paddingArray).setUint16(0, paddingSize); // Store the length of the padding
129
+ // Return the new payload with padding added
130
+ return concatTypedArrays([new Uint8Array(paddingArray), payload]);
131
+ };
132
+ /**
133
+ * Encrypts the payload for a push notification using the provided keys and context.
134
+ *
135
+ * @param {CryptoKeyPair} localKeys - The local key pair used for encryption.
136
+ * @param {Uint8Array} salt - The salt used in the encryption process.
137
+ * @param {string} payload - The original payload to encrypt.
138
+ * @param {PushSubscription} target - The target push subscription containing client keys.
139
+ * @returns {Promise<ArrayBuffer>} A promise that resolves to the encrypted payload.
140
+ */
141
+ export const encryptPayload = async (localKeys, salt, payload, target) => {
142
+ const clientKeys = await importClientKeys(target.keys);
143
+ const sharedSecret = await deriveSharedSecret(clientKeys.p256, localKeys.privateKey);
144
+ const pseudoRandomKey = await derivePseudoRandomKey(clientKeys.auth, sharedSecret);
145
+ const context = await createContext(clientKeys.p256, localKeys.publicKey);
146
+ const nonce = await deriveNonce(pseudoRandomKey, salt, context);
147
+ const contentEncryptionKey = await deriveContentEncryptionKey(pseudoRandomKey, salt, context);
148
+ const encodedPayload = new TextEncoder().encode(payload);
149
+ const paddedPayload = padPayload(encodedPayload);
150
+ return crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, contentEncryptionKey, paddedPayload);
151
+ };
@@ -0,0 +1,60 @@
1
+ import type { BuilderOptions } from './types.js';
2
+ /**
3
+ * Builds an HTTP request body and headers for sending a push notification.
4
+ *
5
+ * This function constructs the necessary components for a push notification request,
6
+ * including the payload, headers, and any required cryptographic operations.
7
+ *
8
+ * @param {BuilderOptions} options - The options for building the push notification request.
9
+ * @param {JsonWebKey | string} options.privateJWK - The private JSON Web Key (JWK) used for signing.
10
+ * @param {PushMessage} options.message - The message to be sent in the push notification, including user-defined options.
11
+ * @param {PushSubscription} options.subscription - The subscription details for the push notification.
12
+ * @returns {Promise<{ endpoint: string, body: ArrayBuffer, headers: Record<string, string> | Headers }>} A promise that resolves to an object containing the endpoint, encrypted body, and headers for the push notification.
13
+ *
14
+ * @throws {Error} Throws an error if the privateJWK is invalid, if the request fails, or if the payload encryption fails.
15
+ *
16
+ * @example
17
+ * // Example usage
18
+ * const privateJWK = '{"kty":"EC","crv":"P-256","d":"_eQ..."}'; // Your private VAPID key
19
+ *
20
+ * const message = {
21
+ * payload: {
22
+ * title: "New Message",
23
+ * body: "You have a new message!",
24
+ * icon: "/images/icon.png"
25
+ * },
26
+ * options: {
27
+ * ttl: 3600, // 1 hour in seconds
28
+ * urgency: "high",
29
+ * topic: "new-messages"
30
+ * },
31
+ * adminContact: "mailto:admin@example.com"
32
+ * };
33
+ *
34
+ * const subscription = {
35
+ * endpoint: "https://fcm.googleapis.com/fcm/send/...",
36
+ * keys: {
37
+ * p256dh: "BNn5....",
38
+ * auth: "tBHI...."
39
+ * }
40
+ * };
41
+ *
42
+ * // Build the request
43
+ * const request = await buildPushHTTPRequest({
44
+ * privateJWK,
45
+ * message,
46
+ * subscription
47
+ * });
48
+ *
49
+ * // Send the push notification
50
+ * const response = await fetch(request.endpoint, {
51
+ * method: 'POST',
52
+ * headers: request.headers,
53
+ * body: request.body
54
+ * });
55
+ */
56
+ export declare function buildPushHTTPRequest({ privateJWK, message, subscription, }: BuilderOptions): Promise<{
57
+ endpoint: string;
58
+ body: ArrayBuffer;
59
+ headers: Record<string, string> | Headers;
60
+ }>;
@@ -0,0 +1,98 @@
1
+ import { crypto } from './crypto.js';
2
+ import { encryptPayload } from './payload.js';
3
+ import { vapidHeaders } from './vapid.js';
4
+ /**
5
+ * Builds an HTTP request body and headers for sending a push notification.
6
+ *
7
+ * This function constructs the necessary components for a push notification request,
8
+ * including the payload, headers, and any required cryptographic operations.
9
+ *
10
+ * @param {BuilderOptions} options - The options for building the push notification request.
11
+ * @param {JsonWebKey | string} options.privateJWK - The private JSON Web Key (JWK) used for signing.
12
+ * @param {PushMessage} options.message - The message to be sent in the push notification, including user-defined options.
13
+ * @param {PushSubscription} options.subscription - The subscription details for the push notification.
14
+ * @returns {Promise<{ endpoint: string, body: ArrayBuffer, headers: Record<string, string> | Headers }>} A promise that resolves to an object containing the endpoint, encrypted body, and headers for the push notification.
15
+ *
16
+ * @throws {Error} Throws an error if the privateJWK is invalid, if the request fails, or if the payload encryption fails.
17
+ *
18
+ * @example
19
+ * // Example usage
20
+ * const privateJWK = '{"kty":"EC","crv":"P-256","d":"_eQ..."}'; // Your private VAPID key
21
+ *
22
+ * const message = {
23
+ * payload: {
24
+ * title: "New Message",
25
+ * body: "You have a new message!",
26
+ * icon: "/images/icon.png"
27
+ * },
28
+ * options: {
29
+ * ttl: 3600, // 1 hour in seconds
30
+ * urgency: "high",
31
+ * topic: "new-messages"
32
+ * },
33
+ * adminContact: "mailto:admin@example.com"
34
+ * };
35
+ *
36
+ * const subscription = {
37
+ * endpoint: "https://fcm.googleapis.com/fcm/send/...",
38
+ * keys: {
39
+ * p256dh: "BNn5....",
40
+ * auth: "tBHI...."
41
+ * }
42
+ * };
43
+ *
44
+ * // Build the request
45
+ * const request = await buildPushHTTPRequest({
46
+ * privateJWK,
47
+ * message,
48
+ * subscription
49
+ * });
50
+ *
51
+ * // Send the push notification
52
+ * const response = await fetch(request.endpoint, {
53
+ * method: 'POST',
54
+ * headers: request.headers,
55
+ * body: request.body
56
+ * });
57
+ */
58
+ export async function buildPushHTTPRequest({ privateJWK, message, subscription, }) {
59
+ // Parse the private JWK if it's a string
60
+ const jwk = typeof privateJWK === 'string' ? JSON.parse(privateJWK) : privateJWK;
61
+ const MAX_TTL = 24 * 60 * 60;
62
+ if (message.options?.ttl && message.options.ttl > MAX_TTL) {
63
+ throw new Error('TTL must be less than 24 hours');
64
+ }
65
+ // Determine the time-to-live (TTL) for the push notification
66
+ const ttl = message.options?.ttl && message.options.ttl > 0
67
+ ? message.options.ttl
68
+ : MAX_TTL; // Default to 24 hours
69
+ // Create the JWT payload
70
+ const jwt = {
71
+ aud: new URL(subscription.endpoint).origin,
72
+ exp: Math.floor(Date.now() / 1000) + ttl,
73
+ sub: message.adminContact,
74
+ };
75
+ // Construct the options for the push notification
76
+ const options = {
77
+ jwk,
78
+ jwt,
79
+ payload: JSON.stringify(message.payload),
80
+ ttl,
81
+ ...(message.options?.urgency && {
82
+ urgency: message.options.urgency,
83
+ }),
84
+ ...(message.options?.topic && {
85
+ topic: message.options.topic,
86
+ }),
87
+ };
88
+ // Generate a random salt for encryption
89
+ const salt = crypto.getRandomValues(new Uint8Array(16));
90
+ // Generate local keys for encryption
91
+ const localKeys = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);
92
+ // Encrypt the payload for the push notification
93
+ const body = await encryptPayload(localKeys, salt, options.payload, subscription);
94
+ // Construct the VAPID headers for the push notification request
95
+ const headers = await vapidHeaders(options, body.byteLength, salt, localKeys.publicKey);
96
+ // Return the constructed request components
97
+ return { endpoint: subscription.endpoint, body, headers };
98
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Derives a shared secret from a client's public key and a local private key using the ECDH algorithm.
3
+ *
4
+ * This function uses the Web Crypto API to derive a shared secret, which can then be used
5
+ * for further cryptographic operations, such as key derivation using HKDF.
6
+ *
7
+ * @param {CryptoKey} clientPublicKey - The public key of the client, used to derive the shared secret.
8
+ * @param {CryptoKey} localPrivateKey - The local private key used in the derivation process.
9
+ * @returns {Promise<CryptoKey>} A promise that resolves to a CryptoKey representing the derived shared secret.
10
+ *
11
+ * @throws {Error} Throws an error if the key derivation fails.
12
+ */
13
+ export declare const deriveSharedSecret: (clientPublicKey: CryptoKey, localPrivateKey: CryptoKey) => Promise<CryptoKey>;
@@ -0,0 +1,17 @@
1
+ import { crypto } from './crypto.js';
2
+ /**
3
+ * Derives a shared secret from a client's public key and a local private key using the ECDH algorithm.
4
+ *
5
+ * This function uses the Web Crypto API to derive a shared secret, which can then be used
6
+ * for further cryptographic operations, such as key derivation using HKDF.
7
+ *
8
+ * @param {CryptoKey} clientPublicKey - The public key of the client, used to derive the shared secret.
9
+ * @param {CryptoKey} localPrivateKey - The local private key used in the derivation process.
10
+ * @returns {Promise<CryptoKey>} A promise that resolves to a CryptoKey representing the derived shared secret.
11
+ *
12
+ * @throws {Error} Throws an error if the key derivation fails.
13
+ */
14
+ export const deriveSharedSecret = async (clientPublicKey, localPrivateKey) => {
15
+ const sharedSecretBytes = await crypto.subtle.deriveBits({ name: 'ECDH', public: clientPublicKey }, localPrivateKey, 256);
16
+ return crypto.subtle.importKey('raw', sharedSecretBytes, { name: 'HKDF' }, false, ['deriveBits', 'deriveKey']);
17
+ };
@@ -0,0 +1,137 @@
1
+ import type { Jsonifiable, RequireAtLeastOne } from './utils.js';
2
+ /**
3
+ * Options for configuring the builder.
4
+ */
5
+ export interface BuilderOptions {
6
+ /**
7
+ * The private JSON Web Key (JWK) used for signing.
8
+ */
9
+ privateJWK: JsonWebKey | string;
10
+ /**
11
+ * The subscription details for push notifications.
12
+ */
13
+ subscription: PushSubscription;
14
+ /**
15
+ * The message to be sent in the push notification.
16
+ */
17
+ message: PushMessage;
18
+ }
19
+ /**
20
+ * Represents a push message to be sent.
21
+ *
22
+ * @template T - The type of the payload, which must be JSON-compatible.
23
+ */
24
+ export interface PushMessage<T extends Jsonifiable = Jsonifiable> {
25
+ /**
26
+ * The payload of the push message, which can be any JSON-compatible value.
27
+ */
28
+ payload: T;
29
+ /**
30
+ * The contact information of the administrator, typically a URL or email address.
31
+ */
32
+ adminContact: JwtData['sub'];
33
+ /**
34
+ * Optional settings for the push message.
35
+ * At least one of the specified options must be provided.
36
+ */
37
+ options?: RequireAtLeastOne<{
38
+ /**
39
+ * The time-to-live for the push message in seconds.
40
+ * Must be a positive number greater than 0.
41
+ * Default value is 24 * 60 * 60 (24 hours).
42
+ * If set to 0, undefined, or a negative value, it will default to 24 hours.
43
+ * The VAPID JWT expiration claim (`exp`) must not exceed 24 hours from the time of the request.
44
+ * If it does, the push service (like FCM) will reject the request with a 403 Forbidden error.
45
+ */
46
+ ttl?: PushOptions['ttl'];
47
+ /**
48
+ * The topic of the push message.
49
+ */
50
+ topic?: PushOptions['topic'];
51
+ /**
52
+ * The urgency level of the push message.
53
+ */
54
+ urgency?: PushOptions['urgency'];
55
+ }>;
56
+ }
57
+ /**
58
+ * Represents the data contained in a JSON Web Token (JWT).
59
+ */
60
+ export interface JwtData {
61
+ /**
62
+ * The audience for the JWT, typically the origin of the push service.
63
+ */
64
+ aud: string;
65
+ /**
66
+ * The expiration time of the JWT, in seconds.
67
+ * This prevents reuse of the JWT after it has expired.
68
+ * The `exp` value must not exceed 24 hours from the time of the request.
69
+ * If it does, the push service (like FCM) will reject the request with a 403 Forbidden error.
70
+ */
71
+ exp: number;
72
+ /**
73
+ * The subject of the JWT, which should be a URL or a mailto email address.
74
+ * This is used for contact information if the push service needs to reach out to the sender.
75
+ */
76
+ sub: string;
77
+ }
78
+ /**
79
+ * Options for configuring a push notification.
80
+ */
81
+ export interface PushOptions {
82
+ /**
83
+ * The JSON Web Key (JWK) used for signing the push notification.
84
+ */
85
+ jwk: JsonWebKey;
86
+ /**
87
+ * The JWT data associated with the push notification.
88
+ */
89
+ jwt: JwtData;
90
+ /**
91
+ * The payload of the push notification, typically a string.
92
+ */
93
+ payload: string;
94
+ /**
95
+ * The time-to-live for the push notification in seconds.
96
+ * Must be a positive number greater than 0.
97
+ * Default value is 24 * 60 * 60 (24 hours).
98
+ * If set to 0, undefined, or a negative value, it will default to 24 hours.
99
+ * The VAPID JWT expiration claim (`exp`) must not exceed 24 hours from the time of the request.
100
+ * If it does, the push service (like FCM) will reject the request with a 403 Forbidden error.
101
+ */
102
+ ttl: number;
103
+ /**
104
+ * The topic of the push notification (optional).
105
+ */
106
+ topic?: string;
107
+ /**
108
+ * The urgency level of the push notification.
109
+ */
110
+ urgency?: 'very-low' | 'low' | 'normal' | 'high';
111
+ }
112
+ /**
113
+ * Represents the keys used for push subscription encryption.
114
+ */
115
+ export interface PushSubscriptionKey {
116
+ /**
117
+ * The public key used for encrypting messages.
118
+ */
119
+ p256dh: string;
120
+ /**
121
+ * The authentication secret used for encrypting messages.
122
+ */
123
+ auth: string;
124
+ }
125
+ /**
126
+ * Represents a push subscription, which includes the endpoint and keys.
127
+ */
128
+ export interface PushSubscription {
129
+ /**
130
+ * The endpoint URL for the push service to send notifications.
131
+ */
132
+ endpoint: string;
133
+ /**
134
+ * The keys used for encrypting message data sent with the push notification.
135
+ */
136
+ keys: PushSubscriptionKey;
137
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Represents any value that can be handled by JSON.stringify without loss.
3
+ * This includes primitive types such as strings, numbers, booleans, and null.
4
+ */
5
+ export type JsonPrimitive = string | number | boolean | null;
6
+ /**
7
+ * Represents a JSON-compatible value, which can be a primitive,
8
+ * an array of Jsonifiable values, or an object with string keys
9
+ * and Jsonifiable values.
10
+ */
11
+ export type Jsonifiable = JsonPrimitive | Jsonifiable[] | {
12
+ [key: string]: Jsonifiable;
13
+ };
14
+ /**
15
+ * A utility type that requires at least one of the specified keys from type T.
16
+ * This is useful for creating types that enforce the presence of certain properties
17
+ * while allowing others to be optional.
18
+ *
19
+ * @template T - The base type from which keys are required.
20
+ * @template Keys - The keys of T that are required.
21
+ */
22
+ export type RequireAtLeastOne<T, Keys extends keyof T = keyof T> = {
23
+ [K in Keys]: Required<Pick<T, K>> & Partial<Omit<T, K>>;
24
+ }[Keys] & Omit<T, Keys>;
25
+ /**
26
+ * Converts an ArrayBuffer or Uint8Array to a string.
27
+ *
28
+ * @param {ArrayBuffer | Uint8Array} s - The input array to convert.
29
+ * @returns {string} The resulting string representation of the input.
30
+ */
31
+ export declare const stringFromArrayBuffer: (s: ArrayBuffer | Uint8Array) => string;
32
+ /**
33
+ * Cross-platform function to decode a Base64 string into a binary string.
34
+ * Works in both browser and Node.js environments.
35
+ *
36
+ * @param {string} base64String - The Base64 encoded string to decode.
37
+ * @returns {string} The decoded binary string.
38
+ */
39
+ export declare const base64Decode: (base64String: string) => string;
40
+ /**
41
+ * Extracts the public key from a JSON Web Key (JWK) and encodes it in base64 URL format.
42
+ *
43
+ * @param {JsonWebKey} jwk - The JSON Web Key from which to extract the public key.
44
+ * @returns {string} The base64 URL encoded public key.
45
+ */
46
+ export declare const getPublicKeyFromJwk: (jwk: JsonWebKey) => string;
47
+ /**
48
+ * Concatenates multiple Uint8Array instances into a single Uint8Array.
49
+ *
50
+ * @param {Uint8Array[]} arrays - An array of Uint8Array instances to concatenate.
51
+ * @returns {Uint8Array} A new Uint8Array containing all the concatenated data.
52
+ */
53
+ export declare const concatTypedArrays: (arrays: Uint8Array[]) => Uint8Array;
@@ -0,0 +1,74 @@
1
+ import { base64UrlDecodeString, base64UrlEncode } from './base64.js';
2
+ /**
3
+ * Converts an ArrayBuffer or Uint8Array to a string.
4
+ *
5
+ * @param {ArrayBuffer | Uint8Array} s - The input array to convert.
6
+ * @returns {string} The resulting string representation of the input.
7
+ */
8
+ export const stringFromArrayBuffer = (s) => {
9
+ let result = '';
10
+ for (const code of new Uint8Array(s))
11
+ result += String.fromCharCode(code);
12
+ return result;
13
+ };
14
+ /**
15
+ * Cross-platform function to decode a Base64 string into a binary string.
16
+ * Works in both browser and Node.js environments.
17
+ *
18
+ * @param {string} base64String - The Base64 encoded string to decode.
19
+ * @returns {string} The decoded binary string.
20
+ */
21
+ export const base64Decode = (base64String) => {
22
+ // Add padding if needed
23
+ const paddedBase64 = base64String.padEnd(base64String.length + ((4 - (base64String.length % 4 || 4)) % 4), '=');
24
+ // Node.js environment
25
+ if (typeof Buffer !== 'undefined') {
26
+ return Buffer.from(paddedBase64, 'base64').toString('binary');
27
+ }
28
+ // Browser environment
29
+ if (typeof atob === 'function') {
30
+ return atob(paddedBase64);
31
+ }
32
+ // Pure JavaScript implementation for environments without atob or Buffer
33
+ const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
34
+ let result = '';
35
+ let i = 0;
36
+ while (i < paddedBase64.length) {
37
+ const enc1 = characters.indexOf(paddedBase64.charAt(i++));
38
+ const enc2 = characters.indexOf(paddedBase64.charAt(i++));
39
+ const enc3 = characters.indexOf(paddedBase64.charAt(i++));
40
+ const enc4 = characters.indexOf(paddedBase64.charAt(i++));
41
+ const char1 = (enc1 << 2) | (enc2 >> 4);
42
+ const char2 = ((enc2 & 15) << 4) | (enc3 >> 2);
43
+ const char3 = ((enc3 & 3) << 6) | enc4;
44
+ result += String.fromCharCode(char1);
45
+ if (enc3 !== 64)
46
+ result += String.fromCharCode(char2);
47
+ if (enc4 !== 64)
48
+ result += String.fromCharCode(char3);
49
+ }
50
+ return result;
51
+ };
52
+ /**
53
+ * Extracts the public key from a JSON Web Key (JWK) and encodes it in base64 URL format.
54
+ *
55
+ * @param {JsonWebKey} jwk - The JSON Web Key from which to extract the public key.
56
+ * @returns {string} The base64 URL encoded public key.
57
+ */
58
+ export const getPublicKeyFromJwk = (jwk) => base64UrlEncode(`\x04${base64Decode(base64UrlDecodeString(jwk.x))}${base64Decode(base64UrlDecodeString(jwk.y))}`);
59
+ /**
60
+ * Concatenates multiple Uint8Array instances into a single Uint8Array.
61
+ *
62
+ * @param {Uint8Array[]} arrays - An array of Uint8Array instances to concatenate.
63
+ * @returns {Uint8Array} A new Uint8Array containing all the concatenated data.
64
+ */
65
+ export const concatTypedArrays = (arrays) => {
66
+ const length = arrays.reduce((accumulator, current) => accumulator + current.byteLength, 0);
67
+ let index = 0;
68
+ const targetArray = new Uint8Array(length);
69
+ for (const array of arrays) {
70
+ targetArray.set(array, index);
71
+ index += array.byteLength;
72
+ }
73
+ return targetArray;
74
+ };
@@ -0,0 +1,16 @@
1
+ import type { PushOptions } from './types.js';
2
+ /**
3
+ * Constructs the VAPID headers for a push notification request.
4
+ *
5
+ * This function generates the necessary headers for sending a push notification
6
+ * using the VAPID protocol, including authentication and encryption information.
7
+ *
8
+ * @param {PushOptions} options - The options for the push notification, including the JSON Web Key (JWK) and JWT data.
9
+ * @param {number} payloadLength - The length of the payload being sent in the push notification.
10
+ * @param {Uint8Array} salt - A salt value used in the encryption process.
11
+ * @param {CryptoKey} localPublicKey - The local public key used for encryption.
12
+ * @returns {Promise<Record<string, string> | Headers>} A promise that resolves to an object containing the VAPID headers.
13
+ *
14
+ * @throws {Error} Throws an error if the JWT creation fails or if key export fails.
15
+ */
16
+ export declare const vapidHeaders: (options: PushOptions, payloadLength: number, salt: Uint8Array, localPublicKey: CryptoKey) => Promise<Record<string, string> | Headers>;
@@ -0,0 +1,54 @@
1
+ import { base64UrlEncode } from './base64.js';
2
+ import { crypto } from './crypto.js';
3
+ import { createJwt } from './jwt.js';
4
+ import { getPublicKeyFromJwk } from './utils.js';
5
+ /**
6
+ * Constructs the VAPID headers for a push notification request.
7
+ *
8
+ * This function generates the necessary headers for sending a push notification
9
+ * using the VAPID protocol, including authentication and encryption information.
10
+ *
11
+ * @param {PushOptions} options - The options for the push notification, including the JSON Web Key (JWK) and JWT data.
12
+ * @param {number} payloadLength - The length of the payload being sent in the push notification.
13
+ * @param {Uint8Array} salt - A salt value used in the encryption process.
14
+ * @param {CryptoKey} localPublicKey - The local public key used for encryption.
15
+ * @returns {Promise<Record<string, string> | Headers>} A promise that resolves to an object containing the VAPID headers.
16
+ *
17
+ * @throws {Error} Throws an error if the JWT creation fails or if key export fails.
18
+ */
19
+ export const vapidHeaders = async (options, payloadLength, salt, localPublicKey) => {
20
+ // Export the local public key to a raw format and encode it in Base64 URL format
21
+ const localPublicKeyBase64 = await crypto.subtle
22
+ .exportKey('raw', localPublicKey)
23
+ .then((bytes) => base64UrlEncode(bytes));
24
+ // Get the server public key from the JWK
25
+ const serverPublicKey = getPublicKeyFromJwk(options.jwk);
26
+ // Create the JWT for authentication
27
+ const jwt = await createJwt(options.jwk, options.jwt);
28
+ // Construct the header values for the VAPID request
29
+ const headerValues = {
30
+ Encryption: `salt=${base64UrlEncode(salt)}`,
31
+ 'Crypto-Key': `dh=${localPublicKeyBase64}`,
32
+ 'Content-Length': payloadLength.toString(),
33
+ 'Content-Type': 'application/octet-stream',
34
+ 'Content-Encoding': 'aesgcm',
35
+ Authorization: `vapid t=${jwt}, k=${serverPublicKey}`,
36
+ };
37
+ let headers;
38
+ // Add optional headers if they are defined
39
+ if (options.ttl !== undefined)
40
+ headerValues.TTL = options.ttl.toString();
41
+ if (options.topic !== undefined)
42
+ headerValues.Topic = options.topic;
43
+ if (options.urgency !== undefined)
44
+ headerValues.Urgency = options.urgency;
45
+ // Create Headers object if available (for browser or Node.js 18+)
46
+ if (typeof Headers !== 'undefined') {
47
+ headers = new Headers(headerValues);
48
+ }
49
+ else {
50
+ // Fallback for Node.js < 18 without polyfill
51
+ headers = headerValues;
52
+ }
53
+ return headers; // Return the constructed headers
54
+ };
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@pushforge/builder",
3
+ "version": "1.0.0",
4
+ "description": "A robust, cross-platform Web Push notification library that handles VAPID authentication and payload encryption following the Web Push Protocol standard. Works in Node.js 16+, Browsers, Deno, Bun and Cloudflare Workers.",
5
+ "private": false,
6
+ "main": "dist/lib/main.js",
7
+ "module": "dist/lib/main.js",
8
+ "types": "dist/lib/main.d.ts",
9
+ "author": "David Raphi",
10
+ "bugs": {
11
+ "url": "https://github.com/draphy/pushforge/issues"
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "https://github.com/draphy/pushforge"
16
+ },
17
+ "homepage": "https://github.com/draphy/pushforge",
18
+ "license": "MIT",
19
+ "type": "module",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/lib/main.d.ts",
23
+ "default": "./dist/lib/main.js"
24
+ }
25
+ },
26
+ "files": [
27
+ "dist/lib/**/*.js",
28
+ "dist/lib/**/*.d.ts",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "scripts": {
36
+ "build": "tsc --build",
37
+ "test": "vitest run",
38
+ "test:watch": "vitest",
39
+ "semantic-release": "semantic-release"
40
+ },
41
+ "bin": {
42
+ "generate-vapid-keys": "./dist/lib/commandLine/keys.js"
43
+ },
44
+ "devDependencies": {
45
+ "@semantic-release/commit-analyzer": "^11.1.0",
46
+ "@semantic-release/github": "^9.2.6",
47
+ "@semantic-release/npm": "^11.0.3",
48
+ "@semantic-release/release-notes-generator": "^12.1.0",
49
+ "@types/node": "^22.14.1",
50
+ "semantic-release": "^24.2.3",
51
+ "vitest": "^3.1.2"
52
+ },
53
+ "keywords": [
54
+ "web-push",
55
+ "webcrypto",
56
+ "vapid",
57
+ "web-push-protocol",
58
+ "typescript",
59
+ "node",
60
+ "cloudflare",
61
+ "browser",
62
+ "ecdh",
63
+ "hkdf",
64
+ "push-service"
65
+ ]
66
+ }