WebSockets
Two separate WebSocket connections power Speechwave, and they don’t know about each other. This chapter goes one level deeper than the Overview table into how each one is set up and gated.
The LiveView socket (/live)
This one powers the attendee’s page and is managed entirely by Phoenix LiveView. It handles phx-click events going up, push_event going down, and the diff-based page sync that makes LiveView feel instant without a page reload.
socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]
The Channel socket (/socket)
This is a bare Phoenix Channel socket, built for the Chrome extension, which isn’t a web page and can’t use LiveView.
defmodule SpeechwaveWeb.UserSocket do
use Phoenix.Socket
channel "reactions:*", SpeechwaveWeb.ReactionChannel
def connect(_params, socket, _info), do: {:ok, socket}
def id(_socket), do: nil
end
The "reactions:*" pattern means the extension can attempt to join any reactions:<slug> topic at the socket level, but joining is gated much more strictly by the channel itself.
def join("reactions:" <> slug, %{"api_key" => api_key}, socket) do
with {:talk, %Talks.Talk{} = talk} <- {:talk, Talks.get_talk_by_slug(slug)},
{:user, %Accounts.User{} = user} <- {:user, Accounts.get_user_by_api_key(api_key)},
{:owner, true} <- {:owner, talk.user_id == user.id},
{:capacity, :ok} <-
{:capacity, Plans.check(:max_participants, user.plan,
Presence.list("reactions:#{slug}") |> map_size())} do
Phoenix.PubSub.subscribe(Speechwave.PubSub, "user:#{user.id}:disconnect")
send(self(), :after_join)
{:ok, join_payload(user), assign(socket, talk: talk, user: user)}
else
{:talk, nil} -> {:error, %{reason: "not_found"}}
{:user, nil} -> {:error, %{reason: "unauthorized"}}
{:owner, false} -> {:error, %{reason: "unauthorized"}}
{:capacity, {:error, :limit_reached}} -> {:error, %{reason: "capacity_reached"}}
end
end
Four checks run in order, and each one has a specific failure reason:
| Reason | What it means |
|---|---|
| not_found | The slug doesn't match any talk |
| unauthorized | The API key doesn't resolve, or resolves to a user who doesn't own this talk |
| capacity_reached | The owner's plan-based participant limit has been reached, tracked live via Presence. See Plans and limits for how limits work. |
A successful join also subscribes the channel to a per-user disconnect topic. If the owner logs out or regenerates their API key somewhere else, that topic gets a broadcast and the channel force-disconnects, pushing the extension to reconnect with a fresh key.
join_payload/1 sends back the presenter’s overlay size and fireworks settings, plus a batch of tuning constants the extension uses to size and animate the overlay. See Chrome extension for what happens to that payload after it lands.
check_origin: false is set specifically on this socket so a chrome-extension:// origin isn’t rejected, something the LiveView socket doesn’t need since browsers hit it directly.
socket "/socket", SpeechwaveWeb.UserSocket, websocket: [check_origin: false]
Why PubSub connects them
Endpoint.broadcast!/3 doesn’t care whether a subscriber is a LiveView process or a Channel process, it just delivers to everyone on the topic. That’s the whole trick behind Emoji journey: TalkLive.handle_event never needs to know the extension exists at all.
Rate limiting
defmodule Speechwave.RateLimiter do
use GenServer
@table :rate_limiter
def allow?(session_id) do
now = System.monotonic_time(:millisecond)
case :ets.lookup(@table, session_id) do
[{^session_id, last_at}] when now - last_at < @cooldown_ms -> false
_ -> :ets.insert(@table, {session_id, now}); true
end
end
end
Each browser tab gets its own bucket, keyed by its connection id. Taps inside a short cooldown window are silently dropped; the button’s client-side disabled state and countdown are the only UX signal, but the real enforcement happens here, server-side.
The ETS table is public and readable concurrently, so any process calls allow?/1 directly instead of going through the GenServer's mailbox. That avoids a bottleneck when a room full of people are tapping at once. The GenServer's only job is owning the table's lifetime.
The join-time capacity check above is backed by Presence.track/3, called once a join succeeds. Presence is what makes Presence.list("reactions:#{slug}") |> map_size() an accurate live headcount. See Plans and limits for how that count gets compared against a plan's limit.
There’s also a client-side cooldown in the emoji buttons themselves, a disabled state plus a visible countdown, but that’s UX polish. The ETS check above is the actual enforcement.