PushX.WebPush (PushX v0.15.0)

Copy Markdown View Source

Standards-based Web Push: send to any browser's push service — Chrome, Firefox, Edge, Safari 16+ (macOS Ventura / iOS 16.4 and later), Opera, Samsung Internet — using the subscription the browser's PushManager gives you. Payloads are encrypted end-to-end per RFC 8291 (aes128gcm) and the request is authenticated with VAPID (RFC 8292); transport is RFC 8030.

This is the modern path for all browsers. (FCM's webpush block is for apps that use the Firebase JS SDK — see PushX.FCM.send_web/5; Safari's legacy APNS website push is PushX.APNS.web_notification/4.)

Setup

Generate a VAPID key pair once (mix pushx.vapid, or generate_vapid_keys/0) and configure it with a contact for the push services:

config :pushx,
  webpush_vapid_subject: "mailto:ops@example.com",
  webpush_vapid_public_key: "BJ...",        # optional — derived from the private key
  webpush_vapid_private_key: "k1..."        # base64url (web-push CLI format) or EC PEM

The public key is what your front end passes as applicationServerKey to pushManager.subscribe/1. Keep the private key secret; rotating it invalidates every existing subscription.

Sending

The target is the subscription object from the browser, as JSON-decoded (string or atom keys):

subscription = %{
  "endpoint" => "https://fcm.googleapis.com/fcm/send/...",
  "keys" => %{"p256dh" => "BNc...", "auth" => "tBH..."}
}

PushX.push(:webpush, subscription, %{title: "Hi", body: "...", icon: "/icon.png"})  # unified API
PushX.WebPush.send(subscription, %{"title" => "Hi", "body" => "...", "icon" => "/icon.png"})

A PushX.Message maps to the Notification API shape your service worker shows (title, body, icon, tag, dataMessage.to_webpush_payload/1; its ttl/2 and priority/2 become the TTL / Urgency headers, see Message.to_webpush_options/1); a map is sent as JSON as-is — through PushX.push/4 too, which never rewrites Web Push maps; a binary is sent verbatim by send/3, while PushX.push(:webpush, sub, "Hello") treats the string as the title ({"title": "Hello", "body": ""}), as it does for APNS/FCM. The service worker reads the payload with event.data.json() / .text().

The circuit breaker and rate limiter are keyed per provider: for :webpush that is one key across every push service (Google, Mozilla, Apple, Microsoft, ...). Consecutive 5xx from one service can open the breaker for all of them; they are off by default.

