Realtime on Bun with end-to-end types: a whole chat (rooms, auth, history) in one file — no event-name strings, no generated types (I'm the author)
I'm the author of Point0, a fullstack TypeScript framework on Bun, and I just shipped its realtime layer. Sharing it here because the whole thing rides on Bun: `Bun.serve`'s WebSocket server underneath, and Bun's built-in Redis client is the one-line option when you run more than one process.
The idea: instead of a second stack next to your app (event-name strings, `any` payloads, rooms as string concatenation, your own bookkeeping of users and sockets), realtime is four more declarations of the same kind the framework already uses for pages, queries and mutations. One WebSocket per client, everything else rides it.
- a **channel** is the connection, and its connector turns the HTTP handshake into the connection's identity, stored server-side
- a **space** is a family of rooms of one shape; a room is an object, not a string, and it's the pub/sub address
- a **server handler** is client → server, a **client handler** is server → client, both typed by their schemas
Here's a whole chat: rooms per conversation, auth, persisted history, live updates. One file, and the compiler strips the server half out of the client bundle and the client half out of the server one.
// the channel: one connection per client. The connector runs on a normal HTTP
// request (cookies, headers, middleware), so your existing auth just works
export const appChannel = root.lets
.channel()
.connector(async ({ request }) => {
const user = await getUserFromRequest(request) // your app's auth, unchanged
// whatever you return IS this connection's identity: server-side, never sent
// to the client, readable in every callback below. No type declared anywhere
return user
? { authorized: true as const, id: user.id }
: { authorized: false as const, id: null }
})
.channel()
// a space: a family of rooms of one shape. Here, one room per chat
export const chatSpace = appChannel.lets
.space<{ chatId: string }>() // the room shape, declared like component props
.input(z.object({ chatId: z.string() })) // what the client passes to join
.joiner(({ input, identity }) => {
// entering the room IS the read gate, and it runs on the server
if (!identity.authorized) throw new AppError('Sign in first', { status: 401 })
return { chatId: input.chatId } // type-checked against the room shape above
})
.space()
// client → server: persist, then fan out to the room
export const messageSendHandler = chatSpace.lets
.serverHandler()
.clientSend(z.object({ text: z.string().min(1).max(1000) }))
.serverReply(async ({ input, identity, room }) => {
if (!identity.authorized) throw new AppError('Sign in first', { status: 401 })
const message = await prisma.message.create({
data: { text: input.text, chatId: room.chatId, authorId: identity.id },
})
// one publish into the room's topic — not a walk over connections
void messageAddedHandler.sendToClient(message, { room })
return message // what the sender gets back from its own send
})
.serverHandler()
// server → client
export const messageAddedHandler = chatSpace.lets
.clientHandler()
.serverSend(messageSchema)
.clientHandler()
// ...and the component, in the same file
const Chat = ({ chatId }: { chatId: string }) => {
const membership = chatSpace.useMembership({ chatId }) // in the room while mounted
const { data } = messagesQuery.useQuery({ chatId }) // history: an ordinary HTTP query
const [text, setText] = useState('')
// `message` is typed by .serverSend, `room` by the space's generic
messageAddedHandler(membership).useOnMessageFromServer(({ message }) => {
messagesQuery.setQueryData({ chatId }, (old) => ({
messages: [...(old?.messages ?? []), message],
}))
})
return (
<>
<ul>{data?.messages.map((m) => <li key={m.id}>{m.text}</li>)}</ul>
<form onSubmit={(e) => {
e.preventDefault()
setText('')
void messageSendHandler(membership).sendToServer({ text })
}}>
<input value={text} onChange={(e) => setText(e.target.value)} />
</form>
</>
)
}
Nothing is generated and nothing is annotated: the identity type comes from the connector's return, the room type from the space's generic, the payload types from the schemas, and they reach every callback on both sides.
A few design decisions worth stating plainly, because they're the part people argue with:
- **A push is a signal, not storage.** Delivery is at-most-once by default: the truth lives in a query, the push only says it went stale. There's an opt-in resume buffer for short drops, and it tells the client honestly whether the gap was covered, so the catch-up refetch is one condition.
- **A room is an object, and its serialization is its address.** `{ members: [a, b].sort() }` is a DM room. No prefix conventions to typo.
- **Two ways in.** `.joiner` is the client asking in and able to leave. `.enroller` is the server putting a connection into a room at connect time, which the client cannot leave — that's what makes a personal push room something you can rely on.
- **Multi-process is config, not rewriting.** Default is process memory; a Redis URL, Postgres LISTEN/NOTIFY, or five functions of your own turn it into a backplane.
More examples (a live board, DMs, and a site where every request travels the socket instead of HTTP): https://1gr14.dev/blog/point0-socket
Socket docs: https://1gr14.dev/point0/latest/socket
The example app: https://github.com/1gr14/point0/tree/main/examples/socket
The framework: https://github.com/1gr14/point0
It's the newest part of the framework and I say so in the docs: the API design has settled, what's underneath still needs a refactor. Happy to take the uncomfortable questions — especially from anyone who has run Socket.IO at scale and sees where this breaks.zc