Powered by AppSignal & Oban Pro

Trilha Phoenix · Channels

18_channels.livemd

Trilha Phoenix · Channels

Mix.install(
  [
    {:phoenix, "~> 1.8.0"},
    {:phoenix_live_view, "~> 1.1"},
    {:phoenix_ecto, "~> 4.6"},
    {:ecto_sql, "~> 3.13"},
    {:postgrex, ">= 0.0.0"},
    {:bandit, "~> 1.5"},
    {:jason, "~> 1.4"},
    {:kino, "~> 0.14"},
    {:swoosh, "~> 1.16"},
    {:pbkdf2_elixir, "~> 2.0"}
  ],
  consolidate_protocols: false,
  config: [
    phoenix: [json_library: Jason, plug_init_mode: :runtime],
    swoosh: [api_client: false],
    hello: [
      {HelloWeb.Endpoint,
       [
         adapter: Bandit.PhoenixAdapter,
         http: [ip: {127, 0, 0, 1}, port: 4010],
         server: true,
         debug_errors: true,
         secret_key_base: String.duplicate("trilha-phoenix-", 5),
         render_errors: [formats: [html: HelloWeb.ErrorHTML, json: HelloWeb.ErrorJSON], layout: false],
         pubsub_server: Hello.PubSub
       ]},
      {Hello.Mailer, [adapter: Swoosh.Adapters.Local]}
    ]
  ]
)

