lily/client

The client owns the browser-side Runtime, pretty much everything that makes a Lily app tick in the page. The update loop, component subscriptions, local persistence, and (once a transport is connected) keeping your model in sync with the server all live here. It even watches online/offline status for you, tucking messages into localStorage while you’re disconnected and replaying them the moment the server is back. This module is browser-only, Erlang need not apply.

A frontend comes together as one pipeline, build a Store with store.new, start the runtime with start (handing it the shared Wiring), mount your components, pipe on your event handlers, and connect to a server if you have one:

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")
}

If you aren’t syncing with a server, stop after mount, the runtime is perfectly happy on its own. When you are, subscribe to the topics this client cares about (see topic) and unsubscribe when it stops caring.

To feed messages in from outside your components, a timer, a callback, an FFI shim, grab a dispatch function with dispatch:

let dispatch = client.dispatch(runtime)
dispatch(Increment)

Everything you dispatch, plus every frame the server sends, runs through a single message queue, so your update function is only ever called one message at a time even when several land at once. That ordering is what keeps optimistic client updates from racing the server’s authoritative snapshots.

For client-side reactions that don’t belong in update, focus management, analytics, kicking off a fetch, register a hook with on_message; it runs after each dispatched message with the full model. The connection lifecycle has its own hooks, on_connect fires once on the first acknowledged connection, on_disconnect and on_reconnect track drops and recoveries (pair them with connection_status for a “reconnecting…” banner), and on_snapshot lets you decide how a fresh server snapshot merges into what the client already has.

Lily does client-side routing too, though it is opt-in and deliberately not a router, Lily is for connection-preserving apps, not for turning every site into an SPA. Mirror the location into your model with url, whose set callback hands you the parsed Uri to map onto your own route type, then move around with navigate to push a new history entry or replace to swap the current one without leaving a back-button stop. To make ordinary <a href> links navigate warmly (no page reload), pipe on intercept_links; and when a path must actually be handled by the server, use load for a full page navigation.

Because the wire only ever carries messages, an offline client keeps working and catches up on reconnect. For state that should outlive a reload or a navigation, describe it once with session_persistence plus session_field and switch it on with attach_session; each field is mirrored to localStorage. Model fields wrapped in store.Local stay client-only and are preserved when a reconnect snapshot lands.

Each Runtime is fully isolated, so several can coexist on one page, but we’d steer you towards one runtime per page. Splitting your state across many runtimes gets hard to reason about fast (badly designed React apps with state scattered everywhere come to mind). If you genuinely need independent, self-contained stateful widgets, a different framework might suit you better.

Types

pub opaque type InterceptOption

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, shared.wiring())
|> 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 wiring 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, shared.wiring())
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 hydrate(
  store: store.Store(model, message),
  wiring wiring: store.Wiring(model, message),
  serialiser serialiser: transport.Serialiser(model, message),
) -> Runtime(model, message)

Variant of start that adopts a pre-rendered DOM and reads the embedded snapshot from <script id="lily-snapshot">. Pair with transport.encode_initial_snapshot for the embedded state and component.render_to_string for the surrounding HTML. This is hydration from a fixed initial snapshot (markup rendered ahead of time), not per-request server-side rendering.

If the embedded snapshot is missing or fails to decode, hydrate silently falls back to the model in the supplied store. No warning is raised: in production the script tag may legitimately be absent (a CDN stripping inline scripts, a CSP blocking inline content), and the store’s model is a valid initial state by definition. Detect missing snapshots in dev by checking the embed yourself before calling hydrate.

Lenient hydration: components do not assert byte-equality with the pre-rendered DOM. The first event after hydrate triggers a full render, replacing any mismatched content.

pub fn main() {
  store.new(shared.initial_model(), with: shared.update)
  |> client.hydrate(shared.wiring(), shared.serialiser())
  |> component.mount(
    selector: "#app",
    to_html: element.to_string,
    to_slot: fn() { element.element("lily-slot", [], []) },
    view: shared.view,
  )
}
pub fn intercept_links(
  runtime: Runtime(model, message),
  config config: List(InterceptOption),
) -> Runtime(model, message)

