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
JavaScript
'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