Channels / Web push
Web push
VAPID and aes128gcm on Web Crypto — no node:crypto, so it runs on Workers and Edge, not only Node.
Web push is a plugin (push) plus a provider (webPush), because the channel needs a device
registry the core schema doesn't have — see Push devices for that table and
its routes.
Generating VAPID keys
One keypair, generated once and stored as secrets — never regenerated per deploy, or every existing subscription breaks.
import { generateVapidKeys } from "easy-ping/providers/web-push";
const vapid = await generateVapidKeys();
// { publicKey: "...", privateKey: "..." } — save both
Provider and plugin
import { push } from "easy-ping/plugins/push";
import { webPush } from "easy-ping/providers/web-push";
const pushPlugin = push({
provider: webPush({
subject: "mailto:ops@acme.dev", // or an https: URL
vapid: { publicKey: process.env.VAPID_PUBLIC_KEY!, privateKey: process.env.VAPID_PRIVATE_KEY! },
}),
render: ({ type, payload }) => {
const data = payload as { title?: string; body?: string };
return { title: data.title ?? type, body: data.body ?? "You have a new notification" };
},
});
export const notify = easyPing({
// ...
plugins: [pushPlugin],
});render builds what actually shows up in the OS notification. It receives the same typed payload
your notification's schema validates.
The service worker
This is the one piece the library genuinely cannot supply — it has to run in your app's own
origin. Without a push listener, the browser shows its own generic "site updated" text instead
of your title and body.
self.addEventListener("push", (event) => {
const payload = event.data ? event.data.json() : {};
event.waitUntil(
self.registration.showNotification(payload.title ?? "Notification", {
body: payload.body ?? "",
data: payload.data ?? {},
tag: payload.data?.notificationId,
}),
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
event.waitUntil(self.clients.openWindow("/"));
});Subscribing a browser
import { subscribeToPush, isPushSupported } from "easy-ping/browser";
async function enable() {
if (!isPushSupported()) return;
const { vapidPublicKey } = await fetch("/api/config").then((r) => r.json());
await subscribeToPush({
publicKey: vapidPublicKey,
baseUrl: "/api/notifications",
});
}This registers the service worker (if not already registered), requests the notification
permission, calls pushManager.subscribe, and POSTs the resulting subscription to
/push/devices — one call covers the whole browser-side flow.
Dead endpoints
When a push service reports an endpoint gone (404/410 — the user uninstalled, cleared site data,
or the subscription expired), the provider surfaces { expired: true } and the device row is
deleted automatically on the next delivery attempt. An unpruned registry accumulates dead
subscriptions forever, and every send slows down fanning out to endpoints that will never accept
anything again.
Platform caveats
localhost counts as secure, so push works there without HTTPS. Any other host needs real TLS —
the service worker will refuse to register otherwise.
Web push only reaches an iOS PWA installed to the home screen (Safari 16.4+). Desktop Safari 16+, and Chrome/Edge/Firefox everywhere, work as a normal browser tab.