Broadcasting Guide
Guren provides a broadcasting system for real-time event broadcasting to connected clients. This is useful for building features like live notifications, chat applications, and real-time dashboards.
Broadcasting Guide
Guren provides a broadcasting system for real-time event broadcasting to connected clients. This is useful for building features like live notifications, chat applications, and real-time dashboards.
Core Concepts
- BroadcastManager – Central hub for managing channels, drivers, and SSE clients.
- Channel – A named conduit for broadcasting events. Channels can be public, private, or presence.
- BroadcastDriver – Backend for event distribution (Memory or Redis).
- SSE (Server-Sent Events) – Built-in support for pushing events to browser clients.
- WebSocket Clients – Built-in lifecycle helpers for registering socket clients and subscribing them to channels.
Channel Types
- Public Channels – Anyone can subscribe.
- Private Channels – Require user authentication to subscribe.
- Presence Channels – Track who is subscribed (e.g., for "who's online" features).
Basic Usage
Setup
import { BroadcastManager } from '@guren/core'
const broadcast = new BroadcastManager({
default: 'memory',
drivers: {
memory: () => new MemoryDriver(),
},
})
// Broadcast an event
await broadcast.broadcast('notifications', 'NewMessage', {
content: 'Hello world!',
})
Container Integration
The broadcasting subsystem is registered as a singleton via a ServiceProvider. You can resolve it from the container:
// Access via app.container or this.container in providers
const broadcast = container.make('broadcast') // BroadcastManager
await broadcast.broadcast('notifications', 'NewMessage', { content: 'Hello!' })
Using Channel Helpers
// Public channel
await broadcast.toChannel('notifications').broadcast('NewMessage', data)
// Private channel (auto-prefixed with 'private-')
await broadcast.toPrivate('orders.123').broadcast('OrderUpdated', {
status: 'shipped',
})
// Presence channel (auto-prefixed with 'presence-')
await broadcast.toPresence('chat.general').broadcast('UserJoined', {
user: 'John',
})
Subscribing to Channels
const channel = broadcast.toChannel('notifications')
// Subscribe to events
const unsubscribe = channel.subscribe((event, data) => {
console.log(`Event: ${event}`, data)
})
// Later, unsubscribe
unsubscribe()
Channel Authorization
Public Channels
broadcast.channel('notifications', () => true)
broadcast.channel('public.*', () => true) // Wildcard pattern
Private Channels
Private channels require authentication:
broadcast.privateChannel('orders.{orderId}', async (channel, user) => {
// Extract orderId from channel name
const orderId = channel.replace('private-orders.', '')
// Check if user owns this order
const order = await Order.find(orderId)
return order?.userId === user.id
})
// User-specific channels
broadcast.privateChannel('user.{userId}', (channel, user) => {
const userId = channel.replace('private-user.', '')
return String(user.id) === userId
})
Presence Channels
Presence channels return member info when authorized:
broadcast.presenceChannel('chat.{roomId}', async (channel, user) => {
if (!user) return null // Not authorized
// Return presence member info
return {
id: user.id,
info: {
name: user.name,
avatar: user.avatar,
},
}
})
Pattern Matching
Channel patterns support:
{param}– Match any non-dot segment*– Match any single segment**– Match multiple segments
broadcast.channel('posts.*', () => true) // posts.123, posts.456
broadcast.channel('users.{id}.posts', authorizer) // users.1.posts
broadcast.channel('admin.**', isAdmin) // admin.users, admin.settings.email
Server-Sent Events (SSE)
SSE Endpoint
import { Router } from '@guren/core'
export function registerBroadcastRoutes(router: Router): void {
router.get('/broadcasting/events', broadcast.sseMiddleware({
pingInterval: 30000,
retry: 3000,
// Resolve the connecting user so channels requested up front via
// ?channels= can be authorized when the stream opens
getUser: (ctx) => ctx.get('user'),
}))
router.post('/broadcasting/auth', broadcast.authMiddleware({
getUser: (ctx) => ctx.get('user'),
}))
}
The SSE endpoint accepts a ?channels= query parameter listing channels to subscribe before the stream starts. Each requested channel is authorized against the user returned by getUser, so a plain EventSource works for public channels with zero extra calls. Private and presence channels are subscribed later through /broadcasting/auth (see Authorizing Channels (Client)).
WebSocket Foundation
Guren now includes WebSocket client lifecycle helpers on BroadcastManager.
You can register a socket client, subscribe it to channels, and reuse the same broadcast events used by SSE.
const clientId = broadcast.registerWebSocketClient({
send: (event, data) => socket.send(JSON.stringify({ event, data })),
close: () => socket.close(),
})
broadcast.subscribeWebSocketClient(clientId, 'notifications')
// Later
broadcast.unsubscribeWebSocketClient(clientId, 'notifications')
broadcast.removeWebSocketClient(clientId)
These APIs provide the server-side foundation so a Bun WebSocket upgrade route can plug into the existing channel/driver system.
Typed channel codegen
guren codegen now generates .guren/channels.gen.ts from your server-side broadcast usage.
// app/Providers/BroadcastProvider.ts
broadcast.channel('announcements', () => true)
broadcast.privateChannel('posts.{id}', () => true)
broadcast.broadcast('announcements', 'NewPost', { id: 1 })
Generated artifacts include:
ChannelName– channel name union with pattern-aware template literal types.ChannelEvents– channel-to-event map with inferred payload types for literal/object/array broadcast payloads.channelEventManifest– runtime manifest for discovered channels/events.
import type { ChannelEvents } from '@/.guren/channels.gen'
import { createUseChannel } from '@guren/inertia-client'
const useChannel = createUseChannel<ChannelEvents>()
const feed = useChannel('announcements')
const off = feed.on('NewPost', (payload) => {
console.log(payload)
})
This gives you typed channel/event names and inferred payload shapes in the frontend.
End-to-end typed realtime flow
Use the generated ChannelEvents on the server too, so emit-side payloads are checked at compile time.
import type { ChannelEvents } from '@/.guren/channels.gen'
import { createTypedBroadcaster } from '@guren/core'
const typed = createTypedBroadcaster<ChannelEvents>(broadcast)
await typed.broadcast('announcements', 'NewPost', { id: 1 }) // typed payload
await typed.toChannel('announcements').broadcast('NewPost', { id: 2 })
Client-Side Integration
Pass public channels in the ?channels= query parameter to subscribe them as soon as the stream opens. Right after connecting, the server sends a connected event carrying your clientId and the list of channels that were authorized and subscribed — capture the clientId, because you need it to subscribe to private and presence channels later.
// Connect to SSE and subscribe public channels up front
const eventSource = new EventSource(
'/broadcasting/events?channels=notifications,announcements'
)
// The server sends a `connected` event first — capture the clientId
let clientId: string | null = null
eventSource.addEventListener('connected', (e) => {
const data = JSON.parse(e.data)
clientId = data.clientId
console.log('Subscribed channels:', data.channels)
})
// Messages are dispatched by EVENT name, not channel name
eventSource.addEventListener('NewMessage', (e) => {
const data = JSON.parse(e.data)
console.log('New message:', data)
})
// Listen for ping
eventSource.addEventListener('ping', () => {
console.log('Keepalive ping')
})
eventSource.onerror = (error) => {
console.error('Connection error', error)
}
Authorizing Channels (Client)
Private and presence channels are subscribed through POST /broadcasting/auth. A single request with { clientId, channel } both authorizes the channel for the current user and subscribes your SSE connection to it — the response reports both results per channel:
async function subscribeToPrivateChannel(channel: string) {
if (!clientId) {
throw new Error('Not connected yet — wait for the `connected` event')
}
// Authorize AND subscribe in one call
const response = await fetch('/broadcasting/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clientId, channel }),
credentials: 'include',
})
const result = await response.json()
// e.g. { 'private-orders.123': { authorized: true, subscribed: true } }
return result[channel]?.authorized && result[channel]?.subscribed
}
if (await subscribeToPrivateChannel('private-orders.123')) {
// Events arrive on the same EventSource, dispatched by event name
eventSource.addEventListener('OrderUpdated', (e) => {
const data = JSON.parse(e.data)
console.log('Order updated:', data)
})
}
Important
Omitting clientId from the request only authorizes the channel (subscribed: false) — no events will reach the browser. Always send the clientId you received in the connected event.
Note
Channels with a private- or presence- prefix that have no registered authorizer are denied by default. Register them with broadcast.privateChannel() / broadcast.presenceChannel() before clients can subscribe.
Configuration
Redis Driver
For production and multi-server support:
import { BroadcastManager, RedisDriver } from '@guren/core'
import { createRedisClient } from '@guren/core'
const redis = createRedisClient({ url: process.env.REDIS_URL })
const broadcast = new BroadcastManager({
default: 'redis',
drivers: {
redis: () => new RedisDriver(redis),
memory: () => new MemoryDriver(), // Fallback for testing
},
})
Multiple Drivers
const broadcast = new BroadcastManager({
default: 'redis',
drivers: {
redis: () => new RedisDriver(redis),
memory: () => new MemoryDriver(),
},
})
// Use specific driver
const driver = broadcast.driver('memory')
await driver.publish('test-channel', 'TestEvent', data)
Broadcasting from Events
Integrate broadcasting with the event system:
import { Event } from '@guren/core'
export class OrderShipped extends Event {
constructor(
public readonly orderId: string,
public readonly trackingNumber: string
) {
super()
}
// Implement BroadcastableEvent interface
broadcastOn(): string[] {
return [`private-orders.${this.orderId}`]
}
broadcastAs(): string {
return 'OrderShipped'
}
broadcastWith(): Record<string, unknown> {
return {
orderId: this.orderId,
trackingNumber: this.trackingNumber,
}
}
}
// Usage
const event = new OrderShipped('123', 'ABC456')
for (const channel of event.broadcastOn()) {
await broadcast.broadcast(
channel,
event.broadcastAs?.() ?? event.eventName,
event.broadcastWith?.() ?? {}
)
}
Presence Channel Members
Track who's in a presence channel:
import { PresenceChannel } from '@guren/core'
const channel = broadcast.toPresence('chat.general')
// Get current members (requires PresenceBroadcastDriver)
const driver = broadcast.driver() as PresenceBroadcastDriver
const members = driver.getMembers('presence-chat.general')
// Broadcast member joined
await channel.broadcast('MemberJoined', {
member: { id: user.id, name: user.name },
})
// Broadcast member left
await channel.broadcast('MemberLeft', {
memberId: user.id,
})
Testing
Using container.fake()
Swap the broadcast manager in tests:
// Access via app.container or this.container in providers
import { BroadcastManager, MemoryDriver } from '@guren/core'
test('broadcasts order update', async () => {
const fakeBroadcast = new BroadcastManager({
default: 'memory',
drivers: { memory: () => new MemoryDriver() },
})
using _ = container.fake('broadcast', fakeBroadcast)
// Code under test that broadcasts events will use fakeBroadcast
})
Manual Testing
import { describe, test, expect, beforeEach, mock } from 'bun:test'
import { BroadcastManager, MemoryDriver } from '@guren/core'
describe('Broadcasting', () => {
let broadcast: BroadcastManager
beforeEach(() => {
broadcast = new BroadcastManager({
default: 'memory',
drivers: {
memory: () => new MemoryDriver(),
},
})
})
test('broadcasts to channel', async () => {
const received: unknown[] = []
broadcast.driver().subscribe('test', (event) => {
received.push(event)
})
await broadcast.broadcast('test', 'TestEvent', { value: 1 })
expect(received).toHaveLength(1)
expect(received[0]).toMatchObject({
channel: 'test',
event: 'TestEvent',
data: { value: 1 },
})
})
test('authorizes private channel', async () => {
broadcast.privateChannel('orders.{orderId}', (channel, user) => {
return user?.id === '123'
})
const result = await broadcast.authorize('private-orders.456', { id: '123' })
expect(result).toBe(true)
const denied = await broadcast.authorize('private-orders.456', { id: '999' })
expect(denied).toBe(false)
})
test('presence channel returns member info', async () => {
broadcast.presenceChannel('chat.{roomId}', (channel, user) => {
if (!user) return null
return { id: user.id, info: { name: user.name } }
})
const result = await broadcast.authorize('presence-chat.1', {
id: '123',
name: 'John',
})
expect(result).toMatchObject({
id: '123',
info: { name: 'John' },
})
})
})
Best Practices
Use Redis in production: Memory driver doesn't work across multiple servers.
Authorize sensitive channels: Always protect private and presence channels with proper authorization.
Keep payloads small: Broadcast only essential data to reduce bandwidth.
Handle disconnections: Implement reconnection logic on the client side.
Use presence channels for real-time features: Track online users, typing indicators, etc.
Consider message ordering: Events may arrive out of order; include timestamps if order matters.
Clean up subscriptions: Always unsubscribe when components unmount or users leave.
Test authorization logic: Write tests for channel authorizers to prevent security issues.