Skip to content
Closed
47 changes: 47 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,53 @@ All notable changes to `@mmmike/web-push` are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **`SubscribeResult.replacedEndpoint`.** When `subscribe` rotates a stale-key
subscription, the old endpoint is now returned so the server can delete its record.
Before, it was discarded, and the record lingered until a send happened to report it
gone, which never happens while the push service answers the new key with 401/403.
The property is absent when nothing was replaced, or when the push service reissued
the same endpoint (deleting it would remove the record just saved).

### Fixed

- **`subscribe` no longer discards a working subscription when given a malformed VAPID
key.** An empty or wrong-length key (an unset env var, the private key by mistake)
never matched the existing subscription's key, so it was unsubscribed as a stale-key
rotation before `pushManager.subscribe` rejected the key. The key is now validated
first and throws `VAPID public key must be a 65-byte uncompressed P-256 point`
before the permission prompt.
- **`getCurrentSubscription`, `unsubscribe` and `unsubscribeFromPush` resolve when no
service worker is registered** (`null`, `null`, `true`) instead of waiting on
`navigator.serviceWorker.ready` forever.
- **A VAPID public key or subscription `p256dh` key that is not base64url** (an env
var with its quotes left on, say) now throws the same descriptive error as a
wrong-length key, instead of `atob`'s `InvalidCharacterError`.
- **Send options that could only fail on the wire are now rejected up front.**
`timeoutMs` must be a whole number of milliseconds up to 2^31 - 1: Node fired a
larger one after 1ms and threw per send on a fraction. `ttl` must be a whole number
of seconds, 0 or more, instead of reaching the `TTL` header as `NaN` or `-1` and
drawing a 400 per subscription. A NaN `vapidExpiration` no longer signs a token
with `"exp": null` that every push service rejects. `sendPushBatch` throws once for
each of these, before any send, rather than filling `failed`.
- **`sendPushBatch` re-signs its per-origin VAPID token at half its lifetime.** One
token was cached for the whole batch, so a batch that outlasted `vapidExpiration`
sent expired tokens and filled `failed` with 401/403s.
- **A `null` row no longer rejects the whole `sendPushBatch`.** The error handler read
`.endpoint` off the row, threw, and rejected the batch while its workers kept
sending unobserved. The row now lands in `failed` with an empty `endpoint`.

### Security

- **Push service responses are read up to 4 KB, then the stream is cancelled.**
Endpoints come from subscribers, so a hostile one could stream an unbounded body that
was buffered whole on every send. `WebPushError.body` now holds at most the first 4 KB.
- **The per-message ECDH key pair is generated non-extractable.** Only its public
half is ever exported, and public keys stay exportable regardless.

## [1.3.0] - 2026-08-14

### Added
Expand Down
28 changes: 23 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Sending a push notification should not require Node, a compatibility shim, or th
- **All four pieces.** VAPID key generation, a copy-paste service worker to display the notification, the client subscribe helpers, and the server send, one at a time or fanned out to a list.
- **No Node built-ins, no polyfills.** If it has `fetch` and `crypto.subtle`, it works. No `node:crypto`, no `node:https`, no compat layer. Cloudflare Workers, Bun, Deno, Node, and the browser.
- **Ratified specs.** RFC 8291 `aes128gcm` encryption and RFC 8292 VAPID, pinned byte-for-byte against RFC 8291's Appendix A test vector.
- **Small.** Minified and gzipped per subpath entry: 1.0 kB for the browser client, 2.9 kB for the server. The badge above measures the whole package, ~3.6 kB.
- **Small.** Minified and gzipped per subpath entry: 1.1 kB for the browser client, 3.1 kB for the server. The badge above measures the whole package, ~3.9 kB.

## Installation

Expand Down Expand Up @@ -67,20 +67,38 @@ This is the receiving end of `PushPayload`: `title` and `body` become the notifi
### 3. Client: subscribe

```typescript
import { subscribe, sendSubscriptionToServer } from "@mmmike/web-push/client";
import {
subscribe,
sendSubscriptionToServer,
removeSubscriptionFromServer,
} from "@mmmike/web-push/client";

await navigator.serviceWorker.register("/sw.js");

const result = await subscribe(vapidPublicKey);
if (result.status === "subscribed") {
await sendSubscriptionToServer(result.subscription, "/api/push/subscribe");
if (result.replacedEndpoint) {
await removeSubscriptionFromServer(result.replacedEndpoint, "/api/push/subscribe");
}
}
```

