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)Acknowledgeis sent by the server on the reception of aClientMessageand after it assigns a sequence number to the received message. -
ClientMessage(payload: message)ClientMessagecarries any updates made by the client. -
Resync(after_sequence: Int)Resyncis used by the client to request the current model within the the server store after a full reconnect. Theafter_sequencenumber attached allows the server to know the last synced sequence state. -
ServerMessage(sequence: Int, payload: message)ServerMessagecarries any updates from the server alongside a sequence number. -
Snapshot(sequence: Int, state: model)Snapshotis sent by the server on the reception of aResyncrequest 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
-
Serialiser( encode_message: fn(message) -> json.Json, decode_message: decode.Decoder(message), encode_model: fn(model) -> json.Json, decode_model: decode.Decoder(model), )
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("")])