Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Sending Push Notifications

Sending Push Notifications

Exploring the process of wiring up push notifications using the Web Push Protocol and Elixir/OTP.

441211cbec372b4ffc4c5dbd9278bc25?s=128

Derrick Reimer

September 18, 2018
Tweet

Transcript

  1. Sending Push Notifications An OTP Journey Derrick Reimer @derrickreimer level.app

  2. The Process Check browser compatibility Register a service worker Ask

    permission and subscribe Store the subscription Send push payloads to the endpoint Receive the push in service worker
  3. Compatibility

  4. Generate VAPID keys ➜ ~ npm install web-push -g ➜

    ~ web-push generate-vapid-keys ======================================= Public Key: BMz3R7GFA3j4TM4IJRKEO86qbhiACrntSS2LgD41PYo27Vs-dyJpVPr2BcU_YNt3-dJC6_i64PNLB1yvTrs2zf4 Private Key: mFXz3q23AMNsYfT_ZqkSqh9W79yeha20RjPpV1CH5B4 =======================================
  5. Register the worker export function registerWorker() { return navigator.serviceWorker .register("/service-worker.js")

    .then(registration => { return registration; }) .catch(err => { error("Unable to register service worker.", err); }); } We’ll peek inside service-worker.js in a moment.
  6. Subscribe the user export function subscribe() { return navigator.serviceWorker.ready.then(registration =>

    { const convertedKey = urlBase64ToUint8Array(getPublicKey()); return registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: convertedKey }); }); } Your VAPID public key A special utility function
  7. Send the subscription payload to your server { endpoint: '<

    Push Subscription URL >', keys: { p256dh: '< User Public Encryption Key >', auth: '< User Auth Secret >' } }
  8. Create a table defmodule Level.Repo.Migrations.CreatePushSubscriptions do use Ecto.Migration def change

    do create table(:push_subscriptions, primary_key: false) do add :id, :binary_id, primary_key: true add :user_id, references(:users, on_delete: :nothing, type: :binary_id), null: false add :digest, :text, null: false add :data, :text, null: false timestamps() end create unique_index(:push_subscriptions, [:user_id, :digest]) end end A hashed version of the payload
  9. Parse them defmodule Level.WebPush.Subscription do defstruct [:endpoint, :keys] @spec parse(String.t())

    :: {:ok, t()} | {:error, :invalid_keys | :parse_error} def parse(data) do data |> Poison.decode() |> after_decode() end defp after_decode( {:ok, %{"endpoint" => endpoint, "keys" => %{"auth" => auth, "p256dh" => p256dh}}} ) do {:ok, %__MODULE__{endpoint: endpoint, keys: %{auth: auth, p256dh: p256dh}}} end defp after_decode({:ok, _}), do: {:error, :invalid_keys} defp after_decode(_), do: {:error, :parse_error} end
  10. Persist them for later defmodule Level.WebPush do # ... defp

    persist(user_id, data) do %Schema{} |> Changeset.change(%{user_id: user_id, data: data}) |> Changeset.change(%{digest: compute_digest(data)}) |> Repo.insert(on_conflict: :nothing) end defp compute_digest(data) do :sha256 |> :crypto.hash(data) |> Base.encode16() end end If the particular subscription is 
 already persisted for the user, 
 do nothing.
  11. Build payloads defmodule Level.WebPush.Payload do @enforce_keys [:body] defstruct [:body, :tag]

    @type t :: %__MODULE__{ body: String.t(), tag: String.t() | nil } def serialize(%__MODULE__{} = payload) do payload |> Map.from_struct() |> Poison.encode!() end end Include pretty much anything
 you may need in your service
 worker.
  12. Post your payload to
 a subscription @spec make_request(Payload.t(), Subscription.t()) ::

    {:ok, HTTPoison.Response.t()} | {:error, atom()} | no_return() def make_request(payload, subscription) do payload |> Payload.serialize() |> WebPushEncryption.send_web_push(subscription) end
  13. Listen for pushes
 and show notifications // Register event listener

    for the 'push' event. self.addEventListener('push', function (event) { const data = event.data ? event.data.json() : {}; const payload = { body: data.body }; // Keep the service worker alive until the notification is created. event.waitUntil(self.registration.showNotification('Level', payload)); });
  14. Send pushes from
 your domain logic # After someone posts

    a message... defp send_push_notifications(notifiable_ids, payload) do Enum.each(notifiable_ids, fn user_id -> WebPush.send_web_push(user_id, payload) end) end
  15. Proof of concept WebPush.send_web_push WebPush.fetch_subscriptions WebPush.make_request … WebPush.make_request WebPush.send_web_push WebPush.fetch_subscriptions

    WebPush.make_request … WebPush.make_request Enum.each(notifiable_users, …
  16. Limitations with 
 the proof of concept • Queries and

    requests should not block the main process • Ordering of notifications should be controlled • Failed requests should be retried • Subscriptions should be deleted when they are no longer valid
  17. 
 I think I need 
 a GenServer…

  18. The Flow • Build the payload and a list of

    user IDs that should see it • For each user, fetch/cache their subscriptions (could be > 1) • For each subscription, send the payload and handle the response — retry, delete, etc. — independent of the other subscriptions
  19. Servers User Responsible for fetching subscriptions out of the database,

    caching those subscriptions to reduce DB roundtrips, and dispatching messages to send payloads. Subscription Responsible for making network requests to the endpoint, handling responses, retrying the request as necessary, and deleting the subscription if it become invalid.
  20. Supervision Strategy • There will be a potentially large number

    of users and subscriptions in the system • Users and subscriptions can be created and destroyed in realtime • (Static) Supervisors are not sufficient for our needs because we can’t know what processes we need upfront • Thus, we need to use DynamicSupervisors!
  21. The Supervision Tree Level.WebPush Level.WebPush.UserSupervisor Level.WebPush.SubscriptionSupervisor Level.Supervisor Level.Registry Level.WebPush.UserWorker Level.WebPush.SubscriptionWorker

  22. Level.Supervisor defmodule Level do use Application def start(_type, _args) do

    children = [ # ... {Registry, keys: :unique, name: Level.Registry}, Level.WebPush ] opts = [strategy: :one_for_one, name: Level.Supervisor] Supervisor.start_link(children, opts) end end Fire up the Registry process
 and WebPush supervisor
 on application boot
 
 More on why we need the
 Registry momentarily…
  23. WebPush (Supervisor) defmodule Level.WebPush do use Supervisor def start_link(arg) do

    Supervisor.start_link(__MODULE__, arg, name: __MODULE__) end @impl true def init(_arg) do children = [ UserSupervisor, SubscriptionSupervisor ] Supervisor.init(children, strategy: :one_for_one) end end Responsible for keeping our dynamic
 supervisors alive and healthy This one is just a plain old supervisor
  24. WebPush (API) defmodule Level.WebPush do def send_web_push(user_id, %Payload{} = payload)

    do user_id |> UserSupervisor.start_worker() |> handle_start_worker(user_id, payload) end defp handle_start_worker({:ok, _pid}, user_id, payload) do UserWorker.send_web_push(user_id, payload) end defp handle_start_worker({:error, {:already_started, _pid}}, user_id, payload) do UserWorker.send_web_push(user_id, payload) end defp handle_start_worker(err, _, _), do: err end Calls the public 
 API of the worker Dynamically starts a 
 worker process
  25. UserSupervisor defmodule Level.WebPush.UserSupervisor do use DynamicSupervisor alias Level.WebPush.UserWorker def start_link(arg)

    do DynamicSupervisor.start_link(__MODULE__, arg, name: __MODULE__) end def start_worker(user_id) do spec = {UserWorker, user_id} DynamicSupervisor.start_child(__MODULE__, spec) end @impl true def init(_arg) do DynamicSupervisor.init(strategy: :one_for_one) end end No children need to 
 be specified on init!
  26. UserWorker (API) defmodule Level.WebPush.UserWorker do use GenServer defstruct [:user_id] def

    start_link(user_id) do GenServer.start_link(__MODULE__, user_id, name: via_tuple(user_id)) end defp via_tuple(user_id) do {:via, Registry, {Level.Registry, {:web_push_user, user_id}}} end def send_web_push(user_id, %Payload{} = payload) do GenServer.cast(via_tuple(user_id), {:send_web_push, payload}) end end Instead of by static name,
 children are “addressed”
 via the Registry
  27. UserWorker (Server) defmodule Level.WebPush.UserWorker do @impl true def init(user_id) do

    {:ok, %__MODULE__{user_id: user_id}} end @impl true def handle_cast({:send_web_push, payload}, state) do state.user_id |> fetch_subscriptions() |> Enum.each(fn {id, subscription} -> send_to_subscription(id, subscription, payload) end) {:noreply, state} end end Fetch subscriptions for 
 the user and dispatch to
 subscription workers
  28. SubscriptionWorker (Happy Path) defmodule Level.WebPush.SubscriptionWorker do @impl true def handle_cast({:send_web_push,

    payload}, state) do make_request(state, payload, 0) end defp make_request(state, payload, attempts) do payload |> adapter().make_request(state.subscription) |> handle_push_response(state, payload, attempts) end defp handle_push_response({:ok, %_{status_code: 201}}, state, _, _) do {:noreply, state} end end Do nothing special
 when the request responds 
 with a 201 status code.
  29. SubscriptionWorker (Unsubscribed Path) defmodule Level.WebPush.SubscriptionWorker do @impl true def handle_cast({:send_web_push,

    payload}, state) do make_request(state, payload, 0) end defp make_request(state, payload, attempts) do payload |> adapter().make_request(state.subscription) |> handle_push_response(state, payload, attempts) end defp handle_push_response({:ok, %_{status_code: 404}}, state, _, _) do delete_subscription(state.digest) {:stop, :normal, state} end end Delete the subscription
 and shutdown the worker
 process
  30. SubscriptionWorker 
 (Retry Path) defmodule Level.WebPush.SubscriptionWorker do def handle_info({:retry_web_push, payload,

    attempts}, state) do make_request(state, payload, attempts) end defp handle_push_response(_, state, payload, attempts) do if attempts < max_attempts() - 1, do: schedule_retry(payload, attempts + 1) {:noreply, state} end defp schedule_retry(payload, attempts) do Process.send_after(self(), {:retry_web_push, payload, attempts}, retry_timeout()) end end Schedule a retry in a configured
 interval if we have not exceeded
 max attempts
  31. Now, this operation is 100% async 
 and satisfies our

    requirements # After someone posts a message... defp send_push_notifications(notifiable_ids, payload) do Enum.each(notifiable_ids, fn user_id -> WebPush.send_web_push(user_id, payload) end) end
  32. :observer.start

  33. Thanks! Full source available on GitHub: github.com/levelhq/level Derrick Reimer @derrickreimer

    level.app