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.

FeatureBrokerTopicData
MessagesEvent Brokerchat:{room}event message: { clientId, name, text }
Who's onlineState Brokerpresence:{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:

  1. Event Broker → Claims: a claim "Chat browser" with chat = P S R; a claim "Chat server" with chat = R.
  2. State Broker → Claims: "Chat browser" with presence = P S R.
  3. App Keys: chat-browser (token TTL 60 s) linked to both browser claims; chat-server (token TTL 300 s) linked to "Chat server".
  4. Event Broker → Topics: chat with max_events 100000 and ttl_seconds 604800 (a week).
  5. State Broker → Topics: presence with ttl_seconds 120.

2. Server: token and history routes#

Two routes, both behind your own authentication. The key names below are environment variables.

TypeScript
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:

TypeScript
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.

TypeScript
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#

TypeScript
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 entries

Render text and name as text, never as HTML.

Where to go from here#

  • Typing throttling. Send typing: true at most every few seconds while keys are pressed, and false after a pause.
  • Unread counts. Remember the last ts a user saw per room; count newer events.
  • Moderation. A backend key with chat = W R can purge a room's history (DELETE /event/data/chat:{room}) and read every room by subscribing to chat.
  • Private rooms. A browser that can subscribe to chat can subscribe to every room. For rooms only some users may read, see Security model.