FluxyChat

Reference

Web Push (VAPID) — browser notifications

FluxyChat ships a self-hosted Web Push implementation that is wire-compatible with Pusher Beams for browser notifications. You don't need to sign up for a t

FluxyChat ships a self-hosted Web Push implementation that is wire-compatible with Pusher Beams for browser notifications. You don't need to sign up for a third-party push service — VAPID + the Web Push protocol (RFC 8292 / RFC 8188 / RFC 8291) are implemented in the worker.

Why self-host

ProviderCost (1M msg/mo)Data sharingSelf-contained
FluxyChat (VAPID, this)$0 (only Cloudflare Workers + D1 cost)NoneYes
Pusher Beams$0–$199Sends payloads to PusherNo
Firebase FCM Web$0Sends payloads to GoogleNo
OneSignal$0–$99Sends payloads to OneSignalNo

The implementation is < 250 LOC of pure WebCrypto and matches what Pusher Beams exposes:

  • GET /push/web/vapid-public-key — fetch the project's VAPID public key
  • POST /push/web/subscribe — register a PushSubscription
  • DELETE /push/web/subscribe/:idOrEndpoint — unregister
  • GET /push/web/subscriptions — list (with masked endpoint, failure count, last-sent)

Protocol overview

[fluxy chat]                  [browser sw]                    [push service]
   |                               |                                |
   | <-- subscribe endpoint, keys  |                                |
   |     (publicKey from worker)   |                                |
   |                               |                                |
   |-- POST /push/web/subscribe -->|                                |
   |   (endpoint, p256dh, auth)    |                                |
   |                               |                                |
   | (new message in room)         |                                |
   |                               |                                |
   | generate ephemeral ECDH key + |                                |
   | derive shared secret, encrypt |                                |
   | sign VAPID JWT (ES256),       |                                |
   | build headers                 |                                |
   |                               |                                |
   |                              POST (aes128gcm body, vapid t=, k=) -> 
   |                                                              [success 201] -> deliver

