pre-0.1 · API unstable

Phoenix channel
wire frames,
nothing more.

roost is a pure Gleam package that encodes and decodes the Phoenix channel wire protocol. It owns no sockets, no channel processes, no reconnect loops — just the frames. You bring the transport; roost handles the protocol.

gleam add roost
View source

The roost logo: an orange bird settling into a woven slate nest.

// the boundary is the feature

roost does one thing.
It does it completely.

Most of evaluating a small library is finding out where it stops. So here's the line, drawn on purpose — what roost owns, and what stays yours.

roost handles 5 things
  • Encode outbound frames Build the Phoenix wire array [join_ref, ref, topic, event, payload] from typed Gleam values.
  • Decode inbound frames Parse raw socket text back into a typed Incoming value, with explicit DecodeErrors.
  • Reserved-event constants phx_join, phx_leave, phx_reply, phx_error, phx_close, heartbeat.
  • Heartbeat & reply helpers Construct heartbeat frames on the "phoenix" topic and well-formed reply frames.
  • Reply statuses & error types Typed reply statuses and decode errors instead of stringly-typed guesswork.
you bring your runtime
  • Opening & closing sockets roost never touches a WebSocket. Use Mist, Gleam JS, or any transport.
  • Channel processes & lifecycle Join / leave state machines and supervision live in your runtime.
  • Ref counters & join refs Refs are plain strings on the wire; the monotonic counter is yours.
  • Reconnects, rejoins & timeouts Backoff, retry, and rejoin policy are application decisions.
  • Heartbeat scheduling & Presence roost builds the frame; when to send it is up to you.

// show, don't tell

Typed both ways.

encode turns labelled Gleam arguments into a Phoenix wire string. decode turns a raw socket message into an Incoming value — or an explicit, typed error. No stringly-typed payloads, no surprises.

  • Labelled args mirror the wire array order
  • Errors are values: InvalidJson / InvalidFormat
  • Same shape compiles to Erlang and JavaScript
channel.gleam
import gleam/json
import gleam/option.{Some}
import roost
import roost/frame

/// Encode a phx_join frame for "room:lobby".
pub fn join() -> String {
  roost.encode(
    join_ref: Some("1"),
    ref: Some("1"),
    topic: "room:lobby",
    event: "phx_join",
    payload: json.object([#("name", json.string("alice"))]),
  )
  // => ["1","1","room:lobby","phx_join",{"name":"alice"}]
}

/// Decode whatever the socket hands you into a typed value.
pub fn handle(text: String) {
  case roost.decode(text) {
    Ok(frame.Incoming(topic:, event:, ..)) -> route(topic, event)
    Error(frame.InvalidJson(reason)) -> log(reason)
    Error(frame.InvalidFormat(reason)) -> log(reason)
  }
}

// the whole protocol, on one line

Five slots. That's the wire.

Every Phoenix channel frame is a five-element JSON array. roost encodes to it and decodes from it — so it helps to see exactly what each slot means. Hover or focus a slot to read its role.

  1. 0 join_ref "1" Option(String) Ties a message to a specific channel join. Null until the channel is joined.
  2. 1 ref "1" Option(String) Per-message reference used to match a reply back to its request.
  3. 2 topic "room:lobby" String The channel topic the frame belongs to, e.g. a room or stream.
  4. 3 event "phx_join" String Event name — a reserved system event or one of your own.
  5. 4 payload { … } json.Json The JSON body. Anything your application needs to send.

heartbeat frames are the same shape with topic "phoenix" and a null join_ref — roost ships a helper so you never hand-write one.

// three steps, no ceremony

Settle in.

  1. Add the package

    Pull roost into your Gleam project. No transitive runtime — it depends only on the standard library and gleam_json.

    gleam add roost
  2. Import & encode

    Build a frame with labelled arguments and hand the string to whatever socket you already use.

    import roost
    
    roost.encode(
      join_ref: Some("1"),
      ref: Some("1"),
      topic: "room:lobby",
      event: "phx_join",
      payload: payload,
    )
  3. Decode what comes back

    Turn inbound socket text into a typed Incoming value, and handle InvalidJson / InvalidFormat as ordinary results.

    case roost.decode(text) {
      Ok(incoming) -> handle(incoming)
      Error(err) -> log(err)
    }

Pre-0.1 and honest about it — the API can still move. Watch the repo to track it.

Read the source on GitHub