`subscribe` resolves to `{ status: "unsupported" }` when the browser can't do push, `{ status: "denied" }` when the user declines the permission prompt, and `{ status: "subscribed", subscription, isNew }` otherwise. `isNew` is `true` when this call created the subscription: a first subscribe, or a VAPID key rotation replacing the stale one.
`subscribe` resolves to `{ status: "unsupported" }` when the browser can't do push, `{ status: "denied" }` when the user declines the permission prompt, and `{ status: "subscribed", subscription, isNew, replacedEndpoint? }` otherwise. `isNew` is `true` when this call created the subscription: a first subscribe, or a VAPID key rotation replacing the stale one. `replacedEndpoint` is present only after a rotation that issued a new endpoint, and names the stale subscription your server should delete.

POST the subscription on every visit rather than gating on `isNew`. Your endpoint has to upsert by endpoint URL anyway, since the browser hands back the same subscription on every call, and gating means one failed upload strands a subscription your server never hears about. Use `isNew` for what it does tell you: counting fresh subscribes and key rotations.

To turn push off, `unsubscribe` resolves to the endpoint your server keyed the record by, or `null` when there was nothing to unsubscribe:

```typescript
import { unsubscribe, removeSubscriptionFromServer } from "@mmmike/web-push/client";

const endpoint = await unsubscribe();
if (endpoint) {
await removeSubscriptionFromServer(endpoint, "/api/push/subscribe");
}
```

Requirements:

- a secure context (HTTPS or `localhost`)
Expand All @@ -89,7 +107,7 @@ Requirements:

Encrypted-payload push works in Chrome, Edge, Firefox, Opera and Samsung Internet, and in Safari 16+ on macOS 13+ and iOS 16.4+. `isPushSupported()` is the runtime check.

Rotating VAPID keys is handled for you: when the existing subscription was created with a different key, `subscribe` unsubscribes it and creates a fresh one (`isNew: true`), since the push service would reject the old one anyway. The one gap is a browser that doesn't expose which key a subscription was bound to (`subscription.options.applicationServerKey` is null): with nothing to compare against, the existing subscription is kept. If it was in fact bound to a retired key, every send to it fails with a 401/403 `WebPushError`, and nothing in that error names the rotation as the cause; if you rotate keys and then see those, this is where to look.
Rotating VAPID keys is handled for you: when the existing subscription was created with a different key, `subscribe` unsubscribes it and creates a fresh one (`isNew: true`), since the push service would reject the old one anyway. The old endpoint comes back as `replacedEndpoint`; delete its record rather than waiting for a send to report it gone, because a push service that still holds the old subscription answers sends signed with the new key with 401/403, not 404/410. The one gap is a browser that doesn't expose which key a subscription was bound to (`subscription.options.applicationServerKey` is null): with nothing to compare against, the existing subscription is kept. If it was in fact bound to a retired key, every send to it fails with a 401/403 `WebPushError`, and nothing in that error names the rotation as the cause; if you rotate keys and then see those, this is where to look.

### 4. Server: send

Expand Down Expand Up @@ -236,7 +254,7 @@ Every type ships with per-field documentation, so your editor is the reference.
| Type | Purpose | Exported from |
| ---------------------- | ------------------------------------------------------------------------------------- | ------------------------ |
| `PushSubscriptionData` | A subscription in transit, endpoint plus the `p256dh`/`auth` keys | root, `/send`, `/client` |
| `SubscribeResult` | Outcome of `subscribe`: `subscribed` (with `isNew`), `unsupported`, or `denied` | root, `/client` |
| `SubscribeResult` | Outcome of `subscribe`: `subscribed` (`isNew`, `replacedEndpoint`), or why not | root, `/client` |
| `PushPayload` | Notification contents: title (the only required field), body, click URL, grouping tag | root, `/send` |
| `RawPushPayload` | A caller-serialized payload from `rawPayload`, accepted wherever `PushPayload` is | root, `/send` |
| `VapidConfig` | Your VAPID key pair and contact subject | root, `/send` |
Expand Down
49 changes: 37 additions & 12 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