Key implementation details:

  • VAPID JWT (RFC 8292): EC P-256 key pair per project (auto-generated, stored in D1 project_vapid_keys), ES256 signed JWT, aud=<origin of endpoint>, sub defaults to mailto:admin@fluxychat.local (override with VAPID_SUBJECT).
  • Payload encryption (RFC 8188 + RFC 8291): ECDH-ES on P-256, HKDF-SHA-256, AES-128-GCM. Salt is 16 random bytes, record size 4096, padding delimiter 0x02. The browser decrypts natively via PushMessageData.
  • Audience is the origin of the push endpoint (e.g. https://fcm.googleapis.com for Google FCM web, https://updates.push.services.mozilla.com for Mozilla). Computed in sendWebPushToUser per subscription.
  • Headers: Authorization: vapid t=<jwt>, k=<base64url-pubkey>, Content-Encoding: aes128gcm, Content-Type: application/octet-stream, TTL: 60, Urgency: normal.

Browser setup

1. Service Worker

// sw.js
self.addEventListener("push", (event) => {
  const data = event.data ? event.data.json() : {};
  event.waitUntil(
    self.registration.showNotification(data.title || "New message", {
      body: data.body || "",
      icon: data.icon || "/icon-192.png",
      badge: data.badge || "/badge-72.png",
      data: { url: data.url || "/" },
    }),
  );
});

self.addEventListener("notificationclick", (event) => {
  event.notification.close();
  if (event.notification.data?.url) {
    event.waitUntil(clients.openWindow(event.notification.data.url));
  }
});

2. Subscribe (with the SDK)

import { FluxyChatClient, enableWebPushInBrowser } from "@fluxy-chat/sdk";

const client = new FluxyChatClient({ baseUrl: WORKER_URL, token: jwt });
const reg = await navigator.serviceWorker.ready;

const result = await enableWebPushInBrowser(client, {
  serviceWorkerRegistration: reg,
  projectId: "proj_1",
});
if (result.ok) console.log("subscribed");

2b. React hook

import { useWebPush } from "@fluxy-chat/sdk";

export function NotificationsToggle({ client, projectId }) {
  const {
    permission, subscribed, subscriptions,
    requestPermissionAndSubscribe, unsubscribe, loading, error,
  } = useWebPush(client, { projectId });

  return (
    <div>
      <p>Permission: {permission}</p>
      <button disabled={loading} onClick={requestPermissionAndSubscribe}>
        {subscribed ? "Subscribed" : "Enable notifications"}
      </button>
      {subscriptions.map((s) => (
        <div key={s.id}>
          {s.endpointHost} (failures: {s.failureCount})
          <button onClick={() => unsubscribe(s.id)}>×</button>
        </div>
      ))}
      {error && <pre>{error}</pre>}
    </div>
  );
}

3. (Manual) Without the SDK

const { publicKey } = await fetch(`${WORKER_URL}/push/web/vapid-public-key`)
  .then((r) => r.json());

const rawKey = (b64) => {
  const pad = "=".repeat((4 - (b64.length % 4)) % 4);
  return Uint8Array.from(
    atob(b64.replace(/-/g, "+").replace(/_/g, "/") + pad),
    (c) => c.charCodeAt(0),
  );
};

const sub = await reg.pushManager.subscribe({
  userVisibleOnly: true,
  applicationServerKey: rawKey(publicKey),
});

await fetch(`${WORKER_URL}/push/web/subscribe`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${jwt}`,
    "X-Fluxy-Project-Id": "proj_1",
  },
  body: JSON.stringify({
    endpoint: sub.endpoint,
    keys: {
      p256dh: sub.toJSON().keys.p256dh,
      auth: sub.toJSON().keys.auth,
    },
    userAgent: navigator.userAgent,
  }),
});

Server-side hookup

The push loop is already wired into the post-message automations: when a message is sent to a room, every member with a registered web push subscription gets a notification (subject to failure-count gating). The VAPID key pair is auto-generated on first use — no manual seeding required.

To inspect/manage subscriptions:

# List all subscriptions for the authenticated user
curl -H "Authorization: Bearer $JWT" $WORKER/push/web/subscriptions

# Remove one (by id or endpoint URL)
curl -X DELETE -H "Authorization: Bearer $JWT" $WORKER/push/web/subscribe/<id>

Triage behavior

Push service responseFluxyChat action
201 / 202Delivered → reset failure_count to 0
404 / 410Subscription gone → DELETE row
429Rate-limited → bump failure_count, keep row
400 / 401 / 403Config error → bump failure_count, log push.config_error
5xx (other)Transient → bump failure_count, retry next message

Subscriptions with failure_count >= 10 are skipped until a manual unsubscribe/resubscribe cycle.

Environment variables

VariableDefaultPurpose
VAPID_SUBJECTmailto:admin@fluxychat.localRFC 8292 sub claim (must be mailto: or https://)
WEB_PUSH_ICON/icon-192.pngDefault icon for showNotification (client-side)
WEB_PUSH_BADGE/badge-72.pngDefault badge (client-side)
PUBLIC_APP_URLhttps://fluxy.chatOrigin for url field in payload

Migrations

  • 0047_web_push_vapid.sql — adds project_vapid_keys and web_push_subscriptions tables. Apply via:
    cd apps/worker
    wrangler d1 migrations apply fluxychat-db --remote

Cross-browser support

BrowserStatusNotes
Chrome / Edge / OperaFull support
FirefoxFull support, no quota difference
Safari 16+ macOSmacOS only — Web Push is not supported in iOS Safari before 16.4
iOS Safari 16.4+✅ PWA onlyUser must install to home screen

For iOS Safari < 16.4, fall back to FCM (POST /push/devices with platform: "fcm").

Security notes

  • The VAPID public key endpoint is unauthenticated by design — the public key is not secret; the JWT it signs is authenticated by the browser's push service.
  • Subscription endpoints are bound to the JWT user — one user cannot see another user's subscriptions via the API.
  • The subject (mailto: or https://) is required by RFC 8292; push services may refuse requests with a missing or invalid subject.
  • VAPID JWT TTL defaults to 12 hours (per RFC 8292 recommendation) and is regenerated on every push.

On this page