Opt in to client-side navigation for ordinary <a href> links: after this, a left-click on a same-origin internal link is turned into a warm navigate (history push + url setter), with no full page reload. Everything that should stay a real navigation falls through untouched — external/cross-origin links, target="_blank", download, rel="external", mailto:/tel: schemes, modified/middle clicks, in-page #fragment anchors, and any link carrying the opt-out attribute (default data-lily-native). Pipe it once, after url.

This is opt-in and deliberately minimal — Lily is not a router. Reach for it only when navigation should preserve the live socket and offline state; otherwise let links do full server navigations.

runtime
|> client.url(set: fn(model, uri) { Model(..model, route: parse(uri)) })
|> client.intercept_links(config: [])
pub fn intercept_opt_out_attribute(
  name: String,
) -> InterceptOption

Override the attribute an anchor can carry to force a full page load instead of warm navigation (default "data-lily-native").

pub fn intercept_within(selector: String) -> InterceptOption

Only intercept anchors inside this selector’s subtree (default "document", the whole page). Use it to let, say, a static marketing header do real page loads while only the app shell navigates warmly.

pub fn load(
  runtime: Runtime(model, message),
  path path: String,
) -> Nil

Perform a full page navigation (window.location.assign), leaving the Lily app entirely — the counterpart to navigate’s in-app history push. Use it when a path must be handled by the server, not the client router: after a logout that clears a server cookie, entering a server-rendered flow, or otherwise “actually going to the server”. The socket is torn down and the destination is loaded fresh.

client.load(runtime, "/logout")
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 navigate(
  runtime: Runtime(model, message),
  path path: String,
) -> Nil

Push a new history entry and update the URL. Fires the url setter so the model reflects the new location, which lets component.switch re-render based on the route field of your model.

client.navigate(runtime, "/projects/42")
pub fn on_connect(
  runtime: Runtime(model, message),
  hook: fn(String) -> Nil,
) -> Runtime(model, message)

Register a hook that fires once after the first server-acknowledged connection. Receives the server-assigned client id. Use this for per-session bootstrap work like registering with analytics or kicking off a one-time fetch.

Attach before connect so the hook is in place by the time the first Connected frame arrives.

runtime
|> client.on_connect(fn(client_id) {
  logging.info("connected as " <> client_id)
})
pub fn on_disconnect(
  runtime: Runtime(model, message),
  hook: fn() -> Nil,
) -> Runtime(model, message)

Register a hook that fires every time the transport drops the connection. Companion to on_reconnect and connection_status.

runtime
|> client.on_disconnect(fn() { show_offline_toast() })
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_reconnect(
  runtime: Runtime(model, message),
  hook: fn() -> Nil,
) -> Runtime(model, message)

Register a hook that fires every time the transport restores the connection after a drop. Does not fire on the first connect, see on_connect for that.

runtime
|> client.on_reconnect(fn() { show_reconnected_toast() })
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 wiring 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 replace(
  runtime: Runtime(model, message),
  path path: String,
) -> Nil

Replace the current history entry and update the URL. Fires the url setter. Use for view-state-in-URL changes that should not create a back-button stop, such as a sort-order or filter toggle.

client.replace(runtime, "/projects?sort=newest")
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: sends an unsubscribe frame so the server stops pushing updates for it. Fire-and-forget, the server sends no confirmation and the topic’s last slice value is left as-is in the model. Re-subscribing pulls a fresh snapshot that replaces it (for stateful topics); clear it sooner with your own message if you need to. Must be called after client.connect.

runtime
|> client.unsubscribe("chat")
pub fn url(
  runtime: Runtime(model, message),
  set set: fn(model, uri.Uri) -> model,
) -> Runtime(model, message)

Track the browser URL in the model. The set callback receives the parsed uri.Uri; map it to your own route ADT inside. The initial URL is read on attach, and changes from popstate, navigate, and replace all flow through the same setter.

Mirrors client_id and connection_status.

runtime
|> client.url(set: fn(model, uri) {
  Model(..model, route: shared.parse_route(uri))
})
Search Document