Trilha Phoenix · Presence
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: abre conexões WebSocket de verdade com o socket da
aplicação e com as LiveViews, mostra as mensagens que trafegam, confere os
exercícios e guarda as perguntas e as soluções.
Não faz parte do Phoenix. Você não precisa ler este módulo para seguir o guia.
"""
@endpoint HelloWeb.Endpoint
@router HelloWeb.Router
@repo Hello.Repo
@presence HelloWeb.Presence
# --- 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(["&", "<", ">", """, "'", " "], fn
"&" -> "&"
"<" -> "<"
">" -> ">"
""" -> "\""
"'" -> "'"
" " -> " "
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 e LiveView pelo fio ---------------------------------------------------
@doc """
Abre uma conexão WebSocket de verdade com o socket da aplicação, em
/socket/websocket, como o `socket.connect()` do phoenix.js.
Opções: `params:` (os parâmetros da conexão, que vão na URL) e `token:` (o
`authToken` do phoenix.js, que vai no cabeçalho Sec-WebSocket-Protocol).
Devolve `{:ok, conexao}` ou `{:erro, linha_de_status}` se o servidor recusar.
"""
def conectar(opcoes \\ []) do
parametros = opcoes |> Keyword.get(:params, %{}) |> Map.put("vsn", "2.0.0")
cabecalhos =
case opcoes[:token] do
nil -> []
token -> ["Sec-WebSocket-Protocol: phoenix, base64url.bearer.phx." <> Base.encode64(token, padding: false)]
end
case abrir_ws("/socket/websocket?" <> URI.encode_query(parametros), cabecalhos) do
{:ok, socket, resto} -> {:ok, %{socket: socket, buffer: resto, pendentes: [], topico: nil, join_ref: nil, ref: 1}}
{:erro, status} -> {:erro, status}
end
end
@doc "Como `conectar/1`, mas levanta um erro se o servidor recusar a conexão."
def conectar!(opcoes \\ []) do
case conectar(opcoes) do
{:ok, c} -> c
{:erro, status} -> raise "o servidor recusou a conexão: #{status}"
end
end
@doc """
Entra num tópico, como `socket.channel(topico, payload).join()`. Devolve
`{conexao, {:ok, resposta}}` ou `{conexao, {:error, resposta}}`.
"""
def entrar(c, topico, payload \\ %{}) do
ref = Integer.to_string(c.ref)
c = %{c | topico: topico, join_ref: ref, ref: c.ref + 1}
enviar_quadro(c, [ref, ref, topico, "phx_join", payload])
esperar_resposta(c, ref)
end
@doc "Conecta e entra num tópico de uma vez; levanta um erro se algo for recusado. Devolve a conexão."
def entrar!(topico, payload \\ %{}, opcoes \\ []) do
case opcoes |> conectar!() |> entrar(topico, payload) do
{c, {:ok, _resposta}} -> c
{_c, {:error, resposta}} -> raise "o canal recusou o join: #{inspect(resposta)}"
end
end
@doc "Manda um evento para o canal, como `channel.push(evento, payload)`, e espera a resposta, se houver."
def empurrar(c, evento, payload \\ %{}) do
ref = Integer.to_string(c.ref)
enviar_quadro(c, [c.join_ref, ref, c.topico, evento, payload])
esperar_resposta(%{c | ref: c.ref + 1}, ref, 300)
end
@doc """
Espera a próxima mensagem que o servidor mandou sem ter sido pedida (um push ou
um broadcast). Devolve `{conexao, {evento, payload}}` ou `{conexao, :nada}`.
"""
def receber(c, tempo \\ 1000) do
case c.pendentes do
[[_, _, _, evento, payload] | resto] ->
{%{c | pendentes: resto}, {evento, payload}}
[] ->
case ler_mensagem(c, tempo) do
{c, [_, _ref, _topico, evento, payload]} -> {c, {evento, payload}}
{c, nil} -> {c, :nada}
end
end
end
@doc "Todas as mensagens que chegarem até o servidor ficar `tempo` ms em silêncio. Devolve `{conexao, lista}`."
def receber_todas(c, tempo \\ 300, juntadas \\ []) do
case receber(c, tempo) do
{c, :nada} -> {c, Enum.reverse(juntadas)}
{c, mensagem} -> receber_todas(c, tempo, [mensagem | juntadas])
end
end
@doc "Fecha a conexão sem avisar, como fechar a aba do navegador."
def fechar(c) do
:gen_tcp.close(c.socket)
:ok
end
@doc """
Abre uma LiveView como o navegador faz: um GET normal, depois o WebSocket em
/live/websocket e o "phx_join" no tópico da LiveView. Devolve `{lv, resposta_do_join}`.
Use `receber/2` e `fechar/1` com a conexão devolvida.
"""
def abrir_lv(caminho) do
quem = "lv-#{System.unique_integer([:positive])}"
conn =
case visitar(quem, :get, caminho) do
{:ok, conn} -> conn
{:erro, erro} -> raise erro
end
html = to_string(conn.resp_body)
csrf = atributo_html(html, ~r/<meta name="csrf-token" content="([^"]+)"/)
id =
atributo_html(html, ~r/<div[^>]*\sid="(phx-[^"]+)"[^>]*data-phx-main/) ||
atributo_html(html, ~r/<div[^>]*data-phx-main[^>]*\sid="(phx-[^"]+)"/)
sessao = atributo_html(html, ~r/data-phx-session="([^"]+)"/)
estatico = atributo_html(html, ~r/data-phx-static="([^"]*)"/)
if is_nil(id) or is_nil(sessao),
do: raise("a página #{caminho} não tem uma LiveView (status #{conn.status})")
# O LiveView confere o token CSRF contra o cookie da sessão.
cookie = Enum.map_join(cookies(quem), "; ", fn {nome, valor} -> "#{nome}=#{valor}" end)
{:ok, socket, resto} =
abrir_ws("/live/websocket?_csrf_token=#{URI.encode_www_form(csrf)}&vsn=2.0.0", ["Cookie: " <> cookie])
lv = %{socket: socket, buffer: resto, pendentes: [], topico: "lv:" <> id, join_ref: "1", ref: 2}
url = "http://localhost:#{porta()}#{caminho}"
payload = %{"url" => url, "params" => %{"_csrf_token" => csrf, "_mounts" => 0}, "session" => sessao, "static" => estatico}
enviar_quadro(lv, ["1", "1", lv.topico, "phx_join", payload])
case esperar_resposta(lv, "1") do
{lv, {:ok, resposta}} -> {lv, resposta}
{_lv, {:error, resposta}} -> raise "a LiveView recusou o join: #{inspect(resposta)}"
end
end
@doc "Mostra uma mensagem do fio como JSON, como aparece nas ferramentas do navegador."
def json(valor), do: Kino.Markdown.new(cerca(Jason.encode!(valor, pretty: true), "json"))
defp porta, do: apply(@endpoint, :config, [:http])[:port]
# O aperto de mão do WebSocket: um GET com Upgrade, que o servidor responde com 101.
defp abrir_ws(caminho, cabecalhos) do
{:ok, socket} = :gen_tcp.connect(~c"127.0.0.1", porta(), [:binary, active: false])
chave = Base.encode64(:crypto.strong_rand_bytes(16))
linhas =
["GET #{caminho} HTTP/1.1", "Host: localhost:#{porta()}", "Upgrade: websocket", "Connection: Upgrade",
"Sec-WebSocket-Key: #{chave}", "Sec-WebSocket-Version: 13"] ++ cabecalhos ++ ["", ""]
:ok = :gen_tcp.send(socket, Enum.join(linhas, "\r\n"))
{:ok, resposta} = :gen_tcp.recv(socket, 0, 5000)
[cabeca, resto] = String.split(resposta, "\r\n\r\n", parts: 2)
if cabeca =~ ~r/^HTTP\/1.1 101/ do
{:ok, socket, resto}
else
:gen_tcp.close(socket)
{:erro, cabeca |> String.split("\r\n") |> hd()}
end
end
# Espera a resposta ("phx_reply") com a referência dada. As outras mensagens que
# chegarem antes (pushes e broadcasts) ficam guardadas para o próximo `receber/2`.
defp esperar_resposta(c, ref, tempo \\ 5000) do
case ler_mensagem(c, tempo) do
{c, [_, ^ref, _, "phx_reply", %{"status" => status, "response" => resposta}]} ->
{c, {String.to_atom(status), resposta}}
{c, nil} ->
{c, :sem_resposta}
{c, outra} ->
esperar_resposta(%{c | pendentes: c.pendentes ++ [outra]}, ref, tempo)
end
end
defp enviar_quadro(c, 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(c.socket, [cabeca, mascara, mascarar(dados, mascara)])
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 de texto do servidor (sem máscara) e decodifica o JSON.
defp ler_mensagem(c, tempo) do
case quadro(c.buffer) do
{:ok, texto, resto} ->
{%{c | buffer: resto}, Jason.decode!(texto)}
:incompleto ->
case :gen_tcp.recv(c.socket, 0, tempo) do
{:ok, dados} -> ler_mensagem(%{c | buffer: c.buffer <> dados}, tempo)
{:error, _} -> {c, nil}
end
end
end
defp quadro(<<_fin::1, _::3, _op::4, 0::1, 126::7, n::16, dados::binary-size(n), resto::binary>>), do: {:ok, dados, resto}
defp quadro(<<_fin::1, _::3, _op::4, 0::1, 127::7, n::64, dados::binary-size(n), resto::binary>>), do: {:ok, dados, resto}
defp quadro(<<_fin::1, _::3, _op::4, 0::1, n::7, dados::binary-size(n), resto::binary>>) when n < 126, do: {:ok, dados, resto}
defp quadro(_), do: :incompleto
defp atributo_html(html, regex) do
case Regex.run(regex, html) do
[_, valor] -> String.replace(valor, "&", "&")
nil -> nil
end
end
@doc "Um token como o que a página leva em window.userToken: a identidade assinada pelo servidor."
def token(nome), do: Phoenix.Token.sign(@endpoint, "user socket", nome)
@doc """
Termina os processos do Presence e os inicia de novo. É preciso depois de mudar
os callbacks do módulo (`init/1`, `handle_metas/4`). As presenças registradas se perdem.
"""
def reiniciar_presence do
case Process.whereis(Module.concat(@presence, "Supervisor")) do
nil -> :ok
pid -> Kino.terminate_child(pid)
end
{:ok, _} = Kino.start_child(@presence)
Kino.Markdown.new("`#{inspect(@presence)}` reiniciado: nenhuma presença registrada.")
end
# --- 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
%{
central: %{
texto:
"uma aplicação roda em três servidores, e cada um tem usuários conectados. Para mostrar \"quem está online\", onde o Phoenix Presence guarda a lista?",
opcoes: [
a: "Numa tabela do banco de dados, atualizada a cada entrada e saída.",
b: "Num servidor escolhido como principal, que os outros consultam.",
c: "Em cada servidor: cada um guarda as presenças que viu e troca as mudanças com os outros.",
d: "No navegador de cada usuário, que avisa os outros diretamente."
],
correta: :c,
explicacao:
"Não existe uma fonte única da verdade. Cada nó guarda as presenças dos processos que ele mesmo acompanha, e os nós trocam as mudanças pelo PubSub. A estrutura de dados usada (um CRDT) garante que todos cheguem à mesma lista, mesmo que as mensagens cheguem fora de ordem. Se um nó cai, os outros removem as presenças dele; não há banco nem servidor principal para falhar."
},
arvore: %{
texto:
"o módulo `HelloWeb.Presence` foi definido, mas ainda não entrou na árvore de supervisão. O que acontece ao chamar `HelloWeb.Presence.list(\"room:lobby\")`?",
opcoes: [
a: "Devolve um mapa vazio, porque ninguém entrou ainda.",
b: "Levanta um erro: as presenças ficam em tabelas que só existem enquanto o processo do Presence roda.",
c: "O Phoenix inicia o processo na primeira chamada.",
d: "Devolve `nil`."
],
correta: :b,
explicacao:
"`use Phoenix.Presence` só define funções. Quem guarda as presenças é um conjunto de processos (um tracker e suas tabelas ETS), que só existe depois que o módulo é iniciado na árvore de supervisão. Sem ele, `list/1` procura uma tabela que não existe e falha com `ArgumentError`."
},
abas: %{
texto:
"Alice abre a página em duas abas, e Bob em uma. As três conexões fazem `Presence.track(socket, nome, %{...})` com o nome de cada um. O que `Presence.list/1` devolve?",
opcoes: [
a: "Três entradas: Alice, Alice e Bob.",
b: "Duas entradas: Alice, com dois `metas`, e Bob, com um.",
c: "Duas entradas, cada uma com um `meta`: a segunda aba de Alice substitui a primeira.",
d: "Um erro: a chave `Alice` já está em uso."
],
correta: :b,
explicacao:
"As presenças são agrupadas pela chave. Cada processo registrado sob a mesma chave acrescenta um item à lista `metas`. É assim que a página do guia mostra `Alice (count: 2)`: a mesma pessoa, em dois aparelhos ou duas abas."
},
queda: %{
texto:
"a aba de Bob trava e a conexão cai, sem que o JavaScript tenha chance de avisar o servidor. O que acontece com a presença dele?",
opcoes: [
a: "Fica na lista até alguém chamar `Presence.untrack/3`.",
b: "Fica na lista até o servidor reiniciar.",
c: "Some sozinha: o Presence acompanha o processo do canal e, quando ele termina, remove a presença e avisa os outros.",
d: "Some depois de 24 horas."
],
correta: :c,
explicacao:
"`track/3` liga a presença a um processo, o do canal. Quando a conexão cai, o processo do canal termina; o Presence, que o monitora, remove a presença e faz o broadcast de um `presence_diff` com ela em `leaves`. Ninguém precisa escrever código de limpeza."
},
update: %{
texto:
"Bob já está na sala, e o canal chama `Presence.update/3` para marcar `typing: true` no meta dele. O que as outras conexões recebem?",
opcoes: [
a: "Nada: `update/3` só muda o estado no servidor.",
b: "Um evento novo, `presence_update`, só com o campo que mudou.",
c: "Um `presence_diff` com Bob em `leaves` (o meta antigo) e em `joins` (o meta novo).",
d: "Um `presence_state` com a lista inteira de novo."
],
correta: :c,
explicacao:
"Para o Presence, atualizar é trocar um meta por outro: o antigo sai e o novo entra, no mesmo `presence_diff`. O meta novo ganha um `phx_ref` novo e guarda o anterior em `phx_ref_prev`. Como o cliente aplica as entradas antes das saídas, a pessoa nunca some da lista durante a troca."
},
confiar: %{
texto:
"na página do guia, o nome vem da URL (`/?name=Alice`) e o JavaScript o manda no join. O que impede alguém de aparecer na lista como `Alice` sem ser a Alice?",
opcoes: [
a: "O Presence confere o nome com a sessão do navegador.",
b: "O token CSRF da página.",
c: "Nada: o servidor acredita em qualquer nome que o cliente mandar.",
d: "O canal só aceita um join por nome."
],
correta: :c,
explicacao:
"Tudo o que vem do cliente pode ser forjado: basta abrir `/?name=Alice`, ou mandar o join à mão, como as células deste notebook fazem. Para que a presença signifique alguma coisa, a identidade tem de vir de algo que só o servidor consegue produzir: um token assinado, conferido no `connect/3` do socket."
},
memoria: %{
texto:
"mil pessoas abrem a página de \"quem está online\" feita em LiveView. Se cada LiveView guardasse a lista completa nos seus assigns, quantas cópias da lista de mil nomes existiriam na memória do servidor?",
opcoes: [
a: "Uma, compartilhada entre todas as LiveViews.",
b: "Mil: cada LiveView é um processo com o seu próprio estado.",
c: "Nenhuma: os assigns ficam no navegador.",
d: "Duas: uma para as LiveViews e uma para o Presence."
],
correta: :b,
explicacao:
"Processos não compartilham memória: mil LiveViews com a lista nos assigns são mil cópias, um milhão de nomes. Por isso o guia deixa a lista só no Presence, que já a guarda, e faz cada LiveView usar um stream: os itens são mandados ao navegador e esquecidos pelo processo."
},
fetch: %{
texto:
"o `fetch/2` do guia espera que todo meta tenha um `:id` (`meta.id`). A `OnlineLive` registra presenças com `%{id: nome}`. O canal `room:lobby` registra com `%{online_at: ...}`. Para quais tópicos o `fetch/2` do módulo é chamado?",
opcoes: [
a: "Só para `online_users`, o tópico da LiveView.",
b: "Só para os tópicos que começam com `proxy:`.",
c: "Para todos os tópicos do módulo, inclusive `room:lobby`.",
d: "Para nenhum, até alguém chamar `fetch/2` à mão."
],
correta: :c,
explicacao:
"`fetch/2` é um callback do módulo, não de um tópico: toda chamada a `list/1` e todo diff passam por ele, com o tópico como primeiro argumento. Um `fetch/2` que supõe um formato de meta quebra todos os tópicos que usam outro formato. É o que acontece logo abaixo com o `room:lobby`."
}
}
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
%{
rastrear: ~S'''
# Sua vez (rastrear e digitando): lib/hello_web/channels/room_channel.ex
defmodule HelloWeb.RoomChannel do
use Phoenix.Channel
alias HelloWeb.Presence
def join("room:lobby", %{"name" => name}, socket) do
send(self(), :after_join)
{:ok, assign(socket, :name, name)}
end
def handle_info(:after_join, socket) do
{:ok, _} =
Presence.track(socket, socket.assigns.name, %{
online_at: inspect(System.system_time(:second))
})
push(socket, "presence_state", Presence.list(socket))
{:noreply, socket}
end
end
''',
digitando: ~S'''
# Sua vez (rastrear e digitando): lib/hello_web/channels/room_channel.ex
defmodule HelloWeb.RoomChannel do
use Phoenix.Channel
alias HelloWeb.Presence
def join("room:lobby", %{"name" => name}, socket) do
send(self(), :after_join)
{:ok, assign(socket, :name, name)}
end
def handle_info(:after_join, socket) do
{:ok, _} =
Presence.track(socket, socket.assigns.name, %{
online_at: inspect(System.system_time(:second))
})
push(socket, "presence_state", Presence.list(socket))
{:noreply, socket}
end
def handle_in("typing", %{"typing" => typing}, socket) do
{:ok, _} = Presence.update(socket, socket.assigns.name, &Map.put(&1, :typing, typing))
{:noreply, socket}
end
end
''',
seguro: ~S'''
# Sua vez (seguro): lib/hello_web/channels/user_socket.ex e room_channel.ex
defmodule HelloWeb.UserSocket do
use Phoenix.Socket
channel "room:lobby", 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, :user_id, user_id)}
{:error, _reason} -> :error
end
end
@impl true
def id(_socket), do: nil
end
defmodule HelloWeb.RoomChannel do
use Phoenix.Channel
alias HelloWeb.Presence
def join("room:lobby", _params, socket) do
send(self(), :after_join)
{:ok, socket}
end
def handle_info(:after_join, socket) do
{:ok, _} =
Presence.track(socket, socket.assigns.user_id, %{
online_at: inspect(System.system_time(:second))
})
push(socket, "presence_state", Presence.list(socket))
{:noreply, socket}
end
def handle_in("typing", %{"typing" => typing}, socket) do
{:ok, _} = Presence.update(socket, socket.assigns.user_id, &Map.put(&1, :typing, typing))
{:noreply, socket}
end
end
''',
fetch: ~S'''
# Sua vez (fetch): lib/hello_web/channels/presence.ex
defmodule HelloWeb.Presence do
use Phoenix.Presence,
otp_app: :hello,
pubsub_server: Hello.PubSub
def init(_opts) do
{:ok, %{}}
end
def fetch(_topic, presences) do
for {key, %{metas: [meta | metas]}} <- presences, into: %{} do
# Nem todo tópico guarda um :id nos metas; a chave da presença serve de reserva.
id = Map.get(meta, :id, key)
{key, %{metas: [meta | metas], id: id, user: %{name: id}}}
end
end
def handle_metas(topic, %{joins: joins, leaves: leaves}, presences, state) do
for {user_id, presence} <- joins do
user_data = %{id: user_id, user: presence.user, metas: Map.fetch!(presences, user_id)}
msg = {__MODULE__, {:join, user_data}}
Phoenix.PubSub.local_broadcast(Hello.PubSub, "proxy:#{topic}", msg)
end
for {user_id, presence} <- leaves do
metas =
case Map.fetch(presences, user_id) do
{:ok, presence_metas} -> presence_metas
:error -> []
end
user_data = %{id: user_id, user: presence.user, metas: metas}
msg = {__MODULE__, {:leave, user_data}}
Phoenix.PubSub.local_broadcast(Hello.PubSub, "proxy:#{topic}", msg)
end
{:ok, state}
end
def list_online_users(), do: list("online_users") |> Enum.map(fn {_id, presence} -> presence end)
def track_user(name, params), do: track(self(), "online_users", name, params)
def subscribe(), do: Phoenix.PubSub.subscribe(Hello.PubSub, "proxy:online_users")
end
''',
total: ~S'''
# Sua vez (total): lib/hello_web/live/online/index.ex
defmodule HelloWeb.OnlineLive do
use HelloWeb, :live_view
def mount(params, _session, socket) do
socket = stream(socket, :presences, [])
socket =
if connected?(socket) do
HelloWeb.Presence.track_user(params["name"], %{id: params["name"]})
HelloWeb.Presence.subscribe()
stream(socket, :presences, HelloWeb.Presence.list_online_users())
else
socket
end
{:ok, contar(socket)}
end
def render(assigns) do
~H"""
<p id="total">{@total} online</p>
<ul id="online_users" phx-update="stream">
<li :for={{dom_id, %{id: id, metas: metas}} <- @streams.presences} id={dom_id}>{id} ({length(metas)})</li>
</ul>
"""
end
def handle_info({HelloWeb.Presence, {:join, presence}}, socket) do
{:noreply, socket |> stream_insert(:presences, presence) |> contar()}
end
def handle_info({HelloWeb.Presence, {:leave, presence}}, socket) do
if presence.metas == [] do
{:noreply, socket |> stream_delete(:presences, presence) |> contar()}
else
{:noreply, stream_insert(socket, :presences, presence)}
end
end
defp contar(socket), do: assign(socket, :total, length(HelloWeb.Presence.list_online_users()))
end
''',
brecho: ~S'''
# Sem roteiro: lib/hello_web/live/peca_live.ex
defmodule HelloWeb.PecaLive do
use HelloWeb, :live_view
alias HelloWeb.Presence
def mount(%{"id" => id}, _session, socket) do
produto = Hello.Vitrine.buscar_produto!(id)
topico = "peca:#{produto.id}"
if connected?(socket) do
# Assina antes de se registrar, para receber o diff da própria entrada.
Phoenix.PubSub.subscribe(Hello.PubSub, topico)
{:ok, _} = Presence.track(self(), topico, socket.id, %{})
end
{:ok, assign(socket, produto: produto, topico: topico, olhando: contar(topico))}
end
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
{@produto.nome}
<:subtitle>{@produto.preco_centavos} centavos</:subtitle>
</.header>
<p id="olhando">{@olhando} olhando esta peça agora</p>
</Layouts.app>
"""
end
def handle_info(%Phoenix.Socket.Broadcast{event: "presence_diff"}, socket) do
{:noreply, assign(socket, :olhando, contar(socket.assigns.topico))}
end
defp contar(topico), do: topico |> Presence.list() |> map_size()
end
'''
}
end
# --- conferência dos exercícios ----------------------------------------------
@doc "Confere um exercício consultando o banco e fazendo requisições de verdade."
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
%{
rastrear: [
{"Quem entra em `room:lobby` com um nome recebe `presence_state` com o próprio nome, `online_at` e `phx_ref`.",
fn ->
nome = nome_unico("ana")
c = na_sala(nome)
{c, mensagens} = receber_todas(c, 400)
fechar(c)
case List.keyfind(mensagens, "presence_state", 0) do
{_, %{^nome => %{"metas" => [%{"online_at" => _, "phx_ref" => _}]}}} ->
:ok
{_, estado} ->
{:erro, "O `presence_state` veio sem `#{nome}` com `online_at`: `#{inspect(estado, limit: 6)}`. Confira o `Presence.track/3`."}
nil ->
{:erro, "Nenhum `presence_state` chegou depois do join (vieram: `#{inspect(Enum.map(mensagens, &elem(&1, 0)))}`). O `join/3` precisa mandar `:after_join` para o próprio processo, e o `handle_info/2` precisa fazer o `push`."}
end
end},
{"Quando outra pessoa entra, quem já estava recebe um `presence_diff` com ela em `joins`.",
fn ->
{ana, bia} = {nome_unico("ana"), nome_unico("bia")}
c1 = na_sala(ana)
{c1, _} = receber_todas(c1, 400)
c2 = na_sala(bia)
{c1, mensagens} = receber_todas(c1, 400)
fechar(c1)
fechar(c2)
if Enum.any?(mensagens, &match?({"presence_diff", %{"joins" => %{^bia => _}}}, &1)),
do: :ok,
else: {:erro, "Quem já estava na sala recebeu `#{inspect(mensagens, limit: 6)}`. O `Presence.track/3` com o `socket` do canal faz esse broadcast sozinho; ele foi chamado?"}
end},
{"A mesma pessoa em duas conexões aparece uma vez, com dois `metas`.",
fn ->
nome = nome_unico("ana")
c1 = na_sala(nome)
{c1, _} = receber_todas(c1, 400)
c2 = na_sala(nome)
{c2, mensagens} = receber_todas(c2, 400)
fechar(c1)
fechar(c2)
case List.keyfind(mensagens, "presence_state", 0) do
{_, %{^nome => %{"metas" => [_, _]}}} -> :ok
outro -> {:erro, "A segunda conexão recebeu `#{inspect(outro, limit: 6)}`. A chave do `track` deve ser o nome, igual nas duas conexões."}
end
end},
{"Quando uma conexão cai, as outras recebem um `presence_diff` com ela em `leaves`.",
fn ->
{ana, bia} = {nome_unico("ana"), nome_unico("bia")}
c1 = na_sala(ana)
c2 = na_sala(bia)
{c1, _} = receber_todas(c1, 400)
fechar(c2)
{c1, mensagens} = receber_todas(c1, 600)
fechar(c1)
if Enum.any?(mensagens, &match?({"presence_diff", %{"leaves" => %{^bia => _}}}, &1)),
do: :ok,
else: {:erro, "Depois de a outra conexão fechar, chegou `#{inspect(mensagens, limit: 6)}`."}
end}
],
digitando: [
{"`typing` com `true` chega aos outros como um `presence_diff` em que o meta tem `typing: true`.",
fn -> digitando(true) end},
{"O meta atualizado mantém o `online_at` e ganha `phx_ref_prev`.",
fn ->
case meta_depois_de_digitar(true) do
{:ok, %{"online_at" => _, "phx_ref_prev" => _}} -> :ok
{:ok, meta} -> {:erro, "O meta novo veio como `#{inspect(meta)}`. Use `Presence.update/3` com uma função que altera o meta atual (`&Map.put(&1, :typing, typing)`), em vez de um mapa novo."}
erro -> erro
end
end},
{"`typing` com `false` desliga o aviso.", fn -> digitando(false) end}
],
seguro: [
{"Sem token, o servidor recusa a conexão.",
fn ->
case conectar() do
{:erro, _status} -> :ok
{:ok, c} -> fechar(c) && {:erro, "A conexão sem token foi aceita. O `connect/3` precisa devolver `:error` quando `Phoenix.Token.verify/4` falha."}
end
end},
{"Um token inventado também é recusado.",
fn ->
case conectar(token: "token-inventado") do
{:erro, _status} -> :ok
{:ok, c} -> fechar(c) && {:erro, "Um token inventado foi aceito."}
end
end},
{"Com um token válido, a conexão é aceita.",
fn ->
case conectar(token: token(nome_unico("ana"))) do
{:ok, c} -> fechar(c)
{:erro, status} -> {:erro, "O servidor respondeu `#{status}` a um token válido. Confira o salt (`\"user socket\"`) e o `connect_info[:auth_token]`."}
end
end},
{"A presença usa a identidade do token, não o `name` que o cliente manda.",
fn ->
nome = nome_unico("ana")
c = entrar!("room:lobby", %{"name" => "admin"}, token: token(nome))
{c, mensagens} = receber_todas(c, 400)
fechar(c)
case List.keyfind(mensagens, "presence_state", 0) do
{_, %{"admin" => _}} -> {:erro, "A presença foi registrada como `admin`, o nome que veio do cliente. Use `socket.assigns.user_id` no `track`."}
{_, %{^nome => _}} -> :ok
outro -> {:erro, "Depois do join chegou `#{inspect(outro, limit: 6)}`. O `connect/3` deve guardar a identidade com `assign(socket, :user_id, user_id)`."}
end
end}
],
fetch: [
{"`fetch/2` aceita metas sem `:id` e usa a chave da presença no lugar.",
fn ->
case apply(@presence, :fetch, ["t", %{"ana" => %{metas: [%{online_at: "1"}]}}]) do
%{"ana" => %{id: "ana", user: %{name: "ana"}, metas: [%{online_at: "1"}]}} -> :ok
outro -> {:erro, "`fetch/2` devolveu `#{inspect(outro)}`."}
end
end},
{"Quando o meta tem `:id`, ele continua valendo.",
fn ->
case apply(@presence, :fetch, ["t", %{"chave" => %{metas: [%{id: "bob"}]}}]) do
%{"chave" => %{id: "bob", user: %{name: "bob"}}} -> :ok
outro -> {:erro, "`fetch/2` devolveu `#{inspect(outro)}`. O `:id` do meta deve ter prioridade sobre a chave."}
end
end},
{"O canal `room:lobby` voltou a funcionar: o join recebe `presence_state`.",
fn ->
nome = nome_unico("ana")
c = na_sala(nome)
{c, mensagens} = receber_todas(c, 500)
fechar(c)
case List.keyfind(mensagens, "presence_state", 0) do
{_, %{^nome => %{"metas" => [_]}}} -> :ok
_ -> {:erro, "O canal ainda cai depois do join (chegou `#{inspect(Enum.map(mensagens, &elem(&1, 0)))}`). Você reiniciou o `HelloWeb.Presence` depois de mudar o módulo?"}
end
end}
],
total: [
{"A página `/online/:name` tem um elemento com `id=\"total\"`.",
fn ->
case visitar("conferencia", :get, "/online/" <> nome_unico("ana")) do
{:ok, %{status: 200, resp_body: corpo}} ->
if to_string(corpo) =~ ~s(id="total"), do: :ok, else: {:erro, "A página não tem `<p id=\"total\">`."}
{:ok, conn} -> {:erro, "A página respondeu `#{conn.status}`."}
{:erro, erro} -> {:erro, explicar(erro)}
end
end},
{"Quando outra pessoa entra, quem já estava vê o total subir.",
fn ->
antes = online()
{a, _} = abrir_lv("/online/" <> nome_unico("ana"))
{a, _} = receber_todas(a, 400)
{b, _} = abrir_lv("/online/" <> nome_unico("bia"))
{a, mensagens} = receber_todas(a, 500)
fechar(a)
fechar(b)
esperar_sair(antes)
tem_valor(mensagens, antes + 2, "Com mais duas pessoas, o total deveria ir para #{antes + 2}. Atualize o assign `:total` no `handle_info/2` do join.")
end},
{"Quando ela sai, o total desce.",
fn ->
antes = online()
{a, _} = abrir_lv("/online/" <> nome_unico("ana"))
{b, _} = abrir_lv("/online/" <> nome_unico("bia"))
{a, _} = receber_todas(a, 500)
fechar(b)
{a, mensagens} = receber_todas(a, 700)
fechar(a)
esperar_sair(antes)
tem_valor(mensagens, antes + 1, "Depois da saída, o total deveria voltar para #{antes + 1}. Atualize o assign `:total` também no `handle_info/2` do leave, quando a pessoa some de vez.")
end}
],
brecho: [
{"`GET /pecas/:id` mostra o nome da peça e um elemento com `id=\"olhando\"`.",
fn ->
[id, nome] = peca(0)
case visitar("conferencia", :get, "/pecas/#{id}") do
{:ok, %{status: 200, resp_body: corpo}} ->
corpo = to_string(corpo)
cond do
not (corpo =~ nome) -> {:erro, "A página não mostra o nome da peça (`#{nome}`)."}
not (corpo =~ ~s(id="olhando")) -> {:erro, "A página não tem um elemento com `id=\"olhando\"`."}
true -> :ok
end
{:ok, conn} -> {:erro, "A página respondeu `#{conn.status}`."}
{:erro, erro} -> {:erro, explicar(erro)}
end
end},
{"Cada conexão é registrada no tópico `peca:ID` da peça que está olhando.",
fn ->
[[id1, _], [id2, _]] = [peca(0), peca(1)]
{a, _} = abrir_lv("/pecas/#{id1}")
{b, _} = abrir_lv("/pecas/#{id1}")
{c, _} = abrir_lv("/pecas/#{id2}")
Process.sleep(200)
contagem = {olhando("peca:#{id1}"), olhando("peca:#{id2}")}
Enum.each([a, b, c], &fechar/1)
esperar_vazio(["peca:#{id1}", "peca:#{id2}"])
if contagem == {2, 1},
do: :ok,
else: {:erro, "Com duas conexões na peça #{id1} e uma na #{id2}, o Presence contou `#{inspect(contagem)}`. Registre cada LiveView conectada com `Presence.track(self(), \"peca:\#{id}\", socket.id, %{})`."}
end},
{"Quando mais alguém abre a mesma peça, quem já estava vê o número subir para 2.",
fn ->
[id, _] = peca(0)
{a, _} = abrir_lv("/pecas/#{id}")
{a, _} = receber_todas(a, 400)
{b, _} = abrir_lv("/pecas/#{id}")
{a, mensagens} = receber_todas(a, 500)
fechar(a)
fechar(b)
esperar_vazio(["peca:#{id}"])
tem_valor(mensagens, 2, "A primeira conexão não recebeu um diff com o número 2. Assine o tópico com `Phoenix.PubSub.subscribe/2` e trate o `%Phoenix.Socket.Broadcast{event: \"presence_diff\"}` no `handle_info/2`. O número precisa estar sozinho na interpolação: `{@olhando}`.")
end},
{"Quando a outra pessoa fecha a página, o número volta para 1.",
fn ->
[id, _] = peca(0)
{a, _} = abrir_lv("/pecas/#{id}")
{b, _} = abrir_lv("/pecas/#{id}")
{a, _} = receber_todas(a, 500)
fechar(b)
{a, mensagens} = receber_todas(a, 700)
fechar(a)
esperar_vazio(["peca:#{id}"])
tem_valor(mensagens, 1, "Depois de a outra conexão fechar, a primeira não recebeu um diff com o número 1.")
end},
{"Quem olha outra peça não é contado.",
fn ->
[[id1, _], [id2, _]] = [peca(0), peca(1)]
{a, _} = abrir_lv("/pecas/#{id1}")
{a, _} = receber_todas(a, 400)
{b, _} = abrir_lv("/pecas/#{id2}")
{a, mensagens} = receber_todas(a, 500)
fechar(a)
fechar(b)
esperar_vazio(["peca:#{id1}", "peca:#{id2}"])
if mensagens == [],
do: :ok,
else: {:erro, "Quem olhava a peça #{id1} recebeu `#{inspect(mensagens, limit: 5)}` quando alguém abriu a peça #{id2}. Cada peça tem o seu tópico."}
end}
]
}
end
# --- ajudantes da conferência --------------------------------------------------
defp nome_unico(base), do: "conf-#{base}-#{System.unique_integer([:positive])}"
# Entra em room:lobby mandando o nome dos dois jeitos: no payload do join (o do
# guia) e num token (o da seção "Making it safe"). Serve antes e depois dela.
defp na_sala(nome), do: entrar!("room:lobby", %{"name" => nome}, token: token(nome))
defp digitando(valor) do
case meta_depois_de_digitar(valor) do
{:ok, %{"typing" => ^valor}} -> :ok
{:ok, meta} -> {:erro, "O meta novo veio como `#{inspect(meta)}`, sem `typing: #{valor}`."}
erro -> erro
end
end
defp meta_depois_de_digitar(valor) do
{ana, bia} = {nome_unico("ana"), nome_unico("bia")}
c1 = na_sala(ana)
c2 = na_sala(bia)
{c1, _} = receber_todas(c1, 400)
{c2, _} = empurrar(c2, "typing", %{"typing" => valor})
{c1, mensagens} = receber_todas(c1, 500)
fechar(c1)
fechar(c2)
Enum.find_value(mensagens, {:erro, "Quem estava na sala não recebeu um `presence_diff` de `#{bia}` depois do evento `typing` (chegou `#{inspect(mensagens, limit: 5)}`). Falta o `handle_in(\"typing\", ...)` com `Presence.update/3`?"}, fn
{"presence_diff", %{"joins" => %{^bia => %{"metas" => [meta | _]}}}} -> {:ok, meta}
_ -> nil
end)
end
defp online, do: length(apply(@presence, :list_online_users, []))
defp olhando(topico), do: map_size(apply(@presence, :list, [topico]))
# As saídas são assíncronas: espera o Presence registrar que as conexões fecharam.
defp esperar_sair(esperado, tentativas \\ 20) do
if online() > esperado and tentativas > 0 do
Process.sleep(50)
esperar_sair(esperado, tentativas - 1)
end
end
defp esperar_vazio(topicos, tentativas \\ 20) do
if Enum.any?(topicos, &(olhando(&1) > 0)) and tentativas > 0 do
Process.sleep(50)
esperar_vazio(topicos, tentativas - 1)
end
end
# Procura um número entre os valores dinâmicos dos diffs recebidos.
defp tem_valor(mensagens, numero, dica) do
valores = for {"diff", diff} <- mensagens, valor <- valores(diff), do: valor
if Integer.to_string(numero) in valores,
do: :ok,
else: {:erro, dica <> " Os diffs recebidos: `#{inspect(for({"diff", d} <- mensagens, do: d), limit: 8)}`."}
end
defp valores(mapa) when is_map(mapa), do: Enum.flat_map(mapa, fn {_chave, valor} -> valores(valor) end)
defp valores(lista) when is_list(lista), do: Enum.flat_map(lista, &valores/1)
defp valores(valor), do: [valor]
defp peca(posicao) do
case sql("SELECT id, nome FROM produtos ORDER BY id LIMIT 1 OFFSET $1", [posicao]).rows do
[linha] -> linha
[] -> raise "a tabela produtos não tem peças: avalie as células de \"O Brechó até aqui\""
end
end
defp explicar(%Phoenix.Router.NoRouteError{} = erro) do
"Etapa: **router**. `#{Exception.message(erro)}`."
end
defp explicar(%UndefinedFunctionError{module: modulo, function: funcao, arity: aridade}) do
"`#{inspect(modulo)}.#{funcao}/#{aridade}` não existe. O módulo foi definido e a célula foi avaliada?"
end
defp explicar(%RuntimeError{message: "o canal recusou o join" <> _ = mensagem}) do
"#{mensagem}. O `join/3` do `RoomChannel` casa com o tópico e com o payload que o cliente manda?"
end
defp explicar(%RuntimeError{message: "o servidor recusou a conexão" <> _ = mensagem}) do
"#{mensagem}. O `connect/3` do `UserSocket` recusou um token válido."
end
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(%ArgumentError{message: mensagem}) do
"`#{mensagem |> String.split("\n") |> hd()}`. Se o erro fala de uma tabela ETS, o `HelloWeb.Presence` não está rodando: avalie a célula que o inicia."
end
defp explicar(%KeyError{key: :id}) do
"`fetch/2` levantou `KeyError`: ele ainda lê `meta.id`, e o meta não tem `:id`. Use `Map.get(meta, :id, key)`."
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 Presence da documentação do Phoenix 1.8, na mesma ordem e com o link de cada seção. O guia parte do RoomChannel do guia de Channels (notebook 18) e acrescenta a ele a lista de quem está conectado; depois faz a mesma lista com LiveView (notebook 16).
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.
Presença só tem graça com várias conexões ao mesmo tempo. Há dois jeitos de tê-las aqui:
- no navegador: abra http://localhost:4010/?name=Alice em duas abas e http://localhost:4010/?name=Bob em outra, como o guia pede, e feche abas para ver a lista mudar;
- no notebook: o módulo
Trilhaabre conexões WebSocket de verdade com a aplicação e mostra as mensagens que trafegam, as mesmas que aparecem nas ferramentas do navegador (aba Network, conexão WS).
Os ajudantes do Trilha para conexões:
Trilha.entrar!("room:lobby", %{"name" => "Alice"})conecta em/socket/websockete entra no tópico, comosocket.connect()echannel.join()do phoenix.js; devolve a conexão;Trilha.receber_todas(conexao)devolve{conexao, mensagens}com o que o servidor mandou sem ter sido pedido, como pares{evento, payload};Trilha.empurrar(conexao, evento, payload)é ochannel.push;Trilha.fechar(conexao)fecha a conexão sem avisar, como fechar a aba;Trilha.abrir_lv("/caminho")abre uma LiveView pelo WebSocket dela, como no notebook 16;Trilha.json/1mostra uma mensagem como JSON.
A instalação é a dos notebooks 13 a 17. O banco de dados só entra no desafio do fim, mas a aplicação o inicia como nos outros notebooks.
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
A aplicação começa onde o guia de Channels termina: com um socket em /socket, o UserSocket e o RoomChannel que mix phx.gen.channel Room gera. As peças de LiveView do notebook 16 também estão aqui, para a segunda metade do guia.
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, com os blocos live_view e channel. use HelloWeb, :channel é só use Phoenix.Channel.
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 controller do
quote do
use Phoenix.Controller, formats: [:html, :json]
import Plug.Conn
unquote(verified_routes())
end
end
def live_view do
quote do
use Phoenix.LiveView
unquote(html_helpers())
end
end
def channel do
quote do
use Phoenix.Channel
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: além da página inicial, o router já traz o que as seções seguintes usam. /entrar/:nome é um login de mentira: guarda um nome na sessão, sem senha, só para a seção "Deixando seguro" ter uma identidade vinda do servidor (o login de verdade está no notebook 13). O plug put_user_token é o do guia de Channels: quando há alguém na sessão, assina um token com a identidade e o deixa em conn.assigns.user_token para o layout. As duas rotas live apontam para LiveViews definidas mais abaixo; por isso a primeira avaliação mostra dois avisos __live__/0 is undefined, como os init/1 is undefined de sempre.
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 :put_user_token
end
pipeline :api do
plug :accepts, ["json"]
end
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :home
get "/entrar/:nome", PageController, :entrar
live "/online/:name", OnlineLive, :index
live "/pecas/:id", PecaLive
end
defp put_user_token(conn, _) do
if current_user = get_session(conn, :current_user) do
token = Phoenix.Token.sign(conn, "user socket", current_user)
assign(conn, :user_token, token)
else
conn
end
end
end
lib/hello_web/channels/user_socket.ex e room_channel.ex: o que mix phx.gen.socket User e mix phx.gen.channel Room geram, com a linha channel "room:lobby", HelloWeb.RoomChannel que o gerador manda acrescentar. O canal gerado aceita qualquer join em room:lobby, responde a ping e repassa shout a todos.
defmodule HelloWeb.UserSocket do
use Phoenix.Socket
channel "room:lobby", HelloWeb.RoomChannel
@impl true
def connect(_params, socket, _connect_info) do
{:ok, socket}
end
@impl true
def id(_socket), do: nil
end
defmodule HelloWeb.RoomChannel do
use HelloWeb, :channel
@impl true
def join("room:lobby", payload, socket) do
if authorized?(payload) do
{:ok, socket}
else
{:error, %{reason: "unauthorized"}}
end
end
# Channels can be used in a request/response fashion
# by sending replies to requests from the client
@impl true
def handle_in("ping", payload, socket) do
{:reply, {:ok, payload}, socket}
end
# It is also common to receive messages from the client and
# broadcast to everyone in the current topic (room:lobby).
@impl true
def handle_in("shout", payload, socket) do
broadcast(socket, "shout", payload)
{:noreply, socket}
end
# Add authorization logic here as required.
defp authorized?(_payload) do
true
end
end
lib/hello_web/endpoint.ex: a linha socket "/socket", HelloWeb.UserSocket cria o endereço /socket/websocket. A opção auth_token: true é o passo 1 da autenticação por token do guia de Channels: deixa o cliente mandar um token junto com o pedido de conexão. Ela já fica ligada desde o início; enquanto o UserSocket não olhar o token, não muda nada. Os Plug.Static servem os arquivos JavaScript que, num projeto, o esbuild empacota no app.js; o phoenix.min.js traz as classes Socket e Presence que o guia usa.
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
socket "/live", Phoenix.LiveView.Socket,
websocket: [connect_info: [session: @session_options]],
longpoll: [connect_info: [session: @session_options]]
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.Static, at: "/assets", from: {:phoenix_live_view, "priv/static"}, only: ~w(phoenix_live_view.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, como nos notebooks anteriores.
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 dos notebooks 16 e 17, com uma linha a mais, o passo 3 do guia de Channels: window.userToken recebe o token que o plug put_user_token deixou nos assigns (ou fica vazio, se ninguém entrou).
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>window.userToken = "<%= assigns[:user_token] %>";</script>
<script defer src={~p"/assets/phoenix_html.js"}>
</script>
<script src={~p"/assets/phoenix.min.js"}>
</script>
<script src={~p"/assets/phoenix_live_view.min.js"}>
</script>
<script>
window.addEventListener("DOMContentLoaded", () => {
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveView.LiveSocket("/live", Phoenix.Socket, {params: {_csrf_token: csrfToken}})
liveSocket.connect()
window.liveSocket = liveSocket
})
</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 action entrar é o login de mentira: guarda o nome na sessão e volta para a página inicial.
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
def entrar(conn, %{"nome" => nome}) do
conn
|> put_session(:current_user, nome)
|> redirect(to: ~p"/")
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, mas só no desafio do fim. 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. O Presence depende do PubSub, mas ainda não está aqui: acrescentá-lo é um passo do guia. Como nos notebooks 16 e 17, o signing_salt do LiveView entra na configuração do endpoint em tempo de execução, para não mudar a lista do Mix.install.
{:ok, _} = Kino.start_child(Hello.Repo)
{:ok, _} = Kino.start_child({Phoenix.PubSub, name: Hello.PubSub})
config = Application.get_env(:hello, HelloWeb.Endpoint)
Application.put_env(:hello, HelloWeb.Endpoint, Keyword.put(config, :live_view, signing_salt: "trilhaLV"))
{: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. Avalie-a sempre que reabrir o notebook. Ela só mexe no banco da trilha.
Trilha.recomecar()
Presence
Na documentação: Presence
O Phoenix Presence registra informações de processos num tópico e as replica, de forma transparente, por todos os nós de um cluster. É a combinação de uma biblioteca do lado do servidor com uma do lado do cliente. O uso mais comum é mostrar quem está online numa aplicação.
Trilha.pergunta(:central)
O guia destaca quatro características. O Presence não tem um ponto único de falha, não tem uma fonte única da verdade, depende só da biblioteca padrão (nada de Redis nem de banco) e se recupera sozinho de falhas. Por baixo, cada nó mantém a sua parte da lista e troca as diferenças com os outros pelo Phoenix.PubSub. A estrutura de dados que torna isso possível é um CRDT, um tipo de dado feito para que cópias atualizadas em lugares diferentes cheguem ao mesmo resultado. Neste notebook há um nó só, mas o código é o mesmo que roda num cluster.
Preparação
Na documentação: Setting up
O guia usa o Presence para acompanhar quem está conectado e avisar os clientes quando alguém entra ou sai. Os avisos vão por Channels, então o primeiro passo é ter um RoomChannel, como no guia de Channels:
$ mix phx.gen.channel Room
Nesta aplicação o canal já existe, com os passos que o gerador manda seguir: o socket no endpoint e a linha channel "room:lobby", HelloWeb.RoomChannel no UserSocket. Um teste rápido, com uma conexão de verdade:
conexao = Trilha.entrar!("room:lobby")
{conexao, resposta} = Trilha.empurrar(conexao, "ping", %{"oi" => "tudo bem?"})
Trilha.fechar(conexao)
resposta
O canal gerado responde ao ping com o que recebeu: {:ok, %{"oi" => "tudo bem?"}}.
O gerador do Presence
Na documentação: The Presence generator
O módulo de presença vem de um gerador:
$ mix phx.gen.presence
* creating lib/hello_web/channels/presence.ex
Add your new module to your supervision tree,
in lib/hello/application.ex:
children = [
...
HelloWeb.Presence
]
You're all set! See the Phoenix.Presence docs for more details:
https://phoenix.hexdocs.pm/Phoenix.Presence.html
lib/hello_web/channels/presence.ex: o arquivo gerado, inteiro.
defmodule HelloWeb.Presence do
@moduledoc """
Provides presence tracking to channels and processes.
See the [`Phoenix.Presence`](https://phoenix.hexdocs.pm/Phoenix.Presence.html)
docs for more details.
"""
use Phoenix.Presence,
otp_app: :hello,
pubsub_server: Hello.PubSub
end
use Phoenix.Presence define no módulo as funções de que o resto do guia precisa: track, untrack, update, list. A opção pubsub_server diz por onde as mudanças são publicadas.
Trilha.pergunta(:arvore)
Antes de seguir a instrução do gerador, veja o que acontece sem ela:
HelloWeb.Presence.list("room:lobby")
A célula falha com ArgumentError: the table identifier does not refer to an existing ETS table. As presenças ficam em tabelas ETS criadas pelos processos do Presence, e esses processos ainda não existem.
lib/hello/application.ex: o módulo entra na árvore de supervisão, depois do PubSub. No projeto, é uma linha a mais na lista children; aqui, uma célula.
{:ok, _} = Kino.start_child(HelloWeb.Presence)
HelloWeb.Presence.list("room:lobby")
Agora a resposta é %{}: ninguém foi registrado ainda.
Uso com Channels e JavaScript
Na documentação: Usage With Channels and JavaScript
O canal é o lugar onde as presenças trafegam. Depois que alguém entra, o canal registra a conexão no Presence e manda para ela a lista de quem está presente. O registro pode levar um mapa com informações extras, os metas.
O RoomChannel do guia:
defmodule HelloWeb.RoomChannel do
use Phoenix.Channel
alias HelloWeb.Presence
def join("room:lobby", %{"name" => name}, socket) do
send(self(), :after_join)
{:ok, assign(socket, :name, name)}
end
def handle_info(:after_join, socket) do
{:ok, _} =
Presence.track(socket, socket.assigns.name, %{
online_at: inspect(System.system_time(:second))
})
push(socket, "presence_state", Presence.list(socket))
{:noreply, socket}
end
end
Três detalhes:
- o
join/3não registra nada: ele manda a mensagem:after_joinpara o próprio processo e responde. O registro e opushacontecem nohandle_info/2, depois que o join terminou, porque um canal só pode fazerpushdepois de ter entrado; Presence.track(socket, chave, meta)registra o processo do canal no tópico do socket (room:lobby), sob a chave dada, aqui o nome;push(socket, "presence_state", Presence.list(socket))manda a lista inteira só para quem acabou de entrar. Os outros são avisados pelo própriotrack, que faz o broadcast de um eventopresence_diffno tópico.
Sua vez: o canal que registra presenças
A célula abaixo começa com o RoomChannel gerado. Troque-o pelo do guia e avalie a célula.
# Sua vez (rastrear e digitando): lib/hello_web/channels/room_channel.ex
defmodule HelloWeb.RoomChannel do
use HelloWeb, :channel
@impl true
def join("room:lobby", payload, socket) do
if authorized?(payload) do
{:ok, socket}
else
{:error, %{reason: "unauthorized"}}
end
end
@impl true
def handle_in("ping", payload, socket) do
{:reply, {:ok, payload}, socket}
end
@impl true
def handle_in("shout", payload, socket) do
broadcast(socket, "shout", payload)
{:noreply, socket}
end
defp authorized?(_payload) do
true
end
end
Trilha.solucao(:rastrear)
Trilha.conferir(:rastrear)
As células daqui até o fim da seção mostram o canal em uso. Sem o exercício feito, elas rodam, mas as listas de mensagens vêm vazias.
As mensagens no fio
Alice entra na sala. A célula mostra tudo o que o servidor mandou para ela depois do join, como pares de evento e payload:
alice = Trilha.entrar!("room:lobby", %{"name" => "Alice"})
{alice, mensagens} = Trilha.receber_todas(alice)
Trilha.json(Enum.map(mensagens, &Tuple.to_list/1))
Chegaram duas mensagens, nesta ordem:
- um
presence_diffcom Alice emjoins. É o broadcast que oPresence.track/3fez no tópico; Alice também está inscrita nele, então recebe a própria entrada; - o
presence_state, que o canal mandou compush: a lista inteira, um mapa em que cada chave é uma presença e cada uma tem uma lista demetas.
Cada meta tem o online_at que o canal passou e um phx_ref, uma referência única que o Presence dá a cada registro. A ordem (diff antes do estado) não atrapalha o cliente: a classe Presence do phoenix.js guarda os diffs que chegam antes do primeiro estado e os aplica depois.
Trilha.pergunta(:abas)
Alice abre uma segunda aba, e Bob entra. O que Bob recebe:
alice2 = Trilha.entrar!("room:lobby", %{"name" => "Alice"})
bob = Trilha.entrar!("room:lobby", %{"name" => "Bob"})
{bob, mensagens} = Trilha.receber_todas(bob)
Trilha.json(Enum.map(mensagens, &Tuple.to_list/1))
No presence_state de Bob, Alice aparece uma vez, com dois itens em metas, um por conexão, cada um com o seu phx_ref. A primeira aba de Alice, que já estava na sala, recebeu as duas entradas como presence_diff:
{alice, mensagens} = Trilha.receber_todas(alice)
Trilha.json(Enum.map(mensagens, &Tuple.to_list/1))
São dois presence_diff: um com a segunda conexão de Alice em joins, outro com Bob. Nenhum deles repete a lista inteira; cada um traz só quem entrou e quem saiu.
A mesma lista pode ser lida no servidor, de qualquer lugar, pelo nome do tópico. Aqui as chaves dos metas são átomos; no fio, viraram texto, porque a mensagem vai como JSON:
HelloWeb.Presence.list("room:lobby")
Trilha.pergunta(:queda)
A segunda aba de Alice fecha sem avisar, e depois a de Bob:
Trilha.fechar(alice2)
{alice, saiu_uma_aba} = Trilha.receber_todas(alice, 500)
Trilha.fechar(bob)
{alice, saiu_bob} = Trilha.receber_todas(alice, 500)
Trilha.json(%{"1. a segunda aba de Alice fechou" => Enum.map(saiu_uma_aba, &Tuple.to_list/1), "2. Bob fechou" => Enum.map(saiu_bob, &Tuple.to_list/1)})
Nos dois casos chega um presence_diff com alguém em leaves. No primeiro, sai um dos dois metas de Alice, identificado pelo phx_ref; ela continua na lista com o outro. No segundo, Bob sai de vez. Ninguém chamou untrack: o processo de cada canal terminou quando a conexão caiu, e o Presence, que acompanha esses processos, fez o resto.
O lado do cliente
O phoenix.js traz uma classe Presence que cuida do estado no navegador. Ela escuta os eventos presence_state e presence_diff do canal, mantém a lista atualizada e chama o callback de onSync a cada mudança. O método list percorre as presenças, e o callback dele recebe a chave e os metas de cada uma. O JavaScript do guia, para o assets/js/app.js:
import {Socket, Presence} from "phoenix"
let socket = new Socket("/socket", {authToken: window.userToken})
let channel = socket.channel("room:lobby", {name: window.location.search.split("=")[1]})
let presence = new Presence(channel)
function renderOnlineUsers(presence) {
let response = ""
presence.list((id, {metas: [first, ...rest]}) => {
let count = rest.length + 1
response += `<br>${id} (count: ${count})</br>`
})
document.querySelector("main").innerHTML = response
}
socket.connect()
presence.onSync(() => renderOnlineUsers(presence))
channel.join()
lib/hello_web/controllers/page_html.ex: no notebook não há app.js compilado, então o mesmo código vai num <script> da página inicial. A única diferença é a primeira linha: em vez de import, as classes vêm da global Phoenix, que o phoenix.min.js do root layout define.
defmodule HelloWeb.PageHTML do
use HelloWeb, :html
def home(assigns) do
~H"""
<Layouts.app flash={@flash}>
<p>Abra esta página com <code>?name=Alice</code> no endereço.</p>
<script>
const {Socket, Presence} = Phoenix
let socket = new Socket("/socket", {authToken: window.userToken})
let channel = socket.channel("room:lobby", {name: window.location.search.split("=")[1]})
let presence = new Presence(channel)
function renderOnlineUsers(presence) {
let response = ""
presence.list((id, {metas: [first, ...rest]}) => {
let count = rest.length + 1
response += `<br>${id} (count: ${count})</br>`
})
document.querySelector("main").innerHTML = response
}
socket.connect()
presence.onSync(() => renderOnlineUsers(presence))
channel.join()
</script>
</Layouts.app>
"""
end
end
Agora no navegador: abra http://localhost:4010/?name=Alice em duas abas e http://localhost:4010/?name=Bob em outra. As três devem mostrar:
Alice (count: 2)
Bob (count: 1)
Feche uma das abas de Alice e a contagem cai para 1; feche a outra e Alice some da lista. A Alice do notebook, da célula mais acima, também aparece: para o servidor, ela é mais uma conexão. Rode a célula abaixo com as abas abertas e a contagem de Alice desce.
Trilha.fechar(alice)
Sua vez: digitando...
Os metas não precisam ficar parados. Presence.update/3 troca o meta de uma presença, e a mudança chega a todos. É um jeito comum de mostrar "fulano está digitando".
Trilha.pergunta(:update)
Na célula do RoomChannel, acrescente um handle_in/3 para o evento "typing". O cliente manda %{"typing" => true} ou %{"typing" => false}, e o canal atualiza o meta da própria presença, mantendo o que já estava nele:
Presence.update(socket, socket.assigns.name, &Map.put(&1, :typing, typing))
update/3 devolve {:ok, ref}. Um handle_in/3 que não responde ao cliente termina com {:noreply, socket}.
Trilha.solucao(:digitando)
Trilha.conferir(:digitando)
Veja a mensagem que os outros recebem quando Bob começa a digitar:
alice = Trilha.entrar!("room:lobby", %{"name" => "Alice"})
bob = Trilha.entrar!("room:lobby", %{"name" => "Bob"})
{alice, _} = Trilha.receber_todas(alice)
{bob, _} = Trilha.empurrar(bob, "typing", %{"typing" => true})
{alice, mensagens} = Trilha.receber_todas(alice)
Trilha.fechar(alice)
Trilha.fechar(bob)
Trilha.json(Enum.map(mensagens, &Tuple.to_list/1))
Sem o exercício feito, a lista vem vazia e o log mostra o canal de Bob caindo com FunctionClauseError em handle_in/3: nenhuma cláusula trata o evento "typing". Com ele feito, chega um único presence_diff, com Bob em leaves e em joins. O meta que sai é o antigo; o que entra tem typing: true, o mesmo online_at, um phx_ref novo e, em phx_ref_prev, o phx_ref do antigo. A classe Presence do cliente aplica primeiro os joins e depois os leaves: Bob ganha o meta novo antes de perder o antigo, e não some da lista nem por um instante. O phx_ref_prev fica no meta para quem quiser distinguir, no próprio código, uma atualização de uma entrada nova.
Deixando seguro
Na documentação: Making it safe
Trilha.pergunta(:confiar)
Até aqui, o nome vem da URL e o servidor acredita nele. O guia lembra que, em muitos sistemas, só quem fez login deve aparecer na lista, e manda usar a autenticação por token do guia de Channels, a seção Using Token Authentication. São cinco passos, e quatro já estão prontos nesta aplicação:
| Passo do guia de Channels | Aqui |
|---|---|
1. Ligar auth_token: true no socket do endpoint |
Pronto, na célula do endpoint |
2. Assinar um token com a identidade (Phoenix.Token.sign/3) e deixá-lo nos assigns |
Pronto: o plug put_user_token do router, para quem passou por /entrar/:nome |
3. Passar o token ao JavaScript (window.userToken) |
Pronto, no root layout |
4. Conferir o token no connect/3 do UserSocket |
Falta: é o exercício |
5. Criar o socket com authToken: window.userToken |
Pronto: o script da página já faz isso |
O token é a identidade assinada com o segredo da aplicação. Quem não tem o segredo não consegue fabricar um, nem trocar o nome dentro de um token existente:
token = Trilha.token("Alice")
%{
"o token" => token,
"conferido" => Phoenix.Token.verify(HelloWeb.Endpoint, "user socket", token, max_age: 1_209_600),
"adulterado" => Phoenix.Token.verify(HelloWeb.Endpoint, "user socket", token <> "x", max_age: 1_209_600)
}
Trilha.token/1 faz o mesmo Phoenix.Token.sign(HelloWeb.Endpoint, "user socket", nome) do plug. O "user socket" é o salt: um token assinado para um fim não serve para outro.
O connect/3 do guia de Channels:
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
Um detalhe de nomes: o guia de Channels guarda a identidade em :current_user, mas o guia de Presence diz para ler socket.assigns.user_id. Os dois têm de combinar. Este notebook usa :user_id, o nome do guia de Presence.
Sua vez: a identidade vem do token
Na célula abaixo:
- no
UserSocket, troque oconnect/3pelo do guia, guardando a identidade em:user_id; - cole o seu
RoomChannellogo depois doUserSockete troquesocket.assigns.nameporsocket.assigns.user_idnotracke noupdate. Ojoin/3deixa de precisar do"name":def join("room:lobby", _params, socket), sem oassign.
# Sua vez (seguro): lib/hello_web/channels/user_socket.ex e room_channel.ex
defmodule HelloWeb.UserSocket do
use Phoenix.Socket
channel "room:lobby", HelloWeb.RoomChannel
@impl true
def connect(_params, socket, _connect_info) do
{:ok, socket}
end
@impl true
def id(_socket), do: nil
end
# Cole aqui o seu RoomChannel, usando socket.assigns.user_id.
Trilha.solucao(:seguro)
Trilha.conferir(:seguro)
Uma conexão sem token agora é recusada antes de chegar a qualquer canal. O servidor responde ao pedido de WebSocket com um status de erro, em vez de trocar de protocolo:
Trilha.conectar()
A resposta é {:erro, "HTTP/1.1 403 Forbidden"}. (Sem o exercício feito, a conexão é aceita e a célula devolve {:ok, ...}.) Com um token, a conexão entra, e o nome que o cliente manda no join não vale mais nada:
ana = Trilha.entrar!("room:lobby", %{"name" => "admin"}, token: Trilha.token("Ana"))
{ana, mensagens} = Trilha.receber_todas(ana)
Trilha.fechar(ana)
Trilha.json(Enum.map(mensagens, &Tuple.to_list/1))
A presença aparece como Ana, a identidade do token. No navegador, http://localhost:4010/?name=Alice agora não mostra lista nenhuma: a página não tem token, e o console do navegador mostra a conexão recusada. Passe antes por http://localhost:4010/entrar/Alice, o login de mentira, que guarda o nome na sessão e volta para a página inicial; a partir daí o root layout entrega um token em window.userToken e a lista aparece. O ?name= do endereço deixou de ter efeito.
No navegador, o token não vai na URL do WebSocket. O phoenix.js o manda no cabeçalho Sec-WebSocket-Protocol do pedido de conexão, e o Trilha.conectar/1 faz o mesmo. Assim ele não fica em logs de acesso.
Uso com LiveView
Na documentação: Usage With LiveView
O Phoenix traz a API JavaScript para presenças, mas também dá para estender o módulo HelloWeb.Presence para trabalhar com LiveView, sem JavaScript próprio.
Trilha.pergunta(:memoria)
Cada LiveView é um processo com estado. Se a lista de presenças ficasse nos assigns, cada LiveView teria na memória a lista inteira de quem está online. O guia propõe outra divisão: a lista fica só no processo do Presence, que avisa as LiveViews de cada entrada e saída, e cada LiveView usa um stream para atualizar a página sem guardar os itens.
Para isso o módulo ganha três callbacks opcionais e três funções:
init/1devolve o estado inicial que o Presence guarda para os callbacks. Ele é obrigatório quando existehandle_metas/4;fetch/2recebe as presenças de um tópico e pode mudar o formato delas. O do guia acrescentaideusera cada uma. É o lugar para buscar dados do banco de uma vez só, para todas as presenças;handle_metas/4é chamado a cada mudança, com quem entrou e quem saiu. O do guia publica uma mensagem{:join, ...}ou{:leave, ...}para cada pessoa, num tópico próprio,"proxy:" <> topic, comlocal_broadcast(só para os processos deste nó; os outros nós fazem o mesmo para os seus). O tópico separado existe para a LiveView não receber ospresence_diffcrus, só as mensagens já preparadas;list_online_users/0,track_user/2esubscribe/0escondem esses detalhes de quem usa o módulo.
lib/hello_web/channels/presence.ex: o módulo com o código do guia.
defmodule HelloWeb.Presence do
use Phoenix.Presence,
otp_app: :hello,
pubsub_server: Hello.PubSub
def init(_opts) do
{:ok, %{}}
end
def fetch(_topic, presences) do
for {key, %{metas: [meta | metas]}} <- presences, into: %{} do
# user can be populated here from the database here we populate
# the name for demonstration purposes
{key, %{metas: [meta | metas], id: meta.id, user: %{name: meta.id}}}
end
end
def handle_metas(topic, %{joins: joins, leaves: leaves}, presences, state) do
for {user_id, presence} <- joins do
user_data = %{id: user_id, user: presence.user, metas: Map.fetch!(presences, user_id)}
msg = {__MODULE__, {:join, user_data}}
Phoenix.PubSub.local_broadcast(Hello.PubSub, "proxy:#{topic}", msg)
end
for {user_id, presence} <- leaves do
metas =
case Map.fetch(presences, user_id) do
{:ok, presence_metas} -> presence_metas
:error -> []
end
user_data = %{id: user_id, user: presence.user, metas: metas}
msg = {__MODULE__, {:leave, user_data}}
Phoenix.PubSub.local_broadcast(Hello.PubSub, "proxy:#{topic}", msg)
end
{:ok, state}
end
def list_online_users(), do: list("online_users") |> Enum.map(fn {_id, presence} -> presence end)
def track_user(name, params), do: track(self(), "online_users", name, params)
def subscribe(), do: Phoenix.PubSub.subscribe(Hello.PubSub, "proxy:online_users")
end
O processo do Presence decide, ao iniciar, se o módulo tem handle_metas/4. Por isso não basta redefinir o módulo: é preciso reiniciar o processo. No projeto, isso é reiniciar o servidor; aqui, Trilha.reiniciar_presence/0 termina o processo e o inicia de novo. As presenças registradas se perdem, o que neste momento não é problema.
Trilha.reiniciar_presence()
Trilha.pergunta(:fetch)
Erro como conteúdo: o fetch/2 do guia e o canal
O fetch/2 do guia lê meta.id. A LiveView que vem a seguir registra as presenças com um :id. O RoomChannel da primeira metade do guia não: o meta dele só tem online_at. Veja o que acontece com o canal agora:
ana = Trilha.entrar!("room:lobby", %{"name" => "Ana"}, token: Trilha.token("Ana"))
{ana, mensagens} = Trilha.receber_todas(ana, 500)
Trilha.fechar(ana)
mensagens
A lista de mensagens termina num phx_error com channel_crash, e o log acima da resposta mostra um KeyError: key :id not found in: %{online_at: ..., phx_ref: ...}, dentro de HelloWeb.Presence.fetch/2. O Presence.list/1 do handle_info(:after_join, ...) passou pelo fetch/2 novo, que não encontrou :id no meta do canal, e o processo do canal caiu.
O log mostra ainda um segundo erro, um FunctionClauseError em Phoenix.Presence.handle_info/2. O fetch/2 também roda dentro do próprio Presence, numa tarefa que prepara cada diff; quando ela cai, o processo que guarda as presenças cai junto, e o supervisor o inicia de novo, vazio. Um callback quebrado no módulo de presença derruba as presenças de todos os tópicos, não só do que ele não sabe tratar.
Sua vez: um fetch/2 que serve para todos os tópicos
Na célula abaixo, uma cópia do módulo, mude o fetch/2 para usar o :id do meta quando ele existir e, quando não existir, a chave da presença (key). Map.get(meta, :id, key) faz isso.
# Sua vez (fetch): lib/hello_web/channels/presence.ex
defmodule HelloWeb.Presence do
use Phoenix.Presence,
otp_app: :hello,
pubsub_server: Hello.PubSub
def init(_opts) do
{:ok, %{}}
end
def fetch(_topic, presences) do
for {key, %{metas: [meta | metas]}} <- presences, into: %{} do
# user can be populated here from the database here we populate
# the name for demonstration purposes
{key, %{metas: [meta | metas], id: meta.id, user: %{name: meta.id}}}
end
end
def handle_metas(topic, %{joins: joins, leaves: leaves}, presences, state) do
for {user_id, presence} <- joins do
user_data = %{id: user_id, user: presence.user, metas: Map.fetch!(presences, user_id)}
msg = {__MODULE__, {:join, user_data}}
Phoenix.PubSub.local_broadcast(Hello.PubSub, "proxy:#{topic}", msg)
end
for {user_id, presence} <- leaves do
metas =
case Map.fetch(presences, user_id) do
{:ok, presence_metas} -> presence_metas
:error -> []
end
user_data = %{id: user_id, user: presence.user, metas: metas}
msg = {__MODULE__, {:leave, user_data}}
Phoenix.PubSub.local_broadcast(Hello.PubSub, "proxy:#{topic}", msg)
end
{:ok, state}
end
def list_online_users(), do: list("online_users") |> Enum.map(fn {_id, presence} -> presence end)
def track_user(name, params), do: track(self(), "online_users", name, params)
def subscribe(), do: Phoenix.PubSub.subscribe(Hello.PubSub, "proxy:online_users")
end
O fetch/2 roda a cada chamada, então a mudança vale assim que a célula é avaliada. A célula abaixo reinicia o Presence mesmo assim, para limpar o que o erro deixou pelo caminho:
Trilha.reiniciar_presence()
Trilha.solucao(:fetch)
Trilha.conferir(:fetch)
A LiveView de quem está online
lib/hello_web/live/online/index.ex: a LiveView do guia. A rota live "/online/:name", OnlineLive, :index já está no router.
# lib/hello_web/live/online/index.ex
defmodule HelloWeb.OnlineLive do
use HelloWeb, :live_view
def mount(params, _session, socket) do
socket = stream(socket, :presences, [])
socket =
if connected?(socket) do
HelloWeb.Presence.track_user(params["name"], %{id: params["name"]})
HelloWeb.Presence.subscribe()
stream(socket, :presences, HelloWeb.Presence.list_online_users())
else
socket
end
{:ok, socket}
end
def render(assigns) do
~H"""
<ul id="online_users" phx-update="stream">
<li :for={{dom_id, %{id: id, metas: metas}} <- @streams.presences} id={dom_id}>{id} ({length(metas)})</li>
</ul>
"""
end
def handle_info({HelloWeb.Presence, {:join, presence}}, socket) do
{:noreply, stream_insert(socket, :presences, presence)}
end
def handle_info({HelloWeb.Presence, {:leave, presence}}, socket) do
if presence.metas == [] do
{:noreply, stream_delete(socket, :presences, presence)}
else
{:noreply, stream_insert(socket, :presences, presence)}
end
end
end
O mount/3 roda duas vezes, como toda LiveView. Na renderização estática, o stream começa vazio e nada é registrado. Na conectada, a LiveView se registra no tópico online_users com track_user/2 (o processo registrado é o da própria LiveView, self()), assina as mensagens preparadas pelo handle_metas/4 e preenche o stream com quem já está online. Depois disso, cada {:join, ...} vira um stream_insert e cada {:leave, ...} vira um stream_delete, ou outro stream_insert se a pessoa ainda tem outra aba aberta.
Alice abre a página. A resposta do join traz a primeira renderização conectada:
{alice, entrada} = Trilha.abrir_lv("/online/Alice")
Trilha.json(entrada)
A resposta tem o formato que o notebook 17 mostrou para streams. A chave "stream" traz o nome interno do stream e a lista de inserções: "presences-Alice" é o id do elemento no DOM, que o stream monta com o id de cada item. Em "k" vem o conteúdo de cada <li>: o nome e o número de metas, 1. Os itens não ficam no processo da LiveView, só no navegador.
Bob abre a página dele. Alice não clicou em nada, mas recebe um diff:
{bob, _} = Trilha.abrir_lv("/online/Bob")
{alice, mensagens} = Trilha.receber_todas(alice)
Trilha.json(Enum.map(mensagens, &Tuple.to_list/1))
Dois diffs. O segundo é o esperado: presences-Bob entra no stream, com 1 conexão. O primeiro reinsere presences-Alice: é o aviso da entrada da própria Alice, que o handle_metas/4 publicou depois que ela já tinha assinado o tópico proxy:online_users. Inserir de novo um item com o mesmo id no DOM só atualiza o elemento que já existe, então a página não muda.
Bob fecha a aba:
Trilha.fechar(bob)
{alice, mensagens} = Trilha.receber_todas(alice, 600)
Trilha.fechar(alice)
Trilha.json(Enum.map(mensagens, &Tuple.to_list/1))
O stream traz presences-Bob na lista de remoções (o terceiro item de "stream"). Foi o stream_delete do handle_info/2 do :leave: Bob não tinha outra aba aberta, então os metas dele chegaram vazios.
No navegador: abra http://localhost:4010/online/Alice numa aba e http://localhost:4010/online/Bob em outra. Abrir e fechar abas atualiza a lista e o número de conexões de cada pessoa, em tempo real, sem uma linha de JavaScript na página.
Aqui o nome volta a vir da URL, como no começo do guia. Numa aplicação de verdade ele viria do current_scope da LiveView, a identidade conferida pelo servidor (notebooks 13 e 14).
Sua vez: quantos estão online
Na célula da OnlineLive, acrescente um contador acima da lista:
- no
render/1, antes do<ul>:<p id="total">{@total} online</p>; - um assign
:totalcom o número de pessoas online,length(HelloWeb.Presence.list_online_users()). Ele precisa existir já nomount/3, nas duas renderizações, e ser recalculado quando alguém entra e quando alguém sai de vez.
Esse assign é só um número. A lista continua fora da LiveView.
Trilha.solucao(:total)
Trilha.conferir(:total)
Sem roteiro: quem está olhando esta peça
No Brechó, cada peça é única. Saber que mais duas pessoas estão olhando a mesma jaqueta muda a pressa de quem está pensando em comprar. O desafio é mostrar, na página de cada peça, quantas pessoas a estão vendo agora.
O Brechó até aqui
O contexto Hello.Vitrine do notebook 08: a tabela produtos, o schema e o contexto.
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, só com o que este desafio usa:
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.
"""
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
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 a LiveView HelloWeb.PecaLive. A rota live "/pecas/:id", PecaLive já está no router.
- A página mostra o nome da peça (
Hello.Vitrine.buscar_produto!/1) e um elemento<p id="olhando">{@olhando} olhando esta peça agora</p>. O número fica sozinho na interpolação. - Cada LiveView conectada se registra no Presence, no tópico
"peca:ID"da peça ("peca:1","peca:2"...), comPresence.track(self(), topico, socket.id, %{}). A chave é osocket.id, único por conexão: aqui interessa contar telas abertas, não pessoas. @olhandoé o número de presenças no tópico,map_size(Presence.list(topico)).- Quando alguém abre ou fecha a página da mesma peça, o número muda na tela de todos. O
trackcom um pid faz o broadcast dopresence_diffno tópico, pelo PubSub. A LiveView recebe esse broadcast se assinar o tópico comPhoenix.PubSub.subscribe(Hello.PubSub, topico); ele chega aohandle_info/2como%Phoenix.Socket.Broadcast{event: "presence_diff"}. Assine antes de registrar, para receber também o diff da própria entrada. - Quem olha outra peça não entra na conta.
Use <Layouts.app flash={@flash}> em volta do conteúdo.
# Sem roteiro: lib/hello_web/live/peca_live.ex
Para experimentar: abra http://localhost:4010/pecas/1 em duas abas e http://localhost:4010/pecas/2 em outra. Ou, pelo notebook:
{eu, _entrada} = Trilha.abrir_lv("/pecas/1")
{outra, _} = Trilha.abrir_lv("/pecas/1")
{eu, mensagens} = Trilha.receber_todas(eu)
Trilha.fechar(eu)
Trilha.fechar(outra)
Trilha.json(Enum.map(mensagens, &Tuple.to_list/1))
Com o desafio resolvido, a primeira tela recebe um diff pequeno quando a segunda abre, com o "2" lá no fundo da árvore de dinâmicos: só o número mudou.
Sem a PecaLive, a célula falha com UndefinedFunctionError: a rota aponta para um módulo que ainda não existe.
Trilha.solucao(:brecho)
Trilha.conferir(:brecho)
No seu projeto
- Num projeto gerado por
mix phx.new, rodemix phx.gen.socket Useremix phx.gen.channel Room, e siga o que os geradores imprimem: a linhasocket "/socket", HelloWeb.UserSocket, ...no endpoint, oimport "./user_socket.js"noassets/js/app.jse a linhachannel "room:lobby", HelloWeb.RoomChannelnoUserSocket. - Rode
mix phx.gen.presencee acrescenteHelloWeb.Presenceà listachildrendelib/hello/application.ex, depois doPhoenix.PubSub. - Troque o
RoomChannelpelo do guia e ponha o JavaScript do guia noassets/js/app.js. Rodemix phx.servere abra http://localhost:4000/?name=Alice em duas abas e http://localhost:4000/?name=Bob em outra. - Para a versão segura, siga os cinco passos de Using Token Authentication. Com o
mix phx.gen.auth(notebook 13), a identidade para assinar o token está emconn.assigns.current_scope.user.id. - Para a versão com LiveView, acrescente os callbacks ao módulo de presença, crie
lib/hello_web/live/online/index.exe a rotalive "/online/:name", OnlineLive, :index. Se o canal e a LiveView usarem o mesmo módulo de presença, lembre que ofetch/2vale para todos os tópicos.
Duas diferenças entre o notebook e o projeto. No projeto, o JavaScript do guia é compilado pelo esbuild a partir de assets/js/app.js, com import {Socket, Presence} from "phoenix"; aqui ele é um <script> na página e usa a global Phoenix. E, no projeto, mudar o módulo de presença pede reiniciar o servidor; aqui, Trilha.reiniciar_presence/0.
Num cluster com vários nós, nada no código muda: basta os nós estarem conectados (a biblioteca dns_cluster do projeto gerado cuida disso em produção) para as presenças de todos aparecerem em todos.
Revisão
- O Presence registra processos num tópico, sob uma chave, com metas. Não tem servidor central nem banco: cada nó guarda a sua parte e troca diferenças com os outros.
- O módulo vem de
mix phx.gen.presencee precisa estar na árvore de supervisão, depois do PubSub. Presence.track/3num canal registra o processo do canal e faz o broadcast de umpresence_diff. Quem acaba de entrar recebe a lista inteira numpresence_state, mandado pelo próprio canal depois do join.- A mesma chave em várias conexões é uma presença com vários metas. Quando o processo termina, a presença some sozinha.
Presence.update/3troca um meta: o antigo sai e o novo entra no mesmo diff, ligados porphx_ref_prev.- O nome que o cliente manda não é identidade. A identidade vem de um token assinado, conferido no
connect/3do socket. - Com LiveView, a lista fica no Presence;
handle_metas/4avisa as LiveViews, que usam streams.fetch/2vale para todos os tópicos do módulo.
No próximo notebook, o protocolo que essas conexões usam, visto de perto: o guia "Writing a Channels Client".