Current section

Files

Jump to
ash_dispatch lib push_backend.ex
Raw

lib/push_backend.ex

defmodule AshDispatch.PushBackend do
  @moduledoc """
  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.
  """

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