@pushforge/builder
v2.0.5
Published
Zero-dependency Web Push library for Cloudflare Workers, Vercel Edge, Convex, Deno, Bun, and Node.js 20+. A web-push alternative that uses Web Crypto API instead of Node.js crypto.
Maintainers
Readme
PushForge Builder
A lightweight, dependency-free Web Push library built on the standard Web Crypto API.
Send push notifications from any JavaScript runtime · Zero dependencies
GitHub · npm · Report Bug
npm install @pushforge/builderPlayground
Test PushForge in your browser at pushforge.draphy.org — an interactive playground for testing push notifications, powered by Cloudflare Workers.
- Quick Test — enable notifications, send a test message, see it arrive in real time
- Topic Channels — test targeted notifications by subscribing to specific channels
- Notification Customization — experiment with title, body, icon, image, action buttons, vibration, click URL
- Push Options — test urgency levels (battery hints) and TTL (message expiry)
- Cross-Browser — test across Chrome, Firefox, Edge, Safari 16+
- Subscriptions auto-expire (5 min for quick test, 1 hour for topics) — no permanent data stored
- The backend is a single Cloudflare Worker using
buildPushHTTPRequest()with zero dependencies
Why PushForge?
| | PushForge | web-push | |---|:---:|:---:| | Dependencies | 0 | 5+ (with nested deps) | | Cloudflare Workers | Yes | No | | Vercel Edge | Yes | No | | Convex | Yes* | No | | Deno / Bun | Yes | Limited | | TypeScript | First-class | @types package |
* Convex requires "use node"; directive. See example.
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, making it portable across all JavaScript environments.
Quick Start
1. Generate VAPID Keys
npx @pushforge/builder vapidThis outputs a public key (for your frontend) and a private key in JWK format (for your server).
2. Subscribe Users (Frontend)
Use the VAPID public key to subscribe users to push notifications:
// In your frontend application
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: 'YOUR_VAPID_PUBLIC_KEY' // From step 1
});
// Send this subscription to your server
// subscription.toJSON() returns:
// {
// endpoint: "https://fcm.googleapis.com/fcm/send/...",
// keys: {
// p256dh: "BNcRd...",
// auth: "tBHI..."
// }
// }
await fetch('/api/subscribe', {
method: 'POST',
body: JSON.stringify(subscription)
});3. Send Notifications (Server)
import { buildPushHTTPRequest } from "@pushforge/builder";
// Your VAPID private key (JWK format from step 1)
const privateJWK = {
kty: "EC",
crv: "P-256",
x: "...",
y: "...",
d: "..."
};
// The subscription object from the user's browser
const subscription = {
endpoint: "https://fcm.googleapis.com/fcm/send/...",
keys: {
p256dh: "BNcRd...",
auth: "tBHI..."
}
};
// Build and send the notification
const { endpoint, headers, body } = await buildPushHTTPRequest({
privateJWK,
subscription,
message: {
payload: {
title: "New Message",
body: "You have a new notification!",
icon: "/icon.png"
},
adminContact: "mailto:[email protected]"
}
});
const response = await fetch(endpoint, {
method: "POST",
headers,
body
});
if (response.status === 201) {
console.log("Notification sent");
}Understanding Push Subscriptions
When a user subscribes to push notifications, the browser returns a PushSubscription object:
{
// The unique URL for this user's browser push service
endpoint: "https://fcm.googleapis.com/fcm/send/dAPT...",
keys: {
// Public key for encrypting messages (base64url)
p256dh: "BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA...",
// Authentication secret (base64url)
auth: "tBHItJI5svbpez7KI4CCXg=="
}
}| Field | Description |
|-------|-------------|
| endpoint | The push service URL. Each browser vendor has their own (Google FCM, Mozilla autopush, Apple APNs). |
| p256dh | The user's public key for ECDH P-256 message encryption. |
| auth | A shared 16-byte authentication secret. |
Store these securely on your server. You'll need them to send notifications to this user.
API Reference
buildPushHTTPRequest(options)
Builds an HTTP request for sending a push notification.
const { endpoint, headers, body } = await buildPushHTTPRequest({
privateJWK, // Your VAPID private key (JWK object or JSON string)
subscription, // User's push subscription
message: {
payload, // Any JSON-serializable data
adminContact, // Contact email (mailto:...) or URL
options: { // Optional
ttl, // Time-to-live in seconds (default: 86400, max: 86400)
urgency, // "very-low" | "low" | "normal" | "high"
topic // Topic for notification replacement
}
}
});Returns: { endpoint: string, headers: Headers, body: ArrayBuffer }
Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| privateJWK | JsonWebKey | string | Yes | Your VAPID private key (JWK object or JSON string) |
| subscription | PushSubscription | Yes | User's push subscription with endpoint and keys |
| message.payload | any | Yes | Any JSON-serializable data to send (see Notification Payload) |
| message.adminContact | string | Yes | Contact for push service (mailto:[email protected] or URL) |
| message.options | object | No | Push delivery options (see below) |
Push Options (Web Push Protocol Headers)
These options control how the push service handles message delivery:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| ttl | number | 86400 | Time-to-live in seconds. How long the push service retains the message if user is offline. Max 24 hours. |
| urgency | string | - | Battery hint: "very-low" (ads), "low" (topic updates), "normal" (chat), "high" (calls/time-sensitive). |
| topic | string | - | Topic identifier. New message with same topic replaces pending one at push service level (before delivery). |
TypeScript Types
For TypeScript users, these types are exported:
import type {
BuilderOptions, // Parameter type for buildPushHTTPRequest
PushMessage, // The message object type
PushSubscription // The subscription object type
} from "@pushforge/builder";Platform Examples
Cloudflare Workers
export default {
async fetch(request, env) {
const subscription = await request.json();
const { endpoint, headers, body } = await buildPushHTTPRequest({
privateJWK: JSON.parse(env.VAPID_PRIVATE_KEY),
subscription,
message: {
payload: { title: "Hello from the Edge!" },
adminContact: "mailto:[email protected]"
}
});
return fetch(endpoint, { method: "POST", headers, body });
}
};Vercel Edge Functions
import { buildPushHTTPRequest } from "@pushforge/builder";
export const config = { runtime: "edge" };
export default async function handler(request: Request) {
const subscription = await request.json();
const { endpoint, headers, body } = await buildPushHTTPRequest({
privateJWK: JSON.parse(process.env.VAPID_PRIVATE_KEY!),
subscription,
message: {
payload: { title: "Edge Notification" },
adminContact: "mailto:[email protected]"
}
});
await fetch(endpoint, { method: "POST", headers, body });
return new Response("Sent", { status: 200 });
}Convex
Note: Convex's default runtime doesn't support ECDH operations required by Web Push. Add
"use node";to use the Node.js runtime.
"use node";
import { action } from "./_generated/server";
import { buildPushHTTPRequest } from "@pushforge/builder";
import { v } from "convex/values";
export const sendPush = action({
args: { subscription: v.any(), title: v.string(), body: v.string() },
handler: async (ctx, { subscription, title, body }) => {
const { endpoint, headers, body: reqBody } = await buildPushHTTPRequest({
privateJWK: JSON.parse(process.env.VAPID_PRIVATE_KEY!),
subscription,
message: {
payload: { title, body },
adminContact: "mailto:[email protected]"
}
});
await fetch(endpoint, { method: "POST", headers, body: reqBody });
}
});Deno
import { buildPushHTTPRequest } from "npm:@pushforge/builder";
const { endpoint, headers, body } = await buildPushHTTPRequest({
privateJWK: JSON.parse(Deno.env.get("VAPID_PRIVATE_KEY")!),
subscription,
message: {
payload: { title: "Hello from Deno!" },
adminContact: "mailto:[email protected]"
}
});
await fetch(endpoint, { method: "POST", headers, body });Bun
import { buildPushHTTPRequest } from "@pushforge/builder";
const { endpoint, headers, body } = await buildPushHTTPRequest({
privateJWK: JSON.parse(Bun.env.VAPID_PRIVATE_KEY!),
subscription,
message: {
payload: { title: "Hello from Bun!" },
adminContact: "mailto:[email protected]"
}
});
await fetch(endpoint, { method: "POST", headers, body });How It Works
Your Server (PushForge) → Push Service (FCM/APNs) → Service Worker → User's DevicePushForge handles:
- Encrypts payload
- Signs with VAPID
- Sets ttl/urgency/topic headers
Your service worker handles:
- Displays notification (title, body, icon, actions, etc.)
- Handles clicks
Notification Payload
The payload field accepts any JSON-serializable data — PushForge encrypts and delivers it as-is. Your service worker receives this payload and passes it to the browser's showNotification() API.
Note: These are standard Web Notifications API options, not PushForge-specific. PushForge handles the transport; your service worker handles the display.
Common fields:
| Field | Type | Description |
|-------|------|-------------|
| title | string | Notification title (required) |
| body | string | Notification body text |
| icon | string | URL for the notification icon |
| badge | string | URL for the badge (small monochrome icon) |
| image | string | URL for a large image |
| dir | string | Text direction: "auto", "ltr", or "rtl" |
| lang | string | Language tag (e.g., "en-US", "es") |
| tag | string | Tag for notification replacement (same tag = replace, not stack) |
| renotify | boolean | Vibrate/alert again when replacing a notification with same tag |
| requireInteraction | boolean | Keep notification visible until user interacts |
| silent | boolean | Suppress sound and vibration |
| timestamp | number | Timestamp in milliseconds (e.g., Date.now()) |
| vibrate | number[] | Vibration pattern [vibrate, pause, vibrate, ...] |
| actions | array | Action buttons (max 2): [{ action: "id", title: "Label", icon?: "url" }] |
| data | object | Custom data (e.g., { url: "/page" } for click handling) |
Example with full options:
const { endpoint, headers, body } = await buildPushHTTPRequest({
privateJWK,
subscription,
message: {
payload: {
title: "New Message",
body: "John: Hey, are you free?",
icon: "/icons/chat.png",
badge: "/icons/badge.png",
image: "/images/preview.jpg",
tag: "chat-john",
renotify: true,
actions: [
{ action: "reply", title: "Reply" },
{ action: "dismiss", title: "Dismiss" }
],
data: { url: "/chat/john", messageId: "123" }
},
adminContact: "mailto:[email protected]",
options: { urgency: "high", ttl: 3600 }
}
});Service Worker Setup
Handle incoming push notifications in your service worker:
// sw.js
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? {};
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: data.icon,
badge: data.badge,
image: data.image,
dir: data.dir,
lang: data.lang,
tag: data.tag,
renotify: data.renotify,
requireInteraction: data.requireInteraction,
silent: data.silent,
timestamp: data.timestamp,
vibrate: data.vibrate,
actions: data.actions,
data: data.data
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
// Handle action button clicks
if (event.action === 'reply') {
clients.openWindow('/chat?action=reply');
return;
}
// Handle main notification click
const url = event.notification.data?.url || '/';
event.waitUntil(clients.openWindow(url));
});Requirements
Node.js 20+ or any runtime with Web Crypto API support.
| Environment | Status |
|-------------|--------|
| Node.js 20+ | Fully supported |
| Cloudflare Workers | Fully supported |
| Vercel Edge | Fully supported |
| Deno | Fully supported |
| Bun | Fully supported |
| Convex | Requires "use node"; (example) |
| Modern Browsers | Fully supported |
import { webcrypto } from "node:crypto";
globalThis.crypto = webcrypto;
import { buildPushHTTPRequest } from "@pushforge/builder";Or: node --experimental-global-webcrypto your-script.js
Security
PushForge validates all inputs before processing:
- VAPID key structure (EC P-256 curve with required x, y, d parameters)
- Subscription endpoint (must be valid HTTPS URL)
- p256dh key format (65-byte uncompressed P-256 point)
- Auth secret length (exactly 16 bytes)
- Payload size (max 4KB per Web Push spec)
- TTL bounds (max 24 hours per VAPID spec)
Contributing
Contributions welcome! See CONTRIBUTING.md for guidelines.
License
MIT © David Raphi
