Guides
Tutorial: a chat with presence
Rooms, message history, live messages, who's online and who's typing. Both brokers, one token route, about 150 lines of code.
This tutorial builds the core of a chat: rooms with history, live messages, and a presence list with typing indicators. It is the same design as Thuum Chat, one of the example apps.
| Feature | Broker | Topic | Data |
|---|---|---|---|
| Messages | Event Broker | chat:{room} | event message: { clientId, name, text } |
| Who's online | State Broker | presence:{room} | key {clientId}: { name, typing } |
Messages are events (things that happened, with history); presence is state (how things are now).
1. Set up Thuum#
In the console:
- Event Broker → Claims: a claim "Chat browser" with
chat=P S R; a claim "Chat server" withchat=R. - State Broker → Claims: "Chat browser" with
presence=P S R. - App Keys:
chat-browser(token TTL 60 s) linked to both browser claims;chat-server(token TTL 300 s) linked to "Chat server". - Event Broker → Topics:
chatwithmax_events100000 andttl_seconds604800 (a week). - State Broker → Topics:
presencewithttl_seconds120.
2. Server: token and history routes#
Two routes, both behind your own authentication. The key names below are environment variables.
import express from 'express';
import { ThuumClient } from '@thuum/client';
const app = express();
const thuum = new ThuumClient({ baseUrl: process.env.THUUM_API_URL! });
const ROOMS = new Set(['general', 'random', 'help']);
app.get('/api/thuum-token', requireUser, async (req, res) => {
const { token } = await thuum.exchangeToken('acme', process.env.CHAT_BROWSER_KEY!);
res.set('cache-control', 'no-store').json({
baseUrl: process.env.THUUM_API_URL,
domain: 'acme',
token
});
});
app.get('/api/rooms/:room/messages', requireUser, async (req, res) => {
if (!ROOMS.has(req.params.room)) return res.status(404).end();
const { token } = await thuum.exchangeToken('acme', process.env.CHAT_SERVER_KEY!);
const events = await thuum
.domain('acme', token)
.eventBroker.data.list(`chat:${req.params.room}`, {
type: 'message',
since: req.query.since ? Number(req.query.since) : undefined,
limit: 100
});
res.set('cache-control', 'no-store').json(events);
});3. Browser: validate what you read#
Anyone with a browser token can publish anything on chat, so check every message before showing
it:
import type { BrokerEvent } from '@thuum/client';
export interface ChatMessage {
id: string;
ts: number;
clientId: string;
name: string;
text: string;
}
export function toChatMessage(event: BrokerEvent): ChatMessage | null {
if (event.type !== 'message') return null;
const data = event.data as Record<string, unknown> | null;
if (!data || typeof data !== 'object') return null;
const { clientId, name, text } = data;
if (typeof clientId !== 'string' || typeof name !== 'string' || typeof text !== 'string')
return null;
if (!text.trim() || text.length > 1000 || name.length > 32) return null;
return { id: event.id, ts: event.ts, clientId, name, text };
}4. Browser: the room connection#
One class owns both sockets for a room. It follows the reliable client pattern: subscribe, then catch up, de-duplicate by id.
import { EventBrokerSocket, StateBrokerSocket, type BrokerEvent } from '@thuum/client';
const HEARTBEAT_MS = 20_000;
const OFFLINE_AFTER_MS = 45_000;
export class Room {
messages: ChatMessage[] = [];
peers = new Map<string, { name: string; typing: boolean; seenAt: number }>();
#eb!: EventBrokerSocket;
#sb!: StateBrokerSocket;
#heartbeat?: ReturnType<typeof setInterval>;
#typing = false;
constructor(
private readonly room: string,
private readonly clientId: string,
private name: string,
private readonly onUpdate: () => void
) {}
get #chatTopic() {
return `chat:${this.room}`;
}
get #presenceTopic() {
return `presence:${this.room}`;
}
async join(): Promise<void> {
const grant = async () => (await fetch('/api/thuum-token', { cache: 'no-store' })).json();
this.#eb = new EventBrokerSocket(await grant());
this.#sb = new StateBrokerSocket(await grant());
this.#eb.onMessage((event) => {
const message = event.topic === this.#chatTopic ? toChatMessage(event) : null;
if (message) this.#add([message]);
});
this.#sb.onChange(({ topic, key, value }) => {
if (topic !== this.#presenceTopic) return;
const peer = value as { name?: unknown; typing?: unknown } | null;
if (peer && typeof peer.name === 'string') {
this.peers.set(key, { name: peer.name, typing: peer.typing === true, seenAt: Date.now() });
} else {
this.peers.delete(key); // deleted, or not a presence value
}
this.onUpdate();
});
await Promise.all([this.#eb.ready, this.#sb.ready]);
await Promise.all([this.#eb.sub(this.#chatTopic), this.#sb.sub(this.#presenceTopic)]);
const history: BrokerEvent[] = await (await fetch(`/api/rooms/${this.room}/messages`)).json();
this.#add(history.map(toChatMessage).filter((m): m is ChatMessage => m !== null));
await this.#announce();
this.#heartbeat = setInterval(() => void this.#announce(), HEARTBEAT_MS);
}
/** Shown when it comes back from the broker, so every screen has the same order. */
async send(text: string): Promise<void> {
await this.#eb.pub(this.#chatTopic, 'message', {
clientId: this.clientId,
name: this.name,
text
});
this.setTyping(false);
}
setTyping(typing: boolean): void {
if (typing === this.#typing) return;
this.#typing = typing;
void this.#announce();
}
/** Peers seen recently: a vanished tab disappears without waiting for the TTL. */
online(now = Date.now()) {
return [...this.peers.entries()].filter(([, p]) => now - p.seenAt < OFFLINE_AFTER_MS);
}
async leave(): Promise<void> {
clearInterval(this.#heartbeat);
await this.#sb.del(this.#presenceTopic, this.clientId).catch(() => {});
this.#eb.close();
this.#sb.close();
}
async #announce(): Promise<void> {
await this.#sb
.set(this.#presenceTopic, this.clientId, { name: this.name, typing: this.#typing })
.catch(() => {});
}
#add(incoming: ChatMessage[]): void {
const known = new Set(this.messages.map((m) => m.id));
const fresh = incoming.filter((m) => !known.has(m.id));
if (!fresh.length) return;
this.messages = [...this.messages, ...fresh].sort(
(a, b) => a.ts - b.ts || (a.id < b.id ? -1 : 1)
);
this.onUpdate();
}
}For brevity this version doesn't reconnect; add the backoff loop from
Build reliable clients around join(), and call it again after a drop with
since set to the newest message's ts.
5. Wire it to your UI#
const room = new Room('general', crypto.randomUUID(), currentUser.name, render);
await room.join();
input.addEventListener('input', () => room.setTyping(input.value.length > 0));
form.addEventListener('submit', async (e) => {
e.preventDefault();
await room.send(input.value);
input.value = '';
});
addEventListener('pagehide', () => void room.leave());
setInterval(render, 5_000); // ages presence entriesRender text and name as text, never as HTML.
Where to go from here#
- Typing throttling. Send
typing: trueat most every few seconds while keys are pressed, andfalseafter a pause. - Unread counts. Remember the last
tsa user saw per room; count newer events. - Moderation. A backend key with
chat=W Rcan purge a room's history (DELETE /event/data/chat:{room}) and read every room by subscribing tochat. - Private rooms. A browser that can subscribe to
chatcan subscribe to every room. For rooms only some users may read, see Security model.