lily/transport

The transport module handles client-server communication and serialisation. It’s target-agnostic and works on both Erlang and JavaScript.

This module provides the wire format (Protocol envelope types for client-server messages), serialisation (Serialiser encode/decode functions), automatic serialisation (automatic zero-config codec, recommended), custom serialisation (custom explicit encode/decode for special cases), and transport abstraction (Connector and Transport for swapping between WebSocket, HTTP, or custom transports).

For most apps, use transport.automatic for zero-configuration serialisation of any Gleam custom type:

import lily/transport

client.connect(
  runtime,
  with: connector,
  serialiser: transport.automatic(),
)

server.start(store: app_store, serialiser: transport.automatic())

The automatic serialiser uses positional encoding with the wire format {"_":"ConstructorName","0":field0,"1":field1,...}. On JavaScript, constructors are discovered automatically from message sends and the initial model. For server-only message types that never get sent by the client, use transport.register.

For cases where automatic serialisation isn’t suitable (third-party APIs, human-readable JSON, backwards compatibility), use transport.custom with explicit encode/decode functions.

Types

A connector is any function that, given Lily’s Handler callbacks, returns a Transport. Users provide a connector to client.connect to establish the server connection using their chosen transport (WebSocket, HTTP, etc.).

pub type Connector =
  fn(Handler) -> Transport

Callbacks the runtime provides to the transport. The transport calls on_receive when a message arrives from the server, on_reconnect when the connection is established or restored, and on_disconnect when the connection is lost.

pub type Handler {
  Handler(
    on_receive: fn(String) -> Nil,
    on_reconnect: fn() -> Nil,
    on_disconnect: fn() -> Nil,
  )
}

Constructors

  • Handler(
      on_receive: fn(String) -> Nil,
      on_reconnect: fn() -> Nil,
      on_disconnect: fn() -> Nil,
    )

Lily’s Protocol takes the sequence of messages taken into account when receiving updates to ensure proper syncing between stores and updating their sequence numbers. Sequence numbers are assigned by the server.

pub type Protocol(model, message) {
  Acknowledge(sequence: Int)
  ClientMessage(payload: message)
  Resync(after_sequence: Int)
  ServerMessage(sequence: Int, payload: message)
  Snapshot(sequence: Int, state: model)
}

Constructors

  • Acknowledge(sequence: Int)

    Acknowledge is sent by the server on the reception of a ClientMessage and after it assigns a sequence number to the received message.

  • ClientMessage(payload: message)

    ClientMessage carries any updates made by the client.

  • Resync(after_sequence: Int)

    Resync is used by the client to request the current model within the the server store after a full reconnect. The after_sequence number attached allows the server to know the last synced sequence state.

  • ServerMessage(sequence: Int, payload: message)

    ServerMessage carries any updates from the server alongside a sequence number.

  • Snapshot(sequence: Int, state: model)

    Snapshot is sent by the server on the reception of a Resync request by the client.

The protocol currently uses JSON serialisation for debugging clarity. Both message and model encoders/decoders should be provided.

pub type Serialiser(model, message) {
  Serialiser(
    encode_message: fn(message) -> json.Json,
    decode_message: decode.Decoder(message),
    encode_model: fn(model) -> json.Json,
    decode_model: decode.Decoder(model),
  )
}

Constructors

This is transport handle returned by a connector. Provides send to transmit messages and close to terminate the connection.

pub opaque type Transport

Values

pub fn automatic() -> Serialiser(model, message)

Create an automatic serialiser. Encodes and decodes any Gleam custom type using positional fields. Works across both targets.

On JavaScript, constructors are discovered automatically:

  • Model types: walked recursively from the initial model at start time
  • Message types: cached on first send

For message types that only arrive from the server (never sent by this client), call transport.register before connecting.

Example

// Zero configuration for most apps:
client.connect(runtime, with: connector, serialiser: transport.automatic())
server.start(store: app_store, serialiser: transport.automatic())
pub fn close(transport: Transport) -> Nil

Close the transport connection. After calling this, the transport should clean up resources and stop attempting to reconnect.

pub fn custom(
  encode_message encode_message: fn(message) -> json.Json,
  decode_message decode_message: decode.Decoder(message),
  encode_model encode_model: fn(model) -> json.Json,
  decode_model decode_model: decode.Decoder(model),
) -> Serialiser(model, message)

Create a serialiser from explicit encode/decode functions for cases where the auto format is not suitable (third-party APIs, human-readable JSON, backwards compatibility).

pub fn decode(
  text: String,
  serialiser serialiser: Serialiser(model, message),
) -> Result(Protocol(model, message), Nil)

Decode a String into a Protocol result. Expects the String to be in a JSON format.

pub fn encode(
  protocol: Protocol(model, message),
  serialiser serialiser: Serialiser(model, message),
) -> String

Encodes a Protocol into a JSON String.

pub fn new(
  send send: fn(String) -> Nil,
  close close: fn() -> Nil,
) -> Transport

Create a new Transport with the given send and close functions. This is used by transport implementations (WebSocket, HTTP) to construct the Transport handle they return from their connector.

pub fn register(constructors: List(anything)) -> Nil

Register constructors for the auto-serialiser’s decoder. Only needed on JavaScript for types that arrive from the server but are never sent by the client and don’t appear in the initial model.

Call before client.connect. Field values are placeholders — only the constructor shape is extracted.

No-op on Erlang (constructors are self-describing).

Example

transport.register([AdminKick(""), ServerAnnouncement("")])
pub fn send(transport: Transport, text: String) -> Nil

Send a message through the transport. The text should be a serialised Protocol message (JSON string).

Search Document