Guide/guides

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

  1. Use Redis in production: Memory driver doesn't work across multiple servers.

  2. Authorize sensitive channels: Always protect private and presence channels with proper authorization.

  3. Keep payloads small: Broadcast only essential data to reduce bandwidth.

  4. Handle disconnections: Implement reconnection logic on the client side.

  5. Use presence channels for real-time features: Track online users, typing indicators, etc.

  6. Consider message ordering: Events may arrive out of order; include timestamps if order matters.

  7. Clean up subscriptions: Always unsubscribe when components unmount or users leave.

  8. Test authorization logic: Write tests for channel authorizers to prevent security issues.