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