lily/topic

Topics are basically ways for clients to listen to a certain broadcast channel, similar to pub/sub patterns within other libraries like Phoenix. This is useful for cases where your own model/store need to easily react to other states shared between a few clients (e.g. chat rooms).

Lily provides two main types of topics, an ephemeral topic that simply broadcasts Push frames with no sequence and no replay:

let assert Ok(typing) = topic.new(server, id: "typing")
// Broadcast from anywhere, server or client
topic.broadcast(typing, UserIsTyping(client_id))

And also more stateful topics which have their own store. Pipe through with_store to make a topic stateful, the topic actor reading its update logic from the server’s Wiring and sends TopicUpdate frames to every subscriber:

let assert Ok(chat) = topic.new(server, id: "chat")
let chat =
  chat
  |> topic.with_store
  |> topic.with_on_subscribe(fn(client_id) {
    [Chat(UserJoined(client_id))]
  })

Making it stateful means the server owns the topic’s state authoritatively. It assigns a sequence number to every change, hands a Snapshot to whoever subscribes so late joiners catch up, and fans out a TopicUpdate as the state changes. The update logic isn’t passed here, it’s borrowed from the store.topic(id:) entry in your shared Wiring whose id matches, so a "chat" topic reads its update function and slice from store.topic(id: "chat", ...). That one entry is what links the server actor to the client’s model slice, so the same id shows up in the wiring, in topic.new, and in client.subscribe.

On the client you subscribe after connecting with client.subscribe, and the topic’s slice is hydrated from the snapshot when it arrives:

runtime
|> client.connect(with: connector, serialiser: shared.serialiser())
|> client.subscribe("chat")

After that, you don’t wire up anything to consume updates. When a TopicUpdate from a stateful topic or a Push from an ephemeral one lands, Lily runs the payload through your update function exactly like a message you’d dispatched locally, with the matching slice updates, your client.on_message hook fires, and the affected components re-render.

With the chat topic set up on the backend (the topic.new plus with_store from above), a client (frontend) sends by dispatching, which the server fans out to the other subscribers as a TopicUpdate:

dispatch(Chat(NewChatMessage(body)))

And consuming that update takes no extra code, a component slicing the chat re-renders whether the change was local or a TopicUpdate from elsewhere (another client/frontend):

component.simple(
  slice: fn(model: Model) { model.chat },
  render: fn(chat, _) { chat_view(chat) },
)

For dynamic topics keyed by a parsed identifier (e.g. "room:42"), use topic.kind to register a factory that creates topic actors on first subscribe:

let assert Ok(_) =
  topic.kind(
    server,
    prefix: "room:",
    parse_id: int.parse,
    configure: fn(room_id, topic) {
      topic
      |> topic.with_store
      |> topic.with_can_subscribe(fn(client_id, _topic_id) {
        auth.may_join_room(client_id, room_id) // your own helper
      })
    },
  )

On the wiring side this pairs with store.topic_kind rather than store.topic. It binds the whole "room:" family to one keyed model slice, so a client can be in several rooms at once and each outgoing message, incoming update, and snapshot still lands on the right one. Back the slice with a keyed collection like Dict(String, RoomState), the key is the bit after the prefix ("42" for "room:42").

Types

Phantom kind marker for ephemeral topics (broadcast only, no store).

pub type Ephemeral

Phantom kind marker for stateful topics (store + sequence + snapshot).

pub type Stateful

Opaque handle to a running topic. The kind phantom parameter is Ephemeral after topic.new and Stateful after topic.with_store. This is enforced at compile time so topic.dispatch cannot be called on an ephemeral topic.

pub opaque type Topic(model, message, kind)

Values

pub fn broadcast(
  topic: Topic(model, message, kind),
  message: message,
) -> Nil

Send a Push frame to every subscriber of this topic. Available on both ephemeral and stateful topics.

topic.broadcast(typing_topic, UserIsTyping(client_id))
pub fn broadcast_from(
  topic: Topic(model, message, kind),
  except client_id: String,
  message message: message,
) -> Nil

Like broadcast but skips the originating client.