import type { PushSubscriptionData } from "./types";
import { uint8ArrayToUrlBase64, urlBase64ToUint8Array } from "./vapid";
import { decodeVapidPublicKey, uint8ArrayToUrlBase64 } from "./encoding";

export type { PushSubscriptionData };

Expand Down Expand Up @@ -34,6 +34,8 @@ export type SubscribeResult =
subscription: PushSubscription;
/** Whether this call created the subscription: first subscribe, or key rotation */
isNew: boolean;
/** Endpoint a key rotation unsubscribed, when it differs from the new one; delete its record */
replacedEndpoint?: string;
}
| { status: "unsupported" }
| { status: "denied" };
Expand All @@ -54,12 +56,17 @@ const isBoundToKey = (subscription: PushSubscription, key: Uint8Array): boolean
*
* Reuses an existing subscription when it was created with the same VAPID key.
* If the key has changed, the stale subscription is unsubscribed and replaced,
* since the push service would reject it anyway. `isNew` reports whether this
* since the push service would reject it anyway, and its endpoint comes back as
* `replacedEndpoint` so the server can delete that record. `isNew` reports whether this
* call created the subscription (first subscribe or key rotation). Send the
* subscription to your server either way: gating the upload on `isNew` means
* one failed request strands a subscription your server never hears about.
*
* @param vapidPublicKey - The server's VAPID public key (URL-safe base64)
* @throws {Error} if `vapidPublicKey` is not a 65-byte uncompressed P-256 point;
* checked before the permission prompt and before any existing subscription is touched.
* @throws {DOMException} if the browser or push service refuses the subscription, or
* the stale-key subscription can't be unsubscribed during a rotation.
* @example
* ```ts
* const result = await subscribe(vapidPublicKey);
Expand All @@ -73,14 +80,16 @@ export const subscribe = async (vapidPublicKey: string): Promise<SubscribeResult
return { status: "unsupported" };
}

// Before the stale-key comparison: a malformed key never matches, so it
// would otherwise unsubscribe a working subscription as a "rotation".
const applicationServerKey = decodeVapidPublicKey(vapidPublicKey);

const permission = await requestNotificationPermission();
if (permission !== "granted") {
return { status: "denied" };
}

const registration = await navigator.serviceWorker.ready;
const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey);

const existingSubscription = await registration.pushManager.getSubscription();
if (existingSubscription) {
if (isBoundToKey(existingSubscription, applicationServerKey)) {
Expand All @@ -93,7 +102,12 @@ export const subscribe = async (vapidPublicKey: string): Promise<SubscribeResult
userVisibleOnly: true,
applicationServerKey,
});
return { status: "subscribed", subscription, isNew: true };
const staleEndpoint = existingSubscription?.endpoint;
// A push service may reissue the same endpoint; reporting it would have the
// caller delete the record it just saved.
return staleEndpoint && staleEndpoint !== subscription.endpoint
? { status: "subscribed", subscription, isNew: true, replacedEndpoint: staleEndpoint }
: { status: "subscribed", subscription, isNew: true };
};

/**
Expand All @@ -106,14 +120,18 @@ export const subscribeToPush = subscribe;

/**
* Get the current push subscription if it exists.
*
* @throws {DOMException} if the browser refuses the registration or subscription lookup.
*/
export const getCurrentSubscription = async (): Promise<PushSubscription | null> => {
if (!isPushSupported()) {
return null;
}

const registration = await navigator.serviceWorker.ready;
return registration.pushManager.getSubscription();
// Not `ready`: it never settles without a registered worker, and no
// registration means no subscription.
const registration = await navigator.serviceWorker.getRegistration();
return registration ? registration.pushManager.getSubscription() : null;
};

/**
Expand All @@ -125,6 +143,9 @@ export const getCurrentSubscription = async (): Promise<PushSubscription | null>
* @deprecated Use {@link unsubscribe} instead: it returns the
* unsubscribed endpoint, which the server needs to prune its matching
* subscription record — this boolean drops it.
* @throws {DOMException} if the browser refuses the subscription lookup, or
* rejects the unsubscribe itself (e.g. the push service is unreachable). In
* that second case `unsubscribe` resolves the endpoint instead.
*/
export const unsubscribeFromPush = async (): Promise<boolean> => {
const subscription = await getCurrentSubscription();
Expand All @@ -140,6 +161,9 @@ export const unsubscribeFromPush = async (): Promise<boolean> => {
* returned even if the browser reports the unsubscribe as failed or rejects
* (e.g. the push service is unreachable): the caller's intent is to stop
* receiving pushes, and removing the server record achieves that regardless.
*
* @throws {DOMException} if the browser refuses the subscription lookup, before
* anything is unsubscribed.
*/
export const unsubscribe = async (): Promise<string | null> => {
const subscription = await getCurrentSubscription();
Expand All @@ -148,11 +172,7 @@ export const unsubscribe = async (): Promise<string | null> => {
}

const { endpoint } = subscription;
try {
await subscription.unsubscribe();
} catch {
// The endpoint is the contract; a failed deregistration must not hide it.
}
await subscription.unsubscribe().catch(() => false);
return endpoint;
};

Expand Down Expand Up @@ -188,6 +208,9 @@ const sendJson = async (endpoint: string, method: string, body: unknown): Promis

/**
* Send a push subscription to a server endpoint.
*
* @throws {Error} if the subscription is missing its `p256dh` or `auth` key.
* @throws {TypeError} if the request never reaches the server.
*/
export const sendSubscriptionToServer = async (
subscription: PushSubscription,
Expand All @@ -196,6 +219,8 @@ export const sendSubscriptionToServer = async (

/**
* Remove a push subscription from a server endpoint.
*
* @throws {TypeError} if the request never reaches the server.
*/
export const removeSubscriptionFromServer = (
subscriptionEndpoint: string,
Expand Down
78 changes: 78 additions & 0 deletions src/encoding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* Not a package entry point: `./vapid` re-exports the base64url codec, and the
* key decoders stay internal.
*/

const P256_PUBLIC_KEY_LENGTH = 65;
const UNCOMPRESSED_POINT_TAG = 0x04;

/**
* Convert a URL-safe base64 string to a Uint8Array.
*
* @throws {DOMException} `InvalidCharacterError` if the input is not base64url.
*/
export const urlBase64ToUint8Array = (base64String: string): Uint8Array<ArrayBuffer> => {
if (!base64String) {
return new Uint8Array(0);
}

const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");

const rawData = atob(base64);
const outputArray = new Uint8Array(rawData.length);

for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}

return outputArray;
};

/**
* Convert a Uint8Array to a URL-safe base64 string (no padding).
*/
export const uint8ArrayToUrlBase64 = (array: Uint8Array): string => {
// Build the binary string in chunks — String.fromCharCode(...array) spreads
// every byte as a call argument and overflows the stack for large inputs.
let binary = "";
const chunkSize = 0x8000;
for (let i = 0; i < array.length; i += chunkSize) {
binary += String.fromCharCode(...array.subarray(i, i + chunkSize));
}
const base64 = btoa(binary);
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
};

const decodeOrUndefined = (base64: string): Uint8Array<ArrayBuffer> | undefined => {
try {
return urlBase64ToUint8Array(base64);
} catch {
return undefined;
}
};

/**
* Decode a URL-safe base64 uncompressed P-256 public key: 65 bytes, `0x04` first.
*
* @param errorMessage Thrown for anything else, including input that is not base64url.
* @throws {Error} with `errorMessage` if the input does not decode to such a key.
*/
export const decodeP256PublicKey = (
base64: string,
errorMessage: string,
): Uint8Array<ArrayBuffer> => {
const key = decodeOrUndefined(base64);
if (key?.length !== P256_PUBLIC_KEY_LENGTH || key[0] !== UNCOMPRESSED_POINT_TAG) {
throw new Error(errorMessage);
}
return key;
};

/**
* Decode an application server's VAPID public key.
*
* @throws {Error} if it is not a base64url 65-byte uncompressed P-256 point.
*/
export const decodeVapidPublicKey = (publicKey: string): Uint8Array<ArrayBuffer> =>
decodeP256PublicKey(publicKey, "VAPID public key must be a 65-byte uncompressed P-256 point");
Loading
Loading