Options

  • :ttl — seconds the push service may hold the message for an offline browser (default 2_419_200, four weeks; 0 = deliver now or drop)
  • :urgency:very_low | :low | :normal | :high (default: provider's default, i.e. normal); lets the device defer low-urgency pushes to save power

  • :topic — collapse key (≤ 32 base64url characters): a newer push with the same topic replaces an undelivered one
  • :retry, :receive_timeout, :pool_timeout — as for PushX.push/4

Responses

Push services answer 201 Created{:ok, %Response{status: :sent}} (id is the Location header when present). 404/410 mean the subscription is gone → :unregisteredPushX.Response.should_remove_token?/1 is true and :on_invalid_token fires with the subscription map, so delete it from your store. 401/403:auth_error (VAPID; PushX re-signs and retries once in case the cached JWT went stale), 413:payload_too_large, 429:rate_limited (with retry_after), 5xx:server_error.

Payloads are limited to 3993 bytes of plaintext (the 4 096-byte record every push service must accept, minus framing); larger payloads are rejected locally with :payload_too_large.

Multi-tenant: PushX.Instance.start(name, :webpush, vapid_subject: ..., vapid_private_key: ...) gives each tenant its own VAPID identity. Test delivery mode records the plaintext payload and never encrypts or contacts a push service.

Standards compliance

Implemented (the mandatory application-server side of each RFC):

  • RFC 8030 (transport)POST to the push resource over TLS; TTL always sent (required); Urgency (very-low | low | normal | high) and Topic (≤ 32 URL-safe base64 characters) when given; 201 Created with Location is success; 404/410 mean the subscription is gone; 413, 429 + Retry-After, 5xx mapped. Plain http endpoints are accepted only so a local push-service stub can be used in tests.
  • RFC 8291 (encryption) / RFC 8188 (aes128gcm) — ECDH P-256 with a fresh ephemeral key pair and a fresh 16-byte salt per message, HKDF-SHA-256 with the subscription's auth secret, "WebPush: info" key info, CEK/nonce derivation, single 4096-byte record with the 0x02 delimiter, GCM tag. The implementation reproduces RFC 8291 Appendix A bit-for-bit (see the encryption tests).
  • RFC 8292 (VAPID) — ES256 JWT with aud = push-service origin, exp 12 h ahead (the RFC allows up to 24 h), sub = your mailto:/https: contact; sent as Authorization: vapid t=<jwt>, k=<public key> with the same key that signed. JWTs are cached per origin and re-signed once when a push service answers 401/403.

Optional parts of the RFCs that are not implemented: multi-record encryption and padding (RFC 8188 allows records > 4096 bytes; push services are only required to accept 4096, so PushX limits plaintext to 3993 bytes instead), push-message receipts and Prefer: respond-async (RFC 8030 §5.1, rarely supported by push services), and HTTP/2 to push services (HTTP/1.1 is used, which RFC 8030 permits for application servers).

Summary

Types

A browser push subscription as produced by PushManager.subscribe/1 and JSON-decoded: endpoint plus keys.p256dh / keys.auth (base64url). String or atom keys are accepted.

Functions

Generates a VAPID key pair (base64url, the format the web-push CLI and browsers use). Do this once per application and keep the private key secret.

Sends a Web Push message to subscription with automatic retry (see PushX.push/4 for the retry semantics and the :retry option).

Sends a Web Push message without retry.

Validates a subscription object: https endpoint, a 65-byte uncompressed P-256 p256dh key and a 16-byte auth secret (both base64url). Returns the parsed form used internally or {:error, %Response{status: :invalid_token}}.

Types

option()

@type option() ::
  {:ttl, non_neg_integer()}
  | {:urgency, urgency()}
  | {:topic, String.t()}
  | {:retry, :blocking | :none}
  | {:receive_timeout, pos_integer()}
  | {:pool_timeout, pos_integer()}

payload()

@type payload() :: binary() | map() | PushX.Message.t()

subscription()

@type subscription() :: %{
  required(:endpoint | String.t()) => String.t(),
  required(:keys | String.t()) => %{required(atom() | String.t()) => String.t()},
  optional(any()) => any()
}

A browser push subscription as produced by PushManager.subscribe/1 and JSON-decoded: endpoint plus keys.p256dh / keys.auth (base64url). String or atom keys are accepted.

urgency()

@type urgency() :: :very_low | :low | :normal | :high

Functions

generate_vapid_keys()

@spec generate_vapid_keys() :: %{public_key: String.t(), private_key: String.t()}

Generates a VAPID key pair (base64url, the format the web-push CLI and browsers use). Do this once per application and keep the private key secret.

send(subscription, payload, opts \\ [])

@spec send(subscription(), payload(), [option()]) ::
  {:ok, PushX.Response.t()} | {:error, PushX.Response.t()}

Sends a Web Push message to subscription with automatic retry (see PushX.push/4 for the retry semantics and the :retry option).

send_once(subscription, payload, opts \\ [])

@spec send_once(subscription(), payload(), [option()]) ::
  {:ok, PushX.Response.t()} | {:error, PushX.Response.t()}

Sends a Web Push message without retry.

validate_subscription(subscription)

@spec validate_subscription(term()) ::
  {:ok, %{endpoint: String.t(), ua_public: binary(), auth: binary()}}
  | {:error, PushX.Response.t()}

Validates a subscription object: https endpoint, a 65-byte uncompressed P-256 p256dh key and a 16-byte auth secret (both base64url). Returns the parsed form used internally or {:error, %Response{status: :invalid_token}}.