import './utils/disposable'; import { generateId } from './utils/id'; import type { BusMessage, Unsubscribe } from './types'; type Listener = (msg: BusMessage) => void; export class MessageBus implements Disposable { private channel: BroadcastChannel; private listeners = new Map>(); private pendingRequests = new Map void; reject: (e: Error) => void; timer: ReturnType }>(); constructor( channelName: string, private readonly tabId: string, ) { this.channel = new BroadcastChannel(channelName); this.channel.onmessage = (ev: MessageEvent) => { this.handleMessage(ev.data); }; } subscribe(topic: string, fn: (data: T) => void): Unsubscribe { const wrapper: Listener = (msg) => { // `broadcast` is "fan out to all tabs INCLUDING me" — `broadcast()` // explicitly self-delivers via handleMessage so the leader's own // handlers fire for events it originated (e.g. ws:message after the // leader receives a server frame). `publish` is fire-and-forget to // OTHER tabs, so skip self-originated publish messages. if (msg.type !== 'publish' || msg.source !== this.tabId) { fn(msg.data as T); } }; this.addListener(topic, wrapper); return () => this.removeListener(topic, wrapper); } publish(topic: string, data: T): void { this.postMessage({ topic, type: 'publish', data }); } broadcast(topic: string, data: T): void { const msg = this.createMessage(topic, 'broadcast', data); this.channel.postMessage(msg); // Also deliver to self this.handleMessage(msg); } async request(topic: string, data: T, timeout = 5000): Promise { const msg = this.createMessage(topic, 'request', data); return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pendingRequests.delete(msg.id); reject(new Error(`MessageBus.request: timeout for topic "${topic}"`)); }, timeout); this.pendingRequests.set(msg.id, { resolve: resolve as (v: unknown) => void, reject, timer }); this.channel.postMessage(msg); }); } respond(topic: string, fn: (data: T) => R | Promise): Unsubscribe { const wrapper: Listener = async (msg) => { if (msg.type !== 'request' || msg.source === this.tabId) return; const result = await fn(msg.data as T); this.postMessage({ topic, type: 'response', data: { requestId: msg.id, result } }); }; this.addListener(topic, wrapper); return () => this.removeListener(topic, wrapper); } private handleMessage(msg: BusMessage): void { // Handle response to pending request if (msg.type === 'response') { const payload = msg.data as { requestId: string; result: unknown }; const pending = this.pendingRequests.get(payload.requestId); if (pending) { clearTimeout(pending.timer); this.pendingRequests.delete(payload.requestId); pending.resolve(payload.result); return; } } const listeners = this.listeners.get(msg.topic); if (listeners) { for (const fn of listeners) fn(msg); } } private postMessage(partial: Pick): void { this.channel.postMessage(this.createMessage(partial.topic, partial.type, partial.data)); } private createMessage(topic: string, type: BusMessage['type'], data: unknown): BusMessage { return { id: generateId(), source: this.tabId, topic, type, data, timestamp: Date.now() }; } private addListener(topic: string, fn: Listener): void { let set = this.listeners.get(topic); if (!set) { set = new Set(); this.listeners.set(topic, set); } set.add(fn); } private removeListener(topic: string, fn: Listener): void { this.listeners.get(topic)?.delete(fn); } [Symbol.dispose](): void { for (const pending of this.pendingRequests.values()) { clearTimeout(pending.timer); pending.reject(new Error('MessageBus disposed')); } this.pendingRequests.clear(); this.listeners.clear(); this.channel.close(); } }