lily/client

The client owns the browser-side Runtime: the update loop, component subscriptions, local persistence, and server synchronisation all live here. When a transport is connected, it also watches online/offline status and tucks messages into localStorage while disconnected, replaying them once the server is back. This module is browser-only, Erlang need not apply.

The typical frontend setup would look like:

  1. Creating a store with store.new
  2. Building a wiring config with store.wiring
  3. Starting the runtime with client.start
  4. Mounting your components using component.mount
  5. Attaching event handlers
  6. Connecting to a server with client.connect
import lily/client
import lily/component
import lily/event
import lily/store
import lily/transport

pub fn main() {
  store.new(shared.initial_model(), with: shared.update)
  |> client.start(shared.wiring())
  |> component.mount(
    selector: "#app",
    to_html: element.to_string,
    to_slot: fn() { element.element("lily-slot", [], []) },
    view: app,
  )
  |> event.on_decoded(
    event: event.click,
    selector: "#app",
    decoder: parse_message,
  )
  |> client.connect(
    with: transport.websocket(url: "ws://localhost:8080/ws")
      |> transport.websocket_connect,
    serialiser: shared.serialiser(),
  )
  |> client.subscribe("chat")
}

Each Runtime is completely isolated, allowing multiple independent Lily runtimes to coexist on the same page. However, we recommend using one runtime per page to avoid splitting your application state (which can become hard to manage à la badly designed React apps with states everywhere). If you need truly stateful, independent widget-style components, a different framework may be more appropriate.

The client runtime uses a message queue to batch updates and prevent race conditions, ensuring your update function is called sequentially even when messages arrive from multiple sources (user events, server messages, timers, etc.).

Types

Complete session persistence configuration. It’s kept opaque so that users avoid having to mess with the fields themselves which can look quite messy.

To interact with the session persistence:

pub opaque type Persistence(session)

Opaque handle to a running Lily application instance. Each runtime is isolated, allowing multiple independent apps on the same page.

pub opaque type Runtime(model, message)

Values

pub fn attach_session(
  runtime: Runtime(model, message),
  persistence persistence: Persistence(session),
  get get: fn(model) -> session,
  set set: fn(model, session) -> model,
) -> Runtime(model, message)

Attach session persistence to the runtime to allow for data to persist across page navigation etc.. This allows for model hydration via local storage, and also allows for local state to be updated by the model through the provided get and set functions.

Pipe this in the chain after client.start.

let persistence =
  client.session_persistence()
  |> client.session_field(
    key: "token",
    get: fn(session) { session.token },
    set: fn(session, value) { SessionData(..session, token: value) },
    encode: json.nullable(json.string),
    decoder: decode.optional(decode.string),
  )

client.start(app_store, routing)
|> client.attach_session(
  persistence:,
  get: fn(model) { model.session },
  set: fn(model, session) { Model(..model, session: session) },
)
pub fn clear_session() -> Nil

Clear all Lily related session data from localStorage by removing all keys with the lily_session_ prefix.

fn update(model, message) {
  case message {
    Logout -> {
      client.clear_session()
      model
    }
    _ -> model
  }
}
pub fn client_id(
  runtime: Runtime(model, message),
  set set: fn(model, String) -> model,
) -> Runtime(model, message)

Inject the server-assigned client identifier into the model when a Connected frame arrives. The server sends this frame immediately after a WebSocket connection is established, so the model is updated before the first snapshot arrives.

runtime
|> client.client_id(set: fn(model, id) {
  shared.Model(
    ..model,
    session: shared.SessionState(..model.session, session_id: id),
  )
})
pub fn connect(
  runtime: Runtime(model, message),
  with connector: transport.Connector,
  serialiser serialiser: transport.Serialiser(model, message),
) -> Runtime(model, message)

Connect the runtime to a server using the provided transport method. The connector function is obtained from a transport implementation, e.g. websocket_connect(config) or http_connect(config).

This also creates all the handlers for handling incoming messages, and changes to connection status. Session messages are sent as SessionMessage frames; topic messages are routed to the correct topic using the routing config passed to client.start.

import lily/transport

runtime
|> client.connect(
  with: transport.websocket(url: "ws://localhost:8080/ws")
    |> transport.reconnect_base_milliseconds(2000)
    |> transport.websocket_connect,
  serialiser: shared.serialiser(),
)
pub fn connection_status(
  runtime: Runtime(model, message),
  set set: fn(model, Bool) -> model,
) -> Runtime(model, message)

Often times you want to be able to track the connection status (for example, if you want to disable an element when there is no connection). This sets up tracking for the connection status in the model: Lily calls set with True when the transport connects and False when it disconnects. Components can slice this field to react to connectivity changes.

This should be called before client.connect to ensure the initial connection state is captured.

Also note that while this call is optional, connection status is tracked regardless internally, this mainly allows the status to be reflected within the model.

