Realtime chat rooms built only on Bun's native APIs, no npm dependencies. Bun.serve handles the HTTP routes and the WebSocket upgrade, Postgres (through Bun.sql) keeps users, rooms and message history, and Redis or Valkey (through Bun.redis) keeps sessions, presence and the pub/sub channel that fans messages out to every running server instance. Passwords are hashed with Bun.password, sessions ride in a cookie managed by Bun.CookieMap, and forms are protected with Bun.CSRF.
docker compose up -d # Postgres on 5432 and Valkey on 6379
cp .env.example .env # Bun loads .env automatically
bun install
bun run dev # http://localhost:3000 with hot reloadOther scripts:
bun start # run without hot reload
bun test # unit tests; the integration test runs when DATABASE_URL and REDIS_URL are setIf the default Docker ports are busy, pick others and point the env vars at them:
POSTGRES_PORT=5433 VALKEY_PORT=6380 docker compose up -d
DATABASE_URL=postgres://postgres:postgres@localhost:5433/chat REDIS_URL=redis://localhost:6380 bun run devMessages never go straight from one socket to another. A POST stores the message in Postgres and publishes it to a Redis channel; every instance is subscribed to that channel and re-publishes what it receives to its own WebSocket subscribers for that room. Presence works the same way with a Redis set per room. To see it, start two servers on different ports against the same services, log in on both, and post in the same room:
PORT=3000 bun start
PORT=3001 bun startOpen http://localhost:3000/rooms/general and http://localhost:3001/rooms/general in two windows. A message sent on one appears on the other, and the online list matches on both.
| Method | Path | What it does |
|---|---|---|
| GET | / |
Home page: log in or sign up, list of rooms |
| GET | /rooms/:room |
Chat page for one room |
| POST | /signup, /login, /logout |
Form posts with a csrf field; set or clear the sid cookie |
| GET | /api/me |
Current user (or null) and a fresh CSRF token for the forms |
| GET | /api/rooms |
All rooms |
| GET | /api/rooms/:room/messages?limit=50 |
Recent history, oldest first |
| POST | /api/rooms/:room/messages |
Post a message (csrf and body fields), requires a session |
| GET | /api/rooms/:room/presence |
Usernames currently connected to the room |
| GET | /ws?room=:room |
WebSocket upgrade, 401 without a valid session |
| Bun API | Where |
|---|---|
Bun.serve with routes |
src/server.ts:42 |
server.upgrade to a WebSocket, 401 without a session |
src/server.ts:128 |
WebSocket handlers with typed ws.data |
src/server.ts:133 |
ws.subscribe / ws.unsubscribe topics |
src/server.ts:136, src/server.ts:144 |
server.publish to a topic |
src/realtime.ts:29 |
Bun.redis pub/sub (publish, duplicate, subscribe) |
src/realtime.ts:20, src/realtime.ts:26 |
Bun.redis presence sets (sadd, expire, srem, smembers) |
src/realtime.ts:40, src/realtime.ts:49, src/realtime.ts:54 |
Bun.redis sessions (set with EX, get, del) |
src/session.ts:23, src/session.ts:30, src/session.ts:36 |
Bun.sql tagged template queries and CREATE TABLE IF NOT EXISTS |
src/db.ts:12, src/db.ts:39, src/db.ts:78 |
Bun.password.hash / Bun.password.verify |
src/server.ts:62, src/server.ts:74 |
Bun.CookieMap via req.cookies (get, set, delete) |
src/session.ts:24, src/session.ts:37, src/server.ts:29 |
Bun.CSRF.generate / Bun.CSRF.verify |
src/csrf.ts:6, src/csrf.ts:10 |
Bun.randomUUIDv7 for session and connection ids |
src/session.ts:22, src/server.ts:127 |
| HTML import served as a route | src/server.ts:1, src/server.ts:45 |
bun:test with test.skipIf for the Docker backed test |
test/integration.test.ts:35 |
railway.json is included. Add a Postgres and a Redis service on Railway, set DATABASE_URL, REDIS_URL and SESSION_SECRET, and the server reads PORT from the environment.