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:

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)

    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.

  • Push(payload: message)

    Push is sent by the server (or a PubSub hub) directly to subscribed clients. Unlike ServerMessage it carries no sequence number and is never replayed on resync — it is ephemeral by design.

  • 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.

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)

Switch the serialiser back to MessagePack encoding after use_json was called. Only meaningful on automatic serialisers; no-op on custom_json or custom_binary.

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(),
)
Search Document