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, saving messages into sessionStorage 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(), shared.serialiser())
  |> 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,
  )
  |> 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

Session persistence configuration, kept opaque so you don’t touch the fields directly.

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 so data survives page navigation. It hydrates the model from localStorage and lets local state update from the model through the get and set functions. Pipe it 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 that frame right after the WebSocket connects, so the model is updated before the first snapshot.

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,
) -> Runtime(model, message)

Connect the runtime to a server over the given transport. The connector comes from a transport implementation, e.g. websocket_connect(config) or http_connect(config). It also wires up the incoming-message and connection-status handlers, sending session messages as SessionMessage frames and routing topic messages by the wiring from client.start.

import lily/transport

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

Track connection status in the model, handy for disabling elements while offline. Lily calls set with True when the transport connects and False when it disconnects, and components can slice the field to react. Call it before client.connect to capture the initial state. It’s optional, status is tracked internally either way.

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,
)
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 how you feed in side-effects like fetch callbacks and timers, calling it to update the store whenever the side-effect fires.

let runtime = client.start(store, shared.wiring(), shared.serialiser())
let dispatch = client.dispatch(runtime)

fetch("/api/data", fn(response) {
  dispatch(DataReceived(response))
})
pub fn enable_hot_reload(
  runtime: Runtime(model, message),
) -> Runtime(model, message)

Opt in to dev hot reload. Connects to the dev-reload socket (the page’s origin on the port one above the page’s, where lily_dev hosts it) and reloads the page on every rebuild, with a storm guard so a misbehaving signal cannot spin into a reload loop. Development only, guard the call behind a dev flag. State survives the reload through the reconnect resync and session persistence.

runtime |> client.enable_hot_reload
pub fn generate_session_id() -> String

Generate a random 32-character hex string for use as a client-side session identifier. Each call returns a unique value from crypto.getRandomValues, so it’s safe to call at 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 intercept_links(
  runtime: Runtime(model, message),
) -> 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.

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

Anchors inside the whole page are intercepted, and any anchor carrying the data-lily-native attribute opts out into a full page load.

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.log(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 on_version_mismatch(
  runtime: Runtime(model, message),
  hook: fn() -> Nil,
) -> Runtime(model, message)

Register a hook that fires when a Version frame’s hash differs from the first one this runtime saw. The server sends one on connect and on every reconnect, so a value that changes between them means a new build is live. Pair with reload, resync then recovers the model.

runtime |> client.on_version_mismatch(client.reload)
pub fn recover_after_reload(initial: model) -> model

Recover state from the model a dev-reload full reload stashed just before it fired, clearing the stash either way. Merges top-level primitive fields (Int, Float, String, Bool) into initial by field name, anything else (a nested custom type, a List, an Option) falls back to initial’s value. Use recover_after_reload_migrate to take over the merge entirely.

let initial = client.recover_after_reload(shared.initial_model())
pub fn recover_after_reload_migrate(
  initial: model,
  migrate: fn(dynamic.Dynamic, model) -> model,
) -> model

Like recover_after_reload but takes over the merge entirely. migrate receives the raw stashed value and the freshly built initial model, and returns whatever model to boot with, for renamed fields, type changes, or reaching into a nested slice.

let initial =
  client.recover_after_reload_migrate(shared.initial_model(), fn(stashed, initial) {
    let count =
      decode.run(stashed, decode.at(["count"], decode.int))
      |> result.unwrap(initial.count)
    Model(..initial, count:)
  })
pub fn reload() -> Nil

Reload the current page. A graceful default for on_version_mismatch, cheap in Lily since resync and session persistence recover most state on the other side.

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 config. Each field is one value stored in localStorage under lily_session_{key}. get and set pull it out of and back into the session type, and encode and decoder handle its JSON.

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),
  serialiser serialiser: transport.Serialiser(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(), shared.serialiser())

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