howdy/websocket/presence

Who is on a topic right now, across every connected node.

A process tracks itself on a topic under a key, usually a user id, with a meta value describing it, such as a name or a status. One key can be tracked by several processes, as when someone has the page open in two tabs: the key stays present until the last of them leaves.

import howdy/websocket
import howdy/websocket/presence

let users = presence.new("users", encode: user_to_json, decoder: user_decoder())

websocket.new(fn(socket) {
  presence.track(users, process.self(), "room:" <> room, key: user.id, meta: user)
  presence.watch(users, socket, "room:" <> room)
  Nil
})

A watching socket is sent the topic’s presences as soon as it watches, then a diff of joins and leaves after every change. The browser keeps the list with the client script served by client:

import { Presence } from "/howdy/presence.js"
const users = new Presence({ name: "users", topic: "room:lobby" })
users.onSync((list) => render(list))
socket.onmessage = (event) => {
  const message = JSON.parse(event.data)
  if (users.receive(message)) return
  // the app's own messages
}

Server code, such as a live view, can subscribe instead and get the same diffs as messages.

A presence lasts as long as the process that tracked it. Nothing needs cleaning up when a socket closes or crashes. Each node runs a tracker that owns its own processes’ presences and shares them with the trackers on the other nodes, so list answers for the whole cluster from local memory. When a node goes away, its presences leave on the others; if it comes back, they rejoin. A tracker that crashes forgets its node’s presences, as their processes are not told to track again.

Metas travel as JSON, so a node running different code can still read them: a meta that decoder cannot read is skipped by list and subscribe, and passed to the browser as it is.

Types

What changed on a topic. Tracking an existing process again with a new meta is a join of the new meta and a leave of the old one, so the key can appear on both sides; it is only gone once no metas remain.

pub type Diff(meta) {
  Diff(
    topic: String,
    joins: List(Entry(meta)),
    leaves: List(Entry(meta)),
  )
}

Constructors

  • Diff(
      topic: String,
      joins: List(Entry(meta)),
      leaves: List(Entry(meta)),
    )

A key and the metas of the processes tracking it, oldest first.

pub type Entry(meta) {
  Entry(key: String, metas: List(meta))
}

Constructors

  • Entry(key: String, metas: List(meta))

One kind of presence, such as the users in a room or the cursors on a document. name keeps kinds apart, so two kinds can share topics. Every node must use the same name for the same kind.

pub opaque type Presence(meta)

Values

pub fn client(at path: String) -> controller.Builder(Nil)

A controller serving the browser half, a JavaScript module exporting Presence, at path, such as "/howdy/presence.js". Responses carry an ETag, so browsers revalidate rather than download it again.

pub fn client_script() -> String

The client script’s source.

pub fn count(presence: Presence(meta), topic: String) -> Int

How many keys are on a topic. Someone with two tabs open counts once.

pub fn list(
  presence: Presence(meta),
  topic: String,
) -> List(Entry(meta))

Everyone on a topic, ordered by key.

pub fn new(
  name: String,
  encode encode: fn(meta) -> json.Json,
  decoder decoder: decode.Decoder(meta),
) -> Presence(meta)
pub fn subscribe(
  presence: Presence(meta),
  topic: String,
  subject: process.Subject(msg),
  map: fn(Diff(meta)) -> msg,
) -> List(Entry(meta))

Send a message made by map to subject after every change to topic, until the calling process exits. Returns the topic’s entries at the moment of subscribing, so no change falls between them and the first diff.

The diffs come from a process linked to the caller, so a crash in map takes the caller down with it.

pub fn track(
  presence: Presence(meta),
  pid: process.Pid,
  topic: String,
  key key: String,
  meta meta: meta,
) -> Nil

Track pid on topic under key until the process exits or calls untrack. In a socket callback, pid is process.self(). Tracking the same process, topic and key again replaces its meta, which is how a status such as “typing” is updated.

pub fn untrack(
  presence: Presence(meta),
  pid: process.Pid,
  topic: String,
  key key: String,
) -> Nil

Stop tracking pid on topic under key. Does nothing if it was not.

pub fn watch(
  presence: Presence(meta),
  socket: websocket.Socket(msg),
  topic: String,
) -> Nil

Keep socket’s client up to date with topic: it is sent every presence at once, then a diff after every change, as JSON text frames that the client script reads. Watching does not track the socket. Call it from a socket callback.

✨ Search Document