Codecs
Aquamarine's channel runtime does not know what a Phoenix frame looks
like. It delegates every encode/decode decision — and the names of the
protocol's special events — to a Codec
value supplied at connect time.
This is what makes the same client work against Phoenix Channels, Beryl, or any other server that speaks a compatible message-with-ref protocol.
The Codec record
Section titled “The Codec record”A codec bundles the functions Aquamarine needs to read and write frames, plus the strings the channel uses to recognise protocol-level events:
pub type Codec { Codec( decode: fn(String) -> Result(Incoming, DecodeError), encode_join: fn(String, String, json.Json) -> String, encode_push: fn(String, String, String, String, json.Json) -> String, encode_heartbeat: fn(String) -> String, matches_reply: fn(Incoming, String) -> Bool, reply_status: fn(Incoming) -> Result(Nil, String), join_event: String, leave_event: String, reply_event: String, close_event: String, error_event: String, heartbeat_topic: String, )}The decoded shape is also codec-defined:
pub type Incoming { Incoming( join_ref: Option(String), ref: Option(String), topic: String, event: String, payload: Dynamic, )}payload is a Dynamic so the channel runtime never has to know your
schema — decoders in your own code can turn it into typed records.
How the channel uses it
Section titled “How the channel uses it”Everything below happens inside the socket actor, which is the only thing that touches a codec.
- On a join, the socket calls
codec.encode_join(join_ref, topic, payload)and sends the resulting text, then waits for an inbound frame for whichcodec.matches_reply(incoming, join_ref)isTrue. It then callscodec.reply_status(incoming):Ok(Nil)joins,Error(reason)rejects. Ref-based protocols match onref; refless protocols (e.g. Socket.IO) can match on event name alone. - On a push, the socket calls
codec.encode_push(join_ref, ref, topic, event, payload). Aleaveis the same call withleave_eventand an empty payload. - The heartbeat calls
codec.encode_heartbeat(ref)on every tick. - On every inbound frame, the socket calls
codec.decode(text), usesmatches_replyto see whether it answers anyone waiting on a ref, and otherwise routes it byincoming.topic.close_eventanderror_eventterminate that topic's channel.
matches_reply is the whole of reply correlation. A codec that cannot
correlate simply never matches, and callers waiting on a reply fall back to
their timeout rather than hanging.
Because the socket must decode every frame to read its topic before it can route, the codec belongs to the socket, not to a channel — one connection speaks one wire protocol, matching Phoenix's one-serializer-per-socket model.
The bundled Phoenix codec
Section titled “The bundled Phoenix codec”aquamarine/phoenix.codec() returns a Codec wired up to the
Roost frame library, which
implements Phoenix's [join_ref, ref, topic, event, payload] JSON wire
format. See Phoenix and Beryl for usage.
Writing your own codec
Section titled “Writing your own codec”To support a different protocol, construct a Codec value with your own
encode/decode functions:
import aquamarine/codec.{Codec, Incoming, InvalidFormat}import gleam/jsonimport gleam/option
pub fn my_codec() -> codec.Codec { Codec( decode: my_decode, encode_join: my_encode_join, encode_push: my_encode_push, encode_heartbeat: my_encode_heartbeat, matches_reply: fn(in, join_ref) { in.event == "reply" && in.ref == option.Some(join_ref) }, reply_status: fn(_in) { Ok(Nil) }, join_event: "join", reply_event: "reply", close_event: "close", error_event: "error", heartbeat_topic: "_heartbeat", )}Then pass it to socket.connect(..., codec: my_codec()), or to
aquamarine.connect(..., codec: my_codec()) for the single-topic path. The
channel runtime will use it for every wire interaction; nothing in
aquamarine/channel needs to change.
Refless protocols
Section titled “Refless protocols”matches_reply lets the codec decide how a join reply is correlated, so
protocols without a ref work too. A Socket.IO-style codec can match the
join purely by event name:
matches_reply: fn(in, _join_ref) { in.event == "connect_document_success" },reply_status: fn(_in) { Ok(Nil) },Decode errors
Section titled “Decode errors”Your decode function returns Result(Incoming, DecodeError). Use the
two provided variants to classify failures:
InvalidJson(reason)— the text was not valid JSON.InvalidFormat(reason)— the JSON did not match the protocol's expected shape.
Both are wrapped by the channel as AquamarineError.DecodeFailed.