# Room Presence 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 ```bash elements create migration 'add room presence' -tables=roomPresence ``` Replace the generated table in `app/migrations/-add-room-presence.migration.sql`: ```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`: ```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("presence"); export function listPresent(roomId: string): PresentUser[] { return sql( `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 ```bash elements create page room ``` `app/pages/room/index.ts`: ```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`: ```ehtml import "./style.css"; import type { Listener } from "@elements/app"; import { PresenceEvent, PresentUser, whoIsHere } from "#app/shared/services/presence"; , users: PresentUser[], private present: PresentUser[] = users, ) oninit={() => listener.on("notify", () => present = whoIsHere(roomId))}> Room

who's here

  • {u.userName}
``` 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`: ```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.