defmodule Trilha do
  @moduledoc """
  Apoio deste notebook: um cliente de Channels que fala o protocolo do Phoenix
  por um WebSocket de verdade (como o JavaScript do navegador) e mostra as
  mensagens que passam por ele, encontra os processos dos channels no servidor,
  verifica a conexão com o PostgreSQL, confere os exercícios e guarda as
  perguntas e as soluções.

  Não faz parte do Phoenix nem do Ecto. Você não precisa ler este módulo para seguir o guia.
  """

  @endpoint HelloWeb.Endpoint
  @router HelloWeb.Router
  @repo Hello.Repo

  # --- banco de dados ------------------------------------------------------------

  @doc "Tenta uma conexão com o PostgreSQL usando a configuração do Hello.Repo."
  def verificar_postgres(extra \\ []) do
    config = Keyword.merge(apply(@repo, :config, []), extra)
    onde = "`#{config[:username]}@#{config[:hostname]}:#{config[:port] || 5432}`"

    # Conecta ao banco "postgres", que sempre existe: o banco da trilha talvez ainda não tenha sido criado.
    resultado =
      try do
        Postgrex.Protocol.connect(Postgrex.Utils.default_opts(Keyword.put(config, :database, "postgres")))
      rescue
        erro -> {:error, erro}
      end

    case resultado do
      {:ok, estado} ->
        Postgrex.Protocol.disconnect(%RuntimeError{message: "fim da verificação"}, estado)
        Kino.Markdown.new("**Conectou** ao PostgreSQL em #{onde}. Pode seguir.")

      {:error, erro} ->
        Kino.Markdown.new(
          "**Não conectou** ao PostgreSQL em #{onde}. A mensagem do Postgrex:\n\n" <>
            cerca(Exception.message(erro), "") <> "\n\n" <> dica_conexao(erro)
        )
    end
  end

  defp dica_conexao(%Postgrex.Error{postgres: %{pg_code: "28P01"}}) do
    "**Senha recusada.** Confira `username` e `password` na célula `config/dev.exs`. " <>
      "Com autenticação por senha, o PostgreSQL dá esta mesma resposta quando o usuário não existe."
  end

  defp dica_conexao(%Postgrex.Error{postgres: %{pg_code: "28000", message: mensagem}}) do
    cond do
      mensagem =~ ~r/ident|peer/i ->
        "**O servidor não aceita senha para esta conexão.** Ele está configurado para autenticação `ident` ou `peer`, " <>
          "o padrão de algumas distribuições Linux, como o Fedora. O `LEIA-ME.md` da trilha mostra como trocar para senha no `pg_hba.conf`."

      mensagem =~ "does not exist" ->
        "**O usuário não existe no PostgreSQL.** Crie-o no `psql` ou troque `username` na célula `config/dev.exs`."

      true ->
        "**O servidor recusou a conexão.** Leia a mensagem acima e confira o `pg_hba.conf` do servidor."
    end
  end

  defp dica_conexao(%DBConnection.ConnectionError{message: mensagem}) do
    cond do
      mensagem =~ "econnrefused" ->
        "**Nada responde nesse endereço.** O servidor PostgreSQL está parado, ou escuta em outra porta. O `LEIA-ME.md` mostra como iniciá-lo."

      mensagem =~ "nxdomain" ->
        "**O nome do host não existe.** Confira `hostname` na célula `config/dev.exs`."

      true ->
        "**A conexão falhou antes de chegar ao PostgreSQL.** Confira `hostname` e `port` na célula `config/dev.exs`."
    end
  end

  defp dica_conexao(_erro), do: "Confira a configuração na célula `config/dev.exs`."

  @doc "Apaga todas as tabelas do banco da trilha, inclusive schema_migrations, e esquece os visitantes."
  def recomecar do
    sql("DROP SCHEMA public CASCADE")
    sql("CREATE SCHEMA public")
    esquecer_visitantes()

    Kino.Markdown.new(
      "O banco `#{apply(@repo, :config, [])[:database]}` está vazio: nenhuma tabela, nenhuma migration registrada."
    )
  end

  @doc "Registra uma migration com a versão que, no projeto, viria do nome do arquivo."
  def migracao(versao, modulo) do
    :persistent_term.put({__MODULE__, :migracoes}, Map.put(mapa_migracoes(), versao, modulo))
    nome = modulo |> Module.split() |> List.last() |> Macro.underscore()

    Kino.Markdown.new("No projeto, esta migration seria o arquivo `priv/repo/migrations/#{versao}_#{nome}.exs`.")
  end

  @doc "As migrations registradas, como pares {versão, módulo}, em ordem de versão."
  def migracoes, do: mapa_migracoes() |> Enum.sort()

  defp mapa_migracoes, do: :persistent_term.get({__MODULE__, :migracoes}, %{})

  @doc "Lista as tabelas e sequências do banco, como o comando \\d do psql."
  def relacoes do
    resultado =
      sql("""
      SELECT n.nspname AS "Schema", c.relname AS "Name",
             CASE c.relkind WHEN 'r' THEN 'table' WHEN 'S' THEN 'sequence' WHEN 'v' THEN 'view' END AS "Type",
             pg_get_userbyid(c.relowner) AS "Owner"
      FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
      WHERE n.nspname = 'public' AND c.relkind IN ('r', 'S', 'v')
      ORDER BY c.relname
      """)

    if resultado.rows == [] do
      Kino.Markdown.new("`\\d`: o banco não tem nenhuma tabela.")
    else
      Kino.Markdown.new("**`\\d`**\n\n" <> tabela_markdown(resultado))
    end
  end

  @doc "Mostra as colunas, os índices e as chaves estrangeiras de uma tabela, como o comando \\d TABELA do psql."
  def descrever(tabela) do
    colunas =
      sql(
        """
        SELECT a.attname AS "Column", format_type(a.atttypid, a.atttypmod) AS "Type",
               CASE WHEN a.attnotnull THEN 'not null' ELSE '' END AS "Nullable",
               coalesce(pg_get_expr(d.adbin, d.adrelid), '') AS "Default"
        FROM pg_attribute a
        LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
        WHERE a.attrelid = to_regclass($1) AND a.attnum > 0 AND NOT a.attisdropped
        ORDER BY a.attnum
        """,
        [tabela]
      )

    if colunas.rows == [] do
      Kino.Markdown.new("`\\d #{tabela}`: a tabela `#{tabela}` não existe.")
    else
      indices = sql("SELECT indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = $1 ORDER BY indexname", [tabela])
      lista = Enum.map_join(indices.rows, "\n", fn [definicao] -> "* `#{definicao}`" end)

      chaves =
        sql(
          """
          SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint
          WHERE conrelid = to_regclass($1) AND contype = 'f' ORDER BY conname
          """,
          [tabela]
        )

      fks =
        if chaves.rows == [],
          do: "",
          else: "\n\n**Chaves estrangeiras**\n\n" <> Enum.map_join(chaves.rows, "\n", fn [nome, definicao] -> "* `#{nome}` #{definicao}" end)

      Kino.Markdown.new(
        "**`\\d #{tabela}`**\n\n" <>
          tabela_markdown(colunas) <> "\n\n**Índices**\n\n" <> if(lista == "", do: "nenhum", else: lista) <> fks
      )
    end
  end

  @doc "Executa um SQL e mostra o resultado como tabela."
  def consultar(consulta, parametros \\ []) do
    resultado = sql(consulta, parametros)
    Kino.Markdown.new(cerca(consulta, "sql") <> "\n\n" <> tabela_markdown(resultado))
  end

  defp sql(consulta, parametros \\ []) do
    apply(@repo, :query!, [consulta, parametros, [log: false]])
  end

  defp tabela_markdown(%{columns: colunas, rows: linhas}) do
    cabecalho = "| " <> Enum.join(colunas, " | ") <> " |"
    separador = "| " <> Enum.map_join(colunas, " | ", fn _ -> "---" end) <> " |"

    corpo =
      Enum.map(linhas, fn linha ->
        "| " <> Enum.map_join(linha, " | ", &celula/1) <> " |"
      end)

    Enum.join([cabecalho, separador | corpo], "\n")
  end

  defp celula(nil), do: ""
  defp celula(""), do: ""
  defp celula(valor) when is_binary(valor), do: "`" <> String.replace(valor, "|", "\\|") <> "`"
  defp celula(%Decimal{} = valor), do: "`" <> Decimal.to_string(valor) <> "`"
  defp celula(valor), do: "`" <> (valor |> inspect() |> String.replace("|", "\\|")) <> "`"

  # --- requisições -----------------------------------------------------------

  @doc "Despacha uma requisição para o endpoint, sem passar pela rede."
  def requisicao(metodo \\ :get, caminho, parametros \\ nil, conn \\ Phoenix.ConnTest.build_conn()) do
    descartar_respostas_antigas()

    conn = Phoenix.ConnTest.dispatch(conn, @endpoint, metodo, caminho, parametros)

    # Para NoRouteError (e só para ela), o Phoenix envia a página de erro e
    # devolve o conn sem relançar a exceção. Reconstruímos a exceção aqui.
    if conn.state == :sent and conn.status == nil do
      {:erro, Phoenix.Router.NoRouteError.exception(conn: conn, router: @router)}
    else
      {:ok, conn}
    end
  rescue
    erro -> {:erro, desembrulhar(erro)}
  end

  # Cada resposta enviada pelo adaptador de testes deixa duas mensagens no
  # processo que fez a requisição. Se ficarem acumuladas, o Phoenix acha que a
  # próxima página de erro já foi enviada e não a renderiza.
  defp descartar_respostas_antigas do
    receive do
      {ref, {status, cabecalhos, _corpo}} when is_reference(ref) and is_integer(status) and is_list(cabecalhos) ->
        descartar_respostas_antigas()

      {:plug_conn, :sent} ->
        descartar_respostas_antigas()
    after
      0 -> :ok
    end
  end

  defp desembrulhar(%Plug.Conn.WrapperError{reason: motivo}), do: desembrulhar(motivo)
  defp desembrulhar(erro), do: erro

  @doc "Faz a requisição e mostra o status, o content-type e o corpo da resposta."
  def mostrar(metodo \\ :get, caminho, parametros \\ nil) do
    titulo = "### #{metodo |> to_string() |> String.upcase()} #{caminho}"

    corpo =
      case requisicao(metodo, caminho, parametros) do
        {:ok, conn} ->
          tipo = conn |> Plug.Conn.get_resp_header("content-type") |> Enum.join()

          "**Status** `#{conn.status}` · **content-type** `#{tipo}`\n\n" <>
            cerca(to_string(conn.resp_body), if(tipo =~ "json", do: "json", else: "html"))

        {:erro, erro} ->
          "**A requisição levantou uma exceção:**\n\n" <> cerca(Exception.format_banner(:error, erro), "")
      end

    Kino.Markdown.new(titulo <> "\n\n" <> corpo)
  end

  # --- navegador ---------------------------------------------------------------

  @doc """
  Faz uma requisição como um navegador: envia os cookies que o visitante `quem`
  recebeu antes (a sessão, onde ficam a flash e o login) e segue os redirects.
  `quem` é um nome qualquer, como "ana"; cada nome tem os seus próprios cookies.
  Devolve `{:ok, conn}` com a última resposta, ou `{:erro, exceção}`.
  """
  def visitar(quem, metodo, caminho, parametros \\ nil) do
    quem |> seguir(metodo, caminho, parametros, []) |> List.last() |> elem(1)
  end

  @doc "Como `visitar/4`, mas mostra cada passo, a flash e o texto da página final."
  def navegar(quem, metodo \\ :get, caminho, parametros \\ nil) do
    passos = seguir(quem, metodo, caminho, parametros, [])

    linhas =
      passos
      |> Enum.with_index(1)
      |> Enum.map(fn {{requisicao, resultado}, i} -> linha_passo(i, requisicao, resultado) end)

    cabecalho = [
      "### #{quem}: #{rotulo(metodo, caminho)}",
      "",
      "| # | Requisição | Status | Redireciona para |",
      "| --- | --- | --- | --- |"
    ]

    Kino.Markdown.new(Enum.join(cabecalho ++ linhas, "\n") <> "\n\n" <> resumo_final(List.last(passos)))
  end

  @doc "Esquece os cookies de todos os visitantes, como se cada um abrisse um navegador novo."
  def esquecer_visitantes do
    for {{__MODULE__, :cookies, _} = chave, _} <- :persistent_term.get(), do: :persistent_term.erase(chave)
    :ok
  end

  defp seguir(quem, metodo, caminho, parametros, feitos) do
    conn = Enum.reduce(cookies(quem), Phoenix.ConnTest.build_conn(), fn {nome, valor}, c -> Plug.Test.put_req_cookie(c, nome, valor) end)
    resultado = requisicao(metodo, caminho, parametros, conn)
    feitos = feitos ++ [{rotulo(metodo, caminho), resultado}]

    with {:ok, resp} <- resultado do
      guardar_cookies(quem, resp)
    end

    with {:ok, resp} <- resultado,
         true <- resp.status in 300..399 and length(feitos) < 6,
         [destino] <- Plug.Conn.get_resp_header(resp, "location"),
         true <- String.starts_with?(destino, "/") do
      seguir(quem, :get, destino, nil, feitos)
    else
      _ -> feitos
    end
  end

  defp cookies(quem), do: :persistent_term.get({__MODULE__, :cookies, quem}, %{})

  defp guardar_cookies(quem, resp) do
    novos =
      Enum.reduce(resp.resp_cookies, cookies(quem), fn {nome, opcoes}, acc ->
        if opcoes[:max_age] == 0, do: Map.delete(acc, nome), else: Map.put(acc, nome, opcoes[:value])
      end)

    :persistent_term.put({__MODULE__, :cookies, quem}, novos)
  end

  defp rotulo(metodo, caminho), do: "#{metodo |> to_string() |> String.upcase()} #{caminho}"

  defp linha_passo(i, requisicao, {:ok, conn}) do
    destino = conn |> Plug.Conn.get_resp_header("location") |> List.first("")
    "| #{i} | `#{requisicao}` | `#{conn.status}` | #{if destino == "", do: "", else: "`#{destino}`"} |"
  end

  defp linha_passo(i, requisicao, {:erro, erro}) do
    "| #{i} | `#{requisicao}` | exceção: `#{erro.__struct__ |> inspect()}` | |"
  end

  defp resumo_final({_requisicao, {:ok, conn}}) do
    flash =
      case conn.assigns[:flash] do
        mapa when is_map(mapa) and map_size(mapa) > 0 ->
          "**Flash:** " <> Enum.map_join(mapa, " · ", fn {tipo, msg} -> "#{tipo}: \"#{msg}\"" end) <> "\n\n"

        _ ->
          ""
      end

    flash <> "**Texto da página** (sem as tags):\n\n" <> cerca(texto(to_string(conn.resp_body)), "")
  end

  defp resumo_final({_requisicao, {:erro, erro}}) do
    "**A requisição levantou uma exceção:**\n\n" <> cerca(Exception.format_banner(:error, erro), "")
  end

  @doc "O texto de uma página HTML, sem as tags: o que se lê no navegador."
  def texto(html) do
    conteudo =
      case Regex.run(~r{<main[^>]*>(.*)</main>}s, html) do
        [_, dentro] -> dentro
        nil -> html
      end

    conteudo
    |> String.replace(~r{<(script|style|head)[^>]*>.*?</\1>}s, "")
    |> String.replace(~r{<input[^>]*type="hidden"[^>]*>}, "")
    |> String.replace(~r{<option[^>]*\sselected[^>]*>}, "[x] ")
    |> String.replace(~r{<option[^>]*>}, "[ ] ")
    |> then(&Regex.replace(~r{<input[^>]*\svalue="([^"]*)"[^>]*>}, &1, fn _, valor -> if valor == "", do: " ", else: " [" <> valor <> "] " end))
    |> String.replace(~r{</?(p|div|li|tr|h\d|header|footer|ul|table|thead|tbody|form|label|select|option|section|br)[^>]*>}, "\n")
    |> String.replace(~r{</t[dh]>}, "  ")
    |> String.replace(~r{<[^>]+>}, "")
    |> String.replace(["&amp;", "<", ">", """, "'", "&nbsp;"], fn
      "&amp;" -> "&"
      "<" -> "<"
      ">" -> ">"
      """ -> "\""
      "'" -> "'"
      "&nbsp;" -> " "
    end)
    |> String.split("\n")
    |> Enum.map(&(&1 |> String.replace(~r/[ \t]+/, " ") |> String.trim()))
    |> Enum.reject(&(&1 == ""))
    |> Enum.join("\n")
  end

  defp cerca(texto, linguagem) do
    marca = String.duplicate("`", 3)
    marca <> linguagem <> "\n" <> String.trim(texto) <> "\n" <> marca
  end

  # --- Channels pelo fio --------------------------------------------------------------

  @doc """
  Abre uma conexão WebSocket de verdade com o socket da aplicação, em
  /socket/websocket, como o cliente JavaScript faz. Opções:

    * `token:` o token de autenticação (o `authToken` do cliente JS);
    * `params:` parâmetros da conexão, que chegam em `connect/3`.

  Devolve `{:ok, conexao}` ou `{:erro, linha_de_status}` quando o servidor recusa.
  """
  def conectar(opcoes \\ []) do
    porta = apply(@endpoint, :config, [:http])[:port]
    params = Map.merge(%{"vsn" => "2.0.0"}, Keyword.get(opcoes, :params, %{}))
    chave = Base.encode64(:crypto.strong_rand_bytes(16))

    # O token viaja no cabeçalho Sec-WebSocket-Protocol, como o phoenix.js o envia.
    protocolo =
      case opcoes[:token] do
        nil -> ""
        token -> "Sec-WebSocket-Protocol: phoenix, base64url.bearer.phx.#{Base.encode64(token, padding: false)}\r\n"
      end

    pedido =
      "GET /socket/websocket?#{URI.encode_query(params)} HTTP/1.1\r\n" <>
        "Host: localhost:#{porta}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" <>
        "Sec-WebSocket-Key: #{chave}\r\nSec-WebSocket-Version: 13\r\n" <> protocolo <> "\r\n"

    {:ok, socket} = :gen_tcp.connect(~c"127.0.0.1", porta, [:binary, active: false])
    :ok = :gen_tcp.send(socket, pedido)
    {:ok, resposta} = :gen_tcp.recv(socket, 0, 5000)
    [cabeca | resto] = String.split(resposta, "\r\n\r\n", parts: 2)
    status = cabeca |> String.split("\r\n") |> hd()

    if status =~ "101" do
      {:ok, %{socket: socket, buffer: Enum.join(resto), ref: 1, joins: %{}, fio: [], guardadas: []}}
    else
      :gen_tcp.close(socket)
      {:erro, status}
    end
  end

  @doc "Como `conectar/1`, mas devolve só a conexão e levanta erro se o servidor recusar."
  def conectar!(opcoes \\ []) do
    case conectar(opcoes) do
      {:ok, conexao} -> conexao
      {:erro, status} -> raise "o servidor recusou a conexão: #{status}"
    end
  end

  @doc """
  Entra num tópico (evento "phx_join"). Devolve `{conexao, {:ok, resposta}}` ou
  `{conexao, {:error, resposta}}`, conforme o `join/3` do channel.
  """
  def entrar(conexao, topico, payload \\ %{}) do
    ref = Integer.to_string(conexao.ref)
    conexao = %{conexao | ref: conexao.ref + 1, joins: Map.put(conexao.joins, topico, ref)}
    conexao = enviar_quadro(conexao, [ref, ref, topico, "phx_join", payload])
    esperar_resposta(conexao, ref)
  end

  @doc "Sai de um tópico (evento \"phx_leave\")."
  def sair(conexao, topico) do
    {conexao, resposta} = pedir(conexao, topico, "phx_leave", %{})
    {%{conexao | joins: Map.delete(conexao.joins, topico)}, resposta}
  end

  @doc "Manda um evento para um tópico e não espera resposta, como `channel.push(...)`."
  def empurrar(conexao, topico, evento, payload \\ %{}) do
    ref = Integer.to_string(conexao.ref)
    enviar_quadro(%{conexao | ref: conexao.ref + 1}, [conexao.joins[topico], ref, topico, evento, payload])
  end

  @doc """
  Manda um evento e espera o "phx_reply" dele. Devolve `{conexao, {status, resposta}}`,
  ou `{conexao, :sem_resposta}` se o channel não responder (um `{:noreply, socket}`).
  As outras mensagens que chegarem enquanto espera ficam guardadas para `receber/2`.
  """
  def pedir(conexao, topico, evento, payload \\ %{}) do
    ref = Integer.to_string(conexao.ref)
    conexao = enviar_quadro(%{conexao | ref: conexao.ref + 1}, [conexao.joins[topico], ref, topico, evento, payload])
    esperar_resposta(conexao, ref)
  end

  @doc """
  A próxima mensagem que o servidor mandou, como `[join_ref, ref, tópico, evento, payload]`.
  Devolve `{conexao, mensagem}`, `{conexao, :nada}` se nada chegar no tempo dado, ou
  `{conexao, :fechada}` se o servidor fechou a conexão.
  """
  def receber(conexao, tempo \\ 500) do
    case conexao.guardadas do
      [mensagem | resto] -> {%{conexao | guardadas: resto}, mensagem}
      [] -> ler_mensagem(conexao, tempo)
    end
  end

  @doc "Todas as mensagens que chegarem até o servidor ficar `tempo` ms em silêncio."
  def receber_todas(conexao, tempo \\ 300, juntadas \\ []) do
    case receber(conexao, tempo) do
      {conexao, mensagem} when is_list(mensagem) -> receber_todas(conexao, tempo, [mensagem | juntadas])
      {conexao, _nada_ou_fechada} -> {conexao, Enum.reverse(juntadas)}
    end
  end

  @doc "O heartbeat: o cliente JS manda um a cada 30 segundos, no tópico \"phoenix\"."
  def heartbeat(conexao) do
    ref = Integer.to_string(conexao.ref)
    conexao = enviar_quadro(%{conexao | ref: conexao.ref + 1}, [nil, ref, "phoenix", "heartbeat", %{}])
    esperar_resposta(conexao, ref)
  end

  @doc "Fecha a conexão. Os processos dos channels dela terminam no servidor."
  def fechar(conexao) do
    :gen_tcp.close(conexao.socket)
    :ok
  end

  @doc """
  Mostra o que passou pelo fio nesta conexão, na ordem: `→` o que o cliente mandou,
  `←` o que o servidor mandou. Cada mensagem é `[join_ref, ref, tópico, evento, payload]`.
  """
  def fio(conexao) do
    linhas =
      conexao.fio
      |> Enum.reverse()
      |> Enum.map_join("\n", fn {sentido, mensagem} ->
        if(sentido == :enviada, do: "→ ", else: "← ") <> Jason.encode!(mensagem)
      end)

    Kino.Markdown.new(cerca(if(linhas == "", do: "(nada ainda)", else: linhas), ""))
  end

  @doc "Esquece o que já foi mostrado: o próximo `fio/1` começa daqui."
  def limpar_fio(conexao), do: %{conexao | fio: []}

  defp esperar_resposta(conexao, ref, tempo \\ 1500) do
    case ler_mensagem(conexao, tempo) do
      {conexao, [_, ^ref, _, "phx_reply", %{"status" => status, "response" => resposta}]} ->
        {conexao, {String.to_atom(status), resposta}}

      {conexao, mensagem} when is_list(mensagem) ->
        esperar_resposta(%{conexao | guardadas: conexao.guardadas ++ [mensagem]}, ref, tempo)

      {conexao, _nada_ou_fechada} ->
        {conexao, :sem_resposta}
    end
  end

  defp enviar_quadro(conexao, mensagem) do
    dados = Jason.encode!(mensagem)
    mascara = :crypto.strong_rand_bytes(4)
    tamanho = byte_size(dados)

    cabeca =
      cond do
        tamanho < 126 -> <<1::1, 0::3, 1::4, 1::1, tamanho::7>>
        tamanho < 65_536 -> <<1::1, 0::3, 1::4, 1::1, 126::7, tamanho::16>>
        true -> <<1::1, 0::3, 1::4, 1::1, 127::7, tamanho::64>>
      end

    :ok = :gen_tcp.send(conexao.socket, [cabeca, mascara, mascarar(dados, mascara)])
    %{conexao | fio: [{:enviada, mensagem} | conexao.fio]}
  end

  # O cliente mascara cada byte com uma chave de 4 bytes (exigência do protocolo WebSocket).
  defp mascarar(dados, <<a, b, c, d>>) do
    chave = {a, b, c, d}

    for {byte, i} <- Enum.with_index(:binary.bin_to_list(dados)), into: <<>> do
      <<Bitwise.bxor(byte, elem(chave, rem(i, 4)))>>
    end
  end

  # Lê um quadro do servidor (sem máscara). Texto vira JSON; um quadro de fechamento vira :fechada.
  defp ler_mensagem(conexao, tempo) do
    case quadro(conexao.buffer) do
      {:ok, 8, _dados, resto} ->
        {%{conexao | buffer: resto}, :fechada}

      {:ok, _op, texto, resto} ->
        mensagem = Jason.decode!(texto)
        {%{conexao | buffer: resto, fio: [{:recebida, mensagem} | conexao.fio]}, mensagem}

      :incompleto ->
        case :gen_tcp.recv(conexao.socket, 0, tempo) do
          {:ok, dados} -> ler_mensagem(%{conexao | buffer: conexao.buffer <> dados}, tempo)
          {:error, :timeout} -> {conexao, :nada}
          {:error, _} -> {conexao, :fechada}
        end
    end
  end

  defp quadro(<<_fin::1, _::3, op::4, 0::1, 126::7, n::16, dados::binary-size(n), resto::binary>>), do: {:ok, op, dados, resto}
  defp quadro(<<_fin::1, _::3, op::4, 0::1, 127::7, n::64, dados::binary-size(n), resto::binary>>), do: {:ok, op, dados, resto}
  defp quadro(<<_fin::1, _::3, op::4, 0::1, n::7, dados::binary-size(n), resto::binary>>) when n < 126, do: {:ok, op, dados, resto}
  defp quadro(_), do: :incompleto

  @doc "Mostra uma mensagem ou resposta como JSON."
  def json(valor), do: Kino.Markdown.new(cerca(Jason.encode!(valor, pretty: true), "json"))

  @doc """
  Os processos de channel inscritos num tópico, neste servidor: o pid, o id do
  socket e os assigns de cada um.
  """
  def canais(topico) do
    case sockets_do_topico(topico) do
      [] ->
        Kino.Markdown.new("Nenhum channel no tópico `#{topico}`.")

      sockets ->
        linhas =
          Enum.map(sockets, fn {pid, socket} ->
            "| `#{inspect(pid)}` | `#{inspect(socket.channel)}` | `#{inspect(socket.id)}` | `#{inspect(socket.assigns)}` |"
          end)

        Kino.Markdown.new(Enum.join(["| Processo | Channel | `socket.id` | `socket.assigns` |", "| --- | --- | --- | --- |" | linhas], "\n"))
    end
  end

  # O PubSub guarda num Registry quem está inscrito em cada tópico. Cada channel
  # se inscreve no próprio tópico quando o join dá certo.
  defp sockets_do_topico(topico) do
    for {pid, _valor} <- Registry.lookup(Hello.PubSub, topico),
        %Phoenix.Socket{} = socket <- [estado_do_canal(pid)] do
      {pid, socket}
    end
  end

  defp estado_do_canal(pid) do
    :sys.get_state(pid, 500)
  catch
    :exit, _ -> nil
  end

  @doc "Um token de socket para o usuário `id`, como o que a página recebe."
  def token(id), do: Phoenix.Token.sign(@endpoint, "user socket", id)

  # --- perguntas -------------------------------------------------------------

  @doc "Uma pergunta de múltipla escolha, respondida antes de ver a explicação."
  def pergunta(id) do
    %{texto: texto, opcoes: opcoes, correta: correta, explicacao: explicacao} = Map.fetch!(perguntas(), id)

    quadro = Kino.Frame.new()

    letras = Enum.map(opcoes, fn {letra, _texto} -> {letra, to_string(letra)} end)
    lista = Enum.map_join(opcoes, "\n", fn {letra, opcao} -> "* **#{letra})** #{opcao}" end)

    formulario =
      Kino.Control.form(
        [resposta: Kino.Input.select("Sua resposta", [{nil, "Escolha"} | letras])],
        submit: "Conferir"
      )

    Kino.listen(formulario, fn %{data: %{resposta: resposta}} ->
      retorno =
        cond do
          resposta == nil -> "Escolha uma opção antes de conferir."
          resposta == correta -> "**Certo.** " <> explicacao
          true -> "**Ainda não.** Releia o trecho acima e tente outra opção."
        end

      Kino.Frame.render(quadro, Kino.Markdown.new(retorno))
    end)

    Kino.Layout.grid([Kino.Markdown.new("**Antes de continuar:** " <> texto <> "\n\n" <> lista), formulario, quadro])
  end

  defp perguntas do
    %{
      http_ou_canal: %{
        texto:
          "um placar de futebol precisa mostrar um gol em todos os navegadores abertos, na hora em que ele acontece. Só com controllers e HTTP, o que o navegador teria de fazer?",
        opcoes: [
          a: "Nada: o servidor manda a página nova sozinho.",
          b: "Perguntar de novo ao servidor de tempos em tempos, porque no HTTP só o cliente começa a conversa.",
          c: "Abrir uma requisição por jogador.",
          d: "Guardar o placar num cookie."
        ],
        correta: :b,
        explicacao:
          "No ciclo requisição-resposta, o servidor só fala quando o cliente pergunta. Para saber de um gol, o navegador teria de perguntar repetidamente (polling), gastando requisições em que nada mudou. Os Channels mantêm uma conexão aberta, e por ela o servidor manda a notícia no momento em que ela acontece."
      },
      processos: %{
        texto:
          "três pessoas abrem a aplicação, cada uma numa aba. Cada aba entra em dois tópicos: `room:lobby` e `product_updates:7`. Quantos processos de channel o servidor cria?",
        opcoes: [
          a: "Um, que atende todo mundo.",
          b: "Dois: um por tópico.",
          c: "Três: um por pessoa.",
          d: "Seis: um por pessoa, por tópico."
        ],
        correta: :d,
        explicacao:
          "O guia diz: um processo de channel por cliente, por tópico. Três clientes em dois tópicos dão seis processos de channel (além de um processo por conexão, que cuida do WebSocket). Cada um guarda o próprio `socket.assigns`, e a falha de um não derruba os outros. Processos da máquina virtual do Erlang são leves; é por isso que um servidor aguenta milhões deles."
      },
      curinga: %{
        texto: "o socket declara `channel \"room:*\", HelloWeb.RoomChannel`. Para onde vai um join no tópico `room:123`? E no tópico `sala:123`?",
        opcoes: [
          a: "Os dois vão para o `RoomChannel`.",
          b: "`room:123` vai para o `RoomChannel`; `sala:123` não casa com nenhuma rota, e o servidor responde com erro.",
          c: "Só `room:*`, escrito assim, com o asterisco, vai para o `RoomChannel`.",
          d: "`room:123` exige uma rota própria, `channel \"room:123\", ...`."
        ],
        correta: :b,
        explicacao:
          "O `*` é um curinga: `room:*` casa com `room:lobby`, `room:123` e qualquer outro tópico que comece com `room:`. Um tópico que não casa com nenhuma rota recebe uma resposta de erro com `reason: \"unmatched topic\"`, sem que nenhum channel seja criado."
      },
      join_recusado: %{
        texto:
          "o `join/3` de `room:123` devolve `{:error, %{reason: \"unauthorized\"}}`. O que acontece com a conexão do cliente?",
        opcoes: [
          a: "O servidor fecha o WebSocket.",
          b: "O cliente recebe uma resposta de erro para aquele join, e a conexão continua aberta; os outros tópicos dele seguem funcionando.",
          c: "O servidor levanta uma exceção.",
          d: "O cliente entra no tópico, mas não recebe mensagens."
        ],
        correta: :b,
        explicacao:
          "Uma conexão carrega vários tópicos. Recusar um join só afeta aquele tópico: o cliente recebe um `phx_reply` com `status: \"error\"` e o mapa devolvido pelo `join/3`. Nenhum processo de channel fica no ar para o tópico recusado."
      },
      remetente: %{
        texto: "Ana manda `new_msg` no `room:lobby`, e o channel chama `broadcast!(socket, \"new_msg\", %{body: body})`. Quem recebe a mensagem?",
        opcoes: [
          a: "Todos os que estão no tópico, menos Ana.",
          b: "Todos os que estão no tópico, inclusive Ana.",
          c: "Todos os conectados ao socket, em qualquer tópico.",
          d: "Só Ana, como confirmação."
        ],
        correta: :b,
        explicacao:
          "`broadcast!/3` manda para todos os inscritos no tópico do socket, inclusive quem enviou. É por isso que o JavaScript do guia não escreve a mensagem na tela ao enviar: ele espera a mensagem voltar pelo canal, como todo mundo. Para pular o remetente existe `broadcast_from!/3`."
      },
      handle_out: %{
        texto:
          "uma sala tem 1.000 pessoas, e o channel intercepta o evento `user_joined` com `handle_out/3`. Alguém entra. Quantas vezes `handle_out/3` roda?",
        opcoes: [
          a: "Uma vez, no processo de quem entrou.",
          b: "Mil vezes: uma em cada processo de channel inscrito no tópico.",
          c: "Nenhuma: `handle_out/3` só roda para mensagens do cliente.",
          d: "Uma vez, no PubSub."
        ],
        correta: :b,
        explicacao:
          "Sem `intercept`, o servidor codifica a mensagem uma vez e a entrega pronta a todas as conexões. Com `intercept`, cada processo de channel recebe a mensagem e roda o próprio `handle_out/3`, que decide o que fazer para o seu cliente. Por isso o guia avisa: uma consulta ao banco dentro de `handle_out/3` seria repetida para cada destinatário."
      },
      token: %{
        texto:
          "a página foi carregada por um usuário logado, com a sessão num cookie. Por que o guia assina um token com `Phoenix.Token` e o passa ao socket, em vez de confiar só no cookie?",
        opcoes: [
          a: "Porque cookies não funcionam com HTTPS.",
          b: "Porque o token não depende do transporte: funciona em WebSocket, em long polling e em clientes que não são navegadores, e serve bem a conexões longas.",
          c: "Porque o token é mais curto que o cookie.",
          d: "Porque o Phoenix não consegue ler cookies numa conexão WebSocket."
        ],
        correta: :b,
        explicacao:
          "O guia diz que a autenticação por token é preferível por não depender do transporte e por servir bem a conexões de longa duração. Um aplicativo de celular ou um dispositivo não tem o cookie do navegador, mas pode guardar um token. O Phoenix até consegue ler a sessão no socket (o LiveView faz isso, com `connect_info: [session: ...]`), mas isso prende o socket ao navegador."
      },
      desconectar: %{
        texto:
          "`id/1` do socket devolve `\"user_socket:7\"` para o usuário 7, que está conectado em três abas. O que faz `HelloWeb.Endpoint.broadcast(\"user_socket:7\", \"disconnect\", %{})`?",
        opcoes: [
          a: "Manda o evento `disconnect` para o JavaScript das três abas, que decide o que fazer.",
          b: "Fecha as três conexões do usuário 7, com todos os channels delas.",
          c: "Fecha só a conexão mais antiga.",
          d: "Nada, porque ninguém entrou num tópico chamado `user_socket:7`."
        ],
        correta: :b,
        explicacao:
          "O id do socket é um tópico em que o processo de cada conexão daquele usuário se inscreve sozinho. O evento `disconnect` nesse tópico é tratado pelo próprio Phoenix: cada conexão se encerra, e os channels dela terminam junto. É o que o `phx.gen.auth` faz quando o usuário sai (o `live_socket_id` do notebook 13)."
      },
      at_most_once: %{
        texto:
          "Bia fecha o notebook do computador às 14h00. Às 14h05, Ana manda uma mensagem na sala. Às 14h10, Bia abre o notebook e o cliente reconecta. Bia recebe a mensagem das 14h05?",
        opcoes: [
          a: "Sim: o servidor guarda as mensagens de quem está offline e as reenvia.",
          b: "Não: o Phoenix entrega cada mensagem no máximo uma vez e não guarda nada; quem estava offline perdeu.",
          c: "Sim, mas só se Bia reconectar em menos de 5 segundos.",
          d: "Depende do navegador."
        ],
        correta: :b,
        explicacao:
          "O Phoenix usa a estratégia at-most-once: manda a mensagem a quem está conectado naquele momento e não a guarda. Se a aplicação precisa de mais garantia, o código é seu: guardar as mensagens no servidor e deixar o cliente pedir o que perdeu. O último exercício do guia faz isso com um `last_seen_id`."
      }
    }
  end

  # --- soluções --------------------------------------------------------------

  @doc "Um botão que revela a solução de um exercício."
  def solucao(id) do
    quadro = Kino.Frame.new()
    botao = Kino.Control.button("Mostrar a solução")

    Kino.listen(botao, fn _evento ->
      Kino.Frame.render(quadro, Kino.Markdown.new(cerca(Map.fetch!(solucoes(), id), "elixir")))
    end)

    Kino.Layout.grid([botao, quadro])
  end

  defp solucoes do
    %{
      canal: ~S'''
      # Na célula do UserSocket: tire o comentário da rota.
      channel "room:*", HelloWeb.RoomChannel

      # Na célula do RoomChannel:
      @impl true
      def join("room:lobby", _message, socket) do
        {:ok, socket}
      end

      def join("room:" <> _private_room_id, _params, _socket) do
        {:error, %{reason: "unauthorized"}}
      end
      ''',
      new_msg: ~S'''
      # Na célula do RoomChannel, depois das cláusulas de join/3:
      @impl true
      def handle_in("new_msg", %{"body" => body}, socket) do
        broadcast!(socket, "new_msg", %{body: body})
        {:noreply, socket}
      end
      ''',
      silenciar: ~S'''
      # No topo do módulo, depois de `use HelloWeb, :channel`:
      intercept ["user_joined"]

      # E, junto das outras funções:
      @impl true
      def handle_out("user_joined", msg, socket) do
        if socket.assigns.silenciar_entradas do
          {:noreply, socket}
        else
          push(socket, "user_joined", msg)
          {:noreply, socket}
        end
      end
      ''',
      token: ~S'''
      defmodule HelloWeb.UserSocket do
        use Phoenix.Socket

        ## Channels
        channel "room:*", HelloWeb.RoomChannel

        @impl true
        def connect(_params, socket, connect_info) do
          # max_age: 1209600 is equivalent to two weeks in seconds
          case Phoenix.Token.verify(socket, "user socket", connect_info[:auth_token], max_age: 1_209_600) do
            {:ok, user_id} ->
              {:ok, assign(socket, :current_user, user_id)}

            {:error, _reason} ->
              :error
          end
        end

        @impl true
        def id(socket), do: "user_socket:#{socket.assigns.current_user}"
      end
      ''',
      historico: ~S'''
      defmodule HelloWeb.RoomChannel do
        use HelloWeb, :channel

        alias Hello.Chat

        @impl true
        def join("room:lobby", params, socket) do
          messages = Chat.list_messages("lobby", params["last_seen_id"])
          {:ok, %{messages: messages}, socket}
        end

        def join("room:" <> _private_room_id, _params, _socket) do
          {:error, %{reason: "unauthorized"}}
        end

        @impl true
        def handle_in("new_msg", %{"body" => body}, socket) do
          message = Chat.create_message("lobby", body, socket.assigns[:current_user])
          broadcast!(socket, "new_msg", message)
          {:noreply, socket}
        end
      end
      ''',
      brecho: ~S'''
      # No UserSocket (a célula da autenticação), junto da outra rota:
      channel "produto:*", HelloWeb.ProdutoChannel

      defmodule HelloWeb.ProdutoChannel do
        use HelloWeb, :channel

        alias Hello.Repo
        alias Hello.Vitrine
        alias Hello.Vitrine.Produto

        @impl true
        def join("produto:" <> id, _params, socket) do
          with {id, ""} <- Integer.parse(id),
               %Produto{vendido: false} = produto <- Repo.get(Produto, id) do
            {:ok, %{nome: produto.nome, preco_centavos: produto.preco_centavos}, assign(socket, :produto_id, id)}
          else
            _ -> {:error, %{reason: "indisponivel"}}
          end
        end

        @impl true
        def handle_in("oferta", %{"valor" => valor}, socket) when is_integer(valor) and valor > 0 do
          broadcast!(socket, "oferta", %{valor: valor, de: socket.assigns.current_user})
          {:noreply, socket}
        end

        def handle_in("oferta", _payload, socket) do
          {:reply, {:error, %{reason: "valor_invalido"}}, socket}
        end

        def handle_in("comprar", _payload, socket) do
          produto = Vitrine.buscar_produto!(socket.assigns.produto_id)

          case Vitrine.vender(produto) do
            {:ok, _produto} ->
              broadcast!(socket, "vendido", %{id: produto.id, para: socket.assigns.current_user})
              {:reply, :ok, socket}

            {:error, :ja_vendido} ->
              {:reply, {:error, %{reason: "ja_vendido"}}, socket}
          end
        end
      end
      '''
    }
  end

  # --- conferência dos exercícios ----------------------------------------------

  @doc "Confere um exercício abrindo conexões de verdade com o socket da aplicação."
  def conferir(id) do
    resultado =
      Enum.reduce_while(Map.fetch!(verificacoes(), id), [], fn {descricao, teste}, feitos ->
        case executar(teste) do
          :ok -> {:cont, ["- Passou: " <> descricao | feitos]}
          {:erro, dica} -> {:halt, ["- **Ainda não:** " <> descricao <> "\n\n  " <> dica | feitos]}
        end
      end)

    Kino.Markdown.new(resultado |> Enum.reverse() |> Enum.join("\n"))
  end

  defp executar(teste) do
    teste.()
  rescue
    erro -> {:erro, explicar(erro)}
  end

  defp verificacoes do
    %{
      canal: [
        {"Um cliente consegue entrar em `room:lobby`.",
         fn ->
           com_conexoes([1], fn [c] ->
             case entrar(c, "room:lobby") do
               {_c, {:ok, _}} -> :ok
               {_c, outra} -> {:erro, dica_join("room:lobby", outra)}
             end
           end)
         end},
        {"O join em `room:123` é recusado com `reason: \"unauthorized\"`.",
         fn ->
           com_conexoes([1], fn [c] ->
             case entrar(c, "room:123") do
               {_c, {:error, %{"reason" => "unauthorized"}}} -> :ok
               {_c, {:ok, _}} -> {:erro, "O servidor aceitou o join em `room:123`. Falta a segunda cláusula de `join/3`, a das salas privadas."}
               {_c, outra} -> {:erro, dica_join("room:123", outra)}
             end
           end)
         end},
        {"Um tópico fora de `room:*` não chega a nenhum channel.",
         fn ->
           com_conexoes([1], fn [c] ->
             case entrar(c, "sala:1") do
               {_c, {:error, %{"reason" => "unmatched topic"}}} -> :ok
               {_c, outra} -> {:erro, "O join em `sala:1` devolveu `#{inspect(outra)}`. A rota deve ser `channel \"room:*\", HelloWeb.RoomChannel`, e nada mais largo que isso."}
             end
           end)
         end}
      ],
      new_msg: [
        {"Uma mensagem `new_msg` chega aos outros clientes do `room:lobby`.",
         fn ->
           com_conexoes([1, 2], fn [ana, bia] ->
             ana = entrar!(ana, "room:lobby")
             bia = entrar!(bia, "room:lobby")
             empurrar(ana, "room:lobby", "new_msg", %{"body" => "Conferência: oi"})

             case esperar_evento(bia, "room:lobby", "new_msg") do
               %{"body" => "Conferência: oi"} -> :ok
               nil -> {:erro, "A outra pessoa da sala não recebeu nada. Escreva `handle_in(\"new_msg\", %{\"body\" => body}, socket)` com `broadcast!(socket, \"new_msg\", %{body: body})`. Se o channel caiu, o erro está no log."}
               outra -> {:erro, "A mensagem chegou com o payload `#{inspect(outra)}`; o esperado é `%{\"body\" => \"Conferência: oi\"}`."}
             end
           end)
         end},
        {"Quem enviou também recebe a própria mensagem.",
         fn ->
           com_conexoes([1], fn [ana] ->
             ana = entrar!(ana, "room:lobby")
             ana = empurrar(ana, "room:lobby", "new_msg", %{"body" => "Conferência: eco"})

             case esperar_evento(ana, "room:lobby", "new_msg") do
               %{"body" => "Conferência: eco"} -> :ok
               _ -> {:erro, "Quem enviou não recebeu a mensagem de volta. Use `broadcast!/3`, e não `broadcast_from!/3`: a página do guia só escreve na tela o que chega pelo canal."}
             end
           end)
         end},
        {"Quem está conectado, mas fora da sala, não recebe.",
         fn ->
           com_conexoes([1, 2], fn [ana, fora] ->
             ana = entrar!(ana, "room:lobby")
             empurrar(ana, "room:lobby", "new_msg", %{"body" => "Conferência: só a sala"})

             case esperar_evento(fora, "room:lobby", "new_msg", 400) do
               nil -> :ok
               _ -> {:erro, "Um cliente que não entrou em `room:lobby` recebeu a mensagem."}
             end
           end)
         end}
      ],
      silenciar: [
        {"Quem está na sala recebe `user_joined` quando outra pessoa entra.",
         fn ->
           com_conexoes([1, 2], fn [ana, bia] ->
             ana = entrar!(ana, "room:lobby", %{"apelido" => "ana"})
             {ana, _} = receber_todas(ana, 200)
             entrar!(bia, "room:lobby", %{"apelido" => "bia-conferencia"})

             case esperar_evento(ana, "room:lobby", "user_joined") do
               %{"apelido" => "bia-conferencia"} -> :ok
               nil -> {:erro, "Ninguém foi avisado da entrada. Em `handle_out/3`, quem não silenciou precisa de `push(socket, \"user_joined\", msg)`. Confira também se a célula tem o `handle_info(:after_join, ...)` que já veio pronto."}
               outra -> {:erro, "O aviso chegou com o payload `#{inspect(outra)}`."}
             end
           end)
         end},
        {"Quem entrou com `silenciar_entradas: true` não recebe `user_joined`.",
         fn ->
           com_conexoes([1, 2], fn [ana, bia] ->
             ana = entrar!(ana, "room:lobby", %{"apelido" => "ana", "silenciar_entradas" => true})
             {ana, _} = receber_todas(ana, 200)
             entrar!(bia, "room:lobby", %{"apelido" => "bia"})

             case esperar_evento(ana, "room:lobby", "user_joined", 400) do
               nil -> :ok
               _ -> {:erro, "Quem pediu silêncio recebeu o aviso mesmo assim. Declare `intercept [\"user_joined\"]` e, em `handle_out/3`, devolva `{:noreply, socket}` sem `push` quando `socket.assigns.silenciar_entradas` for verdadeiro."}
             end
           end)
         end},
        {"Quem silenciou as entradas continua recebendo `new_msg`.",
         fn ->
           com_conexoes([1, 2], fn [ana, bia] ->
             ana = entrar!(ana, "room:lobby", %{"silenciar_entradas" => true})
             bia = entrar!(bia, "room:lobby")
             empurrar(bia, "room:lobby", "new_msg", %{"body" => "Conferência: ainda chega"})

             case esperar_evento(ana, "room:lobby", "new_msg") do
               %{"body" => "Conferência: ainda chega"} -> :ok
               _ -> {:erro, "As mensagens da sala pararam de chegar a quem silenciou as entradas. Intercepte só `\"user_joined\"`."}
             end
           end)
         end}
      ],
      token: [
        {"Sem token, o servidor recusa a conexão.",
         fn ->
           case conectar() do
             {:erro, _status} ->
               :ok

             {:ok, c} ->
               fechar(c)
               {:erro, "Uma conexão sem token foi aceita. Em `connect/3`, verifique `connect_info[:auth_token]` com `Phoenix.Token.verify/4` e devolva `:error` quando falhar."}
           end
         end},
        {"Com um token inventado, também recusa.",
         fn ->
           case conectar(token: "token-inventado") do
             {:erro, _status} ->
               :ok

             {:ok, c} ->
               fechar(c)
               {:erro, "Um token inventado foi aceito. O resultado de `Phoenix.Token.verify/4` precisa decidir entre `{:ok, socket}` e `:error`."}
           end
         end},
        {"Com um token válido, conecta, e o socket guarda o id do usuário em `:current_user`.",
         fn ->
           com_conexoes([2], fn [bia] ->
             entrar!(bia, "room:lobby")

             if Enum.any?(sockets_do_topico("room:lobby"), fn {_pid, socket} -> socket.assigns[:current_user] == 2 end),
               do: :ok,
               else: {:erro, "Nenhum channel de `room:lobby` tem `current_user: 2` nos assigns. Em `connect/3`, devolva `{:ok, assign(socket, :current_user, user_id)}`."}
           end)
         end},
        {"`id/1` identifica o socket: um `disconnect` em `user_socket:2` fecha a conexão do usuário 2, e só a dele.",
         fn ->
           com_conexoes([1, 2], fn [ana, bia] ->
             ana = entrar!(ana, "room:lobby")
             bia = entrar!(bia, "room:lobby")
             {ana, _} = receber_todas(ana, 200)
             {bia, _} = receber_todas(bia, 200)
             apply(@endpoint, :broadcast, ["user_socket:2", "disconnect", %{}])

             cond do
               not fechou?(bia) -> {:erro, "A conexão do usuário 2 continuou aberta. Faça `id/1` devolver `\"user_socket:\" <> ...` com o `current_user` do socket."}
               fechou?(ana) -> {:erro, "A conexão do usuário 1 também fechou. O id precisa ser diferente para cada usuário."}
               true -> :ok
             end
           end)
         end}
      ],
      historico: [
        {"A resposta do join traz a lista `messages`.",
         fn ->
           com_conexoes([1], fn [ana] ->
             case entrar(ana, "room:lobby") do
               {_c, {:ok, %{"messages" => lista}}} when is_list(lista) -> :ok
               {_c, {:ok, outra}} -> {:erro, "O join respondeu `#{inspect(outra)}`. Devolva `{:ok, %{messages: messages}, socket}`: o mapa do meio vai para o cliente."}
               {_c, outra} -> {:erro, dica_join("room:lobby", outra)}
             end
           end)
         end},
        {"Cada `new_msg` é gravada e chega com `id`, `body` e `user_id`.",
         fn ->
           limpando_mensagens(fn ->
             com_conexoes([1, 2], fn [ana, bia] ->
               ana = entrar!(ana, "room:lobby")
               bia = entrar!(bia, "room:lobby")
               empurrar(bia, "room:lobby", "new_msg", %{"body" => "Conferência: gravada"})

               case esperar_evento(ana, "room:lobby", "new_msg") do
                 %{"id" => id, "body" => "Conferência: gravada", "user_id" => 2} when is_integer(id) ->
                   if coluna("SELECT body FROM messages WHERE id = $1", [id]) == ["Conferência: gravada"],
                     do: :ok,
                     else: {:erro, "A mensagem chegou com o id #{id}, mas não está na tabela `messages`."}

                 nil ->
                   {:erro, "A mensagem não chegou. Se o channel caiu, o erro está no log."}

                 outra ->
                   {:erro, "A mensagem chegou como `#{inspect(outra)}`. Grave com `Hello.Chat.create_message(\"lobby\", body, socket.assigns[:current_user])` e transmita o mapa que ela devolve."}
               end
             end)
           end)
         end},
        {"Quem volta com `last_seen_id` recebe só o que perdeu.",
         fn ->
           limpando_mensagens(fn ->
             com_conexoes([1], fn [ana] ->
               ana = entrar!(ana, "room:lobby")
               ana = empurrar(ana, "room:lobby", "new_msg", %{"body" => "Conferência: antes"})
               %{"id" => visto} = esperar_evento(ana, "room:lobby", "new_msg") || raise "a primeira mensagem não voltou pelo canal"
               ana = empurrar(ana, "room:lobby", "new_msg", %{"body" => "Conferência: perdida 1"})
               ana = empurrar(ana, "room:lobby", "new_msg", %{"body" => "Conferência: perdida 2"})
               receber_todas(ana, 300)

               com_conexoes([2], fn [bia] ->
                 case entrar(bia, "room:lobby", %{"last_seen_id" => visto}) do
                   {_c, {:ok, %{"messages" => lista}}} ->
                     corpos = for %{"body" => "Conferência" <> _ = corpo} <- lista, do: corpo

                     if corpos == ["Conferência: perdida 1", "Conferência: perdida 2"],
                       do: :ok,
                       else: {:erro, "Com `last_seen_id: #{visto}`, o esperado eram as duas mensagens seguintes, em ordem. Vieram `#{inspect(corpos)}`. Passe `params[\"last_seen_id\"]` para `Hello.Chat.list_messages/2`."}

                   {_c, outra} ->
                     {:erro, "O join respondeu `#{inspect(outra)}`."}
                 end
               end)
             end)
           end)
         end}
      ],
      brecho: [
        {"Dá para entrar no tópico de uma peça à venda, e a resposta traz `nome` e `preco_centavos`.",
         fn ->
           com_pecas(fn [casaco, _saia, _vendido] ->
             com_conexoes([1], fn [ana] ->
               case entrar(ana, "produto:#{casaco.id}") do
                 {_c, {:ok, %{"nome" => nome, "preco_centavos" => 9000}}} when nome == casaco.nome -> :ok
                 {_c, {:ok, outra}} -> {:erro, "O join respondeu `#{inspect(outra)}`; o esperado é um mapa com `nome` e `preco_centavos` da peça."}
                 {_c, {:error, %{"reason" => "unmatched topic"}}} -> {:erro, "Nenhuma rota casa com `produto:#{casaco.id}`. Acrescente `channel \"produto:*\", HelloWeb.ProdutoChannel` ao `UserSocket` (a célula da autenticação) e avalie-a."}
                 {_c, outra} -> {:erro, dica_join("produto:#{casaco.id}", outra)}
               end
             end)
           end)
         end},
        {"Peça vendida, peça que não existe e id que não é número: join recusado com `reason: \"indisponivel\"`.",
         fn ->
           com_pecas(fn [_casaco, _saia, vendido] ->
             com_conexoes([1], fn [ana] ->
               Enum.reduce_while(["produto:#{vendido.id}", "produto:999999", "produto:abc"], :ok, fn topico, :ok ->
                 case entrar(ana, topico) do
                   {_c, {:error, %{"reason" => "indisponivel"}}} -> {:cont, :ok}
                   {_c, outra} -> {:halt, {:erro, "O join em `#{topico}` devolveu `#{inspect(outra)}`; o esperado é `{:error, %{reason: \"indisponivel\"}}`, sem derrubar o channel."}}
                 end
               end)
             end)
           end)
         end},
        {"Uma oferta chega a quem está na mesma peça, com `valor` e `de`, e a mais ninguém.",
         fn ->
           com_pecas(fn [casaco, saia, _vendido] ->
             com_conexoes([1, 2, 3], fn [ana, bia, caio] ->
               ana = entrar!(ana, "produto:#{casaco.id}")
               bia = entrar!(bia, "produto:#{casaco.id}")
               caio = entrar!(caio, "produto:#{saia.id}")
               empurrar(ana, "produto:#{casaco.id}", "oferta", %{"valor" => 7000})

               cond do
                 esperar_evento(bia, "produto:#{casaco.id}", "oferta") != %{"valor" => 7000, "de" => 1} ->
                   {:erro, "Quem está na mesma peça deveria receber `oferta` com `%{valor: 7000, de: 1}` (`de` é o `current_user` de quem ofereceu)."}

                 esperar_evento(caio, "produto:#{saia.id}", "oferta", 300) != nil ->
                   {:erro, "Quem está em outra peça recebeu a oferta."}

                 true ->
                   :ok
               end
             end)
           end)
         end},
        {"Uma oferta com valor inválido recebe uma resposta de erro, e o channel continua de pé.",
         fn ->
           com_pecas(fn [casaco, _saia, _vendido] ->
             com_conexoes([1], fn [ana] ->
               ana = entrar!(ana, "produto:#{casaco.id}")

               with {ana, {:error, %{"reason" => "valor_invalido"}}} <- pedir(ana, "produto:#{casaco.id}", "oferta", %{"valor" => -5}),
                    {_ana, {:error, %{"reason" => "valor_invalido"}}} <- pedir(ana, "produto:#{casaco.id}", "oferta", %{"valor" => "muito"}) do
                 :ok
               else
                 {_c, outra} -> {:erro, "Uma oferta inválida devolveu `#{inspect(outra)}`; o esperado é `{:reply, {:error, %{reason: \"valor_invalido\"}}, socket}`."}
               end
             end)
           end)
         end},
        {"`comprar` vende a peça, responde `ok` e avisa todos do tópico com `vendido`.",
         fn ->
           com_pecas(fn [casaco, _saia, _vendido] ->
             com_conexoes([1, 2], fn [ana, bia] ->
               ana = entrar!(ana, "produto:#{casaco.id}")
               bia = entrar!(bia, "produto:#{casaco.id}")
               {ana, resposta} = pedir(ana, "produto:#{casaco.id}", "comprar")

               cond do
                 not match?({:ok, _}, resposta) -> {:erro, "`comprar` respondeu `#{inspect(resposta)}`; o esperado é `{:reply, :ok, socket}`."}
                 not vendida?(casaco.id) -> {:erro, "A resposta foi `ok`, mas a peça não está vendida no banco. Use `Hello.Vitrine.vender/1`."}
                 not match?(%{"id" => _}, esperar_evento(bia, "produto:#{casaco.id}", "vendido")) -> {:erro, "A outra pessoa do tópico não recebeu `vendido`."}
                 not match?(%{"id" => _}, esperar_evento(ana, "produto:#{casaco.id}", "vendido")) -> {:erro, "Quem comprou não recebeu `vendido`."}
                 true -> :ok
               end
             end)
           end)
         end},
        {"Na segunda tentativa de compra, a resposta é erro com `reason: \"ja_vendido\"`.",
         fn ->
           com_pecas(fn [casaco, _saia, _vendido] ->
             com_conexoes([1, 2], fn [ana, bia] ->
               ana = entrar!(ana, "produto:#{casaco.id}")
               bia = entrar!(bia, "produto:#{casaco.id}")
               {_ana, _} = pedir(ana, "produto:#{casaco.id}", "comprar")

               case pedir(bia, "produto:#{casaco.id}", "comprar") do
                 {_c, {:error, %{"reason" => "ja_vendido"}}} -> :ok
                 {_c, outra} -> {:erro, "A segunda compra respondeu `#{inspect(outra)}`; o esperado é `{:error, %{reason: \"ja_vendido\"}}`."}
               end
             end)
           end)
         end}
      ]
    }
  end

  # --- ajudantes da conferência --------------------------------------------------

  # Abre uma conexão para cada usuário da lista (com um token válido, que o
  # servidor ignora enquanto não o exige), roda o teste e sempre as fecha.
  defp com_conexoes(usuarios, teste) do
    conexoes = Enum.map(usuarios, fn id -> conectar!(token: token(id)) end)

    try do
      teste.(conexoes)
    after
      Enum.each(conexoes, &fechar/1)
    end
  end

  defp entrar!(conexao, topico, payload \\ %{}) do
    case entrar(conexao, topico, payload) do
      {conexao, {:ok, _}} -> conexao
      {_conexao, outra} -> raise dica_join(topico, outra)
    end
  end

  defp dica_join(topico, {:error, %{"reason" => "unmatched topic"}}) do
    "Nenhuma rota do socket casa com `#{topico}`. Falta a linha `channel ...` no `UserSocket`, ou a célula dele não foi avaliada."
  end

  defp dica_join(topico, {:error, %{"reason" => "join crashed"}}) do
    "O `join/3` de `#{topico}` levantou uma exceção (o erro está no log). O módulo do channel foi definido, e tem uma cláusula de `join/3` para esse tópico?"
  end

  defp dica_join(topico, outra), do: "O join em `#{topico}` devolveu `#{inspect(outra)}`."

  # Espera, por até `tempo` ms, uma mensagem com esse evento nesse tópico. Devolve o payload ou nil.
  defp esperar_evento(conexao, topico, evento, tempo \\ 1000) do
    case receber(conexao, tempo) do
      {_conexao, [_, _, ^topico, ^evento, payload]} -> payload
      {conexao, mensagem} when is_list(mensagem) -> esperar_evento(conexao, topico, evento, tempo)
      {_conexao, _nada_ou_fechada} -> nil
    end
  end

  defp fechou?(conexao) do
    case receber(conexao, 500) do
      {_conexao, :fechada} -> true
      {conexao, mensagem} when is_list(mensagem) -> fechou?(conexao)
      {_conexao, :nada} -> false
    end
  end

  defp limpando_mensagens(teste) do
    teste.()
  after
    sql("DELETE FROM messages WHERE body LIKE 'Conferência%'")
  end

  # Cria três peças de conferência (a terceira já vendida), roda o teste e as apaga.
  defp com_pecas(teste) do
    unless Code.ensure_loaded?(Hello.Vitrine),
      do: raise("o contexto `Hello.Vitrine` não existe: avalie as células de \"O Brechó até aqui\"")

    sufixo = System.unique_integer([:positive])

    pecas =
      for {nome, preco} <- [{"Conferência Casaco #{sufixo}", 9000}, {"Conferência Saia #{sufixo}", 3000}, {"Conferência Chapéu #{sufixo}", 2000}] do
        {:ok, peca} = apply(Hello.Vitrine, :cadastrar_produto, [%{nome: nome, preco_centavos: preco}])
        peca
      end

    {:ok, _} = apply(Hello.Vitrine, :vender, [List.last(pecas)])

    try do
      teste.(pecas)
    after
      sql("DELETE FROM produtos WHERE nome LIKE 'Conferência%'")
    end
  end

  defp vendida?(id), do: coluna("SELECT vendido FROM produtos WHERE id = $1", [id]) == [true]

  defp coluna(consulta, parametros), do: consulta |> sql(parametros) |> Map.fetch!(:rows) |> Enum.map(&hd/1)

  defp explicar(%RuntimeError{message: mensagem}), do: mensagem

  defp explicar(%Postgrex.Error{postgres: %{code: :undefined_table, message: mensagem}}) do
    "O PostgreSQL respondeu `#{mensagem}`. A migration que cria a tabela já rodou?"
  end

  defp explicar(%MatchError{term: termo}) do
    "Um casamento de padrões falhou com o valor `#{inspect(termo, limit: 8)}`."
  end

  defp explicar(erro), do: "Levantou uma exceção:\n\n  `" <> Exception.format_banner(:error, erro) <> "`"
end

Como usar este notebook

Este notebook acompanha o guia Channels da documentação do Phoenix 1.8, na mesma ordem e com o link de cada seção. É o primeiro dos três notebooks do módulo 10, "Tempo real": depois vêm Presence (19) e "Writing a Channels Client" (20).

As convenções são as de sempre: uma célula equivale a um arquivo, as perguntas "Antes de continuar" vêm antes da explicação, e os erros fazem parte do conteúdo.

O guia constrói um chat. As páginas funcionam no navegador: abra http://localhost:4010 em duas abas e converse consigo mesmo. Para ver o que passa entre os clientes e o servidor sem sair do notebook, o módulo Trilha traz um cliente de Channels que fala o protocolo do Phoenix por um WebSocket de verdade, como o JavaScript do navegador:

  • Trilha.conectar/1 abre a conexão em /socket/websocket (opcionalmente com um token) e devolve {:ok, conexao};
  • Trilha.entrar(conexao, tópico, payload) manda o phx_join e devolve {conexao, resposta};
  • Trilha.empurrar/4 manda um evento, como channel.push(...); Trilha.pedir/4 manda e espera a resposta;
  • Trilha.receber/2 e Trilha.receber_todas/2 leem o que o servidor mandou;
  • Trilha.fio/1 mostra tudo o que passou pela conexão, nos dois sentidos;
  • Trilha.canais(tópico) mostra os processos de channel daquele tópico no servidor, com o estado de cada um.

A conexão é um valor: cada chamada devolve a conexão atualizada, que você passa à próxima. Por isso as células escrevem {ana, resposta} = Trilha.entrar(ana, ...).

A instalação é a dos notebooks 13 a 17. Se você vem do 12 ou de antes, o Livebook faz uma instalação nova na primeira avaliação, uma vez só.

A aplicação fica em http://localhost:4010. Se outro notebook da trilha estiver com o runtime ligado, desconecte-o antes. Avalie a primeira célula, no topo, antes de tudo.

A aplicação Hello dentro do notebook

config/dev.exs: a configuração do banco no ambiente de desenvolvimento, a mesma do notebook 07. Se o seu PostgreSQL usa outra senha ou outra porta, troque aqui e avalie a célula de novo.

Application.put_env(:hello, Hello.Repo,
  username: System.get_env("PGUSER", "postgres"),
  password: System.get_env("PGPASSWORD", "postgres"),
  hostname: System.get_env("PGHOST", "localhost"),
  port: String.to_integer(System.get_env("PGPORT", "5432")),
  database: "trilha_phoenix_dev",
  show_sensitive_data_on_connection_error: true,
  pool_size: 10,
  stacktrace: true
)

lib/hello/repo.ex

defmodule Hello.Repo do
  use Ecto.Repo,
    otp_app: :hello,
    adapter: Ecto.Adapters.Postgres
end

lib/hello_web.ex: como no projeto gerado. O bloco channel é só use Phoenix.Channel; é o que use HelloWeb, :channel traz para cada channel, como use HelloWeb, :controller traz o necessário para cada controller.

defmodule HelloWeb do
  def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)

  def router do
    quote do
      use Phoenix.Router, helpers: false

      import Plug.Conn
      import Phoenix.Controller
      import Phoenix.LiveView.Router
    end
  end

  def channel do
    quote do
      use Phoenix.Channel
    end
  end

  def controller do
    quote do
      use Phoenix.Controller, formats: [:html, :json]

      import Plug.Conn

      unquote(verified_routes())
    end
  end

  def html do
    quote do
      use Phoenix.Component

      import Phoenix.Controller,
        only: [get_csrf_token: 0, view_module: 1, view_template: 1]

      unquote(html_helpers())
    end
  end

  defp html_helpers do
    quote do
      import Phoenix.HTML
      import HelloWeb.CoreComponents

      alias Phoenix.LiveView.JS
      alias HelloWeb.Layouts

      unquote(verified_routes())
    end
  end

  def verified_routes do
    quote do
      use Phoenix.VerifiedRoutes,
        endpoint: HelloWeb.Endpoint,
        router: HelloWeb.Router,
        statics: HelloWeb.static_paths()
    end
  end

  defmacro __using__(which) when is_atom(which) do
    apply(__MODULE__, which, [])
  end
end

lib/hello_web/router.ex: só a página inicial, que vai virar a página do chat.

defmodule HelloWeb.Router do
  use HelloWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {HelloWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :home
  end
end

lib/hello_web/channels/user_socket.ex: o arquivo que mix phx.gen.socket User gera no Phoenix 1.8.16, sem nenhuma mudança. A seção "Gerando um socket" volta a ele. Ele vem antes do endpoint porque o endpoint o declara e o inicia.

defmodule HelloWeb.UserSocket do
  use Phoenix.Socket

  # A Socket handler
  #
  # It's possible to control the websocket connection and
  # assign values that can be accessed by your channel topics.

  ## Channels
  # Uncomment the following line to define a "room:*" topic
  # pointing to the `HelloWeb.RoomChannel`:
  #
  # channel "room:*", HelloWeb.RoomChannel
  #
  # To create a channel file, use the mix task:
  #
  #     mix phx.gen.channel Room
  #
  # See the [`Channels guide`](https://phoenix.hexdocs.pm/channels.html)
  # for further details.


  # Socket params are passed from the client and can
  # be used to verify and authenticate a user. After
  # verification, you can put default assigns into
  # the socket that will be set for all channels, ie
  #
  #     {:ok, assign(socket, :user_id, verified_user_id)}
  #
  # To deny connection, return `:error` or `{:error, term}`. To control the
  # response the client receives in that case, [define an error handler in the
  # websocket
  # configuration](https://phoenix.hexdocs.pm/Phoenix.Endpoint.html#socket/3-websocket-configuration).
  #
  # See `Phoenix.Token` documentation for examples in
  # performing token verification on connect.
  @impl true
  def connect(_params, socket, _connect_info) do
    {:ok, socket}
  end

  # Socket IDs are topics that allow you to identify all sockets for a given user:
  #
  #     def id(socket), do: "user_socket:#{socket.assigns.user_id}"
  #
  # Would allow you to broadcast a "disconnect" event and terminate
  # all active sockets and channels for a given user:
  #
  #     Elixir.HelloWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{})
  #
  # Returning `nil` makes this socket anonymous.
  @impl true
  def id(_socket), do: nil
end

lib/hello_web/endpoint.ex: a linha socket "/socket", HelloWeb.UserSocket é a que o gerador pede para acrescentar; ela cria o endereço /socket/websocket. Os Plug.Static servem o phoenix_html.js e o phoenix.min.js, o cliente JavaScript de Channels, que num projeto o esbuild empacota no app.js.

defmodule HelloWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :hello

  @session_options [
    store: :cookie,
    key: "_hello_key",
    signing_salt: "trilha00",
    same_site: "Lax"
  ]

  socket "/socket", HelloWeb.UserSocket,
    websocket: true,
    longpoll: false

  plug Plug.Static, at: "/assets", from: {:phoenix_html, "priv/static"}, only: ~w(phoenix_html.js)
  plug Plug.Static, at: "/assets", from: {:phoenix, "priv/static"}, only: ~w(phoenix.min.js)

  plug Plug.RequestId
  plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]

  plug Plug.Parsers,
    parsers: [:urlencoded, :multipart, :json],
    pass: ["*/*"],
    json_decoder: Phoenix.json_library()

  plug Plug.MethodOverride
  plug Plug.Head
  plug Plug.Session, @session_options
  plug HelloWeb.Router
end

lib/hello_web/components/core_components.ex: os componentes que um projeto novo traz, do arquivo gerado pelo Phoenix 1.8.16, com uma diferença: no projeto, as mensagens de erro passam pelo Gettext; aqui, translate_error/1 só preenche os valores na mensagem.

defmodule HelloWeb.CoreComponents do
  use Phoenix.Component

  alias Phoenix.LiveView.JS

  @doc "Renders flash notices."
  attr :id, :string, doc: "the optional id of flash container"
  attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
  attr :title, :string, default: nil
  attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
  attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"

  slot :inner_block, doc: "the optional inner block that renders the flash message"

  def flash(assigns) do
    assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)

    ~H"""
    <div
      :if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
      id={@id}
      phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
      role="alert"
      class="toast toast-top toast-end z-50"
      {@rest}
    >
      <div class={[
        "alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
        @kind == :info && "alert-info",
        @kind == :error && "alert-error"
      ]}>
        <.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
        <.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
        <div>
          <p :if={@title} class="font-semibold">{@title}</p>
          <p>{msg}</p>
        </div>
        <div class="flex-1" />
        <button type="button" class="group self-start cursor-pointer" aria-label="close">
          <.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
        </button>
      </div>
    </div>
    """
  end

  @doc "Renders a button with navigation support."
  attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
  attr :class, :any
  attr :variant, :string, values: ~w(primary)
  slot :inner_block, required: true

  def button(%{rest: rest} = assigns) do
    variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}

    assigns =
      assign_new(assigns, :class, fn ->
        ["btn", Map.fetch!(variants, assigns[:variant])]
      end)

    if rest[:href] || rest[:navigate] || rest[:patch] do
      ~H"""
      <.link class={@class} {@rest}>
        {render_slot(@inner_block)}
      </.link>
      """
    else
      ~H"""
      <button class={@class} {@rest}>
        {render_slot(@inner_block)}
      </button>
      """
    end
  end

  @doc "Renders an input with label and error messages."
  attr :id, :any, default: nil
  attr :name, :any
  attr :label, :string, default: nil
  attr :value, :any

  attr :type, :string,
    default: "text",
    values: ~w(checkbox color date datetime-local email file month number password
               search select tel text textarea time url week hidden)

  attr :field, Phoenix.HTML.FormField,
    doc: "a form field struct retrieved from the form, for example: @form[:email]"

  attr :errors, :list, default: []
  attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
  attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
  attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
  attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
  attr :class, :any, default: nil, doc: "the input class to use over defaults"
  attr :error_class, :any, default: nil, doc: "the input error class to use over defaults"

  attr :rest, :global,
    include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
                multiple pattern placeholder readonly required rows size step)

  def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
    errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []

    assigns
    |> assign(field: nil, id: assigns.id || field.id)
    |> assign(:errors, Enum.map(errors, &translate_error(&1)))
    |> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
    |> assign_new(:value, fn -> field.value end)
    |> input()
  end

  def input(%{type: "hidden"} = assigns) do
    ~H"""
    <input type="hidden" id={@id} name={@name} value={@value} {@rest} />
    """
  end

  def input(%{type: "checkbox"} = assigns) do
    assigns =
      assign_new(assigns, :checked, fn ->
        Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
      end)

    ~H"""
    <div class="fieldset mb-2">
      <label for={@id}>
        <input
          type="hidden"
          name={@name}
          value="false"
          disabled={@rest[:disabled]}
          form={@rest[:form]}
        />
        <span class="label">
          <input
            type="checkbox"
            id={@id}
            name={@name}
            value="true"
            checked={@checked}
            class={@class || "checkbox checkbox-sm"}
            {@rest}
          />{@label}
        </span>
      </label>
      <.error :for={msg <- @errors}>{msg}</.error>
    </div>
    """
  end

  def input(%{type: "select"} = assigns) do
    ~H"""
    <div class="fieldset mb-2">
      <label for={@id}>
        <span :if={@label} class="label mb-1">{@label}</span>
        <select
          id={@id}
          name={@name}
          class={[@class || "w-full select", @errors != [] && (@error_class || "select-error")]}
          multiple={@multiple}
          {@rest}
        >
          <option :if={@prompt} value="">{@prompt}</option>
          {Phoenix.HTML.Form.options_for_select(@options, @value)}
        </select>
      </label>
      <.error :for={msg <- @errors}>{msg}</.error>
    </div>
    """
  end

  def input(%{type: "textarea"} = assigns) do
    ~H"""
    <div class="fieldset mb-2">
      <label for={@id}>
        <span :if={@label} class="label mb-1">{@label}</span>
        <textarea
          id={@id}
          name={@name}
          class={[
            @class || "w-full textarea",
            @errors != [] && (@error_class || "textarea-error")
          ]}
          {@rest}
        >{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
      </label>
      <.error :for={msg <- @errors}>{msg}</.error>
    </div>
    """
  end

  # All other inputs text, datetime-local, url, password, etc. are handled here...
  def input(assigns) do
    ~H"""
    <div class="fieldset mb-2">
      <label for={@id}>
        <span :if={@label} class="label mb-1">{@label}</span>
        <input
          type={@type}
          name={@name}
          id={@id}
          value={Phoenix.HTML.Form.normalize_value(@type, @value)}
          class={[
            @class || "w-full input",
            @errors != [] && (@error_class || "input-error")
          ]}
          {@rest}
        />
      </label>
      <.error :for={msg <- @errors}>{msg}</.error>
    </div>
    """
  end

  # Helper used by inputs to generate form errors
  defp error(assigns) do
    ~H"""
    <p class="mt-1.5 flex gap-2 items-center text-sm text-error">
      <.icon name="hero-exclamation-circle" class="size-5" />
      {render_slot(@inner_block)}
    </p>
    """
  end

  @doc "Renders a header with title."
  slot :inner_block, required: true
  slot :subtitle
  slot :actions

  def header(assigns) do
    ~H"""
    <header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
      <div>
        <h1 class="text-lg font-semibold leading-8">
          {render_slot(@inner_block)}
        </h1>
        <p :if={@subtitle != []} class="text-sm text-base-content/70">
          {render_slot(@subtitle)}
        </p>
      </div>
      <div class="flex-none">{render_slot(@actions)}</div>
    </header>
    """
  end

  @doc "Renders a table with generic styling."
  attr :id, :string, required: true
  attr :rows, :list, required: true
  attr :row_id, :any, default: nil, doc: "the function for generating the row id"
  attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"

  attr :row_item, :any,
    default: &Function.identity/1,
    doc: "the function for mapping each row before calling the :col and :action slots"

  slot :col, required: true do
    attr :label, :string
  end

  slot :action, doc: "the slot for showing user actions in the last table column"

  def table(assigns) do
    assigns =
      with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
        assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
      end

    ~H"""
    <table class="table table-zebra">
      <thead>
        <tr>
          <th :for={col <- @col}>{col[:label]}</th>
          <th :if={@action != []}>
            <span class="sr-only">Actions</span>
          </th>
        </tr>
      </thead>
      <tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
        <tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
          <td
            :for={col <- @col}
            phx-click={@row_click && @row_click.(row)}
            class={@row_click && "hover:cursor-pointer"}
          >
            {render_slot(col, @row_item.(row))}
          </td>
          <td :if={@action != []} class="w-0 font-semibold">
            <div class="flex gap-4">
              <%= for action <- @action do %>
                {render_slot(action, @row_item.(row))}
              <% end %>
            </div>
          </td>
        </tr>
      </tbody>
    </table>
    """
  end

  @doc "Renders a data list."
  slot :item, required: true do
    attr :title, :string, required: true
  end

  def list(assigns) do
    ~H"""
    <ul class="list">
      <li :for={item <- @item} class="list-row">
        <div class="list-col-grow">
          <div class="font-bold">{item.title}</div>
          <div>{render_slot(item)}</div>
        </div>
      </li>
    </ul>
    """
  end

  @doc "Renders a [Heroicon](https://heroicons.com)."
  attr :name, :string, required: true
  attr :class, :any, default: "size-4"

  def icon(%{name: "hero-" <> _} = assigns) do
    ~H"""
    <span class={[@name, @class]} />
    """
  end

  ## JS Commands

  def show(js \\ %JS{}, selector) do
    JS.show(js,
      to: selector,
      time: 300,
      transition:
        {"transition-all ease-out duration-300",
         "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
         "opacity-100 translate-y-0 sm:scale-100"}
    )
  end

  def hide(js \\ %JS{}, selector) do
    JS.hide(js,
      to: selector,
      time: 200,
      transition:
        {"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
         "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
    )
  end

  @doc """
  Fills in the error message values. In the generated project this function
  translates the message with Gettext.
  """
  def translate_error({msg, opts}) do
    Enum.reduce(opts, msg, fn {key, value}, acc ->
      String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
    end)
  end

  @doc "Translates the errors for a field from a keyword list of errors."
  def translate_errors(errors, field) when is_list(errors) do
    for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
  end
end

lib/hello_web/components/layouts.ex: o root layout carrega o Tailwind e o daisyUI de uma CDN e o phoenix.min.js, que define a global Phoenix no navegador. A linha window.userToken é a do passo 3 da autenticação por token, no fim do guia; até lá, ela escreve um texto vazio.

defmodule HelloWeb.Layouts do
  use HelloWeb, :html

  def root(assigns) do
    ~H"""
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <meta name="csrf-token" content={get_csrf_token()} />
        <title>Hello · Phoenix Framework</title>
        <link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
        <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4">
        </script>
        <script defer src={~p"/assets/phoenix_html.js"}>
        </script>
        <script>window.userToken = "<%= assigns[:user_token] %>";</script>
        <script src={~p"/assets/phoenix.min.js"}>
        </script>
      </head>
      <body>
        {@inner_content}
      </body>
    </html>
    """
  end

  attr :flash, :map, required: true, doc: "the map of flash messages"
  attr :current_scope, :map, default: nil, doc: "the current scope"
  slot :inner_block, required: true

  def app(assigns) do
    ~H"""
    <header class="navbar px-4 sm:px-6 lg:px-8">
      <div class="flex-1">
        <a href="/" class="flex-1 flex w-fit items-center gap-2">
          <span class="text-sm font-semibold">Hello · Phoenix v{Application.spec(:phoenix, :vsn)}</span>
        </a>
      </div>
    </header>

    <main class="px-4 py-20 sm:px-6 lg:px-8">
      <div class="mx-auto max-w-2xl space-y-4">
        {render_slot(@inner_block)}
      </div>
    </main>

    <.flash_group flash={@flash} />
    """
  end

  attr :flash, :map, required: true, doc: "the map of flash messages"
  attr :id, :string, default: "flash-group", doc: "the optional id of flash container"

  def flash_group(assigns) do
    ~H"""
    <div id={@id} aria-live="polite">
      <.flash kind={:info} flash={@flash} />
      <.flash kind={:error} flash={@flash} />
    </div>
    """
  end
end

lib/hello_web/controllers/: as páginas de erro e a página inicial. A seção "Juntando tudo" troca a página inicial pela página do chat.

defmodule HelloWeb.ErrorHTML do
  def render(template, _assigns) do
    Phoenix.Controller.status_message_from_template(template)
  end
end

defmodule HelloWeb.ErrorJSON do
  def render(template, _assigns) do
    %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
  end
end

defmodule HelloWeb.PageController do
  use HelloWeb, :controller

  def home(conn, _params) do
    render(conn, :home)
  end
end

defmodule HelloWeb.PageHTML do
  use HelloWeb, :html

  def home(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <h1>Welcome to Phoenix!</h1>
    </Layouts.app>
    """
  end
end

Antes de começar: o PostgreSQL

Este notebook usa o mesmo PostgreSQL do notebook 07, com o banco trilha_phoenix_dev. O banco só entra na última parte do guia (guardar as mensagens do chat) e no desafio do fim, mas a aplicação o inicia como nos outros notebooks. A célula abaixo tenta uma conexão e, se falhar, traduz a mensagem em uma dica. Os comandos para instalar e configurar o PostgreSQL estão no LEIA-ME.md da trilha.

Trilha.verificar_postgres()

Crie o banco, se ainda não existir (é o mix ecto.create). {:error, :already_up} quer dizer que ele já existe, e serve do mesmo jeito.

Ecto.Adapters.Postgres.storage_up(Hello.Repo.config())

lib/hello/application.ex: a árvore de supervisão inicia o Repo, o Phoenix.PubSub e o endpoint. Neste notebook o PubSub deixa de ser coadjuvante: é por ele que uma mensagem enviada por um cliente chega a todos os outros. Ao iniciar, o endpoint também inicia o que cada socket declarado nele precisa; por isso o HelloWeb.UserSocket vem antes desta célula.

{:ok, _} = Kino.start_child(Hello.Repo)
{:ok, _} = Kino.start_child({Phoenix.PubSub, name: Hello.PubSub})
{:ok, _} = Kino.start_child(HelloWeb.Endpoint)

Kino.Markdown.new("Repo conectado e aplicação no ar em <http://localhost:4010>.")

Comece do zero: a célula abaixo apaga todas as tabelas do banco trilha_phoenix_dev, para que os números de id sejam os mesmos do texto. Avalie-a sempre que reabrir o notebook. Ela só mexe no banco da trilha.

Trilha.recomecar()

Channels

Na documentação: Channels

Trilha.pergunta(:http_ou_canal)

Os Channels são a parte do Phoenix que permite comunicação em tempo quase real com milhões de clientes conectados, e entre eles. O guia lista alguns usos:

  • salas de chat e APIs de aplicativos de mensagens;
  • notícias de última hora, como "saiu um gol" ou "vem aí um terremoto";
  • acompanhar trens, caminhões ou corredores num mapa;
  • eventos em jogos com vários jogadores;
  • monitorar sensores e controlar luzes;
  • avisar o navegador de que o CSS ou o JavaScript de uma página mudou (o recarregamento automático do Phoenix em desenvolvimento funciona assim).

A ideia é simples. O cliente se conecta ao servidor por algum transporte, como o WebSocket. Conectado, ele entra em um ou mais tópicos. Para participar de um chat público, entra num tópico como public_chat; para receber as novidades do produto de id 7, num tópico como product_updates:7.

O cliente manda mensagens para os tópicos em que entrou e recebe mensagens deles. Do outro lado, o servidor recebe as mensagens dos clientes e também pode mandar mensagens a eles. E o servidor pode fazer um broadcast: mandar uma mensagem a todos os clientes inscritos num tópico.

                                                                  +----------------+
                                                     +--Topic X-->| Mobile Client  |
                                                     |            +----------------+
                              +-------------------+  |
+----------------+            |                   |  |            +----------------+
| Browser Client |--Topic X-->| Phoenix Server(s) |--+--Topic X-->| Desktop Client |
+----------------+            |                   |  |            +----------------+
                              +-------------------+  |
                                                     |            +----------------+
                                                     +--Topic X-->|   IoT Client   |
                                                                  +----------------+

O broadcast funciona mesmo com a aplicação rodando em vários computadores. Se dois clientes estão conectados a nós diferentes e inscritos no mesmo tópico, os dois recebem a mensagem. Quem cuida disso é um mecanismo interno, o PubSub.

Qualquer tipo de cliente serve: navegador, aplicativo nativo, relógio, dispositivo embarcado. Basta uma biblioteca que fale um dos transportes que os Channels entendem: hoje, WebSocket ou long polling.

Diferente das requisições HTTP, que não guardam estado, um channel é uma conexão de longa duração. Cada uma é sustentada por um processo leve da máquina virtual do Erlang, que roda em paralelo com os outros e guarda o próprio estado. O guia cita o teste em que um único servidor Phoenix manteve dois milhões de conexões WebSocket.

Se você fez os notebooks 16 e 17, já usou Channels sem escrever nenhum: cada LiveView conectada é um channel, no tópico lv:.... Aqui você escreve os seus.

As peças

Na documentação: The Moving Parts

Para o cliente, usar um channel é simples. Por trás, várias peças levam cada mensagem até os clientes certos, inclusive em outros servidores.

Visão geral

Na documentação: Overview

Trilha.pergunta(:processos)

O cliente se conecta a um nó (um servidor Phoenix) por um transporte e entra em um ou mais channels por essa única conexão. O servidor cria um processo de channel por cliente, por tópico. Cada um guarda um %Phoenix.Socket{} e pode manter o estado que quiser em socket.assigns.

Com a conexão aberta, cada mensagem que chega do cliente é encaminhada, pelo tópico, ao processo de channel certo. Se o channel pede um broadcast, a mensagem vai para o PubSub local, que a entrega a todos os clientes daquele servidor inscritos no tópico. Se houver outros nós no cluster, o PubSub local repassa a mensagem ao PubSub de cada um, e cada um a entrega aos seus inscritos. Só uma mensagem viaja por nó a mais, então acrescentar nós custa pouco, e cada nó novo atende muito mais clientes.

                                 Channel   +-------------------------+      +--------+
                                  route    | Sending Client, Topic 1 |      | Local  |
                              +----------->|     Channel.Server      |----->| PubSub |--+
+----------------+            |            +-------------------------+      +--------+  |
| Sending Client |-Transport--+                                                  |      |
+----------------+                         +-------------------------+           |      |
                                           | Sending Client, Topic 2 |           |      |
                                           |     Channel.Server      |           |      |
                                           +-------------------------+           |      |
                                                                                 |      |
                                           +-------------------------+           |      |
+----------------+                         | Browser Client, Topic 1 |           |      |
| Browser Client |<-------Transport--------|     Channel.Server      |<----------+      |
+----------------+                         +-------------------------+                  |
                                                                                        |
                                           +-------------------------+                  |
+----------------+                         |  Phone Client, Topic 1  |                  |
|  Phone Client  |<-------Transport--------|     Channel.Server      |<-+               |
+----------------+                         +-------------------------+  |   +--------+  |
                                                                        +---| Remote |<-+
                                                                            | PubSub |
                                                                            +--------+

O desenho do guia tem mais clientes e mais um PubSub remoto; a ideia é a mesma. Neste notebook há um nó só, então tudo passa pelo PubSub local, o Hello.PubSub iniciado lá em cima.

Endpoint

Na documentação: Endpoint

No módulo Endpoint da aplicação, uma declaração socket diz qual socket handler recebe as conexões de uma URL:

socket "/socket", HelloWeb.UserSocket,
  websocket: true,
  longpoll: false,
  auth_token: true

O Phoenix traz dois transportes, websocket e longpoll, configurados nessa mesma declaração. A célula do endpoint deste notebook, lá em cima, tem essa linha sem o auth_token, que chega na seção de autenticação. Com websocket: true, o endereço de conexão é /socket/websocket.

Socket handlers

Na documentação: Socket Handlers

No cliente, o JavaScript abre uma conexão com essa rota:

let socket = new Socket("/socket", {authToken: window.userToken})

No servidor, o Phoenix chama HelloWeb.UserSocket.connect/3 com os parâmetros da conexão e o estado inicial do socket. É ali que a aplicação autentica e identifica a conexão e define os assigns iniciais. O guia escreve connect/2, mas o callback que o gerador cria, e que o próprio guia usa mais adiante, é connect/3: o terceiro argumento traz informações da conexão, como o token. O socket handler também é o lugar das rotas de channel.

Rotas de channel

Na documentação: Channel Routes

Trilha.pergunta(:curinga)

As rotas de channel casam com o texto do tópico e mandam a mensagem ao módulo de channel indicado. O asterisco é um curinga. Com a rota abaixo, no UserSocket, tanto room:lobby quanto room:123 vão para o RoomChannel:

channel "room:*", HelloWeb.RoomChannel

Channels

Na documentação: Channels

Os channels tratam eventos dos clientes, e nisso se parecem com os controllers. Há duas diferenças importantes: os eventos de um channel vão nos dois sentidos, de entrada e de saída; e a conexão continua existindo depois de um ciclo de pedido e resposta. Os channels são a abstração de mais alto nível do Phoenix para comunicação em tempo real.

Cada channel implementa uma ou mais cláusulas de quatro callbacks: join/3, terminate/2, handle_in/3 e handle_out/3.

Tópicos

Na documentação: Topics

Tópicos são textos: nomes que as várias camadas usam para garantir que cada mensagem chegue ao lugar certo. Como as rotas aceitam curinga, é comum a convenção "tópico:subtópico", com o subtópico montado a partir de um id da aplicação, como "users:123".

Mensagens

Na documentação: Messages

O módulo Phoenix.Socket.Message define a struct de uma mensagem válida, com estas chaves:

  • topic: o tópico, como "messages" ou "messages:123";
  • event: o nome do evento, como "phx_join";
  • payload: o conteúdo da mensagem;
  • ref: uma referência única, em texto.

No fio, cada mensagem viaja como uma lista JSON de cinco itens, [join_ref, ref, topic, event, payload]. O join_ref é o quinto dado: a referência do join que abriu aquele tópico naquela conexão. Você vai ver essas listas em todas as demonstrações deste notebook. O notebook 20, sobre o guia "Writing a Channels Client", olha esse formato em detalhe.

PubSub

Na documentação: PubSub

O PubSub é o módulo Phoenix.PubSub. Os interessados se inscrevem em tópicos, e outros processos transmitem eventos para esses tópicos. Serve para os broadcasts dos channels e para a aplicação em geral: no notebook 16, foi por ele que uma leitura do sensor chegou a todas as LiveViews abertas.

O PubSub leva as mensagens de um nó a outro. Por padrão usa o Phoenix.PubSub.PG2, com a troca de mensagens nativa da máquina virtual do Erlang. Onde os servidores não podem falar direto uns com os outros, há um adaptador para Redis.

Bibliotecas de cliente

Na documentação: Client Libraries

Qualquer dispositivo em rede pode se conectar aos Channels, desde que tenha uma biblioteca de cliente. A oficial é a de JavaScript, que vem em todo projeto novo (documentação). O guia lista bibliotecas de terceiros para Swift, Java, Kotlin, C#, Elixir e GDScript.

Este notebook usa dois clientes. No navegador, o cliente JavaScript oficial: o arquivo phoenix.min.js, que o endpoint serve e que define a global Phoenix. Dentro do notebook, o cliente do módulo Trilha, escrito para a trilha: Trilha.conectar/1, entrar/3, empurrar/4 e receber/2 abrem um WebSocket de verdade e trocam as mesmas mensagens que o JavaScript troca. Trilha.fio/1 mostra tudo o que passou pela conexão.

Juntando tudo

Na documentação: Tying it all together

O guia junta essas ideias numa aplicação de chat.

Gerando um socket

Na documentação: Generating a socket

O primeiro passo é o gerador de socket. No Phoenix 1.8.16, a saída é esta:

$ mix phx.gen.socket User
* creating lib/hello_web/channels/user_socket.ex
* creating assets/js/user_socket.js

Add the socket handler to your `lib/hello_web/endpoint.ex`, for example:

    socket "/socket", HelloWeb.UserSocket,
      websocket: true,
      longpoll: false

For the front-end integration, you need to import the `user_socket.js`
in your `assets/js/app.js` file:

    import "./user_socket.js"

São dois arquivos: o código do cliente, em assets/js/user_socket.js, e a contraparte do servidor, em lib/hello_web/channels/user_socket.ex. O gerador pede duas coisas: acrescentar a linha socket ao endpoint e importar o user_socket.js no app.js.

Neste notebook, o user_socket.ex gerado e a linha do endpoint já estão na seção "A aplicação Hello dentro do notebook", porque o endpoint precisa deles para iniciar. O JavaScript vem daqui a pouco.

O passo seguinte do guia é fazer o socket encaminhar as mensagens ao channel certo, tirando o comentário da rota "room:*". Com ela, toda mensagem cujo tópico começa com "room:" vai para o RoomChannel.

lib/hello_web/channels/user_socket.ex, de novo: o mesmo arquivo gerado, sem os comentários longos. Avaliar esta célula redefine o módulo; o endpoint, que já está no ar, passa a usar a versão nova na próxima conexão. É a primeira metade do exercício abaixo.

defmodule HelloWeb.UserSocket do
  use Phoenix.Socket

  ## Channels
  # Sua vez (canal): tire o comentário da linha abaixo.
  #
  # channel "room:*", HelloWeb.RoomChannel

  @impl true
  def connect(_params, socket, _connect_info) do
    {:ok, socket}
  end

  @impl true
  def id(_socket), do: nil
end

Entrando em channels

Na documentação: Joining Channels

Trilha.pergunta(:join_recusado)

A primeira obrigação de um channel é autorizar, ou não, a entrada de um cliente num tópico. Isso é o join/3. O guia escreve o RoomChannel assim:

defmodule HelloWeb.RoomChannel do
  use Phoenix.Channel

  def join("room:lobby", _message, socket) do
    {:ok, socket}
  end

  def join("room:" <> _private_room_id, _params, _socket) do
    {:error, %{reason: "unauthorized"}}
  end
end

Qualquer pessoa pode entrar em "room:lobby". Qualquer outra sala é tratada como privada e exigiria uma autorização especial, vinda do banco, por exemplo; o guia só as recusa.

O guia escreve o módulo à mão. Existe também um gerador:

$ mix phx.gen.channel Room
* creating lib/hello_web/channels/room_channel.ex
* creating test/hello_web/channels/room_channel_test.exs
* creating test/support/channel_case.ex

Add the channel to your `lib/hello_web/channels/user_socket.ex` handler, for example:

    channel "room:lobby", HelloWeb.RoomChannel

O arquivo gerado começa com use HelloWeb, :channel (que é só use Phoenix.Channel, pelo bloco channel do HelloWeb), marca cada callback com @impl true e traz dois handle_in/3 de exemplo, "ping" e "shout". O notebook segue o código do guia, com o use e os @impl do gerador.

Sua vez: o channel da sala

Faça as duas metades:

  1. Na célula do UserSocket logo acima, tire o comentário da rota channel "room:*", HelloWeb.RoomChannel e avalie a célula.
  2. Na célula abaixo, troque o join/3 provisório pelas duas cláusulas do guia: "room:lobby" aceita, e "room:" <> _private_room_id recusa com %{reason: "unauthorized"}.

lib/hello_web/channels/room_channel.ex

defmodule HelloWeb.RoomChannel do
  use HelloWeb, :channel

  # Sua vez (canal e new_msg): troque este join/3 provisório pelas duas
  # cláusulas do guia. Mais adiante, o handle_in/3 também vem para esta célula.
  @impl true
  def join(_topic, _payload, _socket) do
    {:error, %{reason: "o join ainda não foi escrito"}}
  end
end
Trilha.solucao(:canal)
Trilha.conferir(:canal)

O que passa pelo fio

Agora abra uma conexão, como o navegador faria, e entre na sala. Trilha.conectar/1 faz o pedido de WebSocket em /socket/websocket; Trilha.entrar/3 manda o evento phx_join e espera a resposta. Se o exercício acima ainda não foi feito, as respostas desta seção são erros, e a explicação não bate.

{:ok, ana} = Trilha.conectar()
{ana, resposta} = Trilha.entrar(ana, "room:lobby")
resposta

A resposta é {:ok, %{}}: o join/3 devolveu {:ok, socket}, sem nada a dizer ao cliente. Veja as duas mensagens que passaram pela conexão:

Trilha.fio(ana)

A seta → marca o que o cliente mandou: ["1","1","room:lobby","phx_join",{}], ou seja, join_ref, ref, tópico, evento e payload. A seta ← marca a resposta do servidor: um evento phx_reply, com o mesmo ref e o payload {"response":{},"status":"ok"}. É assim que o cliente casa cada resposta com o pedido que a causou.

No log da célula anterior, o Phoenix registrou a conexão (CONNECTED TO HelloWeb.UserSocket) e o join (JOINED room:lobby).

Agora uma sala privada, na mesma conexão:

{ana, resposta} = Trilha.entrar(ana, "room:123")
resposta

O servidor respondeu {:error, %{"reason" => "unauthorized"}}, o mapa da segunda cláusula do join/3. A conexão continua aberta, e Ana continua em room:lobby. Um tópico sem rota recebe outro erro, do próprio Phoenix, que também deixa no log o aviso Ignoring unmatched topic "sala:1" in HelloWeb.UserSocket:

{ana, resposta} = Trilha.entrar(ana, "sala:1")
resposta

O cliente JavaScript também manda, a cada 30 segundos, um heartbeat no tópico reservado phoenix, para manter a conexão viva e descobrir quando ela cai:

{ana, resposta} = Trilha.heartbeat(ana)
ana = Trilha.limpar_fio(ana)
resposta

Por fim, o lado do servidor. Abra uma segunda conexão, a de Bia, e veja os processos de channel inscritos no tópico:

{:ok, bia} = Trilha.conectar()
{bia, _} = Trilha.entrar(bia, "room:lobby")
Trilha.canais("room:lobby")

São dois processos, um por cliente, cada um com o seu %Phoenix.Socket{}. O socket.id é nil porque id/1 do UserSocket devolve nil: o socket é anônimo. Isso muda na seção de autenticação.

O cliente JavaScript e a página do chat

O guia continua no arquivo gerado assets/js/user_socket.js. Ele se conecta ao socket e entra num channel; basta trocar o nome da sala por "room:lobby":

// assets/js/user_socket.js
// ...
socket.connect()

// Now that you are connected, you can join channels with a topic:
let channel = socket.channel("room:lobby", {})
channel.join()
  .receive("ok", resp => { console.log("Joined successfully", resp) })
  .receive("error", resp => { console.log("Unable to join", resp) })

export default socket

Depois o guia importa esse arquivo no app.js, troca o conteúdo de home.html.heex por um lugar para as mensagens e um campo de texto, e acrescenta ao JavaScript dois ouvintes: um manda new_msg quando a pessoa aperta Enter, e o outro escreve na página cada new_msg que chega.

Um notebook não tem assets/ nem esbuild. A célula abaixo põe as duas coisas na página inicial: o HTML do guia e, num <script>, o user_socket.js do guia na versão final. Mudam duas coisas: no lugar de import {Socket} from "phoenix", o script usa a global Phoenix.Socket, definida pelo phoenix.min.js que o root layout carrega; e não há export. O authToken: window.userToken é o do arquivo gerado; enquanto não há login, o token é vazio e o cliente simplesmente não o envia.

lib/hello_web/controllers/page_html/home.html.heex e assets/js/user_socket.js

defmodule HelloWeb.PageHTML do
  use HelloWeb, :html

  def home(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <p :if={assigns[:current_user]}>Você entrou como <strong>{@current_user.nome}</strong>.</p>
      <p :if={!assigns[:current_user]}>Você está sem login.</p>

      <div id="messages" role="log" aria-live="polite"></div>
      <input id="chat-input" type="text" class="input w-full" placeholder="Escreva e aperte Enter" />

      <script>
        // assets/js/user_socket.js
        let socket = new Phoenix.Socket("/socket", {authToken: window.userToken})
        socket.connect()

        let channel           = socket.channel("room:lobby", {})
        let chatInput         = document.querySelector("#chat-input")
        let messagesContainer = document.querySelector("#messages")

        chatInput.addEventListener("keypress", event => {
          if(event.key === 'Enter'){
            channel.push("new_msg", {body: chatInput.value})
            chatInput.value = ""
          }
        })

        channel.on("new_msg", payload => {
          let messageItem = document.createElement("p")
          messageItem.innerText = `[${Date()}] ${payload.body}`
          messagesContainer.appendChild(messageItem)
        })

        channel.join()
          .receive("ok", resp => { console.log("Joined successfully", resp) })
          .receive("error", resp => { console.log("Unable to join", resp) })
      </script>
    </Layouts.app>
    """
  end
end

Abra http://localhost:4010 no navegador e olhe o console do JavaScript (F12): deve aparecer "Joined successfully". O cliente e o servidor estão conversando por uma conexão persistente. Na aba Network, filtrando por WS, a conexão websocket mostra as mesmas listas que Trilha.fio/1 mostra. Escrever no campo ainda não faz nada aparecer: falta o servidor tratar o evento. As duas primeiras linhas da página, sobre login, ficam para a seção de autenticação.

Eventos de entrada

Na documentação: Incoming Events

Trilha.pergunta(:remetente)

Os eventos que chegam do cliente são tratados por handle_in/3. A função casa com o nome do evento, como "new_msg", e recebe o payload que o cliente mandou. No chat, basta avisar todos os inscritos em room:lobby, com broadcast!/3:

def handle_in("new_msg", %{"body" => body}, socket) do
  broadcast!(socket, "new_msg", %{body: body})
  {:noreply, socket}
end

broadcast!/3 avisa todos os clientes que entraram no tópico deste socket e passa pelo handle_out/3 de cada um. handle_out/3 não é obrigatório: a implementação padrão só empurra a mensagem ao cliente.

Sua vez: as mensagens do chat

Acrescente o handle_in/3 do guia à célula do RoomChannel, depois das cláusulas de join/3, com @impl true, e avalie a célula.

Trilha.solucao(:new_msg)
Trilha.conferir(:new_msg)

Ana e Bia continuam conectadas, e continuam na sala. Por isso as mensagens que a conferência acima mandou também chegaram a elas. As duas primeiras linhas da célula abaixo leem e descartam o que já tinha chegado; depois Ana manda uma mensagem, e as duas conexões leem o que chegar:

{ana, _} = Trilha.receber_todas(ana)
{bia, _} = Trilha.receber_todas(bia)
{ana, bia} = {Trilha.limpar_fio(ana), Trilha.limpar_fio(bia)}

ana = Trilha.empurrar(ana, "room:lobby", "new_msg", %{"body" => "Oi, sala!"})
{ana, _} = Trilha.receber_todas(ana)
{bia, _} = Trilha.receber_todas(bia)

Kino.Layout.grid([Kino.Markdown.new("**O fio de Ana**"), Trilha.fio(ana), Kino.Markdown.new("**O fio de Bia**"), Trilha.fio(bia)])

No fio de Ana há uma mensagem enviada (→) e uma recebida (←): a própria mensagem voltou. No de Bia, só a recebida. Repare em duas coisas na mensagem que o servidor mandou:

  • o join_ref e o ref são null: não é resposta a nenhum pedido, é um broadcast empurrado pelo servidor;
  • não houve phx_reply para o new_msg de Ana: o handle_in/3 devolveu {:noreply, socket}. Um channel também pode responder a quem pediu, com {:reply, {:ok, payload}, socket}, como o "ping" do arquivo gerado. O desafio do fim usa isso.

Com a página do chat aberta em duas abas do navegador, escreva numa e veja a mensagem aparecer nas duas. Se a célula acima for avaliada de novo, a mensagem de Ana também aparece nas abas.

Interceptando eventos de saída

Na documentação: Intercepting Outgoing Events

Trilha.pergunta(:handle_out)

O guia não implementa esta parte no chat, só a descreve: imagine que as pessoas pudessem ignorar os avisos de que alguém entrou na sala. O channel declara qual evento de saída quer interceptar e define um handle_out/3 para ele:

intercept ["user_joined"]

def handle_out("user_joined", msg, socket) do
  if Accounts.ignoring_user?(socket.assigns[:user], msg.user_id) do
    {:noreply, socket}
  else
    push(socket, "user_joined", msg)
    {:noreply, socket}
  end
end

O exemplo supõe um contexto Accounts com ignoring_user?/2 e um usuário nos assigns, que o chat ainda não tem. O guia avisa: handle_out/3 roda para cada destinatário da mensagem, então operações caras, como uma consulta ao banco, pedem cuidado ali.

Para ver isso funcionando sem usuários, o notebook usa os parâmetros do join. A célula abaixo é o RoomChannel do exercício anterior, com três acréscimos já prontos:

  • o join/3 guarda nos assigns o "apelido" que o cliente mandou e a preferência "silenciar_entradas";
  • depois do join, o channel manda a si mesmo a mensagem :after_join, e o handle_info/2 transmite user_joined para a sala. O broadcast não pode acontecer dentro do join/3, porque ali o cliente ainda não entrou no tópico;
  • falta a sua parte: o intercept e o handle_out/3.

Sua vez: silenciar os avisos de entrada

Na célula abaixo, intercepte "user_joined". No handle_out/3, quem tem socket.assigns.silenciar_entradas verdadeiro não recebe nada; os outros recebem a mensagem com push/3.

lib/hello_web/channels/room_channel.ex, com os avisos de entrada

defmodule HelloWeb.RoomChannel do
  use HelloWeb, :channel

  # Sua vez (silenciar): declare aqui o intercept e, mais abaixo, o handle_out/3.

  @impl true
  def join("room:lobby", params, socket) do
    send(self(), :after_join)

    socket =
      socket
      |> assign(:apelido, params["apelido"] || "anônimo")
      |> assign(:silenciar_entradas, params["silenciar_entradas"] == true)

    {:ok, socket}
  end

  def join("room:" <> _private_room_id, _params, _socket) do
    {:error, %{reason: "unauthorized"}}
  end

  # Depois do join, avisa a sala de que alguém entrou.
  @impl true
  def handle_info(:after_join, socket) do
    broadcast!(socket, "user_joined", %{apelido: socket.assigns.apelido})
    {:noreply, socket}
  end

  @impl true
  def handle_in("new_msg", %{"body" => body}, socket) do
    broadcast!(socket, "new_msg", %{body: body})
    {:noreply, socket}
  end
end
Trilha.solucao(:silenciar)
Trilha.conferir(:silenciar)

Três conexões novas. Ana entra, depois Caio, que pede silêncio, depois Bia:

Trilha.fechar(ana)
Trilha.fechar(bia)

{:ok, ana} = Trilha.conectar()
{ana, _} = Trilha.entrar(ana, "room:lobby", %{"apelido" => "ana"})
{:ok, caio} = Trilha.conectar()
{caio, _} = Trilha.entrar(caio, "room:lobby", %{"apelido" => "caio", "silenciar_entradas" => true})
{:ok, bia} = Trilha.conectar()
{bia, _} = Trilha.entrar(bia, "room:lobby", %{"apelido" => "bia"})

{ana, de_ana} = Trilha.receber_todas(ana)
{caio, de_caio} = Trilha.receber_todas(caio)
{bia, de_bia} = Trilha.receber_todas(bia)

apelidos = fn mensagens -> for [_, _, _, "user_joined", %{"apelido" => apelido}] <- mensagens, do: apelido end
%{ana: apelidos.(de_ana), caio: apelidos.(de_caio), bia: apelidos.(de_bia)}

Ana viu as três entradas (a dela mesma, a de Caio e a de Bia), Bia viu só a própria, e Caio não viu nenhuma. Os assigns de cada processo explicam a diferença:

Trilha.canais("room:lobby")

O chat básico do guia termina aqui. Abra várias abas e veja as mensagens chegarem a todas.

Autenticação por token

Na documentação: Using Token Authentication

Trilha.pergunta(:token)

Na hora de conectar, quase sempre é preciso autenticar o cliente. O guia faz isso em cinco passos, com o Phoenix.Token: a página, carregada por alguém logado, recebe um token assinado pelo servidor; o JavaScript manda o token ao abrir o socket; e o connect/3 o confere.

O guia supõe que a aplicação tem um plug de autenticação, o OurAuth, que põe o usuário em conn.assigns.current_user. Para ter um, o notebook usa um login de mentira, sem senha: visitar /entrar/1 guarda o usuário 1 na sessão. Há três usuários fixos: 1 é Ana, 2 é Bia, 3 é Caio. Num projeto de verdade, esse papel é do phx.gen.auth do notebook 13, e o usuário viria de conn.assigns.current_scope.user.

lib/hello/contas.ex, lib/hello_web/our_auth.ex e lib/hello_web/controllers/sessao_controller.ex: o login de mentira.

defmodule Hello.Contas do
  @usuarios %{1 => "Ana", 2 => "Bia", 3 => "Caio"}

  def get_user(id) when is_map_key(@usuarios, id), do: %{id: id, nome: @usuarios[id]}
  def get_user(_id), do: nil
end

defmodule HelloWeb.OurAuth do
  import Plug.Conn

  def init(opts), do: opts

  def call(conn, _opts) do
    user = conn |> get_session(:user_id) |> Hello.Contas.get_user()
    assign(conn, :current_user, user)
  end
end

defmodule HelloWeb.SessaoController do
  use HelloWeb, :controller

  # Login de mentira: sem senha. Só para o notebook ter um current_user.
  def entrar(conn, %{"id" => id}) do
    conn
    |> put_session(:user_id, String.to_integer(id))
    |> redirect(to: "/")
  end
end

Passo 1: ligar o auth_token no socket

Na documentação: Step 1 - Enable the auth_token functionality in the socket

O Phoenix tem um jeito de passar um token de autenticação ao servidor que não depende do transporte. Ele é ligado com a opção auth_token: true na declaração do socket.

lib/hello_web/endpoint.ex, de novo: a mesma célula lá de cima, com auth_token: true. Avaliar esta célula recompila o módulo; o endpoint que está no ar passa a usar a versão nova na conexão seguinte, sem reiniciar.

defmodule HelloWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :hello

  @session_options [
    store: :cookie,
    key: "_hello_key",
    signing_salt: "trilha00",
    same_site: "Lax"
  ]

  socket "/socket", HelloWeb.UserSocket,
    websocket: true,
    longpoll: false,
    auth_token: true

  plug Plug.Static, at: "/assets", from: {:phoenix_html, "priv/static"}, only: ~w(phoenix_html.js)
  plug Plug.Static, at: "/assets", from: {:phoenix, "priv/static"}, only: ~w(phoenix.min.js)

  plug Plug.RequestId
  plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]

  plug Plug.Parsers,
    parsers: [:urlencoded, :multipart, :json],
    pass: ["*/*"],
    json_decoder: Phoenix.json_library()

  plug Plug.MethodOverride
  plug Plug.Head
  plug Plug.Session, @session_options
  plug HelloWeb.Router
end

Passo 2: pôr um token na conexão

Na documentação: Step 2 - Assign a Token in the Connection

Quando o OurAuth autentica alguém, há um current_user nos assigns. Um plug de função, put_user_token/2, assina um token com o id do usuário e o guarda em conn.assigns.user_token, para o layout usar. Os dois plugs entram na pipeline :browser.

lib/hello_web/router.ex, de novo: com OurAuth, put_user_token/2 e a rota do login de mentira.

defmodule HelloWeb.Router do
  use HelloWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {HelloWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
    plug HelloWeb.OurAuth
    plug :put_user_token
  end

  defp put_user_token(conn, _) do
    if current_user = conn.assigns[:current_user] do
      token = Phoenix.Token.sign(conn, "user socket", current_user.id)
      assign(conn, :user_token, token)
    else
      conn
    end
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :home
    get "/entrar/:id", SessaoController, :entrar
  end
end

Phoenix.Token.sign/3 recebe o contexto de onde tirar a chave secreta (o conn), um "sal" que separa este uso de outros ("user socket") e o dado a assinar. O token não é cifrado: quem o tem consegue ler o id. O que ele garante é que foi este servidor que o emitiu e que ninguém o alterou.

Passo 3: passar o token ao JavaScript

Na documentação: Step 3 - Pass the Token to the JavaScript

O token precisa chegar ao JavaScript. O guia o escreve numa tag <script> do root.html.heex, logo acima do app.js:

<script>window.userToken = "<%= assigns[:user_token] %>";</script>
<script src={~p"/assets/js/app.js"}></script>

O root layout deste notebook tem essa linha desde o começo; sem usuário, ela escrevia window.userToken = "". Entre como Ana e veja a linha na página:

Trilha.navegar("ana", :get, "/entrar/1")
{:ok, conn} = Trilha.visitar("ana", :get, "/")
Regex.run(~r/window\.userToken = "[^"]*"/, to_string(conn.resp_body))

Passo 4: passar o token ao socket e verificar

Na documentação: Step 4 - Pass the Token to the Socket Constructor and Verify

No JavaScript, o token vai na opção authToken do construtor, que o user_socket.js gerado e a página do chat já usam:

let socket = new Socket("/socket", {authToken: window.userToken})

No servidor, o connect/3 recebe o token em connect_info[:auth_token] e o confere com Phoenix.Token.verify/4, que devolve {:ok, user_id} ou {:error, reason}:

def connect(_params_, socket, connect_info) do
  # max_age: 1209600 is equivalent to two weeks in seconds
  case Phoenix.Token.verify(socket, "user socket", connect_info[:auth_token], max_age: 1209600) do
    {:ok, user_id} ->
      {:ok, assign(socket, :current_user, user_id)}
    {:error, reason} ->
      :error
  end
end

Com um token válido, o id do usuário fica em :current_user nos assigns do socket, e todos os channels daquela conexão o enxergam. Sem isso, o connect/3 devolve :error e a conexão é recusada. (Copiado assim, o código do guia compila com um aviso: a variável reason não é usada. A solução do exercício usa _reason.)

Trilha.pergunta(:desconectar)

Com um usuário no socket, o outro callback do UserSocket ganha uso. O arquivo gerado explica, nos comentários, que id/1 pode devolver um tópico que identifica todos os sockets de um usuário:

def id(socket), do: "user_socket:#{socket.assigns.user_id}"

e que um broadcast do evento "disconnect" nesse tópico encerra todos os sockets e channels daquele usuário.

Sua vez: verificar o token

Na célula abaixo:

  1. escreva o connect/3 do guia, que verifica connect_info[:auth_token] e guarda o id em :current_user;
  2. faça id/1 devolver "user_socket:ID", com o id do usuário. O nome do assign aqui é :current_user, como no connect/3 do guia, e não :user_id, como no comentário do arquivo gerado.

lib/hello_web/channels/user_socket.ex, com autenticação

defmodule HelloWeb.UserSocket do
  use Phoenix.Socket

  ## Channels
  channel "room:*", HelloWeb.RoomChannel

  # Sua vez (token): verifique o token em connect/3 e identifique o socket em id/1.
  @impl true
  def connect(_params, socket, _connect_info) do
    {:ok, socket}
  end

  @impl true
  def id(_socket), do: nil
end
Trilha.solucao(:token)
Trilha.conferir(:token)

Passo 5: conectar ao socket no JavaScript

Na documentação: Step 5 - Connect to the socket in JavaScript

Com a autenticação pronta, o JavaScript conecta e entra nos channels como antes; a página do chat já fazia isso. O que mudou é quem consegue. Sem token, a conexão agora é recusada:

Trilha.conectar()

O servidor respondeu 403 Forbidden ao pedido de WebSocket, e nenhum processo foi criado. No navegador, é o que acontece com a página do chat aberta sem login: o cliente JavaScript tenta reconectar de tempos em tempos, e o console mostra as falhas. Visite http://localhost:4010/entrar/1 numa aba e http://localhost:4010/entrar/2 numa janela anônima (a sessão fica num cookie, e as abas de uma mesma janela o dividem) para conversar como Ana e Bia.

No notebook, Trilha.token(id) assina um token como o da página, e Trilha.conectar(token: ...) o manda como o phoenix.js manda: no cabeçalho Sec-WebSocket-Protocol do pedido de WebSocket, e não na URL, onde ficaria registrado em logs.

Enum.each([ana, bia, caio], &Trilha.fechar/1)

{:ok, ana} = Trilha.conectar(token: Trilha.token(1))
{ana, _} = Trilha.entrar(ana, "room:lobby", %{"apelido" => "ana"})
{:ok, bia} = Trilha.conectar(token: Trilha.token(2))
{bia, _} = Trilha.entrar(bia, "room:lobby", %{"apelido" => "bia"})

Trilha.canais("room:lobby")

Cada socket agora tem um socket.id e, nos assigns, o current_user que veio do token. Agora o broadcast de disconnect para o usuário 2:

{ana, _} = Trilha.receber_todas(ana)
{bia, _} = Trilha.receber_todas(bia)

HelloWeb.Endpoint.broadcast("user_socket:2", "disconnect", %{})

{bia, o_que_bia_recebeu} = Trilha.receber(bia)
{ana, o_que_ana_recebeu} = Trilha.receber(ana)

%{bia: o_que_bia_recebeu, ana: o_que_ana_recebeu}

Para Bia, :fechada: o servidor encerrou a conexão dela. Para Ana, :nada: a conexão continua aberta, em silêncio.

O guia fecha a seção com uma observação: a autenticação por token é preferível por não depender do transporte e por servir bem a conexões de longa duração, como as dos channels, ao contrário de sessões e outras abordagens.

Tolerância a falhas e garantias de entrega

Na documentação: Fault Tolerance and Reliability Guarantees

Servidores reiniciam, redes se partem e clientes perdem a conexão. Para projetar um sistema robusto, é preciso saber como o Phoenix reage a isso e o que ele garante.

Reconexão

Na documentação: Handling Reconnection

Os clientes se inscrevem em tópicos, e o Phoenix guarda as inscrições na memória, numa tabela ETS. Se um channel cai, o cliente precisa entrar de novo nos tópicos em que estava. O cliente JavaScript sabe fazer isso: o servidor avisa da queda, o que dispara o callback Channel.onError do cliente; ele tenta de novo, com intervalos cada vez maiores, e, ao conseguir, volta a receber as mensagens.

Dá para ver o aviso. O RoomChannel não tem handle_in/3 para um evento chamado evento_que_nao_existe. Mandar esse evento derruba o processo do channel de Ana, com um FunctionClauseError no log:

ana = Trilha.limpar_fio(ana)
ana = Trilha.empurrar(ana, "room:lobby", "evento_que_nao_existe", %{})
{ana, _} = Trilha.receber_todas(ana)
Trilha.fio(ana)

O servidor mandou um phx_error no tópico. Só aquele channel caiu: a conexão continua aberta, e os outros tópicos e os outros clientes não foram afetados. O cliente JavaScript reage a essa mensagem entrando de novo; aqui, à mão:

{ana, resposta} = Trilha.entrar(ana, "room:lobby", %{"apelido" => "ana"})
resposta

Reenvio das mensagens do cliente

Na documentação: Resending Client Messages

O cliente JavaScript põe as mensagens de saída numa fila, o PushBuffer, e as manda quando há conexão. Sem conexão, ele as guarda na memória até reconectar ou até o tempo limite, de 5.000 milissegundos por padrão. Nada vai para o armazenamento local do navegador: se a aba fechar, as mensagens na fila se perdem.

Isso é comportamento do cliente oficial. O cliente do módulo Trilha não tem fila: ele só escreve na conexão.

Reenvio das mensagens do servidor

Na documentação: Resending Server Messages

Trilha.pergunta(:at_most_once)

O Phoenix usa a estratégia at-most-once ao mandar mensagens aos clientes: cada mensagem é entregue no máximo uma vez. Se o cliente está offline, o Phoenix não reenvia. Ele não guarda as mensagens no servidor; se o servidor reiniciar, o que não foi enviado se perde. Veja com Bia, que foi desconectada há pouco:

ana = Trilha.empurrar(ana, "room:lobby", "new_msg", %{"body" => "Bia, você está aí?"})
{ana, _} = Trilha.receber_todas(ana)

Trilha.fechar(bia)
{:ok, bia} = Trilha.conectar(token: Trilha.token(2))
{bia, _} = Trilha.entrar(bia, "room:lobby", %{"apelido" => "bia"})
{bia, de_bia} = Trilha.receber_todas(bia)

for [_, _, _, evento, payload] <- de_bia, do: {evento, payload}

Bia voltou, e só recebeu o aviso da própria entrada. A pergunta de Ana, mandada enquanto ela estava fora, não chegou.

Se a aplicação precisa de garantias mais fortes, o código é da aplicação. O caminho comum é guardar as mensagens no servidor e deixar o cliente pedir as que perdeu. O guia esboça a ideia: o cliente guarda o id da última mensagem que viu, o last_seen_id; ao entrar, manda esse id nos parâmetros do join, e o servidor responde com o que aconteceu depois dele.

// on the client
let lastSeenMsg = {}
let roomParams = () => lastSeenMsg.id ? {last_seen_id: lastSeenMsg.id} : {}
let roomChannel = socket.channel("rooms:123", roomParams)

roomChannel.on("new_message", msg => {
  lastSeenMsg = msg
  renderNewMessage(msg)
})

roomChannel.join().receive("ok", ({messages}) => {
  lastSeenMsg = messages[messages.length - 1]
  renderMessages(messages)
})
# room_channel.ex on the server
def join("rooms:" <> id, params, socket) do
  ...
  messages = fetch_messages_since(params["last_seen_id"])
  {:ok, %{messages: messages}, socket}
end

Repare em roomParams: é uma função. O cliente JavaScript a chama a cada join, inclusive nas reconexões, então cada volta manda o last_seen_id daquele momento.

O guia para no esboço. O notebook leva a ideia até o fim no chat, com uma tabela para as mensagens e um contexto pequeno, já prontos.

priv/repo/migrations/20250701000000_create_messages.exs

defmodule Hello.Repo.Migrations.CreateMessages do
  use Ecto.Migration

  def change do
    create table(:messages) do
      add :room, :string, null: false
      add :body, :text, null: false
      add :user_id, :integer

      timestamps(type: :utc_datetime, updated_at: false)
    end

    create index(:messages, [:room, :id])
  end
end

Trilha.migracao(20250701000000, Hello.Repo.Migrations.CreateMessages)
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)

lib/hello/chat/message.ex e lib/hello/chat.ex: o contexto. As duas funções devolvem mapas simples, com id, body e user_id, prontos para virar JSON.

defmodule Hello.Chat.Message do
  use Ecto.Schema

  schema "messages" do
    field :room, :string
    field :body, :string
    field :user_id, :integer

    timestamps(type: :utc_datetime, updated_at: false)
  end
end

defmodule Hello.Chat do
  import Ecto.Query

  alias Hello.Repo
  alias Hello.Chat.Message

  @doc "Grava uma mensagem e a devolve como mapa."
  def create_message(room, body, user_id) do
    %Message{room: room, body: body, user_id: user_id}
    |> Repo.insert!()
    |> to_map()
  end

  @doc "As mensagens da sala depois de `last_seen_id`, em ordem. Sem id, todas."
  def list_messages(room, last_seen_id \\ nil) do
    Message
    |> where([m], m.room == ^room and m.id > ^(last_seen_id || 0))
    |> order_by([m], m.id)
    |> Repo.all()
    |> Enum.map(&to_map/1)
  end

  defp to_map(message), do: %{id: message.id, body: message.body, user_id: message.user_id}
end

Sua vez: recuperar o que foi perdido

A célula abaixo é o RoomChannel do chat, sem os avisos de entrada, para ficar curto. Mude duas coisas:

  1. no handle_in/3, grave a mensagem com Hello.Chat.create_message("lobby", body, socket.assigns[:current_user]) e transmita o mapa que ela devolve, no lugar de %{body: body}. Assim cada new_msg leva o id e o user_id;
  2. no join/3 de "room:lobby", busque as mensagens com Hello.Chat.list_messages("lobby", params["last_seen_id"]) e devolva {:ok, %{messages: messages}, socket}. O mapa do meio é a resposta do join, que o cliente recebe.

lib/hello_web/channels/room_channel.ex, com histórico

defmodule HelloWeb.RoomChannel do
  use HelloWeb, :channel

  # Sua vez (historico): grave cada mensagem e devolva, no join, as que o cliente perdeu.

  @impl true
  def join("room:lobby", _params, socket) do
    {:ok, socket}
  end

  def join("room:" <> _private_room_id, _params, _socket) do
    {:error, %{reason: "unauthorized"}}
  end

  @impl true
  def handle_in("new_msg", %{"body" => body}, socket) do
    broadcast!(socket, "new_msg", %{body: body})
    {:noreply, socket}
  end
end
Trilha.solucao(:historico)
Trilha.conferir(:historico)

A mesma cena de antes. Bia está na sala e vê uma mensagem; cai; Ana manda mais duas; Bia volta com o id da última que viu.

Enum.each([ana, bia], &Trilha.fechar/1)
{:ok, ana} = Trilha.conectar(token: Trilha.token(1))
{ana, _} = Trilha.entrar(ana, "room:lobby")
{:ok, bia} = Trilha.conectar(token: Trilha.token(2))
{bia, _} = Trilha.entrar(bia, "room:lobby")

ana = Trilha.empurrar(ana, "room:lobby", "new_msg", %{"body" => "Bia, você está aí?"})
{bia, vistas} = Trilha.receber_todas(bia)
ultima_vista = for([_, _, _, "new_msg", %{"id" => id}] <- vistas, do: id) |> List.last()

Trilha.fechar(bia)
ana = Trilha.empurrar(ana, "room:lobby", "new_msg", %{"body" => "Acho que a Bia caiu."})
ana = Trilha.empurrar(ana, "room:lobby", "new_msg", %{"body" => "Quando voltar, leia isto."})
{ana, _} = Trilha.receber_todas(ana)

{:ok, bia} = Trilha.conectar(token: Trilha.token(2))
{bia, resposta} = Trilha.entrar(bia, "room:lobby", %{"last_seen_id" => ultima_vista})
resposta

A resposta do join traz as duas mensagens que Bia perdeu, e só elas. A entrega em tempo real continua sendo at-most-once; o que mudou é que agora existe um lugar onde buscar o que faltou. As mensagens estão na tabela:

Trilha.consultar("SELECT id, room, body, user_id FROM messages ORDER BY id")

A página do chat continua funcionando, porque o payload ainda tem body. Para ela mostrar o histórico e mandar o last_seen_id nas reconexões, o JavaScript precisaria das mudanças do esboço do guia.

Aplicação de exemplo

Na documentação: Example Application

O guia aponta para o projeto phoenix_chat_example, uma versão completa do chat.

Sem roteiro: a negociação de uma peça do Brechó

No Brechó, quem se interessa por uma peça quer negociar: fazer uma oferta, ver as ofertas dos outros e saber na hora se alguém levou. Cada peça ganha a sua sala.

O Brechó até aqui

O contexto Hello.Vitrine do notebook 08: a tabela produtos, o schema e o contexto, com vender/1, que só vende uma peça que ainda não foi vendida.

priv/repo/migrations/20250320000000_create_produtos.exs (a do notebook 07)

defmodule Hello.Repo.Migrations.CreateProdutos do
  use Ecto.Migration

  def change do
    create table(:produtos) do
      add :nome, :string, null: false
      add :preco_centavos, :integer, null: false
      add :vendido, :boolean, default: false, null: false

      timestamps(type: :utc_datetime)
    end
  end
end

Trilha.migracao(20250320000000, Hello.Repo.Migrations.CreateProdutos)
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)

