Room Presence
elements man recipes/presence Read as markdownA 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
disconnectcallbacks, 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 itshost. 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-byteNOTIFYlimit.