web-push-lite
v0.1.0
Published
Zero-dependency Web Push (VAPID) sender for Node.js — mints the JWT and encrypts the payload (RFC 8291) with only node:crypto. No native addons, no dependency tree.
Maintainers
Readme
web-push-lite
Send Web Push notifications from Node.js with zero runtime dependencies. It mints the VAPID JWT (ES256) and encrypts the payload per RFC 8291 (aes128gcm) using only the built-in node:crypto module — no native addons, no transitive dependency tree to audit.
import { generateVapidKeys, sendWebPush } from 'web-push-lite';
// One-time: generate a key pair and store it.
const keys = generateVapidKeys();
// Per notification:
const result = await sendWebPush(
subscription, // the PushSubscription JSON from the browser
JSON.stringify({ title: 'Job assigned', body: 'Tap to view', url: '/jobs/42' }),
{
vapidPublicKey: keys.publicKey,
vapidPrivateKey: keys.privateKey,
subject: 'mailto:[email protected]',
},
);
if (result.expired) {
// 404/410 — the subscription is gone; delete it from your store.
}Why
The de-facto library, web-push, pulls in a chain of dependencies (https-proxy-agent, jws, asn1.js, …) for what is, at core, a JWT signature and an ECDH+AES-GCM encryption — both of which Node's standard library already does. web-push-lite is that core, and nothing else:
- Zero runtime dependencies. Nothing to audit, nothing to get a CVE.
- Just
node:crypto. Nonode-gyp, no prebuilt binaries. - One small surface you can read in a single sitting.
If you send a handful of notification types from your own server, this is likely all you need.
Install
npm install web-push-liteRequires Node.js 20+ (uses the global fetch and AbortSignal.timeout).
API
generateVapidKeys(): { publicKey, privateKey }
Generate a VAPID application-server key pair, both base64url-encoded. Generate it once, store both halves, and reuse them: the public key is handed to the browser when it calls pushManager.subscribe({ applicationServerKey }) and must stay stable, while the private key signs every push.
const { publicKey, privateKey } = generateVapidKeys();sendWebPush(subscription, payload, options): Promise<WebPushResult>
Send one notification.
subscription— thePushSubscriptionobject serialized by the browser:{ endpoint, keys: { p256dh, auth } }.payload— astringorBufferdelivered to your service worker'spushevent. PassJSON.stringify(obj)for structured data.options:| Field | Required | Default | Notes | |---|---|---|---| |
vapidPublicKey| ✓ | — | base64url, fromgenerateVapidKeys()| |vapidPrivateKey| ✓ | — | base64url; keep secret | |subject| ✓ | — |mailto:orhttps:URL identifying you (VAPIDsubclaim) | |ttl| |86400| seconds the push service retains an undelivered message | |urgency| |'normal'|'very-low' \| 'low' \| 'normal' \| 'high'| |topic| | — | later push with the same topic replaces this one | |signal| | 10s timeout | your ownAbortSignal|
Returns:
interface WebPushResult {
statusCode: number; // the push service's HTTP status
success: boolean; // 2xx
expired: boolean; // 404/410 — subscription gone, delete it
}sendWebPush only throws on a network/transport error (or your signal aborting). A push service rejecting the message is reported via statusCode/success, not thrown — so a single dead subscription never crashes a fan-out loop.
Fanning out to many subscribers
The library sends one message; you own the loop and your subscription store:
for (const sub of subscriptions) {
const { expired } = await sendWebPush(sub, payload, opts).catch(() => ({ expired: false }));
if (expired) await db.deleteSubscription(sub.endpoint);
}encryptPayload(payload, p256dh, auth): Buffer
The RFC 8291 aes128gcm encryption sendWebPush uses internally, exported for advanced callers who build the HTTP request themselves.
How it works
- VAPID JWT — an ES256 (ECDSA P-256 + SHA-256) JWT is signed with your private key, asserting the push endpoint's origin as the audience and your
subjectas contact. Sent in theAuthorization: vapid t=…, k=…header. - Payload encryption — an ephemeral ECDH key agreement with the subscriber's
p256dhkey derives, via HKDF, a content-encryption key and nonce; the payload is sealed with AES-128-GCM and framed with theaes128gcmcontent-coding header (salt, record size, and the ephemeral public key). - Delivery — a single
POSTtosubscription.endpointwith the encrypted body and the standardTTL/Urgency/Content-Encodingheaders.
Every step is covered by tests that verify against the standard: the JWT is checked with an independent ES256 verification, and the encrypted payload is decrypted back to the original plaintext exactly as a browser's push service would.
Limitations & scope
- Sends; doesn't store. Managing subscriptions (persisting them, pruning
expiredones) is your application's job — see the fan-out example. aes128gcmonly. This is the modern encoding every current browser supports. The legacyaesgcm/aesgcm128encodings are intentionally not implemented.- No VAPID key persistence.
generateVapidKeys()returns a pair; storing it is up to you. - One dependency-free HTTP send. It uses Node's global
fetch; there's no built-in retry or proxy support — wrap the call if you need those.
License
MIT © Ian Duncan
