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

This is a handle to a running Lily server instance. Wraps platform-specific internals (OTP actor on Erlang, closure-scoped state on JavaScript).

pub opaque type Server(model, message)

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.

Search Document