WebSockets
Real-time communication with low-level routes, high-level channels, presence tracking, and server-side broadcasting.
Introduction
tekir provides two levels of WebSocket support, both built on Bun's native WebSocket API:
- Low-level routes: register individual WebSocket endpoints with
server.ws().route(path, handler). Full control over the connection lifecycle. - Channels: high-level abstraction with rooms, auth, presence tracking, and broadcasting. All channels are multiplexed over a single
/wsendpoint.
You can use both at the same time: raw routes handle specific paths, channels handle everything through /ws.
Low-Level Routes
For simple WebSocket endpoints, use server.ws().route() directly. Each route gets a path and a WsHandler.
import type { TekirApp } from '@tekir/core'
export default function({ server }: TekirApp) {
server.ws().route('/ws/ping', {
open(ws) {
ws.send('connected')
},
message(ws, msg) {
ws.send(`echo: ${msg}`)
},
close(ws, code, reason) {
console.log(`Closed: ${code} ${reason}`)
}
})
}WsHandler
A WsHandler has five optional callbacks: upgrade, open, message, close, and drain. The upgrade() callback runs before the WebSocket opens, return an object to attach to ws.data.
server.ws().route('/ws/chat', {
// upgrade(): runs on HTTP upgrade request, before WebSocket opens.
// Return an object to attach to ws.data.
upgrade(req) {
const url = new URL(req.url)
return {
room: url.searchParams.get('room') || 'general',
userId: getUserIdFromToken(req)
}
},
open(ws) {
ws.subscribe(ws.data.room)
ws.publish(ws.data.room, JSON.stringify({ type: 'join', userId: ws.data.userId }))
},
message(ws, msg) {
ws.publish(ws.data.room, String(msg))
},
close(ws) {
ws.publish(ws.data.room, JSON.stringify({ type: 'leave', userId: ws.data.userId }))
ws.unsubscribe(ws.data.room)
},
drain(ws) {
// Called when send buffer is flushed: implement backpressure here
}
})Sending Messages
// Send to this client only
ws.send('Hello!')
ws.send(JSON.stringify({ type: 'pong', time: Date.now() }))
// Pub/Sub: Bun's native topic system
ws.subscribe('news')
ws.publish('news', JSON.stringify({ headline: 'Big news!' }))
ws.unsubscribe('news')
// Batch sends atomically
ws.cork(() => {
ws.send('message 1')
ws.send('message 2')
})Pub / Sub
Bun's native pub/sub powers both low-level rooms and high-level channels. Topics are arbitrary strings, subscribe, publish, unsubscribe. No server-side subscriber lists needed.
ws.publish(topic, data): broadcast to all subscribers except the senderserver.publish(topic, data): broadcast to all subscribers including the sender
Channels
Channels are the recommended way to build real-time features. Define a channel class with onJoin, onMessage, and onLeave hooks. Channels are automatically multiplexed over a single /ws endpoint, clients join/leave rooms by sending JSON messages.
import { Channel } from '@tekir/core'
export class ChatChannel extends Channel {
// Called when a client sends a "join" message
onJoin(ws, room) {
this.broadcast(room, 'user:joined', { user: ws.data.user.name })
}
// Called on each client event
onMessage(ws, event, data, room) {
if (event === 'message') {
// broadcastExcept sends to everyone in the room except the sender
this.broadcastExcept(ws, room, 'message', {
text: data.text,
user: ws.data.user.name,
})
}
}
// Called when a client leaves or disconnects
onLeave(ws, room) {
this.broadcast(room, 'user:left', { user: ws.data.user.name })
}
}Register channels in your start file:
import type { TekirApp } from '@tekir/core'
import { ChatChannel } from '#channels/chat'
import { NotificationsChannel } from '#channels/notifications'
export default function({ server }: TekirApp) {
const wsm = server.ws()
// Register channels: multiplexed over a single /ws endpoint
wsm.channel('chat', ChatChannel)
wsm.channel('notifications', NotificationsChannel)
}Channel Auth
Set an auth resolver with wsm.channelAuth(): it runs during the WebSocket upgrade and resolves the user from a token, cookie, or header. The result is stored in ws.data.user. Use requireAuth = true on channels that need authentication, and authorize() for fine-grained access control.
import type { TekirApp } from '@tekir/core'
import { Channel } from '@tekir/core'
import { db } from '#services'
export default function({ server }: TekirApp) {
const wsm = server.ws()
// Auth resolver: runs during WebSocket upgrade.
// Parse JWT, database token, session cookie: whatever you use.
wsm.channelAuth(async (req) => {
const url = new URL(req.url)
const token = url.searchParams.get('token')
if (!token) return null
// Database token lookup
const row = await db.queryOne(
'SELECT u.* FROM users u JOIN tokens t ON t.user_id = u.id WHERE t.token = ?',
[token]
)
return row // stored in ws.data.user
})
wsm.channel('chat', ChatChannel)
wsm.channel('admin', AdminChannel)
}import { Channel } from '@tekir/core'
// Public channel: anyone can join
export class PublicChannel extends Channel {
onMessage(ws, event, data, room) {
this.broadcast(room, event, data)
}
}
// Authenticated-only channel
export class ChatChannel extends Channel {
requireAuth = true // unauthenticated users are automatically denied
onJoin(ws, room) {
// ws.data.user is guaranteed to exist here
this.broadcast(room, 'joined', { user: ws.data.user.name })
}
}
// Role-based access
export class AdminChannel extends Channel {
requireAuth = true
authorize(ws, params) {
return ws.data.user.role === 'admin'
}
}
// Room-level access control
export class DMChannel extends Channel {
requireAuth = true
authorize(ws, params) {
// User can only join their own DM inbox
return String(ws.data.user.id) === params.userId
}
}Presence Channels
Set presence = trueon a channel to automatically track who's online. When a user joins, they receive a presence:sync with all current members. Other users receive presence:join and presence:leave events.
import { Channel } from '@tekir/core'
export class LobbyChannel extends Channel {
presence = true // enable presence tracking
requireAuth = true // only authenticated users
// What data represents a member in the presence list
presenceData(ws) {
return {
id: ws.data.user.id,
name: ws.data.user.name,
avatar: ws.data.user.avatar,
}
}
onJoin(ws, room) {
console.log(`${ws.data.user.name} joined ${room}`)
}
onLeave(ws, room) {
console.log(`${ws.data.user.name} left ${room}`)
}
}
// When a user joins, they receive:
// { type: "presence:sync", channel: "lobby", room: "main", members: [...] }
//
// Other users receive:
// { type: "presence:join", channel: "lobby", room: "main", member: { id, name, avatar } }
//
// When a user leaves or disconnects:
// { type: "presence:leave", channel: "lobby", room: "main", member: { id, name, avatar } }Broadcasting
Broadcast events to channel rooms from anywhere: controllers, queue jobs, cron tasks. Use broadcast.to(channel, room).emit(event, data).
import { createBroadcast } from '@tekir/core'
// In a controller: broadcast to a channel room from HTTP
router.post('/api/messages', async ({ body, auth }) => {
const message = await Message.create({ text: body.text, userId: auth.user.id })
// Emit to all WebSocket clients in chat:general
broadcast.to('chat', 'general').emit('message', {
id: message.id,
text: message.text,
user: auth.user.name,
})
return message.toJSON()
})
// In a queue job
export class OrderNotificationJob {
async handle(data) {
broadcast.to('notifications', String(data.userId)).emit('new-order', {
orderId: data.orderId,
total: data.total,
})
}
}Wire Protocol
Channels use a JSON wire protocol. Clients send join, leave, and event messages. The server responds with confirmations, events, and presence updates.
// Client-side JavaScript
const ws = new WebSocket('ws://localhost:3000/ws?token=eyJ...')
ws.onopen = () => {
// Join a channel room
ws.send(JSON.stringify({
type: 'join',
channel: 'chat',
room: 'general'
}))
}
ws.onmessage = (e) => {
const msg = JSON.parse(e.data)
switch (msg.type) {
case 'joined':
console.log(`Joined ${msg.channel}:${msg.room}`)
break
case 'event':
console.log(`[${msg.event}]`, msg.data)
break
case 'presence:sync':
console.log('Online members:', msg.members)
break
case 'presence:join':
console.log(`${msg.member.name} joined`)
break
case 'presence:leave':
console.log(`${msg.member.name} left`)
break
case 'denied':
console.log(`Access denied: ${msg.reason}`)
break
}
}
// Send a message to a channel room
ws.send(JSON.stringify({
type: 'event',
channel: 'chat',
room: 'general',
event: 'message',
data: { text: 'Hello everyone!' }
}))
// Leave a room
ws.send(JSON.stringify({
type: 'leave',
channel: 'chat',
room: 'general'
}))Message Reference
// ─── Client → Server ─────────────────────────────────────
// Join a channel room (with optional params for auth)
{ "type": "join", "channel": "chat", "room": "general", "params": { "secret": "..." } }
// Leave a channel room
{ "type": "leave", "channel": "chat", "room": "general" }
// Send an event to a channel room
{ "type": "event", "channel": "chat", "room": "general", "event": "message", "data": { "text": "hi" } }
// Join confirmed
{ "type": "joined", "channel": "chat", "room": "general" }
// Join denied
{ "type": "denied", "channel": "chat", "room": "general", "reason": "Authentication required" }
// Left confirmed
{ "type": "left", "channel": "chat", "room": "general" }
// Event broadcast
{ "type": "event", "channel": "chat", "room": "general", "event": "message", "data": { "text": "hi" } }
// Presence: full member list (sent to joining client)
{ "type": "presence:sync", "channel": "lobby", "room": "main", "members": [{ "id": 1, "name": "Ali" }] }
// Presence: someone joined
{ "type": "presence:join", "channel": "lobby", "room": "main", "member": { "id": 2, "name": "Veli" } }
// Presence: someone left
{ "type": "presence:leave", "channel": "lobby", "room": "main", "member": { "id": 2, "name": "Veli" } }
// Error
{ "type": "error", "message": "Unknown channel: nope" }