JavaScript Broadcast Channel API: Complete Cross-Context Communication Guide
Master the Broadcast Channel API for communication between browser contexts. Learn messaging patterns, synchronization, and real-time updates.
JavaScript Broadcast Channel API: Complete Cross-Context Communication Guide
The Broadcast Channel API enables simple communication between different browsing contexts (tabs, windows, iframes) of the same origin, allowing you to synchronize state and share messages across your application.
Understanding the Broadcast Channel API
The Broadcast Channel API provides a simple message bus for communication between different contexts of the same origin, without needing a server or complex setup.
// Check Broadcast Channel API support
if ('BroadcastChannel' in window) {
console.log('Broadcast Channel API is supported');
} else {
console.log('Broadcast Channel API is not supported');
}
// Create a channel
const channel = new BroadcastChannel('my-channel');
// Send a message
channel.postMessage({ type: 'greeting', data: 'Hello from tab 1!' });
// Receive messages
channel.onmessage = (event) => {
console.log('Received:', event.data);
};
// Close channel when done
channel.close();
// Message event properties
/*
event = {
data: any, // The message data
origin: string, // Origin of the sender
lastEventId: string, // Not used in BroadcastChannel
source: null, // Always null for BroadcastChannel
ports: [] // Always empty for BroadcastChannel
}
*/
Basic Channel Communication
Channel Manager
class ChannelManager {
constructor(channelName) {
this.channelName = channelName;
this.channel = null;
this.handlers = new Map();
this.id = this.generateId();
this.isConnected = false;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.connect();
}
// Connect to channel
connect() {
try {
if (!('BroadcastChannel' in window)) {
throw new Error('BroadcastChannel API is not supported');
}
this.channel = new BroadcastChannel(this.channelName);
this.isConnected = true;
this.reconnectAttempts = 0;
// Setup message handling
this.channel.onmessage = this.handleMessage.bind(this);
// Setup error handling
this.channel.onmessageerror = this.handleError.bind(this);
// Announce presence
this.announce();
console.log(`Connected to channel: ${this.channelName}`);
} catch (error) {
console.error('Failed to connect to channel:', error);
this.handleConnectionError(error);
}
}
// Handle incoming messages
handleMessage(event) {
const message = event.data;
// Ignore own messages
if (message.senderId === this.id) {
return;
}
// Log message
this.logMessage('received', message);
// Handle by type
if (message.type && this.handlers.has(message.type)) {
const handlers = this.handlers.get(message.type);
handlers.forEach((handler) => {
try {
handler(message.data, message);
} catch (error) {
console.error(`Handler error for ${message.type}:`, error);
}
});
}
// Global message handler
if (this.handlers.has('*')) {
const handlers = this.handlers.get('*');
handlers.forEach((handler) => handler(message));
}
}
// Handle errors
handleError(event) {
console.error('Message error:', event);
this.emit('error', { error: event });
}
// Handle connection errors
handleConnectionError(error) {
this.isConnected = false;
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
console.log(`Reconnecting in ${delay}ms...`);
setTimeout(() => this.connect(), delay);
} else {
console.error('Max reconnection attempts reached');
this.emit('connection-failed', { error });
}
}
// Send message
send(type, data = {}, options = {}) {
if (!this.isConnected) {
console.warn('Channel not connected');
return false;
}
const message = {
type,
data,
senderId: this.id,
timestamp: Date.now(),
...options,
};
try {
this.channel.postMessage(message);
this.logMessage('sent', message);
return true;
} catch (error) {
console.error('Failed to send message:', error);
this.handleError(error);
return false;
}
}
// Subscribe to message type
on(type, handler) {
if (!this.handlers.has(type)) {
this.handlers.set(type, new Set());
}
this.handlers.get(type).add(handler);
// Return unsubscribe function
return () => {
const handlers = this.handlers.get(type);
if (handlers) {
handlers.delete(handler);
if (handlers.size === 0) {
this.handlers.delete(type);
}
}
};
}
// Emit message (alias for send)
emit(type, data) {
return this.send(type, data);
}
// Request-response pattern
request(type, data = {}, timeout = 5000) {
return new Promise((resolve, reject) => {
const requestId = this.generateId();
const responseType = `${type}-response`;
// Setup response handler
const cleanup = this.on(responseType, (response) => {
if (response.requestId === requestId) {
cleanup();
clearTimeout(timeoutId);
resolve(response);
}
});
// Setup timeout
const timeoutId = setTimeout(() => {
cleanup();
reject(new Error(`Request timeout: ${type}`));
}, timeout);
// Send request
this.send(type, { ...data, requestId });
});
}
// Respond to request
respond(originalMessage, responseData) {
const responseType = `${originalMessage.type}-response`;
this.send(responseType, {
...responseData,
requestId: originalMessage.data.requestId,
});
}
// Announce presence
announce() {
this.send('presence', {
action: 'join',
context: this.getContextInfo(),
});
}
// Get context information
getContextInfo() {
return {
id: this.id,
url: window.location.href,
title: document.title,
timestamp: Date.now(),
userAgent: navigator.userAgent,
};
}
// Close channel
close() {
if (this.channel) {
// Announce departure
this.send('presence', {
action: 'leave',
context: this.getContextInfo(),
});
this.channel.close();
this.channel = null;
this.isConnected = false;
console.log(`Disconnected from channel: ${this.channelName}`);
}
}
// Generate unique ID
generateId() {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
// Log messages (for debugging)
logMessage(direction, message) {
if (this.debug) {
console.log(`[${this.channelName}] ${direction}:`, message);
}
}
}
// Usage
const channel = new ChannelManager('app-sync');
// Listen for messages
channel.on('user-update', (data) => {
console.log('User updated:', data);
});
// Send message
channel.send('user-update', {
userId: 123,
name: 'John Doe',
action: 'profile-edit',
});
// Request-response
channel
.request('get-data', { key: 'settings' })
.then((response) => console.log('Settings:', response))
.catch((error) => console.error('Request failed:', error));
// Handle requests
channel.on('get-data', (data, message) => {
const value = localStorage.getItem(data.key);
channel.respond(message, { value });
});
State Synchronization
Cross-Tab State Manager
class CrossTabStateManager {
constructor(options = {}) {
this.options = {
channelName: 'state-sync',
debounceDelay: 100,
conflictResolution: 'last-write-wins',
persistState: true,
...options,
};
this.state = {};
this.localChanges = new Set();
this.channel = new ChannelManager(this.options.channelName);
this.subscribers = new Map();
this.stateVersion = 0;
this.init();
}
// Initialize state manager
init() {
// Load persisted state
if (this.options.persistState) {
this.loadPersistedState();
}
// Setup channel handlers
this.setupChannelHandlers();
// Request initial state sync
this.requestStateSync();
// Setup unload handler
window.addEventListener('beforeunload', () => {
this.channel.close();
});
}
// Setup channel handlers
setupChannelHandlers() {
// State update handler
this.channel.on('state-update', (data) => {
this.handleRemoteUpdate(data);
});
// State sync request handler
this.channel.on('state-sync-request', (data, message) => {
this.handleSyncRequest(message);
});
// State sync response handler
this.channel.on('state-sync-response', (data) => {
this.handleSyncResponse(data);
});
// Conflict resolution
this.channel.on('state-conflict', (data) => {
this.handleConflict(data);
});
}
// Get state value
get(path, defaultValue = undefined) {
const value = this.getValueByPath(this.state, path);
return value !== undefined ? value : defaultValue;
}
// Set state value
set(path, value, options = {}) {
const oldValue = this.get(path);
if (oldValue === value && !options.force) {
return; // No change
}
// Update local state
this.setValueByPath(this.state, path, value);
this.stateVersion++;
// Track local change
this.localChanges.add(path);
// Notify local subscribers
this.notifySubscribers(path, value, oldValue);
// Broadcast change
if (!options.silent) {
this.broadcastUpdate(path, value, oldValue);
}
// Persist state
if (this.options.persistState) {
this.persistState();
}
}
// Update multiple values
update(updates, options = {}) {
const changes = [];
Object.entries(updates).forEach(([path, value]) => {
const oldValue = this.get(path);
if (oldValue !== value) {
this.setValueByPath(this.state, path, value);
changes.push({ path, value, oldValue });
}
});
if (changes.length > 0) {
this.stateVersion++;
// Notify subscribers
changes.forEach(({ path, value, oldValue }) => {
this.notifySubscribers(path, value, oldValue);
});
// Broadcast changes
if (!options.silent) {
this.channel.send('state-update', {
changes,
version: this.stateVersion,
timestamp: Date.now(),
});
}
// Persist state
if (this.options.persistState) {
this.persistState();
}
}
}
// Subscribe to state changes
subscribe(path, callback) {
if (!this.subscribers.has(path)) {
this.subscribers.set(path, new Set());
}
this.subscribers.get(path).add(callback);
// Call with current value
callback(this.get(path), undefined);
// Return unsubscribe function
return () => {
const callbacks = this.subscribers.get(path);
if (callbacks) {
callbacks.delete(callback);
if (callbacks.size === 0) {
this.subscribers.delete(path);
}
}
};
}
// Handle remote update
handleRemoteUpdate(data) {
const { changes, version, timestamp } = data;
// Check for conflicts
const conflicts = changes.filter(({ path }) => this.localChanges.has(path));
if (conflicts.length > 0) {
this.resolveConflicts(conflicts, data);
} else {
// Apply changes
changes.forEach(({ path, value, oldValue }) => {
this.set(path, value, { silent: true });
});
}
}
// Handle sync request
handleSyncRequest(message) {
this.channel.respond(message, {
state: this.state,
version: this.stateVersion,
timestamp: Date.now(),
});
}
// Handle sync response
handleSyncResponse(data) {
const { state, version, timestamp } = data;
// Merge with local state
if (version > this.stateVersion || Object.keys(this.state).length === 0) {
this.state = { ...state };
this.stateVersion = version;
// Notify all subscribers
this.notifyAllSubscribers();
// Persist merged state
if (this.options.persistState) {
this.persistState();
}
}
}
// Resolve conflicts
resolveConflicts(conflicts, remoteData) {
switch (this.options.conflictResolution) {
case 'last-write-wins':
// Apply remote changes
conflicts.forEach(({ path, value }) => {
this.set(path, value, { silent: true });
});
break;
case 'local-wins':
// Keep local changes, broadcast them
conflicts.forEach(({ path }) => {
const value = this.get(path);
this.broadcastUpdate(path, value);
});
break;
case 'merge':
// Custom merge logic
conflicts.forEach(({ path, value }) => {
const localValue = this.get(path);
const mergedValue = this.mergeValues(localValue, value);
this.set(path, mergedValue);
});
break;
case 'manual':
// Emit conflict event for manual resolution
this.channel.emit('state-conflict', {
conflicts,
localVersion: this.stateVersion,
remoteVersion: remoteData.version,
});
break;
}
// Clear local changes
conflicts.forEach(({ path }) => {
this.localChanges.delete(path);
});
}
// Merge values (for arrays and objects)
mergeValues(local, remote) {
if (Array.isArray(local) && Array.isArray(remote)) {
// Merge arrays (remove duplicates)
return [...new Set([...local, ...remote])];
} else if (typeof local === 'object' && typeof remote === 'object') {
// Merge objects
return { ...local, ...remote };
} else {
// For primitives, use remote value
return remote;
}
}
// Broadcast state update
broadcastUpdate(path, value, oldValue) {
// Debounce broadcasts
clearTimeout(this.broadcastTimeout);
if (!this.pendingBroadcasts) {
this.pendingBroadcasts = [];
}
this.pendingBroadcasts.push({ path, value, oldValue });
this.broadcastTimeout = setTimeout(() => {
this.channel.send('state-update', {
changes: this.pendingBroadcasts,
version: this.stateVersion,
timestamp: Date.now(),
});
this.pendingBroadcasts = [];
this.localChanges.clear();
}, this.options.debounceDelay);
}
// Request state sync
requestStateSync() {
this.channel.send('state-sync-request', {
version: this.stateVersion,
});
}
// Notify subscribers
notifySubscribers(path, value, oldValue) {
// Exact path subscribers
const exactSubscribers = this.subscribers.get(path);
if (exactSubscribers) {
exactSubscribers.forEach((callback) => {
try {
callback(value, oldValue);
} catch (error) {
console.error('Subscriber error:', error);
}
});
}
// Wildcard subscribers (e.g., 'user.*')
this.subscribers.forEach((callbacks, subscriberPath) => {
if (subscriberPath.includes('*')) {
const regex = new RegExp(
'^' + subscriberPath.replace(/\*/g, '.*') + '$'
);
if (regex.test(path)) {
callbacks.forEach((callback) => {
try {
callback(value, oldValue, path);
} catch (error) {
console.error('Subscriber error:', error);
}
});
}
}
});
}
// Notify all subscribers
notifyAllSubscribers() {
this.subscribers.forEach((callbacks, path) => {
const value = this.get(path);
callbacks.forEach((callback) => {
try {
callback(value, undefined);
} catch (error) {
console.error('Subscriber error:', error);
}
});
});
}
// Get value by path
getValueByPath(obj, path) {
const parts = path.split('.');
let current = obj;
for (const part of parts) {
if (current === null || current === undefined) {
return undefined;
}
current = current[part];
}
return current;
}
// Set value by path
setValueByPath(obj, path, value) {
const parts = path.split('.');
const last = parts.pop();
let current = obj;
for (const part of parts) {
if (!(part in current)) {
current[part] = {};
}
current = current[part];
}
current[last] = value;
}
// Persist state to localStorage
persistState() {
try {
localStorage.setItem(
`cross-tab-state-${this.options.channelName}`,
JSON.stringify({
state: this.state,
version: this.stateVersion,
timestamp: Date.now(),
})
);
} catch (error) {
console.error('Failed to persist state:', error);
}
}
// Load persisted state
loadPersistedState() {
try {
const stored = localStorage.getItem(
`cross-tab-state-${this.options.channelName}`
);
if (stored) {
const { state, version } = JSON.parse(stored);
this.state = state;
this.stateVersion = version;
}
} catch (error) {
console.error('Failed to load persisted state:', error);
}
}
// Clear state
clear() {
this.state = {};
this.stateVersion = 0;
this.localChanges.clear();
// Notify subscribers
this.subscribers.forEach((callbacks, path) => {
callbacks.forEach((callback) => {
try {
callback(undefined, this.get(path));
} catch (error) {
console.error('Subscriber error:', error);
}
});
});
// Broadcast clear
this.channel.send('state-update', {
changes: [{ path: '*', value: undefined }],
version: this.stateVersion,
timestamp: Date.now(),
});
// Clear persisted state
if (this.options.persistState) {
localStorage.removeItem(`cross-tab-state-${this.options.channelName}`);
}
}
}
// Usage
const stateManager = new CrossTabStateManager({
channelName: 'app-state',
conflictResolution: 'last-write-wins',
persistState: true,
});
// Subscribe to state changes
stateManager.subscribe('user.name', (name) => {
console.log('User name changed:', name);
document.getElementById('username').textContent = name || 'Guest';
});
// Subscribe with wildcard
stateManager.subscribe('settings.*', (value, oldValue, path) => {
console.log(`Setting ${path} changed:`, value);
});
// Update state
stateManager.set('user.name', 'John Doe');
stateManager.set('user.preferences.theme', 'dark');
// Batch updates
stateManager.update({
'user.name': 'Jane Doe',
'user.email': 'jane@example.com',
'settings.notifications': true,
});
Event Broadcasting
Event Bus Implementation
class BroadcastEventBus {
constructor(channelName = 'event-bus') {
this.channel = new ChannelManager(channelName);
this.localListeners = new Map();
this.eventHistory = [];
this.maxHistorySize = 100;
this.init();
}
// Initialize event bus
init() {
// Handle incoming events
this.channel.on('event', (data) => {
this.handleRemoteEvent(data);
});
// Handle event history requests
this.channel.on('history-request', (data, message) => {
this.handleHistoryRequest(data, message);
});
// Handle event history response
this.channel.on('history-response', (data) => {
this.handleHistoryResponse(data);
});
// Request history on startup
this.requestHistory();
}
// Emit event
emit(eventName, data = {}, options = {}) {
const event = {
id: this.generateEventId(),
name: eventName,
data,
timestamp: Date.now(),
origin: window.location.href,
...options,
};
// Add to history
this.addToHistory(event);
// Trigger local listeners
this.triggerLocalListeners(event);
// Broadcast to other contexts
if (!options.local) {
this.channel.send('event', event);
}
return event;
}
// Listen for events
on(eventName, callback, options = {}) {
const listener = {
callback,
options,
eventName,
};
if (!this.localListeners.has(eventName)) {
this.localListeners.set(eventName, new Set());
}
this.localListeners.get(eventName).add(listener);
// Return unsubscribe function
return () => {
const listeners = this.localListeners.get(eventName);
if (listeners) {
listeners.delete(listener);
if (listeners.size === 0) {
this.localListeners.delete(eventName);
}
}
};
}
// Once listener
once(eventName, callback, options = {}) {
const unsubscribe = this.on(
eventName,
(...args) => {
unsubscribe();
callback(...args);
},
options
);
return unsubscribe;
}
// Emit and wait for response
request(eventName, data = {}, timeout = 5000) {
return new Promise((resolve, reject) => {
const requestId = this.generateEventId();
const responseEvent = `${eventName}:response`;
// Setup response listener
const cleanup = this.once(responseEvent, (responseData) => {
if (responseData.requestId === requestId) {
clearTimeout(timeoutId);
resolve(responseData);
}
});
// Setup timeout
const timeoutId = setTimeout(() => {
cleanup();
reject(new Error(`Request timeout: ${eventName}`));
}, timeout);
// Emit request
this.emit(eventName, { ...data, requestId });
});
}
// Reply to request
reply(originalEvent, responseData) {
const responseEvent = `${originalEvent.name}:response`;
this.emit(responseEvent, {
...responseData,
requestId: originalEvent.data.requestId,
});
}
// Handle remote event
handleRemoteEvent(event) {
// Add to history
this.addToHistory(event);
// Trigger local listeners
this.triggerLocalListeners(event);
}
// Trigger local listeners
triggerLocalListeners(event) {
// Exact match listeners
const exactListeners = this.localListeners.get(event.name);
if (exactListeners) {
exactListeners.forEach((listener) => {
this.invokeListener(listener, event);
});
}
// Wildcard listeners
this.localListeners.forEach((listeners, pattern) => {
if (pattern.includes('*')) {
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
if (regex.test(event.name)) {
listeners.forEach((listener) => {
this.invokeListener(listener, event);
});
}
}
});
// Global listeners
const globalListeners = this.localListeners.get('*');
if (globalListeners) {
globalListeners.forEach((listener) => {
this.invokeListener(listener, event);
});
}
}
// Invoke listener safely
invokeListener(listener, event) {
try {
if (listener.options.filter) {
if (!listener.options.filter(event)) {
return;
}
}
listener.callback(event.data, event);
} catch (error) {
console.error(`Event listener error for ${event.name}:`, error);
}
}
// Add event to history
addToHistory(event) {
this.eventHistory.push(event);
// Limit history size
if (this.eventHistory.length > this.maxHistorySize) {
this.eventHistory = this.eventHistory.slice(-this.maxHistorySize);
}
}
// Request event history
requestHistory(since = 0) {
this.channel.send('history-request', { since });
}
// Handle history request
handleHistoryRequest(data, message) {
const { since = 0 } = data;
const history = this.eventHistory.filter(
(event) => event.timestamp > since
);
this.channel.respond(message, { history });
}
// Handle history response
handleHistoryResponse(data) {
const { history = [] } = data;
// Merge history
history.forEach((event) => {
if (!this.eventHistory.find((e) => e.id === event.id)) {
this.addToHistory(event);
// Replay events
if (this.options.replayHistory) {
this.triggerLocalListeners(event);
}
}
});
// Sort history by timestamp
this.eventHistory.sort((a, b) => a.timestamp - b.timestamp);
}
// Generate unique event ID
generateEventId() {
return `evt-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
// Get event history
getHistory(filter = {}) {
let history = [...this.eventHistory];
// Filter by event name
if (filter.name) {
history = history.filter((event) => event.name === filter.name);
}
// Filter by time range
if (filter.since) {
history = history.filter((event) => event.timestamp > filter.since);
}
if (filter.until) {
history = history.filter((event) => event.timestamp < filter.until);
}
return history;
}
// Clear history
clearHistory() {
this.eventHistory = [];
}
// Destroy event bus
destroy() {
this.localListeners.clear();
this.eventHistory = [];
this.channel.close();
}
}
// Usage
const eventBus = new BroadcastEventBus('app-events');
// Listen for events
eventBus.on('user:login', (data) => {
console.log('User logged in:', data.username);
updateUIForLoggedInUser(data);
});
// Listen with wildcard
eventBus.on('user:*', (data, event) => {
console.log(`User event ${event.name}:`, data);
});
// Emit events
eventBus.emit('user:login', {
username: 'john_doe',
timestamp: Date.now(),
});
// Request-response pattern
eventBus.on('data:fetch', async (data, event) => {
const result = await fetchData(data.query);
eventBus.reply(event, { result });
});
// Make request
eventBus
.request('data:fetch', { query: 'users' })
.then((response) => console.log('Data:', response.result))
.catch((error) => console.error('Request failed:', error));
Real-time Collaboration
Collaborative Editor
class CollaborativeEditor {
constructor(editorElement, options = {}) {
this.editor = editorElement;
this.options = {
channelName: 'collab-editor',
userId: this.generateUserId(),
userName: 'Anonymous',
cursorColors: ['#FF6B6B', '#4ECDC4', '#45B7D1', '#F7DC6F'],
...options,
};
this.channel = new ChannelManager(this.options.channelName);
this.collaborators = new Map();
this.localVersion = 0;
this.remoteVersion = 0;
this.init();
}
// Initialize collaborative editor
init() {
this.setupEditor();
this.setupChannelHandlers();
this.announcePresence();
// Setup cleanup
window.addEventListener('beforeunload', () => {
this.leave();
});
}
// Setup editor
setupEditor() {
// Track local changes
this.editor.addEventListener('input', () => {
this.handleLocalChange();
});
// Track selection changes
document.addEventListener('selectionchange', () => {
if (this.editor.contains(document.activeElement)) {
this.handleSelectionChange();
}
});
// Create cursor container
this.cursorContainer = document.createElement('div');
this.cursorContainer.className = 'cursor-container';
this.editor.parentElement.style.position = 'relative';
this.editor.parentElement.appendChild(this.cursorContainer);
this.applyCursorStyles();
}
// Setup channel handlers
setupChannelHandlers() {
// Handle text changes
this.channel.on('text:change', (data) => {
this.handleRemoteChange(data);
});
// Handle cursor updates
this.channel.on('cursor:update', (data) => {
this.handleCursorUpdate(data);
});
// Handle presence updates
this.channel.on('presence:update', (data) => {
this.handlePresenceUpdate(data);
});
// Handle sync requests
this.channel.on('sync:request', (data, message) => {
this.handleSyncRequest(message);
});
// Handle sync responses
this.channel.on('sync:response', (data) => {
this.handleSyncResponse(data);
});
}
// Handle local text change
handleLocalChange() {
const content = this.editor.value || this.editor.textContent;
const change = this.calculateChange(this.lastContent || '', content);
if (change) {
this.localVersion++;
this.lastContent = content;
// Broadcast change
this.channel.send('text:change', {
change,
version: this.localVersion,
userId: this.options.userId,
});
}
}
// Calculate change delta
calculateChange(oldText, newText) {
// Simple diff algorithm (in production, use a proper diff library)
const commonStart = this.getCommonStart(oldText, newText);
const commonEnd = this.getCommonEnd(
oldText.slice(commonStart),
newText.slice(commonStart)
);
const deleteLength = oldText.length - commonStart - commonEnd;
const insertText = newText.slice(commonStart, newText.length - commonEnd);
if (deleteLength === 0 && insertText.length === 0) {
return null;
}
return {
position: commonStart,
deleteLength,
insertText,
};
}
// Get common start length
getCommonStart(a, b) {
let i = 0;
while (i < a.length && i < b.length && a[i] === b[i]) {
i++;
}
return i;
}
// Get common end length
getCommonEnd(a, b) {
let i = 0;
while (
i < a.length &&
i < b.length &&
a[a.length - 1 - i] === b[b.length - 1 - i]
) {
i++;
}
return i;
}
// Handle remote text change
handleRemoteChange(data) {
const { change, version, userId } = data;
if (userId === this.options.userId) {
return; // Ignore own changes
}
// Apply operational transformation if needed
const transformedChange = this.transformChange(change);
// Apply change
this.applyChange(transformedChange);
// Update version
this.remoteVersion = Math.max(this.remoteVersion, version);
}
// Transform change (simplified OT)
transformChange(change) {
// In a real implementation, this would handle conflicts
// between concurrent edits
return change;
}
// Apply change to editor
applyChange(change) {
const { position, deleteLength, insertText } = change;
const content = this.editor.value || this.editor.textContent;
const newContent =
content.slice(0, position) +
insertText +
content.slice(position + deleteLength);
// Update editor
if (this.editor.value !== undefined) {
const selectionStart = this.editor.selectionStart;
const selectionEnd = this.editor.selectionEnd;
this.editor.value = newContent;
// Restore selection
if (selectionStart > position) {
const delta = insertText.length - deleteLength;
this.editor.setSelectionRange(
selectionStart + delta,
selectionEnd + delta
);
}
} else {
this.editor.textContent = newContent;
}
this.lastContent = newContent;
}
// Handle selection change
handleSelectionChange() {
const selection = window.getSelection();
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const position = this.getCaretPosition();
// Broadcast cursor position
this.channel.send('cursor:update', {
userId: this.options.userId,
position,
userName: this.options.userName,
});
}
}
// Get caret position
getCaretPosition() {
if (this.editor.selectionStart !== undefined) {
return this.editor.selectionStart;
}
// For contenteditable
const selection = window.getSelection();
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const preCaretRange = range.cloneRange();
preCaretRange.selectNodeContents(this.editor);
preCaretRange.setEnd(range.endContainer, range.endOffset);
return preCaretRange.toString().length;
}
return 0;
}
// Handle cursor update
handleCursorUpdate(data) {
const { userId, position, userName } = data;
if (userId === this.options.userId) {
return; // Ignore own cursor
}
// Update collaborator cursor
this.updateCollaboratorCursor(userId, position, userName);
}
// Update collaborator cursor
updateCollaboratorCursor(userId, position, userName) {
let collaborator = this.collaborators.get(userId);
if (!collaborator) {
// Create new collaborator
const colorIndex =
this.collaborators.size % this.options.cursorColors.length;
collaborator = {
userId,
userName,
color: this.options.cursorColors[colorIndex],
cursor: this.createCursorElement(
userName,
this.options.cursorColors[colorIndex]
),
};
this.collaborators.set(userId, collaborator);
this.cursorContainer.appendChild(collaborator.cursor);
}
// Update cursor position
this.positionCursor(collaborator.cursor, position);
// Reset hide timer
clearTimeout(collaborator.hideTimer);
collaborator.cursor.style.opacity = '1';
collaborator.hideTimer = setTimeout(() => {
collaborator.cursor.style.opacity = '0';
}, 5000);
}
// Create cursor element
createCursorElement(userName, color) {
const cursor = document.createElement('div');
cursor.className = 'collaborator-cursor';
cursor.style.borderLeftColor = color;
const label = document.createElement('div');
label.className = 'cursor-label';
label.textContent = userName;
label.style.backgroundColor = color;
cursor.appendChild(label);
return cursor;
}
// Position cursor
positionCursor(cursorElement, position) {
// Calculate position based on text position
// This is simplified - real implementation would be more complex
const text = this.editor.value || this.editor.textContent;
const beforeText = text.slice(0, position);
const lines = beforeText.split('\n');
const lineHeight = parseInt(
window.getComputedStyle(this.editor).lineHeight
);
const top = (lines.length - 1) * lineHeight;
cursorElement.style.top = `${top}px`;
cursorElement.style.left = '0px'; // Simplified
}
// Announce presence
announcePresence() {
this.channel.send('presence:update', {
userId: this.options.userId,
userName: this.options.userName,
action: 'join',
});
// Request sync
this.channel.send('sync:request', {
version: this.localVersion,
});
}
// Handle presence update
handlePresenceUpdate(data) {
const { userId, userName, action } = data;
if (action === 'join' && userId !== this.options.userId) {
console.log(`${userName} joined the session`);
// Send current state to new user
this.channel.send('sync:response', {
content: this.editor.value || this.editor.textContent,
version: this.localVersion,
targetUserId: userId,
});
} else if (action === 'leave') {
// Remove collaborator
const collaborator = this.collaborators.get(userId);
if (collaborator) {
collaborator.cursor.remove();
this.collaborators.delete(userId);
}
console.log(`${userName} left the session`);
}
}
// Handle sync request
handleSyncRequest(message) {
this.channel.respond(message, {
content: this.editor.value || this.editor.textContent,
version: this.localVersion,
});
}
// Handle sync response
handleSyncResponse(data) {
const { content, version, targetUserId } = data;
// Only process if targeted to us or broadcast
if (targetUserId && targetUserId !== this.options.userId) {
return;
}
// Update content if newer
if (version > this.localVersion) {
if (this.editor.value !== undefined) {
this.editor.value = content;
} else {
this.editor.textContent = content;
}
this.lastContent = content;
this.localVersion = version;
}
}
// Apply cursor styles
applyCursorStyles() {
const style = document.createElement('style');
style.textContent = `
.cursor-container {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
z-index: 1000;
}
.collaborator-cursor {
position: absolute;
width: 2px;
height: 1.2em;
border-left: 2px solid;
transition: opacity 0.3s, top 0.1s, left 0.1s;
}
.cursor-label {
position: absolute;
top: -20px;
left: -2px;
padding: 2px 6px;
color: white;
font-size: 12px;
border-radius: 3px;
white-space: nowrap;
user-select: none;
}
`;
document.head.appendChild(style);
}
// Leave session
leave() {
this.channel.send('presence:update', {
userId: this.options.userId,
userName: this.options.userName,
action: 'leave',
});
this.channel.close();
}
// Generate user ID
generateUserId() {
return `user-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
}
// Usage
const editor = document.getElementById('collaborative-editor');
const collabEditor = new CollaborativeEditor(editor, {
userName: 'John Doe',
channelName: 'doc-123',
});
// You can also use it with contenteditable
const richEditor = document.querySelector('[contenteditable]');
const richCollabEditor = new CollaborativeEditor(richEditor, {
userName: 'Jane Smith',
channelName: 'doc-456',
});
Best Practices
-
Always check for API support
if ('BroadcastChannel' in window) { // Use BroadcastChannel } else { // Use fallback (localStorage events, etc.) }
-
Handle connection errors gracefully
try { const channel = new BroadcastChannel('my-channel'); } catch (error) { console.error('Failed to create channel:', error); // Use alternative communication method }
-
Close channels when done
window.addEventListener('beforeunload', () => { channel.close(); });
-
Implement message validation
channel.onmessage = (event) => { if (isValidMessage(event.data)) { processMessage(event.data); } };
Browser Compatibility
The Broadcast Channel API has good support in modern browsers, but you should provide fallbacks:
class BroadcastChannelPolyfill {
constructor(name) {
this.name = name;
this.id = Math.random().toString(36).substr(2, 9);
this.listeners = [];
// Use localStorage events as fallback
window.addEventListener('storage', (e) => {
if (e.key === `broadcast-${this.name}` && e.newValue) {
try {
const data = JSON.parse(e.newValue);
if (data.id !== this.id) {
const event = { data: data.message };
this.listeners.forEach((listener) => listener(event));
}
} catch (error) {
console.error('Failed to parse message:', error);
}
}
});
}
postMessage(message) {
try {
localStorage.setItem(
`broadcast-${this.name}`,
JSON.stringify({
id: this.id,
message,
timestamp: Date.now(),
})
);
} catch (error) {
console.error('Failed to send message:', error);
}
}
set onmessage(handler) {
this.listeners = [handler];
}
addEventListener(type, handler) {
if (type === 'message') {
this.listeners.push(handler);
}
}
removeEventListener(type, handler) {
if (type === 'message') {
const index = this.listeners.indexOf(handler);
if (index > -1) {
this.listeners.splice(index, 1);
}
}
}
close() {
this.listeners = [];
}
}
// Use polyfill if needed
if (!('BroadcastChannel' in window)) {
window.BroadcastChannel = BroadcastChannelPolyfill;
}
Conclusion
The Broadcast Channel API enables powerful cross-context communication:
- State synchronization across tabs and windows
- Real-time collaboration features
- Event broadcasting for distributed applications
- Shared notifications and alerts
- Session management across contexts
- Offline-first architectures
Key takeaways:
- Simple API for complex communication needs
- Same-origin security built-in
- No server required for local communication
- Efficient message broadcasting
- Great for multi-tab applications
- Consider fallbacks for older browsers
Build sophisticated multi-context applications with seamless communication!