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
An option for intercept_links. Build with
intercept_within and
intercept_opt_out_attribute.
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:
- 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, 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))
})