iframe.io
Version:
Easy and friendly API to connect and interact between content window and its containing iframe
584 lines (583 loc) • 22.9 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
function newObject(data) {
return JSON.parse(JSON.stringify(data));
}
function getMessageSize(data) {
try {
return JSON.stringify(data).length;
}
catch {
return 0;
}
}
function sanitizePayload(payload, maxSize) {
if (!payload)
return payload;
const size = getMessageSize(payload);
if (size > maxSize)
throw new Error(`Message size ${size} exceeds limit ${maxSize}`);
// Basic sanitization - remove functions and undefined values
return JSON.parse(JSON.stringify(payload));
}
const ackId = () => {
// Prefer cryptographically strong randomness when available
try {
const globalCrypto = (typeof crypto !== 'undefined'
? crypto
: (typeof window !== 'undefined' && window.crypto)
|| (typeof globalThis !== 'undefined' && globalThis.crypto));
if (globalCrypto && typeof globalCrypto.getRandomValues === 'function') {
const buffer = new Uint32Array(4);
globalCrypto.getRandomValues(buffer);
const randomPart = Array.from(buffer).map(n => n.toString(16)).join('');
return `${Date.now()}_${randomPart}`;
}
}
catch {
// Fall back to Math.random-based implementation below
}
const rmin = 100000, rmax = 999999, timestampFallback = Date.now(), randomFallback = Math.floor(Math.random() * (rmax - rmin + 1) + rmin);
return `${timestampFallback}_${randomFallback}`;
};
const RESERVED_EVENTS = [
'ping',
'pong',
'__heartbeat',
'__heartbeat_response'
];
class IOF {
constructor(options = {}) {
this.messageQueue = [];
this.messageRateTracker = [];
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
if (options && typeof options !== 'object')
throw new Error('Invalid Options');
this.options = {
debug: false,
heartbeatInterval: 30000,
connectionTimeout: 10000,
maxMessageSize: 1024 * 1024,
maxMessagesPerSecond: 100,
autoReconnect: true,
messageQueueSize: 50,
...options
};
this.Events = {};
this.peer = { type: 'IFRAME', connected: false };
if (options.type)
this.peer.type = options.type.toUpperCase();
}
debug(...args) {
this.options.debug && console.debug(...args);
}
isConnected() {
return !!this.peer.connected && !!this.peer.source;
}
// Enhanced connection health monitoring
startHeartbeat() {
if (!this.options.heartbeatInterval)
return;
this.heartbeatTimer = setInterval(() => {
if (this.isConnected()) {
const now = Date.now();
// Check if peer is still responsive
if (this.peer.lastHeartbeat
&& (now - this.peer.lastHeartbeat) > (this.options.heartbeatInterval * 2)) {
this.debug(`[${this.peer.type}] Heartbeat timeout detected`);
this.handleConnectionLoss();
return;
}
// Send heartbeat
try {
this.emit('__heartbeat', { timestamp: now });
}
catch (error) {
this.debug(`[${this.peer.type}] Heartbeat send failed:`, error);
this.handleConnectionLoss();
}
}
}, this.options.heartbeatInterval);
}
stopHeartbeat() {
if (!this.heartbeatTimer)
return;
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = undefined;
}
// Handle connection loss and potential reconnection
handleConnectionLoss() {
if (!this.peer.connected)
return;
this.peer.connected = false;
this.stopHeartbeat();
this.fire('disconnect', { reason: 'CONNECTION_LOST' });
this.options.autoReconnect
&& this.reconnectAttempts < this.maxReconnectAttempts
&& this.attemptReconnection();
}
attemptReconnection() {
if (this.reconnectTimer)
return;
this.reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts - 1), 30000); // Exponential backoff, max 30s
this.debug(`[${this.peer.type}] Attempting reconnection ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms`);
this.fire('reconnecting', { attempt: this.reconnectAttempts, delay });
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = undefined;
// Re-initiate connection for WINDOW type
this.peer.type === 'WINDOW'
&& this.peer.source
&& this.peer.origin
&& this.emit('ping');
// For IFRAME type, just wait for incoming connection
// Set timeout for this reconnection attempt
setTimeout(() => {
if (this.peer.connected)
return;
this.reconnectAttempts < this.maxReconnectAttempts
? this.attemptReconnection()
: this.fire('reconnection_failed', { attempts: this.reconnectAttempts });
}, this.options.connectionTimeout);
}, delay);
}
// Message rate limiting
checkRateLimit() {
if (!this.options.maxMessagesPerSecond)
return true;
const now = Date.now(), aSecondAgo = now - 1000;
// Clean old entries
this.messageRateTracker = this.messageRateTracker.filter(timestamp => timestamp > aSecondAgo);
// Check if limit exceeded
if (this.messageRateTracker.length >= this.options.maxMessagesPerSecond) {
this.fire('error', {
type: 'RATE_LIMIT_EXCEEDED',
limit: this.options.maxMessagesPerSecond,
current: this.messageRateTracker.length
});
return false;
}
this.messageRateTracker.push(now);
return true;
}
// Queue messages when not connected
queueMessage(_event, payload, fn) {
if (this.messageQueue.length >= this.options.messageQueueSize) {
// Remove oldest message
const removed = this.messageQueue.shift();
this.debug(`[${this.peer.type}] Message queue full, removed oldest message:`, removed?._event);
}
this.messageQueue.push({
_event,
payload,
fn,
timestamp: Date.now()
});
this.debug(`[${this.peer.type}] Queued message: ${_event} (queue size: ${this.messageQueue.length})`);
}
// Process queued messages when connection is established
processMessageQueue() {
if (!this.isConnected() || this.messageQueue.length === 0)
return;
this.debug(`[${this.peer.type}] Processing ${this.messageQueue.length} queued messages`);
const queue = [...this.messageQueue];
this.messageQueue = [];
queue.forEach(message => {
try {
this.emit(message._event, message.payload, message.fn);
}
catch (error) {
this.debug(`[${this.peer.type}] Failed to send queued message:`, error);
}
});
}
/**
* Establish a connection with an iframe containing
* in the current window
*/
initiate(contentWindow, iframeOrigin) {
if (!contentWindow || !iframeOrigin)
throw new Error('Invalid Connection initiation arguments');
if (this.peer.type === 'IFRAME')
throw new Error('Expect IFRAME to <listen> and WINDOW to <initiate> a connection');
// Clean up existing listener if any
this.cleanup();
this.peer.source = contentWindow;
this.peer.origin = iframeOrigin;
this.peer.connected = false;
this.reconnectAttempts = 0;
this.messageListener = ({ origin, data, source }) => {
try {
// Enhanced security: check valid message structure
if (origin !== this.peer.origin
|| !source
|| typeof data !== 'object'
|| !data.hasOwnProperty('_event'))
return;
const { _event, payload, cid, timestamp } = data;
// Handle heartbeat responses
if (_event === '__heartbeat_response') {
this.peer.lastHeartbeat = Date.now();
return;
}
// Handle heartbeat requests
if (_event === '__heartbeat') {
this.emit('__heartbeat_response', { timestamp: Date.now() });
this.peer.lastHeartbeat = Date.now();
return;
}
this.debug(`[${this.peer.type}] Message: ${_event}`, payload || '');
// Handshake or availability check events
if (_event == 'pong') {
// Content Window is connected to iframe
this.peer.connected = true;
this.reconnectAttempts = 0;
this.peer.lastHeartbeat = Date.now();
this.startHeartbeat();
this.fire('connect');
this.processMessageQueue();
this.debug(`[${this.peer.type}] connected`);
return;
}
// Optional application-level incoming validation (non-reserved events only)
if (!RESERVED_EVENTS.includes(_event)) {
if (this.options.allowedIncomingEvents
&& !this.options.allowedIncomingEvents.includes(_event)) {
this.fire('error', {
type: 'DISALLOWED_EVENT',
direction: 'incoming',
event: _event,
origin
});
return;
}
if (this.options.validateIncoming
&& !this.options.validateIncoming(_event, payload, origin)) {
this.fire('error', {
type: 'INVALID_MESSAGE',
direction: 'incoming',
event: _event,
origin
});
return;
}
}
// Fire available event listeners
this.fire(_event, payload, cid);
}
catch (error) {
this.debug(`[${this.peer.type}] Message handling error:`, error);
this.fire('error', {
type: 'MESSAGE_HANDLING_ERROR',
error: error instanceof Error ? error.message : String(error),
origin
});
}
};
window.addEventListener('message', this.messageListener, false);
this.debug(`[${this.peer.type}] Initiate connection: IFrame origin <${iframeOrigin}>`);
this.emit('ping');
return this;
}
/**
* Listening to connection from the content window
*/
listen(hostOrigin) {
this.peer.type = 'IFRAME'; // iframe.io connection listener is automatically set as IFRAME
this.peer.connected = false;
this.reconnectAttempts = 0;
this.debug(`[${this.peer.type}] Listening to connect${hostOrigin ? `: Host <${hostOrigin}>` : ''}`);
// Clean up existing listener if any
this.cleanup();
this.messageListener = ({ origin, data, source }) => {
try {
// Enhanced security: check host origin where event must only come from
if (hostOrigin && hostOrigin !== origin) {
this.fire('error', {
type: 'INVALID_ORIGIN',
expected: hostOrigin,
received: origin
});
return;
}
// Enhanced security: check valid message structure
if (!source
|| typeof data !== 'object'
|| !data.hasOwnProperty('_event'))
return;
// Define peer source window and origin
if (!this.peer.source) {
this.peer = { ...this.peer, source: source, origin };
this.debug(`[${this.peer.type}] Connect to ${origin}`);
}
// Origin different from handshaked source origin
else if (origin !== this.peer.origin) {
this.fire('error', {
type: 'ORIGIN_MISMATCH',
expected: this.peer.origin,
received: origin
});
return;
}
const { _event, payload, cid, timestamp } = data;
// Handle heartbeat responses
if (_event === '__heartbeat_response') {
this.peer.lastHeartbeat = Date.now();
return;
}
// Handle heartbeat requests
if (_event === '__heartbeat') {
this.emit('__heartbeat_response', { timestamp: Date.now() });
this.peer.lastHeartbeat = Date.now();
return;
}
this.debug(`[${this.peer.type}] Message: ${_event}`, payload || '');
// Handshake or availability check events
if (_event == 'ping') {
this.emit('pong');
// Iframe is connected to content window
this.peer.connected = true;
this.reconnectAttempts = 0;
this.peer.lastHeartbeat = Date.now();
this.startHeartbeat();
this.fire('connect');
this.processMessageQueue();
this.debug(`[${this.peer.type}] connected`);
return;
}
// Fire available event listeners
this.fire(_event, payload, cid);
}
catch (error) {
this.debug(`[${this.peer.type}] Message handling error:`, error);
this.fire('error', {
type: 'MESSAGE_HANDLING_ERROR',
error: error instanceof Error ? error.message : String(error),
origin
});
}
};
window.addEventListener('message', this.messageListener, false);
return this;
}
fire(_event, payload, cid) {
// Volatile event - check if any listeners exist
if (!this.Events[_event] && !this.Events[_event + '--@once']) {
this.debug(`[${this.peer.type}] No <${_event}> listener defined`);
return;
}
const ackFn = cid
? (error, ...args) => {
this.emit(`${_event}--${cid}--`, { error: error || false, args });
return;
}
: undefined;
let listeners = [];
if (this.Events[_event + '--@once']) {
// Once triggable event
_event += '--@once';
listeners = this.Events[_event];
// Delete once event listeners after fired
delete this.Events[_event];
}
else
listeners = this.Events[_event];
// Fire listeners with error handling
listeners.forEach(fn => {
try {
payload !== undefined ? fn(payload, ackFn) : fn(ackFn);
}
catch (error) {
this.debug(`[${this.peer.type}] Listener error for ${_event}:`, error);
this.fire('error', {
type: 'LISTENER_ERROR',
event: _event,
error: error instanceof Error ? error.message : String(error)
});
}
});
}
emit(_event, payload, fn) {
// Check rate limiting
if (!this.checkRateLimit())
return this;
/**
* Queue message if not connected: Except for
* connection-related events
*/
if (!this.isConnected() && !RESERVED_EVENTS.includes(_event)) {
this.queueMessage(_event, payload, fn);
return this;
}
if (!this.peer.source) {
this.fire('error', { type: 'NO_CONNECTION', event: _event });
return this;
}
if (typeof payload == 'function') {
fn = payload;
payload = undefined;
}
try {
// Enhanced security: sanitize and validate payload
const sanitizedPayload = payload
? sanitizePayload(payload, this.options.maxMessageSize)
: payload;
// Acknowledge event listener
let cid;
if (typeof fn === 'function') {
const ackFunction = fn;
cid = ackId();
this.once(`${_event}--${cid}--`, ({ error, args }) => ackFunction(error, ...args));
}
const messageData = {
_event,
payload: sanitizedPayload,
cid,
timestamp: Date.now(),
size: getMessageSize(sanitizedPayload)
};
this.peer.source.postMessage(newObject(messageData), this.peer.origin);
}
catch (error) {
this.debug(`[${this.peer.type}] Emit error:`, error);
this.fire('error', {
type: 'EMIT_ERROR',
event: _event,
error: error instanceof Error ? error.message : String(error)
});
// Call acknowledgment with error if provided
typeof fn === 'function'
&& fn(error instanceof Error ? error.message : String(error));
}
return this;
}
on(_event, fn) {
// Add Event listener
if (!this.Events[_event])
this.Events[_event] = [];
this.Events[_event].push(fn);
this.debug(`[${this.peer.type}] New <${_event}> listener on`);
return this;
}
once(_event, fn) {
// Add Once Event listener
_event += '--@once';
if (!this.Events[_event])
this.Events[_event] = [];
this.Events[_event].push(fn);
this.debug(`[${this.peer.type}] New <${_event} once> listener on`);
return this;
}
off(_event, fn) {
// Remove Event listener
if (fn && this.Events[_event]) {
// Remove specific listener if provided
const index = this.Events[_event].indexOf(fn);
if (index > -1) {
this.Events[_event].splice(index, 1);
// Remove event array if empty
if (this.Events[_event].length === 0)
delete this.Events[_event];
}
}
// Remove all listeners for event
else
delete this.Events[_event];
typeof fn == 'function' && fn();
this.debug(`[${this.peer.type}] <${_event}> listener off`);
return this;
}
removeListeners(fn) {
// Clear all event listeners
this.Events = {};
typeof fn == 'function' && fn();
this.debug(`[${this.peer.type}] All listeners removed`);
return this;
}
emitAsync(_event, payload, timeout = 5000) {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error(`Event '${_event}' acknowledgment timeout after ${timeout}ms`));
}, timeout);
try {
this.emit(_event, payload, (error, ...args) => {
clearTimeout(timeoutId);
error
? reject(new Error(typeof error === 'string' ? error : 'Ack error'))
: resolve(args.length === 0 ? undefined : args.length === 1 ? args[0] : args);
});
}
catch (error) {
clearTimeout(timeoutId);
reject(error);
}
});
}
onceAsync(_event) {
return new Promise(resolve => this.once(_event, resolve));
}
connectAsync(timeout) {
return new Promise((resolve, reject) => {
if (this.isConnected())
return resolve();
const timeoutId = setTimeout(() => {
this.off('connect', connectHandler);
reject(new Error('Connection timeout'));
}, timeout || this.options.connectionTimeout);
const connectHandler = () => {
clearTimeout(timeoutId);
resolve();
};
this.once('connect', connectHandler);
});
}
// Clean up all resources
cleanup() {
if (this.messageListener) {
window.removeEventListener('message', this.messageListener);
this.messageListener = undefined;
}
this.stopHeartbeat();
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = undefined;
}
}
disconnect(fn) {
// Cleanup on disconnect
this.cleanup();
this.peer.connected = false;
this.peer.source = undefined;
this.peer.origin = undefined;
this.peer.lastHeartbeat = undefined;
this.messageQueue = [];
this.messageRateTracker = [];
this.reconnectAttempts = 0;
this.removeListeners();
typeof fn == 'function' && fn();
this.debug(`[${this.peer.type}] Disconnected`);
return this;
}
// Get connection statistics
getStats() {
return {
connected: this.isConnected(),
peerType: this.peer.type,
origin: this.peer.origin,
lastHeartbeat: this.peer.lastHeartbeat,
queuedMessages: this.messageQueue.length,
reconnectAttempts: this.reconnectAttempts,
activeListeners: Object.keys(this.Events).length,
messageRate: this.messageRateTracker.length
};
}
// Clear message queue manually
clearQueue() {
const queueSize = this.messageQueue.length;
this.messageQueue = [];
this.debug(`[${this.peer.type}] Cleared ${queueSize} queued messages`);
return this;
}
}
exports.default = IOF;