easy-pingv0.10.0

Get in touch

Questions, bug reports, or anything about easy-ping. Either of these reaches me.

Emailteklumo.jembere@gmail.comTelegram@teklumt

For anything others would benefit from, a GitHub issue is better than a DM, because it's searchable.

GitHub

Channels / Web push

Edit this page

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.

import { generateVapidKeys } from "easy-ping/providers/web-push";

// ONE-TIME SETUP. Do not run this again, and never call it from app code or a deploy step:
// a new keypair silently invalidates every existing browser subscription.
const vapid = await generateVapidKeys();
console.log(vapid); // { publicKey, privateKey } -> store both as secrets (VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY)
Never rotate casually

Every browser subscription is bound to the public key it was created with. Generate a new keypair and every existing subscriber stops receiving push, with no error on your side: the push services just reject the old subscriptions and the plugin prunes them. If you ever must rotate, re-subscribe users afterwards (call subscribeToPush again on their next visit).

Provider and plugin

notify.ts
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.

No build step. The file below is plain JavaScript with no imports: put it in your static folder (public/sw.js in Next.js and Vite) and it is served as-is. subscribeToPush registers /sw.js by default; pass serviceWorkerPath if you put it elsewhere.

public/sw.js
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("/"));
});
Want the bell to update too?

handlePush from easy-ping/sw does the same as the snippet above and also tells open tabs the inbox changed (see In-app inbox). It is an ES module, so a worker that imports it must be bundled (esbuild, Vite, or your framework's worker build) into a single file first; an unbundled import fails because subscribeToPush registers a classic worker. Always wrap it: self.addEventListener("push", (event) => event.waitUntil(handlePush(event))).

Subscribing a browser

components/EnablePush.tsx
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.

Browser API reference

Everything below comes from easy-ping/browser and runs in the page, not the worker.

isPushSupported(): boolean
// true when the browser has service workers, PushManager and Notification.

subscribeToPush(options: {
  publicKey: string;            // VAPID public key, base64url — the same one the server signs with
  baseUrl?: string;             // default "/api/notifications"
  serviceWorkerPath?: string;   // default "/sw.js"
  scope?: string;               // service worker scope, default the path's directory
  fetch?: typeof fetch;         // your authenticated fetch, if cookies are not enough
}): Promise<{ endpoint: string; keys: { p256dh: string; auth: string } }>
// Registers the worker, waits for it to be active, asks permission (only if not yet decided),
// reuses an existing subscription or creates one, then POSTs it to /push/devices.
// Throws PushUnsupportedError (no push in this browser), PushPermissionError (denied or dismissed;
// .message carries the permission state) or Error on a non-2xx from the server (409 = the endpoint
// belongs to another account).

unsubscribeFromPush(options: Omit<SubscribeOptions, "publicKey">): Promise<boolean>
// Tells the server first (POST /push/devices/remove), then unsubscribes the browser.
// Resolves false when there was no subscription.

decodeVapidKey(publicKey: string): Uint8Array
// The base64url -> bytes step subscribeToPush does for you; exported for custom flows.

Call subscribeToPush from a click handler: browsers block permission prompts that are not triggered by a user gesture.

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

Secure context

localhost counts as secure, so push works there without HTTPS. Any other host needs real TLS — the service worker will refuse to register otherwise.

iOS

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.