lily/transport
We need the Store that sits between client and
server to communicate somehow, and this transport module provides that.
It works on both Erlang and JS targets since both the client and server
need it.
The WebSocket and HTTP/SSE transports are JS-only for client to receive info (on the server-side, a dedicated webserver should be used instead).
This module provides:
- Wire format:
Protocolenvelope types for client-server messages - Serialisation:
Serialiserwith automatic (automatic) and custom (custom_json,custom_binary) options - WebSocket transport:
websocketconfig builder andwebsocket_connectconnector with automatic reconnection and offline queueing - HTTP/SSE transport:
httpconfig builder andhttp_connectconnector using EventSource + POST
For most apps, use transport.automatic for
zero-configuration serialisation, then pick a transport (WebSockets work
for most, although use HTTP when corporate firewalls might block them):
import lily/client
import lily/transport
pub fn main() {
let runtime = client.start(app_store)
client.connect(runtime,
with: transport.websocket(url: "ws://localhost:8080/ws")
|> transport.reconnect_base_milliseconds(1000)
|> transport.websocket_connect,
serialiser: transport.automatic(),
)
}
Switch to HTTP/SSE when WebSocket connections are blocked:
client.connect(runtime,
with: transport.http(
post_url: "/api/messages",
events_url: "/api/events",
) |> transport.http_connect,
serialiser: transport.automatic(),
)
automatic() defaults to JSON so frames are human-readable in DevTools.
You can use MessagePack for production (for smaller transport packages)
with transport.use_message_pack:
transport.automatic() |> transport.use_message_pack()
The automatic serialiser uses positional encoding:
{"_":"ConstructorName","0":field0,"1":field1,...}. On JavaScript,
constructors must be registered so the decoder can reconstruct them.
The way this module is currently designed (and I have gone back-and-forth
on this a lot) is to use a small FFI shim in your shared types module
that calls registerModule from transport.ffi.mjs:
// my_shared.ffi.mjs
import * as self from "./my_shared.mjs";
import { registerModule } from "../lily/lily/transport.ffi.mjs";
export function registerTypes() { registerModule(self); }
// my_shared.gleam
pub fn serialiser() -> transport.Serialiser(Model, Message) {
let _ = register_types()
transport.automatic()
}
@target(javascript)
@external(javascript, "./my_shared.ffi.mjs", "registerTypes")
fn register_types() -> Nil { Nil }
@target(erlang)
fn register_types() -> Nil { Nil }
For shared types split across multiple modules, call registerModule
once per file in the FFI shim:
import * as messages from "./messages.mjs";
import * as model from "./model.mjs";
import { registerModule } from "../lily/lily/transport.ffi.mjs";
export function registerTypes() {
registerModule(messages);
registerModule(model);
}
For cases where automatic serialisation isn’t suitable, you can use
transport.custom_json or
transport.custom_binary for explicit encode/decode.
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(BitArray) -> Nil,
on_reconnect: fn() -> Nil,
on_disconnect: fn() -> Nil,
)
}
Constructors
-
Handler( on_receive: fn(BitArray) -> Nil, on_reconnect: fn() -> Nil, on_disconnect: fn() -> Nil, )
Configuration for an HTTP/SSE connection. Requires both a POST URL for client-to-server messages and an SSE events URL for server-to-client.
pub opaque type HttpConfig
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)
Push(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. -
Push(payload: message)Pushis sent by the server (or a PubSub hub) directly to subscribed clients. UnlikeServerMessageit carries no sequence number and is never replayed on resync — it is ephemeral by design. -
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.
Serialises and deserialises Protocol values to and from bytes. Carries
JSON encode/decode functions (always present, used when format is JSON) and
an optional binary codec (MessagePack or any custom binary format).
Construct via automatic, custom_json, or
custom_binary. Toggle between formats using
use_json and use_message_pack.
pub opaque type Serialiser(model, message)
This is transport handle returned by a connector. Provides send to
transmit messages and close to terminate the connection.
pub opaque type Transport
Configuration for a WebSocket connection. Use the builder functions to customise reconnection behaviour.
pub opaque type WebSocketConfig
Values
pub fn automatic() -> Serialiser(model, message)
Create an automatic serialiser. Uses JSON by default (human-readable in DevTools). Positional encoding works for any Gleam custom type on both targets without configuration.
Switch to MessagePack for production — smaller, faster binary frames —
with transport.use_message_pack:
// Development: JSON, readable in DevTools
transport.automatic()
// Production: MessagePack
transport.automatic() |> transport.use_message_pack()
On JavaScript, constructors must be registered before connecting so the
decoder can reconstruct all types — including those that only arrive from
the server or from other clients. The recommended pattern is a FFI shim in
your shared types module that calls registerModule from
transport.ffi.mjs. Multiple modules are supported by calling
registerModule once per file. See the module documentation for the
full pattern.
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_binary(
encode_message encode_message: fn(message) -> BitArray,
decode_message decode_message: fn(BitArray) -> Result(
message,
Nil,
),
encode_model encode_model: fn(model) -> BitArray,
decode_model decode_model: fn(BitArray) -> Result(model, Nil),
) -> Serialiser(model, message)
Create a serialiser from explicit binary encode/decode functions. Use this
to provide a custom binary codec (MessagePack, CBOR, or any binary
format). The format is fixed to binary; the use_json and
use_message_pack toggles are no-ops on this serialiser.
pub fn custom_json(
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 JSON encode/decode functions. Useful
when the auto format is not suitable (third-party APIs, human-readable
JSON, backwards compatibility). The format is fixed to JSON; the
use_json and use_message_pack toggles are
no-ops on this serialiser.
pub fn decode(
bytes: BitArray,
serialiser serialiser: Serialiser(model, message),
) -> Result(Protocol(model, message), Nil)
Decode BitArray bytes into a Protocol result.
pub fn encode(
protocol: Protocol(model, message),
serialiser serialiser: Serialiser(model, message),
) -> BitArray
Encodes a Protocol into bytes. Uses MessagePack when a binary codec is
active (the default for automatic), or JSON otherwise.
pub fn flush_batch_size(
config: HttpConfig,
size: Int,
) -> HttpConfig
Set the maximum number of queued messages POSTed in parallel when the HTTP/SSE connection reconnects. Reducing this limits concurrent POST requests during a reconnect burst; increasing it flushes the queue faster. Default is 10.
pub fn http(
post_url post_url: String,
events_url events_url: String,
) -> HttpConfig
Create a new HTTP/SSE transport configuration. The post_url is used for
sending messages to the server, and the events_url is used for receiving
Server-Sent Events.
Example
transport.http(
post_url: "/api/messages",
events_url: "/api/events",
)
pub fn http_connect(
config: HttpConfig,
) -> fn(Handler) -> Transport
Returns a connector function that establishes an HTTP/SSE connection. Pass
the result to client.connect.
Example
client.connect(runtime,
with: transport.http(
post_url: "/api/messages",
events_url: "/api/events",
) |> transport.http_connect,
serialiser: transport.automatic(),
)
pub fn new(
send send: fn(BitArray) -> 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 reconnect_base_milliseconds(
config: WebSocketConfig,
milliseconds: Int,
) -> WebSocketConfig
Set the base delay in milliseconds for WebSocket reconnection attempts. The actual delay doubles on each failed attempt until reaching the maximum.
pub fn reconnect_jitter_ratio(
config: WebSocketConfig,
ratio: Float,
) -> WebSocketConfig
Set the jitter ratio applied to each WebSocket reconnection delay. A ratio
of 0.25 produces ±25% randomisation, which spreads reconnects across
clients after a mass disconnect. Must be between 0.0 (no jitter) and 1.0
(full randomisation). Default is 0.25. I’ve also added caching of store
for mass re-syncs to help with this to prevent unnecessary re-serialisation.
pub fn reconnect_max_milliseconds(
config: WebSocketConfig,
milliseconds: Int,
) -> WebSocketConfig
Set the maximum delay in milliseconds between WebSocket reconnection attempts.
pub fn reconnect_multiplier(
config: WebSocketConfig,
multiplier: Float,
) -> WebSocketConfig
Set the backoff multiplier for WebSocket reconnection attempts. The delay
after each failed attempt is multiplied by this value, up to the maximum
set by reconnect_max_milliseconds. Default
is 2.0 (standard exponential backoff).
pub fn send(transport: Transport, bytes: BitArray) -> Nil
Send bytes through the transport. The bytes should be a serialised
Protocol message.
pub fn url_from_current_location(path path: String) -> String
Derive a WebSocket URL from the browser’s current location. Automatically
uses wss: for HTTPS pages and ws: for HTTP. The path argument
specifies the WebSocket endpoint path.
// On https://example.com:3000/app
transport.url_from_current_location(path: "/ws")
// Returns "wss://example.com:3000/ws"
pub fn use_json(
serialiser: Serialiser(model, message),
) -> Serialiser(model, message)
Switch the serialiser to JSON encoding. Useful for development when you
want human-readable frames in DevTools. Only meaningful on
automatic serialisers; no-op on custom_json or
custom_binary.
// Dev: readable JSON in DevTools
transport.automatic() |> transport.use_json()
pub fn use_message_pack(
serialiser: Serialiser(model, message),
) -> Serialiser(model, message)
pub fn websocket(url url: String) -> WebSocketConfig
Create a new WebSocket configuration with the given URL. Default reconnect settings are 1000ms base delay and 30000ms maximum delay (exponential backoff).
pub fn websocket_connect(
config: WebSocketConfig,
) -> fn(Handler) -> Transport
Returns a connector function that establishes a WebSocket connection. Pass
the result to client.connect.
client.connect(runtime,
with: transport.websocket(url: "ws://localhost:8080/ws")
|> transport.reconnect_base_milliseconds(2000)
|> transport.websocket_connect,
serialiser: transport.automatic(),
)