WebPush

Web Push notifications for Elixir. Payload encryption (RFC 8291 + RFC 8188 aes128gcm) and VAPID authentication (RFC 8292), sent over Finch.

Raw library: it starts no processes and holds no state. Your application supervises the Finch pool, stores the subscriptions, and decides what a notification looks like. WebPush handles the cryptography and the HTTP contract with the push service.

Features

Installation

defp deps do
[
{:web_push, "~> 0.1.0"}
]
end

Setup

Three moving parts: a Finch pool, VAPID keys, and stored subscriptions.

1. Supervise a Finch pool

# lib/my_app/application.ex
children = [
{Finch, name: MyApp.WebPushFinch}
]
# config/config.exs
config :web_push, finch: MyApp.WebPushFinch

2. Generate VAPID keys

mix web_push.gen.vapid
# config/runtime.exs
config :web_push, :vapid,
public_key: System.fetch_env!("VAPID_PUBLIC_KEY"),
private_key: System.fetch_env!("VAPID_PRIVATE_KEY"),
subject: "mailto:you@example.com"

The public key is not secret: the browser needs it to subscribe (see below). The private key signs every push request.

3. Store subscriptions

Browsers hand you a JSON object per subscribed device:

{
"endpoint": "https://fcm.googleapis.com/fcm/send/...",
"keys": {"p256dh": "B...", "auth": "..."}
}

Persist it however you like (an Ecto table with a unique index on endpoint works well). When sending, map the row into a WebPush.Subscription:

%WebPush.Subscription{endpoint: row.endpoint, p256dh: row.p256dh, auth: row.auth}

For request bodies straight from the browser, WebPush.Subscription.from_map/1 parses the nested JSON shape.

Sending

alias WebPush.Subscription
sub = %Subscription{endpoint: endpoint, p256dh: p256dh, auth: auth}
case WebPush.send(sub, %{"title" => "new order", "url" => "/admin/orders/1"}, urgency: "high") do
:ok -> :sent
{:error, :gone} -> MyApp.Notifications.delete_subscription(sub.endpoint)
{:error, reason} -> Logger.warning("push failed: #{inspect(reason)}")
end

Options:

optiondefaultmeaning
:ttl86400seconds the push service may hold the message
:urgency"normal""very-low", "low", "normal", "high"
:topicnonecollapse key; pending messages with the same topic are replaced
:finchconfiguredper-call Finch pool override

Return values:

Client side

WebPush is server-side only. The browser half is two small files, adapted here as a starting point. Yours will differ in routes, CSRF handling, and copy.

Service worker (priv/static/sw.js)

self.addEventListener("push", (event) => {
const data = event.data ? event.data.json() : {};
event.waitUntil(
self.registration.showNotification(data.title || "notification", {
body: data.body || "",
tag: data.tag,
data: { url: data.url || "/" },
}),
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = (event.notification.data && event.notification.data.url) || "/";
event.waitUntil(self.clients.openWindow(url));
});

Subscribing

// Your server exposes the VAPID public key (WebPush.Vapid.public_key/0)
// at some endpoint, e.g. GET /push/vapid-key.
async function subscribe() {
const reg = await navigator.serviceWorker.ready;
const { public_key } = await (await fetch("/push/vapid-key")).json();
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(public_key),
});
// POST sub.toJSON() to your server and persist it.
await fetch("/push/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(sub.toJSON()),
});
}
function urlBase64ToUint8Array(b64) {
const padding = "=".repeat((4 - (b64.length % 4)) % 4);
const raw = atob((b64 + padding).replace(/-/g, "+").replace(/_/g, "/"));
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
}

Note that push requires a service worker registration and a user gesture for the permission prompt on most browsers.

How it fits together

payload map
-> JSON.encode!/1 (WebPush)
-> RFC 8291 encryption (WebPush.Encryption, pure :crypto)
-> VAPID Authorization header (WebPush.Vapid, pure :crypto)
-> POST to subscription endpoint (Finch, your pool)

WebPush.Encryption and WebPush.Vapid are pure functions and can be used on their own if you need a different transport.

License

MIT. See LICENSE.