Real-Time Broadcasting
Stacks.js provides a powerful real-time broadcasting system that enables instant communication between your server and connected clients. Similar to Laravel's Reverb, Stacks includes a first-party WebSocket server built specifically for Bun, delivering exceptional performance for real-time features like live notifications, chat, collaborative editing, and live dashboards.
Introduction
Many modern web applications require real-time features—showing notifications instantly, updating live data, or enabling collaborative features. Traditionally, this required polling or third-party services. Stacks.js includes everything you need for real-time communication out of the box:
- Native WebSocket Server: A high-performance Bun-native WebSocket server (Stacks' equivalent to Laravel Reverb)
- Event Broadcasting: Automatically broadcast server-side events to WebSocket channels
- Multiple Drivers: Support for native WebSocket, Pusher-compatible services, and Redis pub/sub
- Channel Types: Public, private, and presence channels with built-in authorization
- Client Library: Type-safe client for subscribing to channels and handling events
Configuration
Broadcasting Configuration
// config/broadcasting.ts
export default {
// Default broadcaster
default: process.env.BROADCAST_DRIVER ?? 'stacks',
// Broadcaster configurations
connections: {
// Stacks native WebSocket server (recommended)
stacks: {
driver: 'stacks',
host: process.env.BROADCAST_HOST ?? 'localhost',
port: Number(process.env.BROADCAST_PORT ?? 6001),
// TLS configuration
tls: {
enabled: process.env.BROADCAST_TLS === 'true',
cert: process.env.BROADCAST_TLS_CERT,
key: process.env.BROADCAST_TLS_KEY,
},
// Scaling with Redis (for multi-server deployments)
scaling: {
enabled: false,
redis: {
host: process.env.REDIS_HOST ?? 'localhost',
port: Number(process.env.REDIS_PORT ?? 6379),
},
},
// App credentials (for authentication)
app: {
id: process.env.BROADCAST_APP_ID ?? 'stacks',
key: process.env.BROADCAST_APP_KEY ?? 'stacks-key',
secret: process.env.BROADCAST_APP_SECRET ?? 'stacks-secret',
},
// Connection limits
maxConnections: 10000,
maxChannelsPerConnection: 100,
maxPayloadSize: 64 * 1024, // 64KB
},
// Pusher-compatible broadcaster
pusher: {
driver: 'pusher',
key: process.env.PUSHER_APP_KEY,
secret: process.env.PUSHER_APP_SECRET,
appId: process.env.PUSHER_APP_ID,
cluster: process.env.PUSHER_CLUSTER ?? 'us2',
useTLS: true,
options: {
encrypted: true,
},
},
// Redis pub/sub broadcaster
redis: {
driver: 'redis',
connection: 'default',
queue: 'broadcasts',
},
// Null driver (for testing)
null: {
driver: 'null',
},
},
}
Environment Variables
# .env
BROADCAST_DRIVER=stacks
# Stacks WebSocket Server
BROADCAST_HOST=localhost
BROADCAST_PORT=6001
BROADCAST_TLS=false
BROADCAST_APP_ID=my-app
BROADCAST_APP_KEY=my-app-key
BROADCAST_APP_SECRET=my-app-secret
# For Pusher (alternative)
PUSHER_APP_ID=your-app-id
PUSHER_APP_KEY=your-app-key
PUSHER_APP_SECRET=your-app-secret
PUSHER_CLUSTER=us2
WebSocket Server
Starting the Server
Stacks includes a built-in WebSocket server optimized for Bun's performance. Start it alongside your application:
# Start WebSocket server
buddy broadcast:serve
# With custom options
buddy broadcast:serve --host=0.0.0.0 --port=6001
# With TLS
buddy broadcast:serve --tls
# In production (with pm2 or supervisor)
buddy broadcast:serve --daemon
Server Architecture
The Stacks WebSocket server provides:
- High Performance: Built on Bun's native WebSocket support, handling thousands of concurrent connections
- Pusher Protocol Compatible: Works with existing Pusher client libraries
- Horizontal Scaling: Use Redis pub/sub to scale across multiple servers
- Built-in Monitoring: Real-time metrics and connection tracking
// The server is implemented in @stacksjs/realtime
import { WebSocketServer } from '@stacksjs/realtime'
// For advanced customization
const server = new WebSocketServer({
host: 'localhost',
port: 6001,
// Connection handling
onConnection(socket) {
console.log('Client connected:', socket.id)
},
onDisconnection(socket) {
console.log('Client disconnected:', socket.id)
},
// Custom authentication
async authenticate(socket, data) {
// Return user data or false to reject
return await verifyToken(data.token)
},
})
await server.start()
Scaling with Redis
For multi-server deployments, enable Redis scaling to synchronize broadcasts across all WebSocket servers:
// config/broadcasting.ts
export default {
connections: {
stacks: {
driver: 'stacks',
scaling: {
enabled: true,
redis: {
host: process.env.REDIS_HOST,
port: 6379,
prefix: 'stacks_broadcast_',
},
},
},
},
}
With Redis scaling, broadcasts from any application server are distributed to all WebSocket server instances, ensuring all connected clients receive messages regardless of which server they're connected to.
Channel Types
Stacks supports three types of channels, each serving different use cases:
Public Channels
Anyone can subscribe to public channels without authentication. Use these for broadcast announcements or public data updates.
// Server-side: Broadcasting to a public channel
import { broadcast } from '@stacksjs/realtime'
await broadcast('announcements')
.event('NewAnnouncement', {
title: 'Site Maintenance',
message: 'We will be performing maintenance tonight.',
})
// Client-side: Subscribing to a public channel
import { realtime } from '@stacksjs/realtime/client'
realtime.channel('announcements')
.on('NewAnnouncement', (data) => {
showNotification(data.title, data.message)
})
Private Channels
Private channels require authentication. The channel name is prefixed with private-.
// Server-side: Broadcasting to a private channel
await broadcast(`private-user.${userId}`)
.event('NotificationReceived', notification)
// Client-side: Subscribing to a private channel
realtime.private(`user.${userId}`)
.on('NotificationReceived', (notification) => {
addNotification(notification)
})
Presence Channels
Presence channels extend private channels with awareness of who else is subscribed. Perfect for collaborative features, chat rooms, or showing online status.
// Server-side: Broadcasting to a presence channel
await broadcast(`presence-chat.${roomId}`)
.event('MessageSent', message)
// Client-side: Subscribing with presence
const chat = realtime.join(`chat.${roomId}`)
// Access current members
chat.here((members) => {
console.log('Current members:', members)
})
// Listen for members joining
chat.joining((member) => {
console.log(`${member.name} joined the chat`)
})
// Listen for members leaving
chat.leaving((member) => {
console.log(`${member.name} left the chat`)
})
// Listen for events
chat.on('MessageSent', (message) => {
addMessage(message)
})
Channel Authorization
Private and presence channels require authorization before a user can subscribe. Define authorization logic in your channels file:
// routes/channels.ts
import { channel } from '@stacksjs/realtime'
// Private channel authorization
channel('private-user.{userId}', {
authorize(user, userId) {
// Only the user themselves can subscribe
return user.id === parseInt(userId)
},
})
// Private channel with async authorization
channel('private-order.{orderId}', {
async authorize(user, orderId) {
const order = await Order.find(orderId)
return order && order.userId === user.id
},
})
// Presence channel with member data
channel('presence-chat.{roomId}', {
// Authorization check
async authorize(user, roomId) {
const room = await ChatRoom.find(roomId)
return room?.isMember(user.id)
},
// Data to share with other presence members
join(user) {
return {
id: user.id,
name: user.name,
avatar: user.avatarUrl,
status: user.status,
}
},
// Called when a user leaves
leave(user) {
// Optional: Log or cleanup
},
})
// Dynamic authorization based on permissions
channel('private-team.{teamId}', {
async authorize(user, teamId) {
return await user.belongsToTeam(teamId)
},
})
// Admin-only channel
channel('private-admin.*', {
authorize(user) {
return user.isAdmin
},
})
Authorization Endpoint
The client automatically sends authorization requests to your application. Configure the endpoint:
// routes/api.ts
import { Router } from '@stacksjs/router'
Router.post('/broadcasting/auth', async (request) => {
// Stacks handles this automatically via the broadcasting module
return await request.authorize()
})
Broadcasting Events
From Event Classes
The most powerful way to broadcast is from Event classes with automatic channel binding:
// app/Events/OrderShipped.ts
import { Event } from '@stacksjs/events'
export default class OrderShipped extends Event {
constructor(public order: Order) {
super()
}
// Channels to broadcast to
broadcastOn(): string[] {
return [
`private-order.${this.order.id}`,
`private-user.${this.order.userId}`,
]
}
// Custom event name (optional, defaults to class name)
broadcastAs(): string {
return 'order.shipped'
}
// Data to broadcast (optional, defaults to public properties)
broadcastWith(): object {
return {
orderId: this.order.id,
trackingNumber: this.order.trackingNumber,
estimatedDelivery: this.order.estimatedDelivery,
carrier: this.order.carrier,
}
}
// Condition for broadcasting (optional)
broadcastWhen(): boolean {
return this.order.status === 'shipped'
}
}
// Dispatch the event - automatically broadcasts
import { dispatch } from '@stacksjs/events'
await dispatch(new OrderShipped(order))
Direct Broadcasting
For simple broadcasts without Event classes:
import { broadcast } from '@stacksjs/realtime'
// Simple broadcast
await broadcast('news').event('ArticlePublished', {
id: article.id,
title: article.title,
excerpt: article.excerpt,
})
// Private channel broadcast
await broadcast(`private-user.${userId}`).event('BalanceUpdated', {
balance: user.balance,
})
// Presence channel broadcast
await broadcast(`presence-document.${docId}`).event('CursorMoved', {
userId: user.id,
position: cursor.position,
})
// Broadcast to multiple channels
await broadcast(['news', 'updates', `private-admin`])
.event('ImportantUpdate', data)
// Broadcast except to current socket (for the event originator)
await broadcast(`presence-chat.${roomId}`)
.except(request.socketId)
.event('NewMessage', message)
Broadcasting from Models
Automatically broadcast model changes:
// app/Models/Post.ts
import { Model, Broadcastable } from '@stacksjs/orm'
export default class Post extends Model {
use = [Broadcastable]
static fields = {
title: 'string',
content: 'text',
status: 'string',
authorId: 'integer',
}
// Broadcast on model events
broadcastOn(event: string): string[] {
return [
'posts',
`private-user.${this.authorId}`,
]
}
// Customize per-event
broadcastCreated(): string[] {
return ['posts']
}
broadcastUpdated(): string[] {
return [`private-post.${this.id}`]
}
}
// Model changes are automatically broadcast:
await Post.create({ title: 'Hello', authorId: 1 })
// Broadcasts: PostCreated event to 'posts' channel
await post.update({ title: 'Updated' })
// Broadcasts: PostUpdated event to 'private-post.{id}' channel
Broadcast from Notifications
Notifications can automatically broadcast when using the database channel:
// app/Notifications/InvoicePaidNotification.ts
import { Notification } from '@stacksjs/notifications'
export default class InvoicePaidNotification extends Notification {
constructor(private invoice: Invoice) {
super()
}
via(notifiable: User) {
return ['mail', 'database', 'broadcast']
}
toBroadcast(notifiable: User) {
return {
title: 'Invoice Paid',
amount: this.invoice.total,
invoiceId: this.invoice.id,
}
}
// Custom channel (defaults to private-user.{id})
broadcastOn(notifiable: User): string {
return `private-user.${notifiable.id}`
}
}
Client-Side Integration
Installation
bun add @stacksjs/realtime
Configuration
// resources/js/bootstrap.ts
import { createRealtime } from '@stacksjs/realtime/client'
export const realtime = createRealtime({
// WebSocket server URL
host: import.meta.env.VITE_BROADCAST_HOST ?? 'localhost',
port: import.meta.env.VITE_BROADCAST_PORT ?? 6001,
// App credentials
key: import.meta.env.VITE_BROADCAST_APP_KEY,
// Authentication (for private/presence channels)
auth: {
endpoint: '/broadcasting/auth',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content,
},
},
// TLS
forceTLS: import.meta.env.PROD,
// Auto-reconnect
reconnect: true,
reconnectDelay: 1000,
maxReconnectAttempts: 10,
})
// Make globally available
window.realtime = realtime
Channel Subscriptions
// Public channel
const newsChannel = realtime.channel('news')
newsChannel.on('ArticlePublished', (article) => {
addArticleToFeed(article)
})
newsChannel.on('BreakingNews', (news) => {
showAlert(news.headline)
})
// Private channel
const userChannel = realtime.private(`user.${userId}`)
userChannel.on('NotificationReceived', (notification) => {
notifications.value.unshift(notification)
unreadCount.value++
})
userChannel.on('BalanceUpdated', ({ balance }) => {
userBalance.value = balance
})
// Presence channel with member tracking
const chatChannel = realtime.join(`chat.${roomId}`)
// Current members
chatChannel.here((members) => {
onlineMembers.value = members
})
// New member joined
chatChannel.joining((member) => {
onlineMembers.value.push(member)
showToast(`${member.name} joined`)
})
// Member left
chatChannel.leaving((member) => {
onlineMembers.value = onlineMembers.value.filter(m => m.id !== member.id)
showToast(`${member.name} left`)
})
// Events
chatChannel.on('MessageSent', (message) => {
messages.value.push(message)
})
chatChannel.on('UserTyping', ({ userId, isTyping }) => {
typingUsers.value[userId] = isTyping
})
Sending Client Events
On presence channels, clients can send events directly to other subscribers:
// Client events are prefixed with 'client-'
const chat = realtime.join(`chat.${roomId}`)
// Send typing indicator
function onTyping() {
chat.whisper('typing', { isTyping: true })
}
// Listen for client events
chat.listenForWhisper('typing', ({ userId, isTyping }) => {
updateTypingIndicator(userId, isTyping)
})
Unsubscribing
// Leave a specific channel
realtime.leave('news')
realtime.leave(`user.${userId}`)
realtime.leave(`chat.${roomId}`)
// Disconnect entirely
realtime.disconnect()
// Reconnect
realtime.connect()
Connection Events
realtime.on('connected', () => {
console.log('Connected to WebSocket server')
})
realtime.on('disconnected', () => {
console.log('Disconnected from WebSocket server')
})
realtime.on('reconnecting', (attempt) => {
console.log(`Reconnecting... attempt ${attempt}`)
})
realtime.on('error', (error) => {
console.error('WebSocket error:', error)
})
Vue.js Integration
// composables/useRealtime.ts
import { onMounted, onUnmounted, ref } from 'vue'
import { realtime } from '@/bootstrap'
export function useChannel(channelName: string) {
const channel = ref(null)
onMounted(() => {
channel.value = realtime.channel(channelName)
})
onUnmounted(() => {
realtime.leave(channelName)
})
return channel
}
export function usePrivateChannel(channelName: string) {
const channel = ref(null)
onMounted(() => {
channel.value = realtime.private(channelName)
})
onUnmounted(() => {
realtime.leave(channelName)
})
return channel
}
export function usePresenceChannel(channelName: string) {
const channel = ref(null)
const members = ref([])
onMounted(() => {
channel.value = realtime.join(channelName)
channel.value.here((m) => {
members.value = m
})
channel.value.joining((m) => {
members.value.push(m)
})
channel.value.leaving((m) => {
members.value = members.value.filter(member => member.id !== m.id)
})
})
onUnmounted(() => {
realtime.leave(channelName)
})
return { channel, members }
}
<!-- components/LiveNotifications.vue -->
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { realtime } from '@/bootstrap'
const props = defineProps(['userId'])
const notifications = ref([])
let channel = null
onMounted(() => {
channel = realtime.private(`user.${props.userId}`)
channel.on('NotificationReceived', (notification) => {
notifications.value.unshift(notification)
})
})
onUnmounted(() => {
realtime.leave(`user.${props.userId}`)
})
</script>
<template>
<div class="notifications">
<div v-for="notification in notifications" :key="notification.id">
</div>
</div>
</template>
React Integration
// hooks/useRealtime.ts
import { useEffect, useState, useCallback } from 'react'
import { realtime } from '@/bootstrap'
export function useChannel(channelName: string) {
const [channel, setChannel] = useState(null)
useEffect(() => {
const ch = realtime.channel(channelName)
setChannel(ch)
return () => {
realtime.leave(channelName)
}
}, [channelName])
return channel
}
export function usePresenceChannel(channelName: string) {
const [channel, setChannel] = useState(null)
const [members, setMembers] = useState([])
useEffect(() => {
const ch = realtime.join(channelName)
setChannel(ch)
ch.here((m) => setMembers(m))
ch.joining((m) => setMembers(prev => [...prev, m]))
ch.leaving((m) => setMembers(prev => prev.filter(member => member.id !== m.id)))
return () => {
realtime.leave(channelName)
}
}, [channelName])
return { channel, members }
}
// Example component
function LiveChat({ roomId }) {
const { channel, members } = usePresenceChannel(`chat.${roomId}`)
const [messages, setMessages] = useState([])
useEffect(() => {
if (!channel) return
channel.on('MessageSent', (message) => {
setMessages(prev => [...prev, message])
})
}, [channel])
return (
<div>
<div className="members">
{members.map(m => <span key={m.id}>{m.name}</span>)}
</div>
<div className="messages">
{messages.map(m => <div key={m.id}>{m.content}</div>)}
</div>
</div>
)
}
Testing
Faking Broadcasts
import { broadcast, Broadcast } from '@stacksjs/realtime'
describe('OrderShipped Event', () => {
beforeEach(() => {
Broadcast.fake()
})
it('broadcasts when order is shipped', async () => {
const order = await Order.factory().create({ status: 'shipped' })
await dispatch(new OrderShipped(order))
Broadcast.assertBroadcast('order.shipped')
Broadcast.assertBroadcast('order.shipped', (event) => {
return event.orderId === order.id
})
Broadcast.assertBroadcastOn(`private-order.${order.id}`, 'order.shipped')
})
it('does not broadcast when order is not shipped', async () => {
const order = await Order.factory().create({ status: 'pending' })
await dispatch(new OrderShipped(order))
Broadcast.assertNothingBroadcast()
})
})
Testing Channel Authorization
describe('Channel Authorization', () => {
it('authorizes user for their own channel', async () => {
const user = await User.factory().create()
const result = await Channel.authorize(
'private-user.{userId}',
user,
{ userId: user.id.toString() }
)
expect(result).toBe(true)
})
it('rejects unauthorized users', async () => {
const user = await User.factory().create()
const otherUser = await User.factory().create()
const result = await Channel.authorize(
'private-user.{userId}',
user,
{ userId: otherUser.id.toString() }
)
expect(result).toBe(false)
})
})
Production Deployment
Supervisor Configuration
# /etc/supervisor/conf.d/stacks-broadcast.conf
[program:stacks-broadcast]
command=buddy broadcast:serve --host=0.0.0.0 --port=6001
directory=/var/www/myapp
autostart=true
autorestart=true
user=www-data
redirect_stderr=true
stdout_logfile=/var/log/stacks/broadcast.log
stopwaitsecs=10
Nginx Configuration
# WebSocket reverse proxy
upstream websocket {
server 127.0.0.1:6001;
}
server {
listen 443 ssl http2;
server_name ws.example.com;
ssl_certificate /etc/ssl/certs/example.com.crt;
ssl_certificate_key /etc/ssl/private/example.com.key;
location / {
proxy_pass http://websocket;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}
Health Monitoring
// Check WebSocket server health
const health = await fetch('http://localhost:6001/health')
const stats = await health.json()
// {
// status: 'healthy',
// connections: 1234,
// channels: 456,
// uptime: 86400,
// memory: { used: 128, limit: 512 }
// }
Related Topics
For background job processing, scheduled tasks, and queue management, see the dedicated Queues & Background Jobs guide.
For sending notifications through multiple channels (email, SMS, push), see Email & Notifications.