UNPKG

tab-whisper

Version:

A lightweight, browser-only framework for inter-tab/window communication using the Broadcast Channel API

489 lines (484 loc) 15.3 kB
'use strict'; /** * Internal message types used by the framework */ var InternalMessageType; (function (InternalMessageType) { InternalMessageType["REGISTER"] = "__tc_register"; InternalMessageType["DISCONNECT"] = "__tc_disconnect"; InternalMessageType["DISCOVER"] = "__tc_discover"; InternalMessageType["DISCOVER_RESPONSE"] = "__tc_discover_response"; InternalMessageType["HEARTBEAT"] = "__tc_heartbeat"; InternalMessageType["HEARTBEAT_RESPONSE"] = "__tc_heartbeat_response"; })(InternalMessageType || (InternalMessageType = {})); /** * Error thrown when BroadcastChannel API is not supported */ class BroadcastChannelUnsupportedError extends Error { constructor() { super('BroadcastChannel API is not supported in this environment'); this.name = 'BroadcastChannelUnsupportedError'; } } /** * Error thrown when a message is invalid */ class InvalidMessageError extends Error { constructor(message) { super(message); this.name = 'InvalidMessageError'; } } /** * Error thrown when a target peer is not found */ class PeerNotFoundError extends Error { constructor(peerId) { super(`Peer with ID "${peerId}" not found`); this.name = 'PeerNotFoundError'; } } /** * Error thrown when trying to use a closed communicator */ class CommunicatorClosedError extends Error { constructor() { super('TabCommunicator instance has been closed'); this.name = 'CommunicatorClosedError'; } } /** * TabCommunicator - Inter-tab/window communication using BroadcastChannel API */ class TabCommunicator { constructor(options) { this._peers = new Map(); this._eventListeners = new Map(); this._isConnected = false; this._discoveryTimeout = null; this._peerVerificationInterval = null; this._heartbeatInterval = null; // Check BroadcastChannel support if (typeof BroadcastChannel === 'undefined') { throw new BroadcastChannelUnsupportedError(); } this._channelName = options.channelName; this._registrationId = options.registrationId || null; this._id = this._generateInternalId(); // Store callbacks this._onMessage = options.onMessage; this._onPeerConnected = options.onPeerConnected; this._onPeerDisconnected = options.onPeerDisconnected; this._onError = options.onError; // Create broadcast channel this._channel = new BroadcastChannel(this._channelName); this._channel.addEventListener('message', this._handleMessage.bind(this)); // Initialize connection this._initialize(); // Start heartbeat system this._startHeartbeat(); // Set up beforeunload listener for tab close detection this._setupTabCloseDetection(); } /** * Internal ID of this instance (automatically generated) */ get id() { return this._id; } /** * Registration ID of this instance (user-provided) */ get registrationId() { return this._registrationId; } /** * Set of active peer IDs (excluding self) */ get peers() { const peerIds = new Set(); for (const peer of this._peers.values()) { // Prefer registration ID if available, otherwise use internal ID peerIds.add(peer.registrationId || peer.internalId); } return peerIds; } /** * Name of the communication channel */ get channelName() { return this._channelName; } /** * Connection status */ get isConnected() { return this._isConnected; } /** * Send a message to a specific peer or broadcast to all */ send(targetId, type, payload) { this._ensureConnected(); this._validateMessage(type, payload); // If targetId is provided, validate it exists if (targetId !== null && !this._findPeerByAnyId(targetId)) { throw new PeerNotFoundError(targetId); } const message = { from: this._registrationId || this._id, to: targetId, type, payload, timestamp: Date.now() }; this._channel.postMessage(message); } /** * Register an event listener */ on(eventType, callback) { if (!this._eventListeners.has(eventType)) { this._eventListeners.set(eventType, new Set()); } this._eventListeners.get(eventType).add(callback); } /** * Remove an event listener */ off(eventType, callback) { const listeners = this._eventListeners.get(eventType); if (listeners) { listeners.delete(callback); if (listeners.size === 0) { this._eventListeners.delete(eventType); } } } /** * Close the communicator and clean up resources */ close() { if (!this._isConnected) { return; } // Send disconnect message this._sendInternalMessage(InternalMessageType.DISCONNECT, { internalId: this._id, registrationId: this._registrationId }); // Clean up this._isConnected = false; this._channel.close(); this._peers.clear(); this._eventListeners.clear(); if (this._discoveryTimeout) { clearTimeout(this._discoveryTimeout); this._discoveryTimeout = null; } if (this._peerVerificationInterval) { clearInterval(this._peerVerificationInterval); this._peerVerificationInterval = null; } if (this._heartbeatInterval) { clearInterval(this._heartbeatInterval); this._heartbeatInterval = null; } } /** * Initialize the communicator */ _initialize() { this._isConnected = true; // Send registration message this._sendInternalMessage(InternalMessageType.REGISTER, { internalId: this._id, registrationId: this._registrationId }); // Discover existing peers this._sendInternalMessage(InternalMessageType.DISCOVER, { internalId: this._id, registrationId: this._registrationId }); } /** * Generate a unique internal ID */ _generateInternalId() { const timestamp = Date.now().toString(36); const random = Math.random().toString(36).substring(2); return `tc_${timestamp}_${random}`; } /** * Handle incoming messages */ _handleMessage(event) { try { const message = event.data; // Ignore messages from self if (message.from === this._id || message.from === this._registrationId) { return; } // Handle internal messages if (Object.values(InternalMessageType).includes(message.type)) { this._handleInternalMessage(message); return; } // Handle targeted messages if (message.to !== null) { const isForMe = message.to === this._id || message.to === this._registrationId; if (!isForMe) { return; // Message not for us } } // Emit message event this._emitEvent('message', message); // Call onMessage callback if (this._onMessage) { this._onMessage(message); } } catch (error) { this._handleError(error); } } /** * Handle internal framework messages */ _handleInternalMessage(message) { const { type, payload } = message; switch (type) { case InternalMessageType.REGISTER: this._handlePeerRegister(payload); break; case InternalMessageType.DISCONNECT: this._handlePeerDisconnect(payload); break; case InternalMessageType.DISCOVER: this._handlePeerDiscover(message.from); break; case InternalMessageType.DISCOVER_RESPONSE: this._handlePeerRegister(payload); break; case InternalMessageType.HEARTBEAT: this._handlePeerHeartbeat(message.from); break; case InternalMessageType.HEARTBEAT_RESPONSE: this._handlePeerHeartbeatResponse(payload); break; } } /** * Handle peer registration */ _handlePeerRegister(payload) { const { internalId, registrationId } = payload; if (internalId === this._id) { return; // Ignore self } const existingPeer = this._peers.get(internalId); if (!existingPeer) { // New peer const peerInfo = { internalId, registrationId: registrationId || null, lastSeen: Date.now() }; this._peers.set(internalId, peerInfo); const peerId = registrationId || internalId; this._emitEvent('peerConnected', peerId); if (this._onPeerConnected) { this._onPeerConnected(peerId); } } else { // Update existing peer existingPeer.lastSeen = Date.now(); existingPeer.registrationId = registrationId || null; } } /** * Handle peer disconnection */ _handlePeerDisconnect(payload) { const { internalId } = payload; const peer = this._peers.get(internalId); if (peer) { this._peers.delete(internalId); const peerId = peer.registrationId || peer.internalId; this._emitEvent('peerDisconnected', peerId); if (this._onPeerDisconnected) { this._onPeerDisconnected(peerId); } } } /** * Handle peer discovery request */ _handlePeerDiscover(fromId) { // Respond with our registration info this._sendInternalMessage(InternalMessageType.DISCOVER_RESPONSE, { internalId: this._id, registrationId: this._registrationId }); } /** * Handle peer heartbeat request */ _handlePeerHeartbeat(fromId) { // Respond to heartbeat request this._sendInternalMessage(InternalMessageType.HEARTBEAT_RESPONSE, { internalId: this._id, registrationId: this._registrationId }); } /** * Handle peer heartbeat response */ _handlePeerHeartbeatResponse(payload) { const { internalId } = payload; const peer = this._peers.get(internalId); if (peer) { // Update last seen timestamp peer.lastSeen = Date.now(); } } /** * Set up tab close detection */ _setupTabCloseDetection() { const handleBeforeUnload = () => { // Send disconnect message before tab closes this._sendInternalMessage(InternalMessageType.DISCONNECT, { internalId: this._id, registrationId: this._registrationId }); }; // Listen for tab close/refresh window.addEventListener('beforeunload', handleBeforeUnload); // Also listen for page visibility changes (when tab becomes hidden) document.addEventListener('visibilitychange', () => { }); } /** * Start heartbeat system */ _startHeartbeat() { // Send heartbeat every 5 seconds this._heartbeatInterval = setInterval(() => { this._sendHeartbeat(); }, 5000); } /** * Send heartbeat to all peers */ _sendHeartbeat() { if (!this._isConnected) return; this._sendInternalMessage(InternalMessageType.HEARTBEAT, { internalId: this._id, registrationId: this._registrationId }); // Clean up stale peers (haven't been seen for 15 seconds) const now = Date.now(); const stalePeers = []; for (const [internalId, peer] of this._peers.entries()) { if (now - peer.lastSeen > 15000) { stalePeers.push(internalId); } } // Remove stale peers for (const internalId of stalePeers) { const peer = this._peers.get(internalId); if (peer) { this._peers.delete(internalId); const peerId = peer.registrationId || peer.internalId; this._emitEvent('peerDisconnected', peerId); if (this._onPeerDisconnected) { this._onPeerDisconnected(peerId); } } } } /** * Send an internal framework message */ _sendInternalMessage(type, payload) { const message = { from: this._id, to: null, type, payload, timestamp: Date.now() }; this._channel.postMessage(message); } /** * Find a peer by any ID (internal or registration) */ _findPeerByAnyId(id) { for (const peer of this._peers.values()) { if (peer.internalId === id || peer.registrationId === id) { return peer; } } return undefined; } /** * Validate message parameters */ _validateMessage(type, payload) { if (typeof type !== 'string' || type.trim() === '') { throw new InvalidMessageError('Message type must be a non-empty string'); } // Check if payload is JSON serializable try { JSON.stringify(payload); } catch (error) { throw new InvalidMessageError('Message payload must be JSON-serializable'); } } /** * Ensure the communicator is connected */ _ensureConnected() { if (!this._isConnected) { throw new CommunicatorClosedError(); } } /** * Emit an event to listeners */ _emitEvent(eventType, data) { const listeners = this._eventListeners.get(eventType); if (listeners) { for (const callback of listeners) { try { callback(data); } catch (error) { this._handleError(error); } } } } /** * Handle errors */ _handleError(error) { this._emitEvent('error', error); if (this._onError) { this._onError(error); } else { console.error('TabCommunicator error:', error); } } } exports.BroadcastChannelUnsupportedError = BroadcastChannelUnsupportedError; exports.CommunicatorClosedError = CommunicatorClosedError; exports.InvalidMessageError = InvalidMessageError; exports.PeerNotFoundError = PeerNotFoundError; exports.TabCommunicator = TabCommunicator; //# sourceMappingURL=index.js.map