runtime
|> client.connection_status(set: fn(model, status) {
  Model(..model, connected: status)
})
|> client.connect(
  with: transport.websocket(url: "ws://localhost:8080/ws")
    |> transport.websocket_connect,
  serialiser: shared.serialiser(),
)
pub fn dispatch(
  runtime: Runtime(model, message),
) -> fn(message) -> Nil

Get a dispatch function that sends messages into the runtime’s update loop. The Store is pure, so this is needed to handle side-effects (fetch callbacks, timers, etc.). After generating the dispatch function, you are able to use this to send updates whenever some side-effect is called to update the store again.

let runtime = client.start(store, routing)
let dispatch = client.dispatch(runtime)

fetch("/api/data", fn(response) {
  dispatch(DataReceived(response))
})
pub fn generate_session_id() -> String

Generate a random 32-character hex string suitable for use as a client-side session identifier. Each call returns a unique value derived from crypto.getRandomValues, so it is safe to call at application startup and store in the session model.

let session_id = client.generate_session_id()
let initial = shared.Model(
  session: shared.SessionState(..shared.initial_session(), session_id:),
  chat: shared.initial_chat(),
)
pub fn merge_locals(incoming: model, current: model) -> model

A reconciliation helper for use inside on_snapshot: recursively walks the incoming model, preserving any field whose current value is store.Local and otherwise taking the incoming value. Compose this with custom per-field merge logic when the default slice-merge isn’t enough.

Note the argument order matches the on_snapshot hook signature: (incoming, current).

pub fn on_message(
  runtime: Runtime(model, message),
  hook: fn(message, model) -> Nil,
) -> Runtime(model, message)

Register a hook that runs after each locally-dispatched message. This hook fires for both session and topic messages. The model argument is the full outer model after the message has been applied locally.

runtime
|> client.on_message(fn(message, model) {
  case message {
    Chat(NewChatMessage(body, _)) ->
      dispatch(Session(AddPopup(body)))
    _ -> Nil
  }
})
pub fn on_snapshot(
  runtime: Runtime(model, message),
  hook: fn(model, model) -> model,
) -> Runtime(model, message)

Register a hook that runs when a server snapshot arrives on reconnect. The hook receives (incoming, current) and returns the merged model to dispatch into the runtime.

Without a hook, the runtime uses the routing config to merge only the snapshotted target’s slice into the current model, leaving all other slices intact. Compose with merge_locals to additionally preserve store.Local fields.

runtime
|> client.on_snapshot(fn(incoming, current) {
  let merged = client.merge_locals(incoming, current)
  Model(..merged, doc: crdt.merge(incoming.doc, current.doc))
})
pub fn session_field(
  persistence: Persistence(session),
  key key: String,
  get get: fn(session) -> a,
  set set: fn(session, a) -> session,
  encode encode: fn(a) -> json.Json,
  decoder decoder: decode.Decoder(a),
) -> Persistence(session)

Add a field to the session persistence configuration. Each field represents a single value stored in localStorage under lily_session_{key}.

The get and set functions extract and inject the field from the session type. The encode and decoder handle JSON serialisation.

client.session_persistence()
|> client.session_field(
  key: "theme",
  get: fn(session) { session.theme },
  set: fn(session, theme) { SessionData(..session, theme: theme) },
  encode: theme_to_json,
  decoder: theme_decoder,
)
pub fn session_persistence() -> Persistence(session)

Create an empty session persistence configuration, ready to be used by adding fields using client.session_field.

There’s an example above in client.attach_session

pub fn start(
  store: store.Store(model, message),
  wiring wiring: store.Wiring(model, message),
) -> Runtime(model, message)

Start the client runtime with a store and a wiring configuration. The wiring config tells the runtime how to dispatch messages to the correct server-side target (session store or a named topic store) and how to merge incoming snapshots into the outer model.

Build the wiring config in your shared package and import it here:

let runtime =
  store.new(shared.initial_model(), with: shared.update)
  |> client.start(shared.wiring())

runtime
|> component.mount(
  selector: "#app",
  to_html: element.to_string,
  to_slot: fn() { element.element("lily-slot", [], []) },
  view: app,
)
|> event.on_decoded(
  event: event.click,
  selector: "#app",
  decoder: parse_message,
)
pub fn subscribe(
  runtime: Runtime(model, message),
  topic_id: String,
) -> Runtime(model, message)

Subscribe this connection to a topic. The runtime sends a Subscribe frame to the server; on Snapshot arrival the topic’s slice in the model is hydrated and components re-render. Idempotent, no-op if already subscribed. Must be called after client.connect.

runtime
|> client.connect(with: connector, serialiser: shared.serialiser())
|> client.subscribe("chat")
pub fn unsubscribe(
  runtime: Runtime(model, message),
  topic_id: String,
) -> Runtime(model, message)

Unsubscribe from a topic. After unsubscribing the topic’s slice resets to its initial value (set during runtime construction). Idempotent, no-op if not subscribed. Must be called after client.connect.

runtime
|> client.unsubscribe("chat")
Search Document