lily/transport
Transport between client and server. The
Store on each side stays in sync by exchanging
serialised Protocol messages over this module. It works
on both Erlang and JS targets since both ends need it; the WebSocket
and HTTP/SSE connectors are JavaScript-only (a dedicated web server
handles the corresponding server-side I/O).
The module provides:
- Wire format:
Protocolenvelope types for messages exchanged between client and server. - Serialisation:
Serialiserwith automatic (automatic) and custom (custom_json,custom_binary) variants. - 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
suit most cases; switch to HTTP if corporate firewalls block them:
import lily/client
import lily/transport
pub fn main() {
let runtime = client.start(app_store, shared.wiring())
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.
To register constructors, your shared types module exposes a tiny FFI
shim 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
Opaque transport connector. Built by
websocket_connect and
http_connect; passed to
client.connect.
pub opaque type Connector
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
Serialises and deserialises Protocol values to and from bytes. The
Auto variant uses positional encoding and works for any Gleam custom
type without configuration; its format field selects JSON or MessagePack
at runtime. CustomJson and CustomBinary carry user-supplied codecs and
have a fixed format.
Construct via automatic, custom_json, or
custom_binary. Toggle the auto format using
use_json and use_message_pack; these
are no-ops on custom serialisers.
pub opaque type Serialiser(model, message)
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 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(@internal Protocol(model, message), Nil)
Decode BitArray bytes into a Protocol result.
pub fn encode(
protocol: @internal 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) -> Connector
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 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 so the server doesn’t get stampeded. Must
be between 0.0 (no jitter) and 1.0 (full randomisation). Default is 0.25.
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 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) -> Connector
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(),
)