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:
- Creating a store with
store.new - 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() {
// 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:
- 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)
|> 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)