Imported from tylerbutler/aquamarine (
AGENTS.md). Install upstream withnpx skills add tylerbutler/aquamarine. Copyright stays with the author.
Repository instructions for Agents
Aquamarine is a Gleam library for an Erlang-targeted, protocol-agnostic Beryl-style WebSocket channel client. The public facade is src/aquamarine.gleam; most implementation work lives under src/aquamarine/.
Commands
- Download dependencies:
gleam deps downloadorjust deps - Build:
gleam buildorjust build - Strict build:
gleam build --warnings-as-errorsorjust build-strict - Test all:
gleam testorjust test - Test one file:
gleam test -- test/codec_test.gleam - Format:
gleam format src testorjust format - Check formatting:
gleam format --check src testorjust format-check - Type check:
gleam checkorjust check - Full PR/CI check:
just ci(format-check,check,test,build-strict) - Build docs:
gleam docs buildorjust docs
CI currently runs on OTP 28 and Gleam 1.18.1, then executes gleam deps download, gleam test, and gleam format --check src test.
Architecture
src/aquamarine.gleamis intentionally a thin public facade for the one-topic case, re-exportingconnect,push,push_and_await_reply,join_reply,receive,leave, andclose. Multi-topic callers useaquamarine/socketandaquamarine/channeldirectly.src/aquamarine/socket.gleamis the socket actor and public multi-topic API: one connection, many topics. It owns the transport, the ref counter, the codec, the heartbeat, and a routing tableDict(topic, Subject(Event)). Every inbound frame arrives in its mailbox, is decoded, and is routed — to a caller blocked on a specific ref, or to the channel joined to that frame's topic. Errors travel in-band on the channel's subject asResult(Incoming, AquamarineError). Outbound sends are fire-and-forget; a failed send marks the socket gone rather than reporting synchronously.- The codec belongs to the socket, not the channel — the socket must decode every frame to read its topic before it can route, so one socket serves one wire protocol. Frames for a topic nobody joined are dropped with a debug log, never a crash; heartbeat replies arrive on the reserved heartbeat topic and fall out that way, so there is no heartbeat special case in the routing path.
- Close and error events are scoped to their topic: they terminate that channel only, leaving the socket and every other channel alive.
socket.supervisedreturns asupervision.ChildSpecification(Socket)for an OTP tree. Supervised sockets are named (socket.new_name/socket.named), because a restarted socket is a different process and the name is the only handle that survives. The restart isTransient, so a deliberateclose— which exits normally — is not second-guessed by the supervisor. A restarted socket has no joined channels and every priorChannelhandle is stale; restart is not rejoin.- Refs are minted inside the actor, in the same message handler that sends the frame carrying them, so ref order and send order cannot diverge. Actor messages are semantic (
Join,Push,Heartbeat), not pre-encoded strings — encoding needs a ref, and the ref lives here. - Reply correlation is a
Dict(ref, Waiter)in actor state. Matching goes throughcodec.matches_reply, never a directincoming.refcomparison, so refless protocols keep working; a codec that can never match simply lets the caller's timeout take over. A caller that times out sends a cancel so the table cannot grow without bound, and losing the socket fails every waiter. src/aquamarine/channel.gleamis a handle onto one joined topic: socket, topic, join ref, events subject, and whether it owns the socket.channel.connectis the one-call path and owns its socket, socloseon it closes the connection;channel.joinon an existing socket does not, socloseon those is leave-only.leaveis always leave-only. The socket never auto-closes when the last channel leaves.- A join registers its route when the join frame is sent, not when it is accepted, so a server push arriving before the reply still has somewhere to go. The route is withdrawn if the join is rejected, abandoned, or never sent.
src/aquamarine/codec.gleamdefines the protocol abstraction.Codecsupplies decode/encode functions plus protocol event names, so channel logic is not Phoenix-specific.src/aquamarine/phoenix.gleamadaptsroost/frameto Aquamarine'sCodecshape. Phoenix compatibility should generally be implemented here rather than insidechannel.gleam.- The heartbeat is a timed self-message inside the socket actor (
process.send_after(self, interval, Heartbeat)), not a separate process. The actor cancels the pending tick when it stops, so no heartbeat frame outlives a close. src/aquamarine/backoff.gleamis the reconnect schedule: capped exponential growth with a replaceable jitter function.delay_msis a pure function of the attempt number, which is what lets tests assert the schedule without sleeping.- An unexpected disconnect moves the socket to a retrying state rather than stopping it. On reconnect every remembered topic is rejoined with its original payload and a fresh join ref;
Channelhandles stay valid because the actor is the same process. A deliberateclose, and aleave, never reconnect —leaveforgets the topic so a later reconnect does not resurrect it. - Reconnect semantics that are decisions, not accidents: refs keep counting across a reconnect (so a stale in-flight reply cannot correlate against a fresh one); pending replies are failed at the disconnect and never re-correlated; nothing is buffered while disconnected —
push_and_await_replyreturnsDisconnectedand fire-and-forgetpushis dropped with a debug log; a refused rejoin ends that channel withRejoinRejectedand leaves the socket alone. socket.watchdeliversStatusevents on a separate subject. They are deliberately not in the channel event stream, sochannel.receivekeeps meaning "the next thing that happened on my topic" — a quiet channel and a reconnecting socket both simply time out there.src/aquamarine/error.gleamis the public typed error surface. Public operations returnResult(_, AquamarineError); transport failures are wrapped withTransportand classified into a handful of variants a caller can branch on, keeping the transport's own name as a string for the rest.src/aquamarine/transport.gleamis the internal seam over Collie. Outboundsend_textis fire-and-forget and returnsNil— a send that fails takes the connection down and arrives on the sink asClosed.closedoes return aResult, because it happens once and the caller has somewhere to put the answer. Collie'sConnectionhandle only exists inside its own handler, so everything outbound goes in as a user message.
Project conventions
- The package targets Erlang (
target = "erlang"ingleam.toml); avoid introducing JavaScript-target-only APIs. - Preserve the codec boundary: protocol-specific frame formats and event names belong in codec adapters, while channel lifecycle and WebSocket behavior belong in
aquamarine/channel. - Keep
Channel,socket.Socket, andsocket.Messageopaque so callers cannot construct or depend on internal actor details. connectmust clean up partially started resources on every failure path — a failed join closes the socket it opened.- Only the process that called
connectorjoinshould callreceive— a subject can only be received from by the process that created it, and those are what create the events subject. Everything else is safe from other processes because it is a message to the socket actor. receivetakes an explicit timeout and sees only its own topic's frames. Binary frames, and frames for unjoined topics (including heartbeat replies), never reach it; protocol close/error events for its topic becomeError(ChannelClosed).- Tests use gleeunit. The suite entrypoint is
test/aquamarine_test.gleam, and every test is a public zero-argument function whose name ends in_testinside a*_testmodule. - Codec tests compare against
phoenix_channel_fixtures; integration tests stand up a supervised Beryl instance (beryl/supervisor) behind a Mist listener on an ephemeral port (mist.port(0)plusmist.after_start). - Prefer the
assertkeyword for assertions (assert actual == expected), matching the existing tests; avoid the deprecatedgleeunit/shouldmodule.