lib/hello/vitrine/produto.ex e lib/hello/vitrine.ex

defmodule Hello.Vitrine.Produto do
  use Ecto.Schema
  import Ecto.Changeset

  schema "produtos" do
    field :nome, :string
    field :preco_centavos, :integer
    field :vendido, :boolean, default: false

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(produto, attrs) do
    produto
    |> cast(attrs, [:nome, :preco_centavos])
    |> validate_required([:nome, :preco_centavos])
    |> validate_number(:preco_centavos, greater_than: 0)
  end
end

defmodule Hello.Vitrine do
  @moduledoc """
  A vitrine do Brechó: o que está à venda e a venda de cada peça.
  """

  import Ecto.Query, warn: false
  alias Hello.Repo

  alias Hello.Vitrine.Produto

  def listar_disponiveis do
    Repo.all(from p in Produto, where: not p.vendido, order_by: p.preco_centavos)
  end

  def buscar_produto!(id), do: Repo.get!(Produto, id)

  def cadastrar_produto(attrs) do
    %Produto{}
    |> Produto.changeset(attrs)
    |> Repo.insert()
  end

  @doc """
  Marca a peça como vendida, se ninguém a vendeu antes.

  A verificação e a mudança acontecem num só UPDATE, então duas vendas
  simultâneas da mesma peça nunca dão certo ao mesmo tempo.
  """
  def vender(%Produto{id: id}) do
    consulta = from p in Produto, where: p.id == ^id and not p.vendido, select: p

    case Repo.update_all(consulta, set: [vendido: true, updated_at: DateTime.utc_now(:second)]) do
      {1, [produto]} -> {:ok, produto}
      {0, []} -> {:error, :ja_vendido}
    end
  end
