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:
- Creating a store with
store.new - Building a wiring config with
store.wiring - Starting the runtime with
client.start - Mounting your components using
component.mount - Attaching event handlers
- 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:
- Build using
client.session_persistence - Add fields with
client.session_field - Attach to the runtime with
client.attach_session
pub opaque type Persistence(session)
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")