topic.broadcast_from(
  typing_topic,
  except: client_id,
  message: UserIsTyping(client_id),
)
pub fn dispatch(
  topic: Topic(model, message, Stateful),
  message: message,
) -> Nil

Apply a message to the topic’s store and emit TopicUpdate(id, seq, payload) to every subscriber. Only callable on stateful topics (created via with_store).

Ephemeral topics fail at compile time.

topic.dispatch(chat_topic, Chat(NewChatMessage(body)))
pub fn kind(
  server: server.Server(model, message),
  prefix prefix: String,
  parse_id parse_id: fn(String) -> Result(parsed, Nil),
  configure configure: fn(
    parsed,
    Topic(model, message, Ephemeral),
  ) -> Topic(model, message, kind),
) -> Result(Nil, Nil)

Register a parametric topic kind. When a client subscribes to prefix <> suffix and no fixed topic with that id exists, the server parses the suffix via parse_id and calls configure(parsed, topic) to configure a pre-started Topic lazily.

The pre-started topic is passed to configure. Call with_store, with_can_subscribe, etc. on it and return the result. Do not call topic.new inside configure, the topic actor is already started.

A stateful kind (one that calls with_store) reads its store from the store.topic_kind wiring entry sharing this prefix, keyed by instance, so every id in the family updates and snapshots into its own slot.

let assert Ok(_) =
  topic.kind(
    server,
    prefix: "room:",
    parse_id: int.parse,
    configure: fn(room_id, topic) {
      topic |> topic.with_store
    },
  )
pub fn new(
  server: server.Server(model, message),
  id id: String,
) -> Result(Topic(model, message, Ephemeral), Nil)

Register a topic on the given server. Returns an ephemeral handle (broadcast-only) by default; pipe through with_store to make it stateful.

let assert Ok(typing) = topic.new(server, id: "typing")
pub fn stop(topic: Topic(model, message, kind)) -> Nil

Stop the topic actor and remove it from the server registry. Subscribers stop receiving updates, their last slice value is left as-is, not reset. Further subscribes to this id either error (fixed topic) or trigger lazy reinstantiation (parametric kind, if registered), in which case the fresh topic pushes a snapshot on subscribe that replaces it.

topic.stop(chat_topic)
pub fn subscribe(
  topic: Topic(model, message, kind),
  client_id: String,
) -> Nil

Add a subscriber (server-initiated), the client-side counterpart is client.subscribe.

topic.subscribe(chat_topic, client_id)
pub fn unsubscribe(
  topic: Topic(model, message, kind),
  client_id: String,
) -> Nil

Remove a subscriber.

topic.unsubscribe(chat_topic, client_id)
pub fn with_can_subscribe(
  topic: Topic(model, message, kind),
  predicate: fn(String, String) -> Bool,
) -> Topic(model, message, kind)

Set an authorisation predicate for client-initiated subscribes. Server-side topic.subscribe is unaffected (it’s trusted). On False, the server replies with Rejected(topic_id, "denied").

topic.with_can_subscribe(chat_topic, fn(client_id, _topic_id) {
  auth.is_authenticated(client_id)
})
pub fn with_on_subscribe(
  topic: Topic(model, message, kind),
  hook: fn(String) -> List(message),
) -> Topic(model, message, kind)

Set a join hook. Returned messages are broadcast (ephemeral topics) or dispatched (stateful topics) immediately after the new subscriber receives its Snapshot, so the joiner sees them too.

topic.with_on_subscribe(chat_topic, fn(client_id) {
  [Chat(UserJoined(client_id))]
})
pub fn with_on_unsubscribe(
  topic: Topic(model, message, kind),
  hook: fn(String) -> List(message),
) -> Topic(model, message, kind)

Set a leave hook. Symmetric to with_on_subscribe and fires after the subscriber is removed.

topic.with_on_unsubscribe(chat_topic, fn(_client_id) { [] })
pub fn with_store(
  topic: Topic(model, message, Ephemeral),
) -> Topic(model, message, Stateful)

Upgrade an ephemeral topic to stateful by attaching a store. The update logic and initial state are read from the store.Wiring that was passed to server.new, specifically the store.topic(id: topic.id, ...) entry.

topic.new(server, id: "chat")
|> topic.with_store
Search Document