end

Algumas peças à venda:

for {nome, preco} <- [{"Jaqueta jeans", 8000}, {"Vestido floral", 4500}, {"Bota de couro", 12000}] do
  {:ok, _} = Hello.Vitrine.cadastrar_produto(%{nome: nome, preco_centavos: preco})
end

Hello.Vitrine.listar_disponiveis() |> Enum.map(&{&1.id, &1.nome})

O desafio

Escreva o channel HelloWeb.ProdutoChannel, para os tópicos produto:ID:

  • a rota channel "produto:*", HelloWeb.ProdutoChannel no UserSocket. Acrescente-a à célula do UserSocket com autenticação, a do exercício do token, e avalie-a de novo;
  • join: só dá para entrar no tópico de uma peça que existe e ainda não foi vendida. A resposta do join traz nome e preco_centavos da peça. Peça vendida, peça que não existe ou id que não é número: {:error, %{reason: "indisponivel"}}, sem derrubar o channel;
  • "oferta", com payload %{"valor" => centavos}: transmite oferta para o tópico, com valor e de (o current_user de quem ofereceu). Se o valor não for um inteiro positivo, responde a quem mandou com {:error, %{reason: "valor_invalido"}} e não transmite nada;
  • "comprar": vende a peça com Hello.Vitrine.vender/1. Deu certo: responde :ok a quem comprou e transmite vendido para o tópico, com o id da peça. Já estava vendida: responde {:error, %{reason: "ja_vendido"}}.

