lily/server
The Server holds authoritative (with an asterisk) state and
broadcasts updates to connected clients. It works on both Erlang and
JavaScript targets, though we recommend Erlang for production use to make
full use of the BEAM.
The asterisk on the authoritative state comes from client disconnections and reconnections, which allows for local modifications and editing to update the server store on reconnect. This is done by assigning sequence numbers.
On Erlang, the server uses an OTP actor with sequential message
processing, and on JavaScript, it uses closure-scoped mutable state (JS is
single-threaded). Both expose identical APIs - the same
Server opaque type and public functions work on both
targets.
import lily/server
import lily/store
import lily/transport
pub fn main() {
// Create your store
let app_store = store.new(initial_model, with: update)
// Start the server
let assert Ok(srv) = server.start(
store: app_store,
serialiser: transport.automatic(),
)
// Register side-effect hook (optional)
server.on_message(srv, fn(msg, model, client_id) {
case msg {
SaveDocument(doc) -> db.write(doc)
_ -> Nil
}
})
// Wire into your transport (mist/wisp WebSocket handler)
}
The server is transport-agnostic. It doesn’t depend on mist or wisp — those are your backend dependencies, and you can just as easily switch to using a Node server if you so wish (although at this point just use the TypeScript with fp-ts to prevent having to deal with Gleam/JS ffi).
Use server.connect, server.disconnect, and
server.incoming to wire the server into your WebSocket or
HTTP handlers.
Note: within this module, “message” often refers to internal events, not your user-defined message type for model updates.
Types
Values
pub fn auto_log_messages(
server: Server(model, message),
level level: logging.Level,
) -> Server(model, message)
Install a hook that logs every incoming message using
logging.auto_log. Equivalent to:
server.on_message(srv, fn(msg, _model, _client_id) {
logging.auto_log(level, msg)
})
Returns the server so it can be chained.
pub fn connect(
server: Server(model, message),
client_id client_id: String,
send send: fn(BitArray) -> Nil,
) -> Nil
Register a client connection with the server. The send callback is how
the server pushes messages back to this specific client.
On Erlang, if you have a Subject(BitArray) from mist’s WebSocket handler,
wrap it: send: process.send(outgoing_subject, _).
// Erlang with mist WebSocket
let outgoing_subject = process.new_subject()
server.connect(srv, client_id: "abc123", send: process.send(outgoing_subject, _))
// JavaScript with Node.js WebSocket
server.connect(srv, client_id: "abc123", send: fn(bytes) { ws.send(bytes) })
pub fn disconnect(
server: Server(model, message),
client_id client_id: String,
) -> Nil
Unregister a client connection from the server. Called when a client disconnects.
pub fn generate_client_id() -> String
Generate a cryptographically random client identifier (16 bytes, hex-encoded to a 32-character string). Use this to create unique client IDs when wiring up WebSocket connections.
let client_id = server.generate_client_id()
server.connect(srv, client_id: client_id, send: fn(bytes) { ... })
pub fn incoming(
server: Server(model, message),
client_id client_id: String,
bytes bytes: BitArray,
) -> Nil
Process an incoming message from a client. The bytes should be a
serialised transport.Protocol message.
pub fn on_message(
server: Server(model, message),
hook: fn(message, model, String) -> Nil,
) -> Nil
Register a hook that runs after each client message is processed on the server. Receives the decoded message, updated model, and client id.
Example
server.on_message(server, fn(message, model, client_id) {
case message {
SaveDocument(doc) -> db.write(doc)
SendEmail(to, body) -> email.send(to, body)
_ -> Nil
}
})
pub fn start(
store store: store.Store(model, message),
serialiser serialiser: transport.Serialiser(model, message),
) -> Result(Server(model, message), Nil)
pub fn stop(server: Server(model, message)) -> Nil
Start a new server instance with the given store and serialiser. Returns
Ok(server) on success, or Error(Nil) if the server fails to start
(Erlang actor init failure, though this is rare with simple init logic).
On JavaScript, this always returns Ok.
Example
import lily/server
import lily/store
let app_store = store.new(initial_model, with: update)
let assert Ok(srv) = server.start(store: app_store, serialiser: my_serialiser)
Stop a running server. Terminates the underlying actor on Erlang and
releases the state box on JavaScript. Further calls to
connect, incoming, disconnect,
or on_message on the stopped server are silently dropped.
This does not notify connected clients — closing their transports is the
app’s responsibility. See disconnect for per-client
teardown.