Chat: live delivery
The socket
Section titled “The socket”import { z } from 'zod'import { Forbidden } from '@theoven/core'import { requireMember } from './rooms'
app.ws('/rooms/:id/ws', { auth: true, params: z.object({ id: z.uuid() }) }, { /** * Runs before the socket exists. Throwing refuses the connection, and the client gets the * usual problem+json rather than an opaque close. */ upgrade: async (ctx) => { await requireMember(ctx.db, ctx.params.id, ctx.user.id) return { room: ctx.params.id, userId: ctx.user.id } },
open: (socket) => socket.subscribe(`room:${socket.data.data.room}`), close: (socket) => socket.unsubscribe(`room:${socket.data.data.room}`),})Three things have already happened by the time upgrade runs: the auth brick populated ctx.user,
the guard refused an anonymous request, and the params schema rejected a malformed room id. Only
then does membership get checked, and only then does a socket exist.
Broadcasting
Section titled “Broadcasting”Add one line to the send route:
export default async (ctx) => { await requireMember(ctx.db, ctx.params.id, ctx.user.id)
const [message] = await ctx.db .insert(messages) .values({ id: crypto.randomUUID(), roomId: ctx.params.id, authorId: ctx.user.id, body: ctx.body.body }) .returning()
// After the write, so nobody is shown a message that then failed to save. app.publish(`room:${ctx.params.id}`, JSON.stringify({ type: 'message', message }))
ctx.status = 201 return message}publish is Bun’s own pub/sub — the broadcast does not walk a list of connections in JavaScript.
The ordering matters: publish after the insert. Publishing first is faster and occasionally sends a message that the database then rejects, which the sender sees and the readers do not.
The client
Section titled “The client”const socket = new WebSocket(`wss://${location.host}/rooms/${roomId}/ws?access_token=${token}`)
socket.onmessage = (event) => { const { type, message } = JSON.parse(event.data) if (type === 'message') append(message)}
socket.onclose = () => setTimeout(connect, 1000)A browser WebSocket cannot set an Authorization header, so core also reads ?access_token=. On
a same-origin socket prefer the cookie — new WebSocket(url) sends it automatically and it never
touches your JavaScript.
Typing indicators
Section titled “Typing indicators”Ephemeral by nature, so they never touch the database:
app.ws('/rooms/:id/ws', { auth: true, params }, { upgrade: async (ctx) => { /* as above */ }, open: (socket) => socket.subscribe(`room:${socket.data.data.room}`),
message: (socket, raw) => { const parsed = JSON.parse(String(raw)) if (parsed.type !== 'typing') return
app.publish( `room:${socket.data.data.room}`, JSON.stringify({ type: 'typing', userId: socket.data.data.userId }), ) },
close: (socket) => socket.unsubscribe(`room:${socket.data.data.room}`),})Note what the handler does not trust: the room comes from socket.data, set during upgrade,
never from the incoming frame. A client that sends { type: 'typing', room: 'someone-elses' } gets
its own room published to, because the frame’s opinion is ignored.
Where this stops working
Section titled “Where this stops working”- One process.
publishreaches connections held by this server. Behind a load balancer, two people in the same room on different instances cannot see each other. The fix is Redis pub/sub between instances, and it is not built in. - No delivery guarantee. Someone offline gets nothing. History is the record; the socket is the fast path. That is why the send route writes first and publishes second.
- No presence. Bun’s topics are strings; who is currently in a room is yours to track.
app.publish()returns0when aRequestwas dispatched directly, which is what happens in tests — assert on the row and the response, not the broadcast.- No read receipts, edits or deletes. Each is a column and a route; none changes the shape above.
What you built
Section titled “What you built”| Rooms, membership, join and leave | with the last-owner case handled |
| History | cursor-paged, verified over 25 messages and three pages |
| Sending | membership-checked, size-capped before the handler |
| Live delivery | guarded by the same check as the HTTP routes |
Next: Trello for permissions and ordering, or YouTube for uploads and background work.