Para responder a um pedido, o handle_in/3 devolve {:reply, :ok, socket} ou {:reply, {:error, mapa}, socket}. No cliente da trilha, Trilha.pedir/4 manda o evento e espera essa resposta.

# Sua vez (brecho): defina aqui o HelloWeb.ProdutoChannel.

Para experimentar à mão, com a peça de id 1 (a conexão de Ana já tem um token):

{ana, resposta} = Trilha.entrar(ana, "produto:1")
resposta
Trilha.solucao(:brecho)
Trilha.conferir(:brecho)

No seu projeto

Em um projeto gerado por mix phx.new hello, o caminho do guia é este:

Neste notebook No projeto
A célula do UserSocket lá em cima mix phx.gen.socket User, que cria lib/hello_web/channels/user_socket.ex e assets/js/user_socket.js
A linha socket "/socket", ... na célula do endpoint A mesma linha em lib/hello_web/endpoint.ex, como o gerador pede
A célula do RoomChannel lib/hello_web/channels/room_channel.ex, à mão ou com mix phx.gen.channel Room
O <script> na página inicial O conteúdo em assets/js/user_socket.js, com import {Socket} from "phoenix", e a linha import "./user_socket.js" em assets/js/app.js
O <div id="messages"> e o <input id="chat-input"> lib/hello_web/controllers/page_html/home.html.heex
O login de mentira e o OurAuth mix phx.gen.auth (notebook 13); o put_user_token/2 usa conn.assigns.current_scope.user
window.userToken no root layout A mesma linha em lib/hello_web/components/layouts/root.html.heex, acima do app.js
Trilha.conectar/1, entrar/3, fio/1 As ferramentas do navegador: aba Network, filtro WS, e o console do JavaScript

