AshDispatch.PushBackend behaviour (AshDispatch v0.6.1)

View Source

Behaviour that a Web Push backend module implements. Configure a backend in your app config:

config :ash_dispatch, :push_backend, MyApp.Push

The transport (AshDispatch.Transports.Push) will call your backend's deliver/4 whenever an event channel with transport: :push fires.

Why the encryption lives in your app, not here

Web Push (RFC 8291/8292) needs a VAPID keypair, an ECDH/HKDF encryption step and a per-endpoint HTTP POST to whatever push service the browser chose (FCM, Mozilla autopush, WNS…). Those are deployment concerns — key material, egress rules, retry budget — so ash_dispatch owns the routing and the audit trail and leaves the protocol to the consumer, exactly like AshDispatch.SMSBackend.

Implementation contract

  • One receipt fans out to many devices. Unlike email or SMS there is no single address: a user has one subscription per browser and machine. Resolve them from receipt.user_id (or from receipt.recipient if your app stores a device token there) and send to each. The receipt represents the event delivery, not one device.
  • Read the payload from receipt.content — typically :title, :body and the URL your service worker should open on click. Keep it small: push services cap the encrypted payload (4 KB is the safe ceiling).
  • Update the receipt via Ash:
    • Success (at least one device accepted): for_update(:mark_sent, %{provider_id: provider_id})
    • No subscriptions at all: for_update(:skip, %{error_message: "no_subscriptions"}) — a user who never granted permission is not a failure.
    • Failure: for_update(:mark_failed, %{error_message: reason})
  • Return {:ok, updated_receipt} or {:error, reason}.

Pruning dead subscriptions

Push endpoints expire silently. A push service answers 404 or 410 Gone once a subscription is dead, and it will keep answering that forever. Delete the subscription row on those two statuses — a backend that does not prune accumulates dead endpoints and spends its retry budget on browsers that no longer exist.

Treat 429 and 5xx as retryable instead; they are the push service throttling you, not the subscription being gone.

Summary

Callbacks

deliver(receipt, context, channel, event_config)

@callback deliver(
  receipt :: struct(),
  context :: AshDispatch.Context.t(),
  channel :: AshDispatch.Channel.t(),
  event_config :: map()
) :: {:ok, struct()} | {:error, term()}