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
| Provider | Cost (1M msg/mo) | Data sharing | Self-contained |
|---|---|---|---|
| FluxyChat (VAPID, this) | $0 (only Cloudflare Workers + D1 cost) | None | Yes |
| Pusher Beams | $0–$199 | Sends payloads to Pusher | No |
| Firebase FCM Web | $0 | Sends payloads to Google | No |
| OneSignal | $0–$99 | Sends payloads to OneSignal | No |
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 keyPOST /push/web/subscribe— register aPushSubscriptionDELETE /push/web/subscribe/:idOrEndpoint— unregisterGET /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] -> deliverKey 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>,subdefaults tomailto:admin@fluxychat.local(override withVAPID_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 viaPushMessageData. - Audience is the origin of the push endpoint (e.g.
https://fcm.googleapis.comfor Google FCM web,https://updates.push.services.mozilla.comfor Mozilla). Computed insendWebPushToUserper 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 response | FluxyChat action |
|---|---|
| 201 / 202 | Delivered → reset failure_count to 0 |
| 404 / 410 | Subscription gone → DELETE row |
| 429 | Rate-limited → bump failure_count, keep row |
| 400 / 401 / 403 | Config 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
| Variable | Default | Purpose |
|---|---|---|
VAPID_SUBJECT | mailto:admin@fluxychat.local | RFC 8292 sub claim (must be mailto: or https://) |
WEB_PUSH_ICON | /icon-192.png | Default icon for showNotification (client-side) |
WEB_PUSH_BADGE | /badge-72.png | Default badge (client-side) |
PUBLIC_APP_URL | https://fluxy.chat | Origin for url field in payload |
Migrations
0047_web_push_vapid.sql— addsproject_vapid_keysandweb_push_subscriptionstables. Apply via:cd apps/worker wrangler d1 migrations apply fluxychat-db --remote
Cross-browser support
| Browser | Status | Notes |
|---|---|---|
| Chrome / Edge / Opera | ✅ | Full support |
| Firefox | ✅ | Full support, no quota difference |
| Safari 16+ macOS | ✅ | macOS only — Web Push is not supported in iOS Safari before 16.4 |
| iOS Safari 16.4+ | ✅ PWA only | User 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:orhttps://) 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.
Pusher Channels parity (P9)
FluxyChat maps [Pusher Channels](https://pusher.com/docs/channels) concepts onto rooms, JWT auth, Cloudflare Durable Objects, and the `@fluxy-chat/s
Cloudflare AI Gateway (P12-I)
FluxyChat routes shared Worker AI calls through [Cloudflare AI Gateway](https://developers.cloudflare.com/ai-gateway/) when gateway env vars are set. Legacy dir