Com mix phx.server no ar, abra http://localhost:4000 em duas abas. O gerador de channel também cria test/hello_web/channels/room_channel_test.exs e test/support/channel_case.ex, que usam o Phoenix.ChannelTest; rode mix test e leia o teste gerado.

Revisão

  • Um channel é uma conversa de longa duração entre um cliente e o servidor, sobre um tópico. Uma conexão carrega vários tópicos, e o servidor mantém um processo por cliente, por tópico.
  • As peças: a linha socket do endpoint; o socket handler (UserSocket), com connect/3, id/1 e as rotas de channel; o channel (RoomChannel), com join/3, handle_in/3 e handle_out/3; e o PubSub, que leva os broadcasts a todos os inscritos, em todos os nós.
  • No fio, toda mensagem é [join_ref, ref, topic, event, payload]. phx_join entra, phx_reply responde, phx_error avisa que o channel caiu, e heartbeat mantém a conexão viva.
  • join/3 autoriza: {:ok, socket}, {:ok, resposta, socket} ou {:error, mapa}. Recusar um join não fecha a conexão.
  • handle_in/3 trata os eventos do cliente. broadcast!/3 manda a todos do tópico, inclusive o remetente; {:reply, ...} responde só a quem pediu.
  • intercept e handle_out/3 personalizam ou filtram um evento de saída, uma vez por destinatário.
  • A autenticação usa um token assinado com Phoenix.Token: a página o recebe, o JavaScript o manda em authToken, e o connect/3 o verifica. Com id/1, um broadcast de disconnect encerra todas as conexões de um usuário.
  • A entrega é at-most-once. Quem estava offline perde a mensagem, a não ser que a aplicação guarde as mensagens e o cliente peça o que perdeu, com um last_seen_id.

O próximo notebook, 19_presence.livemd, segue o guia Presence: saber quem está em cada tópico, em tempo real.