Web Push Notifications

Web Push sends notifications to a user's browser even when no tab of your app is open. Reepolee ships an opt-in implementation: a VAPID-authenticated endpoint you configure in .env, a service worker that receives push events, and a queued worker job that encrypts and delivers each notification outside the HTTP request.

Web Push stays disabled until all three WEB_PUSH_* variables hold real values. The private VAPID key never leaves the server; only the public key is sent to the browser.

Requirements

  • A secure context - production browsers only allow the Push API over HTTPS. During local development http://localhost:2338 is a secure context; on a LAN host use http://comet:2338-style single-label hosts, or a real HTTPS origin.
  • The queue worker running - HTTP requests only enqueue notifications; worker.ts performs the actual delivery. See Queue / Job System.
  • The subscription table - created by the SQL init scripts (07-init-web-push.sql).

How delivery works

  1. Every logged-in user sees an Enable notifications button at the bottom-right of framework pages while Web Push is configured. Clicking it registers the shipped service worker (/web-push-sw.js), asks for browser permission, and stores the resulting subscription.
  2. Subscriptions are stored per user and per push endpoint in the web_push_subscriptions table. Each browser or device must subscribe separately.
  3. Application code calls queue_web_push_notification(user_id, payload), which enqueues one web_push job per subscription belonging to that user.
  4. The worker claims the jobs, encrypts each payload (RFC 8291 aes128gcm with an ephemeral ECDH key), signs it with your VAPID credentials, and posts it to the browser's push service with a 24-hour TTL.
  5. The push service wakes the browser; the service worker shows the notification. Clicking it focuses an open tab and navigates to the payload's link, or opens it in a new tab.
  6. If the push provider answers 404 or 410 (the subscription is gone), the worker deletes the stored subscription. Other failures retry and dead-letter like any queue job.

1. Configure VAPID credentials

Generate a P-256 VAPID key pair in the base64url format Reepolee expects, from the project root:

bun -e '
const encode = (bytes) => btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const decode = (value) => Uint8Array.from(atob(value.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - value.length % 4) % 4)), (char) => char.charCodeAt(0));
const pair = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]);
const public_key = new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey));
const private_jwk = await crypto.subtle.exportKey("jwk", pair.privateKey);
console.log("WEB_PUSH_PUBLIC_KEY=" + encode(public_key));
console.log("WEB_PUSH_PRIVATE_KEY=" + encode(decode(private_jwk.d)));
console.log("WEB_PUSH_SUBJECT=mailto:admin@example.com");
'

Copy the three lines into .env and replace the subject with a real monitored address or an https:// application URL:

WEB_PUSH_PUBLIC_KEY=<generated public key>
WEB_PUSH_PRIVATE_KEY=<generated private key>
WEB_PUSH_SUBJECT=mailto:admin@example.com

The WEB_PUSH_SUBJECT value is the contact shown to push providers. It must be a mailto: address or an https:// URL; plain http:// is accepted only for local development hosts (localhost, 127.0.0.1, or a bare single-label hostname like comet). Invalid values make the server exit at startup with a clear error, and a malformed key pair is rejected the same way. See Configuration for the full variable reference.

Do not commit .env, and never send WEB_PUSH_PRIVATE_KEY to browser code. Keep the same key pair across restarts and deployments - rotating it invalidates every existing subscription and forces users to subscribe again.

2. Initialize the subscription table

A new SQLite installation runs sql/sqlite/init/07-init-web-push.sql automatically through bun reepolee:install. For an existing database, run the matching migration once:

# SQLite development database
bun reeman run-sql-file sql/sqlite/init/07-init-web-push.sql

# MySQL development database
bun reeman run-sql-file sql/mysql/init/07-init-web-push.sql

Run the migration against the production database as part of the deployment that enables the feature. The migration creates web_push_subscriptions, keyed on a hash of the endpoint so the unique index stays within backend index-length limits while the full endpoint remains available for delivery.

3. Subscribe a browser

  1. Open the app in a secure context (https://your-host/, or http://localhost:2338 during development).
  2. Log in as the user who should receive notifications.
  3. Click Enable notifications in the bottom-right corner.
  4. Accept the browser permission prompt.
  5. Confirm the button now reads Disable notifications.

The button only appears on pages rendered through the framework layout, only for logged-in users, and only when VAPID is configured (web_push_enabled in the render data). In browsers without service-worker or push support the button is hidden. Subscribing stores the browser's endpoint plus its public keys on the server, tied to the current user; clicking Disable removes the subscription both in the browser and on the server.

A logged-in Reepolee page with the Enable notifications toggle button fixed at the bottom-right corner

4. Send a notification

From your own code, enqueue one notification for every subscription belonging to a user:

import { queue_web_push_notification } from "$lib/web_push";

await queue_web_push_notification(user_id, {
    title: "Invoice ready",
    message: "Your invoice is ready to review.",
    link: "/invoices/",
});

The payload is validated before anything is queued:

  • title - required, up to 200 characters.
  • message - optional body text, up to 4096 characters.
  • link - optional destination opened when the notification is clicked. It must be a local path (starting with / but not //) or an https:// URL.

The function returns the number of queued jobs (one per subscription). It returns 0 when Web Push is not configured or the user has no subscriptions - it never fails the caller over a missing setup.

Delivery is asynchronous: the function returns after enqueuing, and the web_push worker (registered in worker.ts, two concurrent deliveries) performs the encryption and the HTTP call to the push provider. Without a running worker the jobs wait in the queue - start one with bun dev:worker (or bun dev:all for the full local stack). In production run bun run worker as a separate process next to the app server, managed by PM2 or systemd.

Test notification

An admin-only route sends a fixed test notification to the subscriptions of the currently authenticated admin. From the logged-in browser's DevTools console:

fetch("/web-push/test", {
    method: "POST",
    headers: {
        "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content,
    },
}).then(async (response) => ({ status: response.status, body: await response.json() }));

A successful response is {"ok":true,"queued":1}. queued: 0 means the admin has no saved subscriptions; 401 means not logged in, 403 means the account lacks the admin module, and 404 means the VAPID variables are missing or invalid. Watch the worker terminal for the delivery attempt.

Production checklist

  • HTTPS - browsers require a secure context before they will subscribe. TLS is normally terminated by a reverse proxy.
  • Secrets - keep WEB_PUSH_PRIVATE_KEY and WEB_PUSH_SUBJECT in the deployment secret store, not in a committed file.
  • Migration - apply 07-init-web-push.sql to the production database before rollout.
  • Worker - run a long-lived bun run worker process next to the HTTP server so queued notifications are actually delivered.
  • Subject - push providers reject deliveries when the VAPID subject is not a valid mailto:, https://, or local http:// URL, or when your server cannot be reached from their end.