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 /ws endpoint.

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.

start/websocket.ts
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 sender
  • server.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.

app/channels/chat.ts
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:

start/websocket.ts
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.

start/websocket.ts
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)
}
app/channels/*.ts
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.

app/channels/lobby.ts
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
// 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" }