lily/client

The client within Lily manages the Runtime within Lily, managing the update loop, component subscriptions, local persistence, and server synchronisation. When connected, it also monitors online/ offline status and queues messages in localStorage while disconnected. The client is meant to be used on the browser-side, so the Erlang compilation target is not supported by this module.

The typical frontend setup would look like:

  1. Creating a store with store.new
  2. Starting the runtime with client.start
  3. Mounting your components using component.mount
  4. Attaching event handlers
  5. 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() {
  // 1. Create your store
  let app_store = store.new(Model(count: 0), with: update)

  // 2. Start the runtime
  let runtime = client.start(app_store)

  // 3. Mount UI
  |> component.mount(selector: "#app", to_html: element.to_string, view: app)
  // 4. Attach events
  |> event.on_click(selector: "#app", decoder: parse_msg)

  // 5. Connect to server
  |> client.connect(
    with: transport.websocket(url: "ws://localhost:8080/ws")
      |> transport.websocket_connect,
    serialiser: my_serialiser, // see transport module for more information
  )
}

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

Example

fn update(model, message) {
  case message {
    Logout -> {
      client.clear_session()
      model
    }
    _ -> model
  }
}
pub fn connect(
  runtime: Runtime(model, message),
  with connector: fn(transport.Handler) -> transport.Transport,
  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.

import lily/transport

runtime
|> client.connect(
  with: transport.websocket(url: "ws://localhost:8080/ws")
    |> transport.reconnect_base_milliseconds(2000)
    |> transport.websocket_connect,
  serialiser: my_serialiser,
)
pub fn connection_status(
  runtime: Runtime(model, message),
  get get: fn(model) -> Bool,
  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, with Lily calling set with True when the transport connects and False when it disconnects. Components can slice this field to react to connectivity changes.

get provides the way to read the connection status from the model (the user-defined model type should then have a way to save this status) and set provides a way to write into the model.

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.

Example

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

Get a dispatch function that sends messages into the runtime’s update loop. Since the Store, this is needed to handle side-effects (like fetch callbacks or timers). 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)
let dispatch = client.dispatch(runtime)

fetch("/api/data", fn(response) {
  dispatch(DataReceived(response))
})
pub fn merge_locals(incoming: model, current: model) -> model

The default snapshot reconciliation: recursively walk the incoming model, preserving any field whose current value is store.Local and otherwise taking the incoming value. Compose with on_snapshot when you want the default plus per-field overrides.

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. Does not fire for remote messages from other clients.

Example

runtime
|> client.connect(with: connector, serialiser: my_serialiser)
|> client.on_message(fn(message, model) {
  case message {
    FetchUsers -> fetch("/api/users", fn(users) {
      dispatch(UsersLoaded(users))
    })
    _ -> 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 preserves any field whose current value is wrapped in store.Local and otherwise adopts the incoming snapshot. Compose with merge_locals to keep that behaviour and add per-field overrides on top.

Example

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.

Example

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

Start the client runtime. Returns a Runtime handle that should be used with component.mount, event handlers, and client.connect.

let runtime =
  store.new(Model(count: 0), with: update)
  |> client.start

runtime
|> component.mount(selector: "#app", to_html: element.to_string, view: app)
|> event.on_click(selector: "#app", decoder: parse_msg)
Search Document