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:

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)

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