Manual Recipes Room Presence

Room Presence

elements man recipes/presence Read as markdown

A live "who's here" list for a room. Each page view writes one row when its listener connects and deletes it when the listener disconnects. Both notify a presence channel filtered per room, and every browser in the room re-fetches the list when the notification arrives.

The row is keyed by the listener's id, which is unique to one page view. There is no heartbeat, no client id, and no cleanup job: the listener's connect and disconnect events (elements man channel) mark the start and end of each page view, and the framework already rides out a dropped socket without firing either.

The room page reads the user from the session, so it assumes a sign-in that calls session.login({ userId, userName }). elements man recipes/chat-rooms builds one.

Migration

elements create migration 'add room presence' -tables=roomPresence

Replace the generated table in app/migrations/<timestamp>-add-room-presence.migration.sql:

-- add room presence

create table roomPresence (
  listenerId text primary key,
  createdAt timestamptz not null default now(),
  roomId text not null,
  userId text not null,
  userName text not null,
  host text not null
);

create index roomPresenceRoomIdIdx on roomPresence (roomId);
create index roomPresenceHostIdx on roomPresence (host);

One row per page view, so a user with two tabs open has two rows and stays listed until the last one closes. Rows are inserted and deleted, never updated, so the table has no updatedAt. host records which app server wrote the row; see "Restarts" below.

Channel and services

app/shared/services/presence.ts:

import { Channel, sql } from "@elements/app";
import { hostname } from "node:os";

export interface PresenceEvent {
  roomId: string;
}

export interface PresentUser {
  userId: string;
  userName: string;
}

export const presence = new Channel<PresenceEvent>("presence");

export function listPresent(roomId: string): PresentUser[] {
  return sql<PresentUser>(
    `select distinct on (userId) userId, userName
     from roomPresence
     where roomId = ${roomId}
     order by userId, createdAt`,
  ).all();
}

/** @rpc */
export function whoIsHere(roomId: string): PresentUser[] {
  return listPresent(roomId);
}

export function join(listenerId: string, roomId: string, userId: string, userName: string) {
  sql(
    `insert into roomPresence (listenerId, roomId, userId, userName, host)
     values (${listenerId}, ${roomId}, ${userId}, ${userName}, ${hostname()})`,
  );

  presence.notify({ roomId });
}

export function leave(listenerId: string, roomId: string) {
  sql(`delete from roomPresence where listenerId = ${listenerId}`);
  presence.notify({ roomId });
}

export function clearThisHost() {
  sql(`delete from roomPresence where host = ${hostname()}`);
}

listPresent collapses a user's rows to one with distinct on (userId). whoIsHere is the same query as an @rpc, for the browser to call when the list changes.

Room page

elements create page room

app/pages/room/index.ts:

import { Request, Response, session } from "@elements/app";
import room from "./template";
import { presence, listPresent, join, leave } from "#app/shared/services/presence";

export default function route(req: Request, res: Response) {
  let roomId = req.params.roomId;
  let userId = session.getOrThrow("userId");
  let userName = session.getOrThrow("userName");

  let listener = presence.listen({ filter: (e) => e.roomId === roomId })
    .on("connect", (l) => join(l.id, roomId, userId, userName))
    .on("disconnect", (l) => setTimeout(() => leave(l.id, roomId), 3000));

  return new room({ roomId, listener, users: listPresent(roomId) });
}

listen() comes before listPresent(), so a join that lands between the two still reaches this page (elements man channel, "Listen Before Select"). The callbacks use roomId, userId and userName from the route rather than reading the session themselves: they run after the route has returned.

The setTimeout is a three second grace period on the way out. When a user reloads or moves between pages of the same room, the old page's disconnect and the new page's connect arrive on different sockets in either order. Keyed by listener id they never touch the same row, and the delay keeps the old row until the new one is in, so the user never drops off the list for other viewers.

app/pages/room/template.ehtml:

import "./style.css";
import type { Listener } from "@elements/app";
import { PresenceEvent, PresentUser, whoIsHere } from "#app/shared/services/presence";

<html lang="en" class="page-room" (
  roomId: string,
  listener: Listener<PresenceEvent>,
  users: PresentUser[],
  private present: PresentUser[] = users,
) oninit={() => listener.on("notify", () => present = whoIsHere(roomId))}>
  <head>
    <title>Room</title>
  </head>
  <body>
    <main class="page-shell">
      <h1>who's here</h1>
      <ul class="present">
        <li e:for={u of present}>{u.userName}</li>
      </ul>
    </main>
  </body>
</html>

The first paint is server-rendered from users. The row for this page view is written once the browser attaches, and its own notify refreshes the list to include it.

Routes

index.ts:

import room from "#app/pages/room";
import { clearThisHost } from "#app/shared/services/presence";

// ...
app.route("/rooms/:roomId", room);

app.start(config);

// Rows this host left behind when it last stopped.
clearThisHost();

Notes

  • When a user leaves the list. Closing the tab or navigating away stops the page's listener at once, so the row goes after the grace period. A browser that disappears without closing (a laptop lid, a lost network) is detected by the socket's ping within about 50 seconds, and its listener stops a minute after that. A brief network drop that reconnects inside that minute fires nothing.
  • Restarts. A process that is killed runs no disconnect callbacks, and one that stops inside the grace period leaves the rows it was about to delete. clearThisHost() removes them when the app starts again, which is why each row records its host. Code changes and routine deploys reload the app in place, so pages reload, their listeners stop, and the callbacks run as usual.
  • Keyed by listener, not by user. A row per user would let the old page's late delete remove the row the new page just wrote. A row per page view has no such race, and multiple tabs come for free.
  • Anonymous rooms. Nothing here needs the session except the name. For an open room, take a display name from the request or a form and pass it to join; the listener id already tells page views apart.
  • Large rooms. Each notify makes every viewer in the room call whoIsHere, so a join costs one small query per viewer. For big rooms, send the list in the notification itself and assign it directly, keeping it under Postgres' 8000-byte NOTIFY limit.