Rooms
@Room is the realtime surface a backend can manage rather than only talk on.
Broadcasting and shared state already existed: a backend could push an event to a channel and write state onto it. What it could not see was the connection — who joined, who left, whether anybody is still watching. That gap had a price. Nothing could tell a project that the last device had gone, so an expensive upstream — an AI session, a market feed, a game loop — kept running, and kept billing, with nobody reading it. @OnFirst and @OnEmpty exist for exactly that pair of moments.
A room is a class, decorated and listed by a module, like everything else in this SDK. It is not a base class to extend and not a naming convention: the marker is the presence of @Room(...) and nothing else.
Quick example
// modules/presence/chat.room.ts
import {
Log,
OnAuthorize,
OnEmpty,
OnFirst,
OnJoin,
OnLeave,
OnMessage,
Room,
z,
} from "@palbase/backend";
// The events map is the room's PUBLIC SURFACE. It is declared rather than
// inferred, because a room emits from anywhere in the class and there is no
// return type to read. It becomes an enum in the generated clients, so a device
// gets a typed case instead of an opaque bag.
const Tick = z.object({ n: z.number() });
const Said = z.object({ from: z.string(), text: z.string() });
const Say = z.object({ text: z.string() });
type Say = z.infer<typeof Say>;
/** What a hook receives. It is a plain object — annotate the fields the method
* actually uses. */
interface RoomCtx {
/** The LIVE topic (`chat:42`), never the pattern. */
room: string;
/** The pattern's named segments — `{ roomId: "42" }`. */
params: Record<string, string>;
/** How many devices are watching RIGHT NOW. */
count: number;
/** Aborts when the room empties. */
signal: AbortSignal;
emit(event: string, payload: unknown): void;
owner: {
hold(work: (signal: AbortSignal) => Promise<void> | void): void;
release(): void;
};
}
@Room("chat:{roomId}", { events: { tick: Tick, said: Said }, graceMs: 90_000 })
export class ChatRoom {
/** Who may enter. A room with NO `@OnAuthorize` admits nobody. */
@OnAuthorize()
can(ctx: { user: { id: string }; params: Record<string, string> }): boolean {
return ctx.user.id !== "" && ctx.params.roomId !== undefined;
}
/** The room is occupied and nobody owns it — start what the room needs. */
@OnFirst()
open(ctx: RoomCtx): void {
ctx.owner.hold(async (signal) => {
for (let n = 0; !signal.aborted; n++) {
ctx.emit("tick", { n });
await new Promise((resolve) => setTimeout(resolve, 1_000));
}
});
}
/** A device arrived. Anything emitted HERE reaches only that device. */
@OnJoin()
greet(ctx: RoomCtx): void {
ctx.emit("tick", { n: ctx.count });
}
@OnLeave()
left(ctx: RoomCtx): void {
Log.info(`${ctx.room} is down to ${ctx.count}`);
}
/** Nobody is watching any more. The signal is already aborted by the time
* this runs, so this is for whatever the signal does not reach. */
@OnEmpty()
close(ctx: RoomCtx): void {
Log.info(`${ctx.room} is empty`);
}
/** A client message, validated against `Say` BEFORE the method runs. */
@OnMessage("say", Say)
say(ctx: RoomCtx & { user: { id: string }; payload: Say }): void {
ctx.emit("said", { from: ctx.user.id, text: ctx.payload.text });
}
}
The class is not registered by living in a folder or by being default-exported. A module lists it, and that list is the registration:
// modules/presence/presence.module.ts
import { Module } from "@palbase/backend";
import { ChatRoom } from "./chat.room";
@Module({
controllers: [],
// A room is a provider, beside `@Job`, `@Webhook` and `@Hook` classes.
providers: [ChatRoom],
exports: [],
imports: [],
})
export class PresenceModule {}
A room class no module lists does not exist — the build refuses it by name, exactly as it refuses an unlisted controller or service.
The six hooks
| Hook | When it runs | What it is for |
|---|---|---|
@OnAuthorize() | a device asks to join | decide who enters, and whether they may publish |
@OnFirst() | the room is occupied and nobody owns it | start the upstream the room needs |
@OnJoin() | a device arrived | greet it — an emit here reaches only that device |
@OnLeave() | a device left | bookkeeping; the room may still be busy |
@OnEmpty() | nobody is watching any more | stop whatever @OnFirst started |
@OnMessage(name, schema) | a client published name | handle the message, after the schema has accepted it |
Every hook is optional except the first: a room with no @OnAuthorize is fail-closed and admits nobody.
@OnFirst is deliberately a condition, not a 0→1 transition, and the difference is load-bearing. A runtime restart, a deploy that leaves the previous release standing, and an owner process that died all leave a full room with nobody holding it. A transition would fire once and never again; a condition recovers every time. Exactly one runtime in the cluster enters this hook for a given topic, so three devices never open three upstreams.
ctx.owner.hold(work) runs work under the room's AbortSignal, so an emptying room tears it down without the hook remembering to. ctx.owner.release() gives ownership up early, which makes the room ownerless again and lets @OnFirst re-run while somebody is still watching.
The pattern is the topic
@Room("chat:{roomId}") addresses the live topic chat:42, and {roomId} arrives in ctx.params. Patterns are colon-separated, the same rule the channel matcher uses on both ends of the wire. A pattern written with slashes matches no live topic, so it is refused by name at decoration time rather than shipping a room that silently never fires. An empty segment and a parameter used twice are refused the same way — dm:{uid}:{uid} would let the last segment win, and an owner check would then compare an attacker's id with itself and pass.
@OnAuthorize decides who enters
The method receives the caller and the pattern's parameters, and its return value is the grant:
| Return | Result |
|---|---|
true | admitted, may subscribe and publish |
{ write: false } | admitted as a reader — subscribe only |
{ read: false } | refused; admitted to nothing is not admitted |
null, undefined or false | refused |
There is no ambient "signed in is enough": a room with no @OnAuthorize method refuses every join, so the closed state is what you get by writing nothing.
graceMs — how long a room is held
graceMs is how long a room is held after its last sign of life. It defaults to 60 000 ms, and that floor is not a matter of taste: the decision has to survive the slowest client's heartbeat gap twice over, and the web SDK beats every 25 seconds. Raise it for an upstream that is expensive to restart; do not lower it below roughly 50 seconds while a web client is beating at 25.
A room is a provider
A room is built by the container like any other class, so it takes its dependencies through its constructor — there is no second wiring syntax for rooms:
// modules/presence/room-names.repo.ts
import { Database, Injectable } from "@palbase/backend";
import type { Tables } from "@palbase/backend/env";
/** One row of `rooms`, exactly as `db/public.ts` declares it. */
export type RoomRow = Tables["rooms"]["row"];
/** The abstraction is the TOKEN — `abstract` so a test can substitute it by
* name through `isolated()`. */
export abstract class RoomNames {
abstract find(id: string): Promise<RoomRow | null>;
}
/** The implementation is the PROVIDER, declared by `extends` and nothing else. */
@Injectable()
export class DbRoomNames extends RoomNames {
find(id: string): Promise<RoomRow | null> {
return Database.public.rooms.findById(id);
}
}
// modules/presence/lobby.room.ts
import { OnFirst, Room, z } from "@palbase/backend";
import { RoomNames } from "./room-names.repo";
const Opened = z.object({ name: z.string() });
@Room("lobby:{lobbyId}", { events: { opened: Opened } })
export class LobbyRoom {
// The seam is the constructor, exactly as it is in a service or a controller.
// A dependency is named by its parameter's TYPE and by nothing else.
constructor(private readonly names: RoomNames) {}
@OnFirst()
open(ctx: {
params: Record<string, string>;
owner: { hold(work: (signal: AbortSignal) => Promise<void> | void): void };
emit(event: string, payload: unknown): void;
}): void {
ctx.owner.hold(async () => {
const row = await this.names.find(ctx.params.lobbyId ?? "");
ctx.emit("opened", { name: row?.name ?? "unnamed" });
});
}
}
Both classes go in the same module's providers. The constructor stays pure wiring — the container builds every class once while the app comes up, so a room's constructor does no I/O; the work belongs in @OnFirst, which runs when a device is actually there.
A room and channels.ts
A room is a channel, declared a different way, so it rides the paths that already exist. channels.ts sits at the project root, default-exports the declaration, and a project with no rooms produces exactly the table it always did:
// channels.ts
import { defineChannels, ownerOnly, publicChannel } from "@palbase/backend";
export default defineChannels({
// One {param}, compared to the token's subject: only the owner subscribes.
"user:{userId}": ownerOnly(),
"prices:{symbol}": publicChannel({ publish: false }),
});
The two declarations answer different questions, and one topic has one owner:
- an undeclared channel is denied by both routes — fail-closed either way;
- on a room's pattern a client publish reaches
@OnMessageinstead of fanning out to the other subscribers, which is the authoritative-handler contract a room needs; adefineChannelsentry fans out unless it declares ahandlerof its own; - declaring the same pattern in both places is a mistake, and where the
channels.tsentry brings its ownauthorizeorhandlerthe boot refuses it by name rather than picking one at match time.
Write the pattern in one place. If a topic needs lifecycle, it is a room; if it only needs a subscribe rule, it is a channels.ts entry.
Related
- Channels — declaring who may subscribe, publish and write state
- Realtime — broadcasting to a channel from a controller or a service
- Modules & Dependency Injection — the lists that make a room exist