One socket, many topics
Phoenix multiplexes many topics over a single WebSocket, and so does Aquamarine. Open a socket once, join as many topics as you need, and they share one connection and one heartbeat.
Two ways in
Section titled “Two ways in”// One call, one topic. The channel owns its socket.channel.connect(scheme:, host:, port:, path:, topic:, payload:, codec:)
// A connection first, then topics on it. The channels do not own the socket.socket.connect(scheme:, host:, port:, path:, codec:)channel.join(socket:, topic:, payload:, timeout:)connect is the convenience for the single-topic case. Anything more, and
you want the socket.
import aquamarine/channelimport aquamarine/phoeniximport aquamarine/socketimport aquamarine/transportimport gleam/json
let assert Ok(sock) = socket.connect( scheme: transport.Ws, host: "localhost", port: 4000, path: "/socket/websocket", codec: phoenix.codec(), )
let assert Ok(lobby) = channel.join(sock, "room:lobby", json.object([]), 5000)let assert Ok(alerts) = channel.join(sock, "user:alerts", json.object([]), 5000)One connection, one heartbeat, two channels. Each channel receives only its own topic's frames.
Who closes the connection
Section titled “Who closes the connection”This is the part worth reading twice.
| Channel came from | leave | close |
|---|---|---|
channel.connect | Leaves the topic | Leaves the topic and closes the connection |
channel.join | Leaves the topic | Leaves the topic |
A channel from connect owns its socket, because connect opened that
socket on your behalf and nothing else is going to close it. A channel from
join does not, because you opened the socket and other channels may still
be using it.
leave always means leave, whichever way the channel arrived. Use it when
you want to be explicit.
To close a connection you opened yourself, close the socket:
let assert Ok(Nil) = socket.close(sock)That takes every channel on it with it.
The socket does not close itself
Section titled “The socket does not close itself”When the last channel leaves, the connection stays open. Refcount-driven
teardown would kill the connection during any transient zero-channel window
— a page that leaves one topic before joining the next would drop and re-open
its socket for no reason. Closing is socket.close, explicitly.
The heartbeat runs for the life of the socket regardless of how many channels are joined, including zero. This matches the Phoenix JS client: the heartbeat lives on the socket, not the channel.
Rules around joining
Section titled “Rules around joining”- Joining a topic you have already joined is an error,
AlreadyJoined(topic). Silently replacing the routing entry would orphan the first channel's subject with no error raised anywhere — the worst available outcome. Re-joining after aleaveis fine. - Joining the protocol's heartbeat topic is rejected,
ReservedTopic(topic). That topic is the socket's own.
Failures are scoped to their topic
Section titled “Failures are scoped to their topic”A phx_close or phx_error for one topic terminates that channel only.
Its receive returns Error(ChannelClosed); the socket and every other
channel carry on.
Frames for a topic nobody joined are dropped with a debug log, never a crash.
Heartbeat replies arrive on the reserved heartbeat topic, which never has a
channel, so they fall out that way — which is why they never reach your
receive.
Related
Section titled “Related”- Supervision — putting the socket in your tree.
- Reconnect — every joined topic is rejoined.