@roostjs/broadcast Guides

Task-oriented instructions for setting up broadcasting, creating broadcastable events, connecting a client, authorizing private channels, and testing.

How to set up broadcasting

1. Add the Durable Object binding in wrangler.jsonc:

{
  "durable_objects": {
    "bindings": [
      { "name": "BROADCAST_DO", "class_name": "ChannelDO" }
    ]
  },
  "migrations": [
    { "tag": "v1", "new_classes": ["ChannelDO"] }
  ]
}

2. Export ChannelDO from your Worker entry point:

export { ChannelDO } from '@roostjs/broadcast';
export { app } from './app';

3. Register BroadcastServiceProvider in your app:

import { createApp } from '@roostjs/core';
import { BroadcastServiceProvider } from '@roostjs/broadcast';

export const app = createApp({
  providers: [new BroadcastServiceProvider()],
});

How to create a broadcastable event

Implement BroadcastableEvent on any event class. If you are also using @roostjs/events, extend Event as well to get in-process listener support alongside broadcasting.

import { Event } from '@roostjs/events';
import { PrivateChannel } from '@roostjs/broadcast';
import type { BroadcastableEvent } from '@roostjs/broadcast';

export class OrderShipped extends Event implements BroadcastableEvent {
  constructor(
    public readonly orderId: string,
    public readonly userId: string,
    public readonly trackingNumber: string
  ) {
    super();
  }

  broadcastOn() {
    return [new PrivateChannel(`users.${this.userId}`)];
  }

  broadcastWith() {
    return {
      orderId: this.orderId,
      trackingNumber: this.trackingNumber,
    };
  }

  broadcastAs() {
    return 'order.shipped';
  }
}

Dispatch the event normally. If @roostjs/events' EventDispatcher is configured, it forwards broadcastable events to BroadcastManager automatically.

await OrderShipped.dispatch(
  new OrderShipped(order.id, order.userId, order.trackingNumber)
);

How to broadcast directly without an event class

Use BroadcastManager.broadcast() when you want to push a message without defining an event class.

import { BroadcastManager, Channel } from '@roostjs/broadcast';

const manager = BroadcastManager.get();

await manager.broadcast({
  broadcastOn: () => [new Channel('announcements')],
  broadcastWith: () => ({ message: 'Server maintenance in 5 minutes' }),
});

How to connect a WebSocket client

Use createBroadcastClient from @roostjs/broadcast/client in browser or client-side code.

import { createBroadcastClient } from '@roostjs/broadcast/client';

const client = createBroadcastClient(
  () => `/ws/channel/users.${currentUserId}?token=${getAuthToken()}`,
  {
    onConnect: () => console.log('Connected'),
    onDisconnect: (code, reason) => console.log('Disconnected', code, reason),
  }
);

const unsubscribe = client.subscribe('users.*', (event, data) => {
  if (event === 'order.shipped') {
    showToast(`Your order shipped! Tracking: ${(data as { trackingNumber: string }).trackingNumber}`);
  }
});

// Later, clean up
unsubscribe();
client.close();

The client reconnects automatically with exponential backoff when the connection drops. Pass a factory function as the URL to generate a fresh token on each reconnect.

How to authorize private and presence channels

Override authorize() in a subclass of ChannelDO to validate tokens against your authentication system.

Warning

The default authorize() implementation accepts any non-empty bearer token. Always override this in production.

import { ChannelDO } from '@roostjs/broadcast';
import { WorkOS } from '@workos-inc/node';

const workos = new WorkOS(process.env.WORKOS_API_KEY!);

export class AppChannelDO extends ChannelDO {
  protected async authorize(request: Request): Promise<{ ok: boolean }> {
    const token = request.headers.get('Authorization')?.replace('Bearer ', '');
    if (!token) return { ok: false };

    try {
      await workos.userManagement.authenticateWithSessionCookie({ sessionData: token });
      return { ok: true };
    } catch {
      return { ok: false };
    }
  }

  protected extractUserId(request: Request): string | undefined {
    const url = new URL(request.url);
    return url.searchParams.get('userId') ?? undefined;
  }
}

Export AppChannelDO from your Worker entry point and update wrangler.jsonc to use class_name: "AppChannelDO".

How to use presence channels

Presence channels emit presence:join and presence:leave events when users connect and disconnect. Subscribe to these on the client.

const client = createBroadcastClient(
  `/ws/channel/room.${roomId}?type=presence&userId=${currentUserId}&token=${token}`
);

client.subscribe('room.*', (event, data) => {
  if (event === 'presence:join') {
    addMemberToList((data as { member: { id: string } }).member);
  }
  if (event === 'presence:leave') {
    removeMemberFromList((data as { member: { id: string } }).member);
  }
});

How to test broadcasting

Call BroadcastManager.fake() before the code that would broadcast. Use assertBroadcast() and assertBroadcastOn() for assertions.

import { describe, it, afterEach } from 'bun:test';
import { BroadcastManager } from '@roostjs/broadcast';
import { OrderShipped } from '../../src/events/OrderShipped';
import { shipOrder } from '../../src/handlers/orders';

describe('OrderShipped', () => {
  afterEach(() => BroadcastManager.restore());

  it('broadcasts when an order is shipped', async () => {
    BroadcastManager.fake();

    await shipOrder({ orderId: 'ord-1', userId: 'user-1', trackingNumber: 'TRK123' });

    BroadcastManager.assertBroadcast(OrderShipped);
  });

  it('broadcasts on the user private channel', async () => {
    BroadcastManager.fake();

    await shipOrder({ orderId: 'ord-1', userId: 'user-1', trackingNumber: 'TRK123' });

    BroadcastManager.assertBroadcastOn('users.user-1');
  });
});