Rooms & Presence#
Rooms give you multi-connection fan-out over WebSockets with a built-in presence roster. A room is a Channel-keyed group: every connection that joins the same channel key sees each other's messages (via server-mediated broadcast) and each other's presence state. The roster (members) is reactive on the client and updates whenever anyone joins, leaves, or changes their presence.
Rooms are the right tool when you need multiple clients to talk to each other and you want to track who is connected. For server-to-client-only data streams, use Realtime Channels or Live Loaders. For a private duplex channel to one client, use WebSockets.
Overview#
Three pieces cooperate:
- Define a channel with
defineChannel. The channel name pattern (e.g.room/:roomId) sets the room key and types the params that flow into the server handler. - Define a room with
defineRoom(channel, handler)inside aserverRoomsexport in a.servermodule. The handler receives aRoomConnectionthat can send to one client, broadcast to others, and update presence. - Join from the client with
serverRooms.<name>.useRoom({ key })or the freeuseRoom(ref, opts)form. The hook returns a reactivemembersroster, asendfunction, andsetPresencefor updating this client's state.
Rooms ride the same /__sockets WebSocket transport as typed sockets and inherit the same guard composition: app use, then route-node use, then the room's own use. Fan-out is server-mediated: the client sends a message and the room's onMessage decides whether to call conn.broadcast.
Example: live cursor board#
Channel definition#
Define the channel in a shared file (not a .server module) so both server and client can import it:
// src/cursors-channel.ts
import { defineChannel } from 'hono-preact';
export type CursorMsg = { x: number; y: number };
// The channel name carries the room-key param.
// The payload type is the message type the room exchanges.
export const cursorsChannel = defineChannel('board/:boardId')<CursorMsg>();
Server module#
Place the room definition in a .server.ts module inside a serverRooms export. The build strips the server body on the client side and replaces it with a lightweight descriptor:
// src/board.server.ts
import { defineRoom } from 'hono-preact';
import { cursorsChannel, type CursorMsg } from './cursors-channel.js';
export type CursorState = { x: number; y: number; name: string };
export const serverRooms = {
cursors: defineRoom(cursorsChannel, {
// data runs at the edge with the live Hono Context. Its serializable result
// seeds conn.data, available in onJoin and onMessage. Use it to capture
// request-derived values (the authenticated user, a header) that are not
// available inside a Durable Object on Cloudflare.
data: (c) => ({ name: c.get('user')?.name ?? 'Guest' }),
// Seed the joining member's initial presence state.
presence: () => ({ x: 0, y: 0, name: 'Anonymous' }),
onJoin(conn, { params }) {
// params is typed: { boardId: string }
// conn.data.name was captured at the edge by the data factory above.
conn.setPresence({ x: 0, y: 0, name: conn.data.name });
},
onMessage(conn, msg) {
// The client sent a cursor position. Broadcast to every other member.
conn.broadcast(msg);
},
onLeave(conn) {
// No explicit cleanup needed; the presence roster removes this member
// automatically when the connection closes.
},
}),
};
Client component#
Import serverRooms from the .server module and call .useRoom with the channel key params:
// src/board.tsx
import { serverRooms } from './board.server.js';
import type { CursorState } from './board.server.js';
export default function Board() {
const { send, setPresence, members, self, status } =
serverRooms.cursors.useRoom({
key: { boardId: 'main' },
// Seed this client's initial presence.
presence: { x: 0, y: 0, name: 'Me' },
onMessage(msg, from) {
// Incoming cursor position from another member.
// `msg` is typed as CursorMsg; `from` is the sender's member id.
console.log(from, 'moved to', msg);
},
});
function handleMouseMove(e: MouseEvent) {
const pos = { x: e.clientX, y: e.clientY };
// Update this client's presence in the roster.
setPresence({ ...pos, name: self?.state?.name ?? 'Me' });
// Notify others of the new position.
send(pos);
}
return (
<div
onMouseMove={handleMouseMove}
style="position:relative;width:100%;height:400px"
>
<p>Status: {status}</p>
{members.map((m) => (
<div
key={m.id}
style={`position:absolute;left:${m.state?.x}px;top:${m.state?.y}px`}
>
{m.state?.name}
</div>
))}
</div>
);
}
members is reactive and updates on every join, leave, or presence change. self is this client's own roster entry, derived from the roster (it is undefined until the first server snapshot arrives). Incoming messages from other members go to onMessage; they do not trigger a re-render.
Broadcast vs send#
The server conn has two delivery methods:
conn.send(msg)sends to this one client only.conn.broadcast(msg)fans out to every other member of the room. The sender is excluded by default; pass{ self: true }to also deliver to the sender.
The client has only send. There is no client-side broadcast because fan-out is server-mediated: the client calls send and the room's onMessage decides whether to call conn.broadcast. This keeps authorization centralized on the server.
Guard inheritance#
The guard chain for a room is: app use (from defineApp({ use })) then the owning route's page-layer use then the room's own use. A room colocated with a route (its .server.ts sibling) inherits that route's use chain automatically, the same as a colocated loader or action:
export const serverRooms = {
cursors: defineRoom(cursorsChannel, {
// The room's own guard, composed after the inherited app and route-node use.
use: [requireBoardAccess],
// ...
}),
};
serverRoute(r).room(channel, handler) binds a room to a route explicitly instead of relying on colocation:
// src/server/boards/cursors.server.ts
import { serverRoute } from 'hono-preact';
import { cursorsChannel } from '../../cursors-channel.js';
const route = serverRoute('/board/:boardId');
export const serverRooms = {
cursors: route.room(cursorsChannel, {
// Inherits `/board/:boardId`'s page-layer `use` chain, wherever this file lives.
onJoin(conn, { params }) {
// params is typed from the CHANNEL pattern (board/:boardId), not the route.
// The route's own `:boardId` is required to be a channel param of the same
// name too, so the guard chain's `ctx.location.pathParams.boardId` and this
// `params.boardId` always denote the same resource.
},
}),
};
The declared pattern is stamped on the def and validated at boot: a pattern that does not match the module's mount, names an unknown route, or targets a childless wildcard fails the boot rather than running the room under an empty gate chain. A declared binding takes precedence over the module-mount derivation, so it also lets a route-bound room live under the src/server registry organized by domain rather than beside its view. A bare defineRoom with no serverRoute binding, defined in a src/server registry module rather than colocated with a route, is route-independent: it only ever runs the app-level use and its own use.
A room's route and its channel must be congruent: every :param the route pattern declares (including an optional :id? or rest :rest*/:rest+ slot) must also be a param the channel pattern declares, and every :param the route requires must be a required param of the channel too (an optional or rest channel slot can be absent at runtime, so it cannot stand in for a required route param). The channel may be finer-grained and carry extra params of its own, but never coarser. /board/:boardId bound to a board/:boardId channel is congruent; /board/:id bound to that same channel is not, because id names no channel key. This applies whether the route param is required or optional/rest: preact-iso's runtime matcher binds an optional or rest param over HTTP just as readily as a required one, so a guard can read it either way.
Whether a mismatch fails the boot depends on how the room reaches its route:
- An explicitly bound room (
serverRoute(r).room, or the equivalentserverRoute(r).socket) fails the boot closed on a mismatch, but only when a guard could actually read the mismatched param, that is, when at least one of the three tiers is non-empty: app-levelusefromdefineApp, the route-nodeusechain, or the room's ownuse. With all three empty, no guard can read the param, so the boot proceeds and prints a console advisory naming the mismatch instead. Either way the mismatch is never silent. - A colocated room (no
serverRoutebinding) never fails the boot for a mismatch. Its route is its module's mount, and it carries no param wire, so its guard chain reads the missing param asundefined. It prints the same console advisory instead, whether a guard reads that param today or you add one later. That advisory (like the socket one below) prints in production too, not just in dev: it is your only signal that the mismatch is live.
When congruence holds (names line up correctly), a separate dev-only advisory names each correspondence (which route param is satisfied by which channel key), so the pairing is never silent even when the names happen to match. A room's guard chain reads its params from the resolved channel key, not the route, so a route param the channel doesn't carry reads as undefined.
The requirement applies to a colocated room the same as an explicitly bound one: a colocated room's effective route is the module's mount, so its params are checked against that mount even with no declared __routeId. A bare defineRoom with no route binding and no colocation is route-independent and is not checked.
A route param outside the supported :name class (one or more of [A-Za-z0-9_], with an optional ?/*/+), like a hyphenated /board/:board-id, gets special treatment. Its value never reaches pathParams for a realtime connection, even though preact-iso's runtime matcher binds it fine over plain HTTP, so room param resolution can't see it. The congruence check above still can: it reasons over every param a guard could actually read off the route, using preact-iso's own matcher rather than this framework's narrower :param grammar, so a hyphenated segment is caught the same as a conforming one.
What happens next depends on how the room reaches that route. A colocated room is never rejected (per the bound/colocated split above); it prints the same console advisory instead. An explicitly bound room (serverRoute(r).room, or an equivalent serverRoute(r).socket) fails the boot outright, whether or not a guard is live: serverRoute('/board/:board-id').room(...) throws at startup rather than run with an empty params object, so every /__loaders request, action POST, and /__sockets upgrade returns a 500 until you rename the segment to use only letters, digits, and underscores.
A guard that denies the upgrade closes the connection with code 4403. Use deny() rather than redirect() in a room guard: a WebSocket handshake cannot follow an HTTP redirect, so a redirect() is treated as a deny (close 4403) and logs a warning.
The room-key params are resolved server-side before the guard chain runs, so a route-node or room use guard can read them via ctx.location.pathParams (for example ctx.location.pathParams.roomId) to make resource-scoped authorization decisions. This holds whether the route-node use is colocation-derived or explicitly bound: binding selects the use chain, not the room-key param wire, which always comes from the channel. The congruence check above is what guarantees that wire actually carries every param the route's own guards expect.
The params a room sees, both onJoin's ctx.params and the guard's ctx.location.pathParams, are plain objects: read them any way you like. You never have to worry about a key name clashing with a built-in like constructor or toString, because a channel can't declare one in the first place: defineChannel('board/:constructor') throws at definition.
Presence reconnect behavior#
On reconnect, the room re-joins and the server sends a fresh presence snapshot. Any presence state the client passed in opts.presence is re-sent on open. There is a brief membership gap between disconnect and rejoin; missed messages during a disconnect are not replayed.
Runtime scope#
On Node, rooms fan out within a single process. Each Cloudflare Worker request is isolated, so in-process fan-out only reaches connections on the same instance. Rooms on Cloudflare run inside a Durable Object where all connections for a channel key share one instance, giving true cross-connection fan-out.
Cloudflare setup#
Rooms on Cloudflare run inside a Durable Object. The framework provides and re-exports the HonoPreactRealtimeDO class from the generated worker entry; you only need to declare the binding and migration in wrangler.jsonc. Apps scaffolded with the Cloudflare template get this pre-wired.
Add these two fields to wrangler.jsonc:
"durable_objects": {
"bindings": [{ "name": "HONO_PREACT_REALTIME", "class_name": "HonoPreactRealtimeDO" }]
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["HonoPreactRealtimeDO"] }]
By default the binding name is HONO_PREACT_REALTIME and the class name is HonoPreactRealtimeDO: the generated entry reads c.env.HONO_PREACT_REALTIME and re-exports HonoPreactRealtimeDO. To use different names, pass them to the adapter in vite.config.ts and keep wrangler.jsonc in sync:
import { cloudflareAdapter } from 'hono-preact/adapter-cloudflare';
honoPreact({
adapter: cloudflareAdapter({
realtimeBinding: 'MY_REALTIME',
realtimeClass: 'MyRealtimeDO',
}),
});
realtimeBinding must match the binding name; realtimeClass must match both the binding class_name and the new_sqlite_classes migration tag. realtimeClass must be a valid JavaScript identifier (it is emitted as a class declaration in the generated entry).
A few behaviors differ between Node and Cloudflare:
- Plain sockets (
defineSocket/useSocket) run in a Durable Object. On Cloudflare, the worker guards the upgrade at the edge and forwards it to a per-connection Durable Object running under the Hibernation API; on Node they run in-process. See WebSockets for setup details. onJointeardown functions run only on Node. A function returned fromonJoinis called on connection close in the Node runtime. On Cloudflare a Durable Object can hibernate between messages, so the teardown cannot be preserved. UseonLeavefor cleanup that must run on both runtimes.conn.datais read-only edge metadata.conn.datais typedReadonly<Data>and seeded by thedata()factory at upgrade time. For state that evolves and must broadcast, usesetPresence. For Node-only mutable per-connection state, capture a closure variable inonJoin()instead.
API reference#
defineRoom(channel, handler)#
Defines a typed broadcasting room bound to a Channel. Place it in a serverRooms export in a .server module.
| Parameter | Type | Description |
|---|---|---|
channel | Channel<Name, Payload> | The channel that keys this room. The name pattern types onJoin's ctx.params. |
handler | RoomHandler | The server-side lifecycle object (see below). |
Returns a RoomRef that the client hook reads. On the client side the .server import is replaced by a lightweight descriptor.
Handler fields#
| Field | Type | Description |
|---|---|---|
use | ReadonlyArray<Middleware> | Guard chain run before the upgrade. A failing guard closes with code 4403. |
presence | () => State | Seeds the joining member's initial presence state. |
data | (c: Context) => Data | Promise<Data> | Optional. Runs at the edge (the worker) with the live Hono Context. May be async. Its serializable result seeds conn.data, available in onJoin and onMessage. Without a data factory, conn.data is undefined. On Cloudflare the result rides a request header to the Durable Object (6KB limit); the Node dev server warns when the factory result exceeds this budget. |
onJoin | (conn, { params }) => void | (() => void) | Promise<...> | Runs once per connection after the upgrade. params is typed from the channel name; request-derived data lives on conn.data. Returning a function registers it as a teardown called on leave. |
onMessage | (conn, msg) => void | Promise<void> | Called for every incoming message from this connection. |
onLeave | (conn) => void | Called when the connection closes. |
onError | (conn, err) => void | Called on a socket error. |
RoomConnection fields (the conn handle)#
| Field | Type | Description |
|---|---|---|
id | string | This connection's stable member id. |
send(msg) | method | Send a message to this one client. |
broadcast(msg, opts?) | method | Fan out to every other member. Pass { self: true } to also include the sender. |
setPresence(state) | method | Publish this connection's presence state to the roster. |
data | Readonly<Data> | Per-connection metadata seeded by the data() factory. Read-only; use setPresence for evolving state. For Node-only mutable per-connection state, capture a closure variable in onJoin(). |
close(code?, reason?) | method | Close this connection. |
ref.useRoom(opts) / useRoom(ref, opts)#
opts is optional for a param-less channel, and required for a channel with
params (to supply key; see below).
| Option | Type | Default | Description |
|---|---|---|---|
key | channel params | required if channel has params | The room-key params (e.g. { roomId: 'demo' }). The server interpolates the topic from these. |
presence | State | Initial presence state, sent on open and re-sent on every reconnect. | |
onMessage | (msg, from: string) => void | Called for each incoming message. Does not trigger a re-render. | |
onOpen | () => void | Called when the connection opens. | |
onClose | (e: CloseEvent) => void | Called when the connection closes. | |
shouldReconnect | (e: CloseEvent) => boolean | See below | Returns true to reconnect. |
reconnect.maxRetries | number | 5 | Maximum reconnect attempts. |
reconnect.minDelay | number | 250 | Minimum delay in ms before first retry. |
reconnect.maxDelay | number | 30000 | Backoff cap in ms. |
reconnect.growth | number | 2 | Exponential growth factor. |
enabled | boolean | true | When false, the room does not connect. |
Default shouldReconnect: false for codes 1000 and 4000-4999, true otherwise.
Returns:
| Field | Type | Description |
|---|---|---|
send | (msg) => void | Send a message to the server. Queued if not yet open. |
setPresence | (state) => void | Publish this client's presence state to the roster. |
members | ReadonlyArray<PresenceMember<State>> | The presence roster, reactive. |
self | PresenceMember<State> | undefined | This client's own roster entry. undefined until the first snapshot arrives. |
status | SocketStatus | Reactive connection status ('connecting', 'open', 'reconnecting', 'closing', 'closed'). |
close | (code?, reason?) => void | Close the connection. Suppresses reconnect. |
closeInfo | SocketCloseInfo | undefined | Code, reason, and wasClean from the last close event. |
Type exports#
import type {
RoomRef,
RoomHandler,
RoomConnection,
UseRoomOptions,
UseRoomResult,
PresenceMember,
UseRoomArgs,
} from 'hono-preact';
UseRoomArgs<R> is the type of useRoom's options rest tuple ([opts?: UseRoomOptions<R>] for a param-less binding, [opts: UseRoomOptions<R>] for a param-bearing one): useful for typing a generic wrapper that forwards its own rest args to useRoom without re-deriving the required-vs-optional rule itself.
See also#
- WebSockets: the typed socket primitive (
defineSocket+useSocket) for private duplex channels. - Realtime Channels: server-to-client pub/sub over SSE with
defineChannel+publish. - Live Loaders: persistent streaming loaders for layout-scoped widgets.