@pushforge/builder 1.1.2 → 2.0.1
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/README.md +283 -121
- package/dist/lib/commandLine/keys.js +41 -37
- package/dist/lib/crypto.js +10 -18
- package/dist/lib/payload.d.ts +1 -1
- package/dist/lib/payload.js +32 -8
- package/dist/lib/request.js +52 -1
- package/dist/lib/utils.d.ts +2 -2
- package/dist/lib/utils.js +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,198 +1,360 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<img src="https://raw.githubusercontent.com/draphy/pushforge/master/images/logo.webp" alt="PushForge Logo" width="120" />
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
# PushForge Builder
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
**A lightweight, dependency-free Web Push library built on the standard Web Crypto API.**
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
[](https://www.npmjs.com/package/@pushforge/builder)
|
|
10
|
+
[](https://opensource.org/licenses/MIT)
|
|
11
|
+
[](https://www.typescriptlang.org/)
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
yarn add @pushforge/builder
|
|
13
|
+
Send push notifications from any JavaScript runtime · Zero dependencies
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
pnpm add @pushforge/builder
|
|
18
|
-
```
|
|
15
|
+
[GitHub](https://github.com/draphy/pushforge) · [npm](https://www.npmjs.com/package/@pushforge/builder) · [Report Bug](https://github.com/draphy/pushforge/issues)
|
|
19
16
|
|
|
20
|
-
|
|
17
|
+
**[Try the Live Demo →](https://pushforge.draphy.org)**
|
|
21
18
|
|
|
22
|
-
|
|
23
|
-
- 🔒 Web Push Protocol encryption
|
|
24
|
-
- 🌐 Cross-platform compatibility (Node.js 16+, Browsers, Deno, Bun, Cloudflare Workers)
|
|
25
|
-
- 🧩 TypeScript definitions included
|
|
26
|
-
- 🛠️ Zero dependencies
|
|
19
|
+
</div>
|
|
27
20
|
|
|
28
|
-
|
|
21
|
+
---
|
|
29
22
|
|
|
30
|
-
|
|
23
|
+
```bash
|
|
24
|
+
npm install @pushforge/builder
|
|
25
|
+
```
|
|
31
26
|
|
|
32
|
-
|
|
27
|
+
## Live Demo
|
|
33
28
|
|
|
34
|
-
|
|
35
|
-
# Generate VAPID keys using npx
|
|
36
|
-
npx @pushforge/builder generate-vapid-keys
|
|
29
|
+
Try PushForge in your browser at **[pushforge.draphy.org](https://pushforge.draphy.org)** — a live test site running on Cloudflare Workers.
|
|
37
30
|
|
|
38
|
-
|
|
39
|
-
|
|
31
|
+
- Toggle push notifications on, send a test message, and see it arrive in real time
|
|
32
|
+
- Works across all supported browsers — Chrome, Firefox, Edge, Safari 16+
|
|
33
|
+
- The backend is a single Cloudflare Worker using `buildPushHTTPRequest()` with zero additional dependencies
|
|
34
|
+
- Subscriptions auto-expire after 5 minutes — no permanent data stored
|
|
40
35
|
|
|
41
|
-
|
|
42
|
-
pnpm dlx @pushforge/builder generate-vapid-keys
|
|
43
|
-
```
|
|
36
|
+
## Why PushForge?
|
|
44
37
|
|
|
45
|
-
|
|
38
|
+
| | PushForge | web-push |
|
|
39
|
+
|---|:---:|:---:|
|
|
40
|
+
| Dependencies | **0** | 5+ (with nested deps) |
|
|
41
|
+
| Cloudflare Workers | Yes | [No](https://github.com/web-push-libs/web-push/issues/718) |
|
|
42
|
+
| Vercel Edge | Yes | No |
|
|
43
|
+
| Convex | Yes | No |
|
|
44
|
+
| Deno / Bun | Yes | Limited |
|
|
45
|
+
| TypeScript | First-class | @types package |
|
|
46
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
|
-
```
|
|
47
|
+
Traditional web push libraries rely on Node.js-specific APIs (`crypto.createECDH`, `https.request`) that don't work in modern edge runtimes. PushForge uses the standard [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API), making it portable across all JavaScript environments.
|
|
69
48
|
|
|
70
|
-
|
|
49
|
+
## Quick Start
|
|
71
50
|
|
|
72
|
-
|
|
73
|
-
- The command uses the WebCrypto API which is built-in to Node.js 16+
|
|
51
|
+
### 1. Generate VAPID Keys
|
|
74
52
|
|
|
75
|
-
|
|
53
|
+
```bash
|
|
54
|
+
npx @pushforge/builder vapid
|
|
55
|
+
```
|
|
76
56
|
|
|
77
|
-
|
|
57
|
+
This outputs a public key (for your frontend) and a private key in JWK format (for your server).
|
|
78
58
|
|
|
79
|
-
|
|
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
|
|
59
|
+
### 2. Subscribe Users (Frontend)
|
|
83
60
|
|
|
84
|
-
|
|
61
|
+
Use the VAPID public key to subscribe users to push notifications:
|
|
85
62
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
4. Handle notification click events
|
|
63
|
+
```javascript
|
|
64
|
+
// In your frontend application
|
|
65
|
+
const registration = await navigator.serviceWorker.ready;
|
|
90
66
|
|
|
91
|
-
|
|
67
|
+
const subscription = await registration.pushManager.subscribe({
|
|
68
|
+
userVisibleOnly: true,
|
|
69
|
+
applicationServerKey: 'YOUR_VAPID_PUBLIC_KEY' // From step 1
|
|
70
|
+
});
|
|
92
71
|
|
|
93
|
-
|
|
72
|
+
// Send this subscription to your server
|
|
73
|
+
// subscription.toJSON() returns:
|
|
74
|
+
// {
|
|
75
|
+
// endpoint: "https://fcm.googleapis.com/fcm/send/...",
|
|
76
|
+
// keys: {
|
|
77
|
+
// p256dh: "BNcRd...",
|
|
78
|
+
// auth: "tBHI..."
|
|
79
|
+
// }
|
|
80
|
+
// }
|
|
81
|
+
await fetch('/api/subscribe', {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
body: JSON.stringify(subscription)
|
|
84
|
+
});
|
|
85
|
+
```
|
|
94
86
|
|
|
95
|
-
|
|
87
|
+
### 3. Send Notifications (Server)
|
|
96
88
|
|
|
97
89
|
```typescript
|
|
98
90
|
import { buildPushHTTPRequest } from "@pushforge/builder";
|
|
99
91
|
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
92
|
+
// Your VAPID private key (JWK format from step 1)
|
|
93
|
+
const privateJWK = {
|
|
94
|
+
kty: "EC",
|
|
95
|
+
crv: "P-256",
|
|
96
|
+
x: "...",
|
|
97
|
+
y: "...",
|
|
98
|
+
d: "..."
|
|
99
|
+
};
|
|
103
100
|
|
|
104
|
-
//
|
|
101
|
+
// The subscription object from the user's browser
|
|
105
102
|
const subscription = {
|
|
106
|
-
endpoint: "https://fcm.googleapis.com/fcm/send
|
|
103
|
+
endpoint: "https://fcm.googleapis.com/fcm/send/...",
|
|
107
104
|
keys: {
|
|
108
|
-
p256dh: "
|
|
109
|
-
auth: "
|
|
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
|
|
105
|
+
p256dh: "BNcRd...",
|
|
106
|
+
auth: "tBHI..."
|
|
107
|
+
}
|
|
128
108
|
};
|
|
129
109
|
|
|
130
|
-
// Build the
|
|
131
|
-
const {endpoint, headers, body} = await buildPushHTTPRequest({
|
|
110
|
+
// Build and send the notification
|
|
111
|
+
const { endpoint, headers, body } = await buildPushHTTPRequest({
|
|
132
112
|
privateJWK,
|
|
133
|
-
message,
|
|
134
113
|
subscription,
|
|
114
|
+
message: {
|
|
115
|
+
payload: {
|
|
116
|
+
title: "New Message",
|
|
117
|
+
body: "You have a new notification!",
|
|
118
|
+
icon: "/icon.png"
|
|
119
|
+
},
|
|
120
|
+
adminContact: "mailto:admin@example.com"
|
|
121
|
+
}
|
|
135
122
|
});
|
|
136
123
|
|
|
137
|
-
// Send the push notification
|
|
138
124
|
const response = await fetch(endpoint, {
|
|
139
125
|
method: "POST",
|
|
140
126
|
headers,
|
|
141
|
-
body
|
|
127
|
+
body
|
|
142
128
|
});
|
|
143
129
|
|
|
144
130
|
if (response.status === 201) {
|
|
145
|
-
console.log("
|
|
146
|
-
} else {
|
|
147
|
-
console.error("Failed to send push notification", await response.text());
|
|
131
|
+
console.log("Notification sent");
|
|
148
132
|
}
|
|
149
133
|
```
|
|
150
134
|
|
|
151
|
-
##
|
|
135
|
+
## Understanding Push Subscriptions
|
|
152
136
|
|
|
153
|
-
|
|
137
|
+
When a user subscribes to push notifications, the browser returns a `PushSubscription` object:
|
|
154
138
|
|
|
155
|
-
|
|
139
|
+
```javascript
|
|
140
|
+
{
|
|
141
|
+
// The unique URL for this user's browser push service
|
|
142
|
+
endpoint: "https://fcm.googleapis.com/fcm/send/dAPT...",
|
|
156
143
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
144
|
+
keys: {
|
|
145
|
+
// Public key for encrypting messages (base64url)
|
|
146
|
+
p256dh: "BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA...",
|
|
147
|
+
|
|
148
|
+
// Authentication secret (base64url)
|
|
149
|
+
auth: "tBHItJI5svbpez7KI4CCXg=="
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
| Field | Description |
|
|
155
|
+
|-------|-------------|
|
|
156
|
+
| `endpoint` | The push service URL. Each browser vendor has their own (Google FCM, Mozilla autopush, Apple APNs). |
|
|
157
|
+
| `p256dh` | The user's public key for ECDH P-256 message encryption. |
|
|
158
|
+
| `auth` | A shared 16-byte authentication secret. |
|
|
159
|
+
|
|
160
|
+
Store these securely on your server. You'll need them to send notifications to this user.
|
|
161
|
+
|
|
162
|
+
## API Reference
|
|
163
|
+
|
|
164
|
+
### `buildPushHTTPRequest(options)`
|
|
165
|
+
|
|
166
|
+
Builds an HTTP request for sending a push notification.
|
|
161
167
|
|
|
162
|
-
|
|
168
|
+
```typescript
|
|
169
|
+
const { endpoint, headers, body } = await buildPushHTTPRequest({
|
|
170
|
+
privateJWK, // Your VAPID private key (JWK object or JSON string)
|
|
171
|
+
subscription, // User's push subscription
|
|
172
|
+
message: {
|
|
173
|
+
payload, // Any JSON-serializable data
|
|
174
|
+
adminContact, // Contact email (mailto:...) or URL
|
|
175
|
+
options: { // Optional
|
|
176
|
+
ttl, // Time-to-live in seconds (default: 86400)
|
|
177
|
+
urgency, // "very-low" | "low" | "normal" | "high"
|
|
178
|
+
topic // Topic for notification coalescing
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
**Returns:** `{ endpoint: string, headers: Headers, body: ArrayBuffer }`
|
|
185
|
+
|
|
186
|
+
## Platform Examples
|
|
187
|
+
|
|
188
|
+
### Cloudflare Workers
|
|
189
|
+
|
|
190
|
+
```javascript
|
|
191
|
+
export default {
|
|
192
|
+
async fetch(request, env) {
|
|
193
|
+
const subscription = await request.json();
|
|
194
|
+
|
|
195
|
+
const { endpoint, headers, body } = await buildPushHTTPRequest({
|
|
196
|
+
privateJWK: JSON.parse(env.VAPID_PRIVATE_KEY),
|
|
197
|
+
subscription,
|
|
198
|
+
message: {
|
|
199
|
+
payload: { title: "Hello from the Edge!" },
|
|
200
|
+
adminContact: "mailto:admin@example.com"
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
return fetch(endpoint, { method: "POST", headers, body });
|
|
205
|
+
}
|
|
206
|
+
};
|
|
163
207
|
```
|
|
164
208
|
|
|
165
|
-
###
|
|
209
|
+
### Vercel Edge Functions
|
|
166
210
|
|
|
167
|
-
```
|
|
211
|
+
```typescript
|
|
168
212
|
import { buildPushHTTPRequest } from "@pushforge/builder";
|
|
169
213
|
|
|
170
|
-
|
|
214
|
+
export const config = { runtime: "edge" };
|
|
215
|
+
|
|
216
|
+
export default async function handler(request: Request) {
|
|
217
|
+
const subscription = await request.json();
|
|
218
|
+
|
|
219
|
+
const { endpoint, headers, body } = await buildPushHTTPRequest({
|
|
220
|
+
privateJWK: JSON.parse(process.env.VAPID_PRIVATE_KEY!),
|
|
221
|
+
subscription,
|
|
222
|
+
message: {
|
|
223
|
+
payload: { title: "Edge Notification" },
|
|
224
|
+
adminContact: "mailto:admin@example.com"
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
await fetch(endpoint, { method: "POST", headers, body });
|
|
229
|
+
return new Response("Sent", { status: 200 });
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### Convex
|
|
234
|
+
|
|
235
|
+
```typescript
|
|
236
|
+
import { action } from "./_generated/server";
|
|
237
|
+
import { buildPushHTTPRequest } from "@pushforge/builder";
|
|
238
|
+
import { v } from "convex/values";
|
|
239
|
+
|
|
240
|
+
export const sendPush = action({
|
|
241
|
+
args: { subscription: v.any(), title: v.string(), body: v.string() },
|
|
242
|
+
handler: async (ctx, { subscription, title, body }) => {
|
|
243
|
+
const { endpoint, headers, body: reqBody } = await buildPushHTTPRequest({
|
|
244
|
+
privateJWK: JSON.parse(process.env.VAPID_PRIVATE_KEY!),
|
|
245
|
+
subscription,
|
|
246
|
+
message: {
|
|
247
|
+
payload: { title, body },
|
|
248
|
+
adminContact: "mailto:admin@example.com"
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
await fetch(endpoint, { method: "POST", headers, body: reqBody });
|
|
253
|
+
}
|
|
254
|
+
});
|
|
171
255
|
```
|
|
172
256
|
|
|
173
257
|
### Deno
|
|
174
258
|
|
|
175
|
-
```
|
|
176
|
-
|
|
177
|
-
|
|
259
|
+
```typescript
|
|
260
|
+
import { buildPushHTTPRequest } from "npm:@pushforge/builder";
|
|
261
|
+
|
|
262
|
+
const { endpoint, headers, body } = await buildPushHTTPRequest({
|
|
263
|
+
privateJWK: JSON.parse(Deno.env.get("VAPID_PRIVATE_KEY")!),
|
|
264
|
+
subscription,
|
|
265
|
+
message: {
|
|
266
|
+
payload: { title: "Hello from Deno!" },
|
|
267
|
+
adminContact: "mailto:admin@example.com"
|
|
268
|
+
}
|
|
269
|
+
});
|
|
178
270
|
|
|
179
|
-
|
|
271
|
+
await fetch(endpoint, { method: "POST", headers, body });
|
|
180
272
|
```
|
|
181
273
|
|
|
182
274
|
### Bun
|
|
183
275
|
|
|
184
|
-
```
|
|
276
|
+
```typescript
|
|
185
277
|
import { buildPushHTTPRequest } from "@pushforge/builder";
|
|
186
278
|
|
|
187
|
-
|
|
279
|
+
const { endpoint, headers, body } = await buildPushHTTPRequest({
|
|
280
|
+
privateJWK: JSON.parse(Bun.env.VAPID_PRIVATE_KEY!),
|
|
281
|
+
subscription,
|
|
282
|
+
message: {
|
|
283
|
+
payload: { title: "Hello from Bun!" },
|
|
284
|
+
adminContact: "mailto:admin@example.com"
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
await fetch(endpoint, { method: "POST", headers, body });
|
|
188
289
|
```
|
|
189
290
|
|
|
190
|
-
|
|
291
|
+
## Service Worker Setup
|
|
292
|
+
|
|
293
|
+
Handle incoming push notifications in your service worker:
|
|
294
|
+
|
|
295
|
+
```javascript
|
|
296
|
+
// sw.js
|
|
297
|
+
self.addEventListener('push', (event) => {
|
|
298
|
+
const data = event.data?.json() ?? {};
|
|
299
|
+
|
|
300
|
+
event.waitUntil(
|
|
301
|
+
self.registration.showNotification(data.title, {
|
|
302
|
+
body: data.body,
|
|
303
|
+
icon: data.icon,
|
|
304
|
+
badge: data.badge,
|
|
305
|
+
data: data.url
|
|
306
|
+
})
|
|
307
|
+
);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
self.addEventListener('notificationclick', (event) => {
|
|
311
|
+
event.notification.close();
|
|
312
|
+
|
|
313
|
+
if (event.notification.data) {
|
|
314
|
+
event.waitUntil(clients.openWindow(event.notification.data));
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
## Requirements
|
|
320
|
+
|
|
321
|
+
**Node.js 20+** or any runtime with Web Crypto API support.
|
|
322
|
+
|
|
323
|
+
| Environment | Status |
|
|
324
|
+
|-------------|--------|
|
|
325
|
+
| Node.js 20+ | Fully supported |
|
|
326
|
+
| Cloudflare Workers | Fully supported |
|
|
327
|
+
| Vercel Edge | Fully supported |
|
|
328
|
+
| Deno | Fully supported |
|
|
329
|
+
| Bun | Fully supported |
|
|
330
|
+
| Convex | Fully supported |
|
|
331
|
+
| Modern Browsers | Fully supported |
|
|
332
|
+
|
|
333
|
+
<details>
|
|
334
|
+
<summary>Node.js 18 (requires polyfill)</summary>
|
|
335
|
+
|
|
336
|
+
```javascript
|
|
337
|
+
import { webcrypto } from "node:crypto";
|
|
338
|
+
globalThis.crypto = webcrypto;
|
|
191
339
|
|
|
192
|
-
```js
|
|
193
340
|
import { buildPushHTTPRequest } from "@pushforge/builder";
|
|
194
341
|
```
|
|
195
342
|
|
|
343
|
+
Or: `node --experimental-global-webcrypto your-script.js`
|
|
344
|
+
|
|
345
|
+
</details>
|
|
346
|
+
|
|
347
|
+
## Security
|
|
348
|
+
|
|
349
|
+
PushForge validates all inputs before processing:
|
|
350
|
+
|
|
351
|
+
- VAPID key structure (EC P-256 curve with required x, y, d parameters)
|
|
352
|
+
- Subscription endpoint (must be valid HTTPS URL)
|
|
353
|
+
- p256dh key format (65-byte uncompressed P-256 point)
|
|
354
|
+
- Auth secret length (exactly 16 bytes)
|
|
355
|
+
- Payload size (max 4KB per Web Push spec)
|
|
356
|
+
- TTL bounds (max 24 hours per VAPID spec)
|
|
357
|
+
|
|
196
358
|
## License
|
|
197
359
|
|
|
198
360
|
MIT
|
|
@@ -1,35 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { getPublicKeyFromJwk } from '../utils.js';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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.");
|
|
3
|
+
if (!globalThis.crypto?.subtle) {
|
|
4
|
+
console.error('Error: Web Crypto API not available.');
|
|
5
|
+
console.error('Please ensure you are running Node.js 20.0.0 or later.');
|
|
11
6
|
process.exit(1);
|
|
12
7
|
}
|
|
13
8
|
async function generateVapidKeys() {
|
|
14
9
|
try {
|
|
15
|
-
console.log('Generating VAPID keys
|
|
16
|
-
const keypair = await
|
|
17
|
-
const privateJWK = await
|
|
10
|
+
console.log('Generating VAPID keys...\n');
|
|
11
|
+
const keypair = await globalThis.crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
|
|
12
|
+
const privateJWK = await globalThis.crypto.subtle.exportKey('jwk', keypair.privateKey);
|
|
18
13
|
const privateJWKWithAlg = { alg: 'ES256', ...privateJWK };
|
|
19
14
|
const publicKey = getPublicKeyFromJwk(privateJWKWithAlg);
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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);
|
|
15
|
+
console.log('VAPID Keys Generated Successfully\n');
|
|
16
|
+
console.log('Public Key:');
|
|
17
|
+
console.log(publicKey);
|
|
18
|
+
console.log('\nPrivate Key (JWK):');
|
|
19
|
+
console.log(JSON.stringify(privateJWKWithAlg));
|
|
20
|
+
console.log('\nStore these keys securely. Never expose your private key.');
|
|
33
21
|
}
|
|
34
22
|
catch (error) {
|
|
35
23
|
console.error('Error generating VAPID keys:');
|
|
@@ -39,23 +27,39 @@ Store these keys securely. Never expose your private key.
|
|
|
39
27
|
else {
|
|
40
28
|
console.error('An unknown error occurred.');
|
|
41
29
|
}
|
|
42
|
-
console.error('\nThis tool requires Node.js
|
|
30
|
+
console.error('\nThis tool requires Node.js 20.0.0 or later with Web Crypto API support.');
|
|
43
31
|
process.exit(1);
|
|
44
32
|
}
|
|
45
33
|
}
|
|
46
|
-
|
|
47
|
-
const args = process.argv.slice(2);
|
|
48
|
-
const command = args[0];
|
|
49
|
-
if (command === 'generate-vapid-keys') {
|
|
50
|
-
generateVapidKeys();
|
|
51
|
-
}
|
|
52
|
-
else {
|
|
34
|
+
function showHelp() {
|
|
53
35
|
console.log(`
|
|
54
|
-
PushForge CLI
|
|
36
|
+
PushForge CLI
|
|
37
|
+
|
|
38
|
+
Usage: npx @pushforge/builder <command>
|
|
39
|
+
|
|
40
|
+
Commands:
|
|
41
|
+
vapid Generate VAPID key pair for Web Push authentication
|
|
42
|
+
help Show this help message
|
|
55
43
|
|
|
56
|
-
|
|
57
|
-
npx @pushforge/builder
|
|
44
|
+
Examples:
|
|
45
|
+
npx @pushforge/builder vapid
|
|
58
46
|
|
|
59
|
-
|
|
60
|
-
|
|
47
|
+
Documentation: https://github.com/draphy/pushforge#readme
|
|
48
|
+
`);
|
|
49
|
+
}
|
|
50
|
+
// Parse command
|
|
51
|
+
const args = process.argv.slice(2);
|
|
52
|
+
const command = args[0]?.toLowerCase();
|
|
53
|
+
switch (command) {
|
|
54
|
+
case 'vapid':
|
|
55
|
+
generateVapidKeys();
|
|
56
|
+
break;
|
|
57
|
+
case 'help':
|
|
58
|
+
case '--help':
|
|
59
|
+
case '-h':
|
|
60
|
+
showHelp();
|
|
61
|
+
break;
|
|
62
|
+
default:
|
|
63
|
+
showHelp();
|
|
64
|
+
process.exit(command ? 1 : 0);
|
|
61
65
|
}
|
package/dist/lib/crypto.js
CHANGED
|
@@ -2,28 +2,20 @@
|
|
|
2
2
|
/// <reference types="node" />
|
|
3
3
|
/**
|
|
4
4
|
* A module that provides a cross-platform cryptographic interface.
|
|
5
|
+
* Uses globalThis.crypto which is available in:
|
|
6
|
+
* - Node.js 20+ (current LTS)
|
|
7
|
+
* - Browsers
|
|
8
|
+
* - Cloudflare Workers
|
|
9
|
+
* - Deno
|
|
10
|
+
* - Bun
|
|
11
|
+
* - Convex
|
|
5
12
|
*
|
|
6
13
|
* @module crypto
|
|
7
14
|
*/
|
|
8
|
-
|
|
9
|
-
|
|
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.');
|
|
15
|
+
if (!globalThis.crypto?.subtle) {
|
|
16
|
+
throw new Error('Web Crypto API not available. Ensure you are using Node.js 20+ or a modern runtime with globalThis.crypto support.');
|
|
26
17
|
}
|
|
18
|
+
const isomorphicCrypto = globalThis.crypto;
|
|
27
19
|
/**
|
|
28
20
|
* A cryptographic interface that provides methods for generating random values
|
|
29
21
|
* and accessing subtle cryptographic operations.
|
package/dist/lib/payload.d.ts
CHANGED
|
@@ -8,4 +8,4 @@ import type { PushSubscription } from './types.js';
|
|
|
8
8
|
* @param {PushSubscription} target - The target push subscription containing client keys.
|
|
9
9
|
* @returns {Promise<ArrayBuffer>} A promise that resolves to the encrypted payload.
|
|
10
10
|
*/
|
|
11
|
-
export declare const encryptPayload: (localKeys: CryptoKeyPair, salt: Uint8Array
|
|
11
|
+
export declare const encryptPayload: (localKeys: CryptoKeyPair, salt: Uint8Array<ArrayBuffer>, payload: string, target: PushSubscription) => Promise<ArrayBuffer>;
|
package/dist/lib/payload.js
CHANGED
|
@@ -28,6 +28,14 @@ const importClientKeys = async (keys) => {
|
|
|
28
28
|
// Node.js environment
|
|
29
29
|
decodedKey = new Uint8Array(Buffer.from(base64Key, 'base64'));
|
|
30
30
|
}
|
|
31
|
+
// Validate p256dh key format: must be 65 bytes (uncompressed P-256 point)
|
|
32
|
+
// Format: 0x04 (1 byte) + x coordinate (32 bytes) + y coordinate (32 bytes)
|
|
33
|
+
if (decodedKey.byteLength !== 65) {
|
|
34
|
+
throw new Error(`Invalid p256dh key: expected 65 bytes but got ${decodedKey.byteLength} bytes`);
|
|
35
|
+
}
|
|
36
|
+
if (decodedKey[0] !== 0x04) {
|
|
37
|
+
throw new Error(`Invalid p256dh key: expected uncompressed point format (0x04 prefix) but got 0x${decodedKey[0].toString(16).padStart(2, '0')}`);
|
|
38
|
+
}
|
|
31
39
|
const p256 = await crypto.subtle.importKey('jwk', {
|
|
32
40
|
kty: 'EC',
|
|
33
41
|
crv: 'P-256',
|
|
@@ -107,6 +115,17 @@ const deriveContentEncryptionKey = async (pseudoRandomKey, salt, context) => {
|
|
|
107
115
|
const bits = await crypto.subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt, info }, pseudoRandomKey, 16 * 8);
|
|
108
116
|
return crypto.subtle.importKey('raw', bits, 'AES-GCM', false, ['encrypt']);
|
|
109
117
|
};
|
|
118
|
+
/**
|
|
119
|
+
* Maximum payload size after accounting for encryption overhead.
|
|
120
|
+
* Web push payloads have an overall max size of 4KB (4096 bytes).
|
|
121
|
+
* With the required overhead (16 bytes auth tag + 2 bytes padding length),
|
|
122
|
+
* the actual max payload size is 4078 bytes.
|
|
123
|
+
*/
|
|
124
|
+
const MAX_PAYLOAD_SIZE = 4078;
|
|
125
|
+
/**
|
|
126
|
+
* Minimum required size for padding length prefix (2 bytes).
|
|
127
|
+
*/
|
|
128
|
+
const PADDING_LENGTH_PREFIX_SIZE = 2;
|
|
110
129
|
/**
|
|
111
130
|
* Pads the payload to ensure it fits within the maximum allowed size for web push notifications.
|
|
112
131
|
*
|
|
@@ -114,17 +133,22 @@ const deriveContentEncryptionKey = async (pseudoRandomKey, salt, context) => {
|
|
|
114
133
|
* required overhead for encryption, the actual max payload size is 4078 bytes.
|
|
115
134
|
*
|
|
116
135
|
* @param {Uint8Array} payload - The original payload to be padded.
|
|
117
|
-
* @returns {Uint8Array} The padded payload, including length information.
|
|
136
|
+
* @returns {Uint8Array<ArrayBuffer>} The padded payload, including length information.
|
|
137
|
+
* @throws {Error} Throws an error if the payload exceeds the maximum allowed size.
|
|
118
138
|
*/
|
|
119
139
|
const padPayload = (payload) => {
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (payloadSizeWithPadding > MAX_PAYLOAD_SIZE) {
|
|
124
|
-
// Adjust padding size if the total exceeds the maximum allowed size
|
|
125
|
-
paddingSize -= payloadSizeWithPadding - MAX_PAYLOAD_SIZE;
|
|
140
|
+
const maxPayloadContentSize = MAX_PAYLOAD_SIZE - PADDING_LENGTH_PREFIX_SIZE;
|
|
141
|
+
if (payload.byteLength > maxPayloadContentSize) {
|
|
142
|
+
throw new Error(`Payload too large. Maximum size is ${maxPayloadContentSize} bytes, but received ${payload.byteLength} bytes`);
|
|
126
143
|
}
|
|
127
|
-
|
|
144
|
+
// Calculate available space for padding
|
|
145
|
+
const availableSpace = MAX_PAYLOAD_SIZE - PADDING_LENGTH_PREFIX_SIZE - payload.byteLength;
|
|
146
|
+
// Generate random padding size, clamped to available space
|
|
147
|
+
const maxRandomPadding = Math.min(100, availableSpace);
|
|
148
|
+
const paddingSize = maxRandomPadding > 0
|
|
149
|
+
? Math.floor(Math.random() * (maxRandomPadding + 1))
|
|
150
|
+
: 0;
|
|
151
|
+
const paddingArray = new ArrayBuffer(PADDING_LENGTH_PREFIX_SIZE + paddingSize);
|
|
128
152
|
new DataView(paddingArray).setUint16(0, paddingSize); // Store the length of the padding
|
|
129
153
|
// Return the new payload with padding added
|
|
130
154
|
return concatTypedArrays([new Uint8Array(paddingArray), payload]);
|
package/dist/lib/request.js
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
import { crypto } from './crypto.js';
|
|
2
2
|
import { encryptPayload } from './payload.js';
|
|
3
3
|
import { vapidHeaders } from './vapid.js';
|
|
4
|
+
/**
|
|
5
|
+
* Validates that a JWK has the required properties for ECDSA P-256.
|
|
6
|
+
*
|
|
7
|
+
* @param {JsonWebKey} jwk - The JSON Web Key to validate.
|
|
8
|
+
* @throws {Error} Throws if the JWK is missing required properties or has invalid values.
|
|
9
|
+
*/
|
|
10
|
+
const validatePrivateJWK = (jwk) => {
|
|
11
|
+
if (jwk.kty !== 'EC') {
|
|
12
|
+
throw new Error(`Invalid JWK: 'kty' must be 'EC', received '${jwk.kty ?? 'undefined'}'`);
|
|
13
|
+
}
|
|
14
|
+
if (jwk.crv !== 'P-256') {
|
|
15
|
+
throw new Error(`Invalid JWK: 'crv' must be 'P-256', received '${jwk.crv ?? 'undefined'}'`);
|
|
16
|
+
}
|
|
17
|
+
if (!jwk.x || typeof jwk.x !== 'string') {
|
|
18
|
+
throw new Error("Invalid JWK: missing or invalid 'x' coordinate");
|
|
19
|
+
}
|
|
20
|
+
if (!jwk.y || typeof jwk.y !== 'string') {
|
|
21
|
+
throw new Error("Invalid JWK: missing or invalid 'y' coordinate");
|
|
22
|
+
}
|
|
23
|
+
if (!jwk.d || typeof jwk.d !== 'string') {
|
|
24
|
+
throw new Error("Invalid JWK: missing or invalid 'd' (private key)");
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Validates that the subscription endpoint is a valid HTTPS URL.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} endpoint - The push subscription endpoint URL.
|
|
31
|
+
* @throws {Error} Throws if the endpoint is not a valid HTTPS URL.
|
|
32
|
+
*/
|
|
33
|
+
const validateEndpoint = (endpoint) => {
|
|
34
|
+
let url;
|
|
35
|
+
try {
|
|
36
|
+
url = new URL(endpoint);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
throw new Error(`Invalid subscription endpoint: '${endpoint}' is not a valid URL`);
|
|
40
|
+
}
|
|
41
|
+
if (url.protocol !== 'https:') {
|
|
42
|
+
throw new Error(`Invalid subscription endpoint: push endpoints must use HTTPS, received '${url.protocol}'`);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
4
45
|
/**
|
|
5
46
|
* Builds an HTTP request body and headers for sending a push notification.
|
|
6
47
|
*
|
|
@@ -57,7 +98,17 @@ import { vapidHeaders } from './vapid.js';
|
|
|
57
98
|
*/
|
|
58
99
|
export async function buildPushHTTPRequest({ privateJWK, message, subscription, }) {
|
|
59
100
|
// Parse the private JWK if it's a string
|
|
60
|
-
|
|
101
|
+
let jwk;
|
|
102
|
+
try {
|
|
103
|
+
jwk = typeof privateJWK === 'string' ? JSON.parse(privateJWK) : privateJWK;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
throw new Error('Invalid privateJWK: failed to parse JSON string');
|
|
107
|
+
}
|
|
108
|
+
// Validate the JWK structure
|
|
109
|
+
validatePrivateJWK(jwk);
|
|
110
|
+
// Validate the subscription endpoint
|
|
111
|
+
validateEndpoint(subscription.endpoint);
|
|
61
112
|
const MAX_TTL = 24 * 60 * 60;
|
|
62
113
|
if (message.options?.ttl && message.options.ttl > MAX_TTL) {
|
|
63
114
|
throw new Error('TTL must be less than 24 hours');
|
package/dist/lib/utils.d.ts
CHANGED
|
@@ -48,6 +48,6 @@ export declare const getPublicKeyFromJwk: (jwk: JsonWebKey) => string;
|
|
|
48
48
|
* Concatenates multiple Uint8Array instances into a single Uint8Array.
|
|
49
49
|
*
|
|
50
50
|
* @param {Uint8Array[]} arrays - An array of Uint8Array instances to concatenate.
|
|
51
|
-
* @returns {Uint8Array} A new Uint8Array containing all the concatenated data.
|
|
51
|
+
* @returns {Uint8Array<ArrayBuffer>} A new Uint8Array containing all the concatenated data.
|
|
52
52
|
*/
|
|
53
|
-
export declare const concatTypedArrays: (arrays: Uint8Array[]) => Uint8Array
|
|
53
|
+
export declare const concatTypedArrays: (arrays: readonly Uint8Array[]) => Uint8Array<ArrayBuffer>;
|
package/dist/lib/utils.js
CHANGED
|
@@ -60,7 +60,7 @@ export const getPublicKeyFromJwk = (jwk) => base64UrlEncode(`\x04${base64Decode(
|
|
|
60
60
|
* Concatenates multiple Uint8Array instances into a single Uint8Array.
|
|
61
61
|
*
|
|
62
62
|
* @param {Uint8Array[]} arrays - An array of Uint8Array instances to concatenate.
|
|
63
|
-
* @returns {Uint8Array} A new Uint8Array containing all the concatenated data.
|
|
63
|
+
* @returns {Uint8Array<ArrayBuffer>} A new Uint8Array containing all the concatenated data.
|
|
64
64
|
*/
|
|
65
65
|
export const concatTypedArrays = (arrays) => {
|
|
66
66
|
const length = arrays.reduce((accumulator, current) => accumulator + current.byteLength, 0);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pushforge/builder",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
4
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
5
|
"private": false,
|
|
6
6
|
"main": "dist/lib/main.js",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"url": "https://github.com/draphy/pushforge/issues"
|
|
15
15
|
},
|
|
16
16
|
"engines": {
|
|
17
|
-
"node": ">=
|
|
17
|
+
"node": ">=20.0.0"
|
|
18
18
|
},
|
|
19
19
|
"repository": {
|
|
20
20
|
"type": "git",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"semantic-release": "semantic-release"
|
|
46
46
|
},
|
|
47
47
|
"bin": {
|
|
48
|
-
"
|
|
48
|
+
"pushforge": "./dist/lib/commandLine/keys.js"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@semantic-release/commit-analyzer": "^11.1.0",
|