iframe.io
Version:
Easy and friendly API to connect and interact between content window and its containing iframe
657 lines (540 loc) • 19.6 kB
text/typescript
export type PeerType = 'WINDOW' | 'IFRAME'
export type AckFunction = ( error: boolean | string, ...args: any[] ) => void
export type Listener = ( payload?: any, ack?: AckFunction ) => void
export type Options = {
type?: PeerType
debug?: boolean
heartbeatInterval?: number
connectionTimeout?: number
maxMessageSize?: number
maxMessagesPerSecond?: number
autoReconnect?: boolean
messageQueueSize?: number
}
export interface RegisteredEvents {
[index: string]: Listener[]
}
export type Peer = {
type: PeerType
source?: Window
origin?: string
connected?: boolean
lastHeartbeat?: number
}
export type MessageData = {
_event: string
payload: any
cid: string | undefined
timestamp?: number
size?: number
}
export type Message = {
origin: string
data: MessageData,
source: Window
}
export type QueuedMessage = {
_event: string
payload: any
fn?: AckFunction
timestamp: number
}
function newObject( data: object ){
return JSON.parse( JSON.stringify( data ) )
}
function getMessageSize( data: any ): number {
try {
return JSON.stringify( data ).length
} catch {
return 0
}
}
function sanitizePayload( payload: any, maxSize: number ): any {
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 = () => {
const rmin = 100000, rmax = 999999
const timestamp = Date.now()
const random = Math.floor( Math.random() * ( rmax - rmin + 1 ) + rmin )
return `${timestamp}_${random}`
}
export default class IOF {
Events: RegisteredEvents
peer: Peer
options: Options
private messageListener?: (event: MessageEvent) => void
private heartbeatTimer?: NodeJS.Timeout
private reconnectTimer?: NodeJS.Timeout
private messageQueue: QueuedMessage[] = []
private messageRateTracker: number[] = []
private reconnectAttempts: number = 0
private maxReconnectAttempts: number = 5
constructor( options: Options = {} ){
if( options && typeof options !== 'object' )
throw new Error('Invalid Options')
this.options = {
debug: false,
heartbeatInterval: 30000, // 30 seconds
connectionTimeout: 10000, // 10 seconds
maxMessageSize: 1024 * 1024, // 1MB
maxMessagesPerSecond: 100,
autoReconnect: true,
messageQueueSize: 50,
...options
}
this.Events = {}
this.peer = { type: 'IFRAME', connected: false }
if( options.type )
this.peer.type = options.type.toUpperCase() as PeerType
}
debug( ...args: any[] ){
this.options.debug && console.debug( ...args )
}
isConnected(): boolean {
return !!this.peer.connected && !!this.peer.source
}
// Enhanced connection health monitoring
private 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 )
}
private stopHeartbeat(){
if( !this.heartbeatTimer ) return
clearInterval( this.heartbeatTimer )
this.heartbeatTimer = undefined
}
// Handle connection loss and potential reconnection
private 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()
}
private 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 ){
this.reconnectAttempts < this.maxReconnectAttempts
? this.attemptReconnection()
: this.fire('reconnection_failed', { attempts: this.reconnectAttempts })
}
}, this.options.connectionTimeout! )
}, delay )
}
// Message rate limiting
private checkRateLimit(): boolean {
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
private queueMessage( _event: string, payload?: any, fn?: AckFunction ){
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
private 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: MessageEventSource, iframeOrigin: string ){
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 as Window
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 as Message['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()
return this.debug(`[${this.peer.type}] connected`)
}
// 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?: string ){
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 as Window, 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()
return this.debug(`[${this.peer.type}] connected`)
}
// 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: string, payload?: MessageData['payload'], cid?: string ){
// Volatile event - check if any listeners exist
if( !this.Events[ _event ]
&& !this.Events[ _event +'--@once'] )
return this.debug(`[${this.peer.type}] No <${_event}> listener defined`)
const ackFn = cid
? ( error: boolean | string, ...args: any[] ): void => {
this.emit(`${_event}--${cid}--`, { error: error || false, args } )
return
}
: undefined
let listeners: Listener[] = []
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<T = any>( _event: string, payload?: T | AckFunction, fn?: AckFunction ){
// Check rate limiting
if( !this.checkRateLimit() ) return this
// Queue message if not connected (except for connection-related events)
if( !this.isConnected() && !['ping', 'pong', '__heartbeat', '__heartbeat_response'].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 as AckFunction
payload = undefined
}
try {
// Enhanced security: sanitize and validate payload
const sanitizedPayload = payload ? sanitizePayload( payload, this.options.maxMessageSize! ) : payload
// Acknowledge event listener
let cid: string | undefined
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 as string )
}
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
if( typeof fn === 'function' ){
fn( error instanceof Error ? error.message : String(error) )
}
}
return this
}
on( _event: string, fn: Listener ){
// 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: string, fn: Listener ){
// 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: string, fn?: Listener ){
// 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?: Listener ){
// Clear all event listeners
this.Events = {}
typeof fn == 'function' && fn()
this.debug(`[${this.peer.type}] All listeners removed`)
return this
}
emitAsync<T = any, R = any>( _event: string, payload?: T ): Promise<R> {
return new Promise(( resolve, reject ) => {
try {
this.emit( _event, payload, ( error, ...args ) => {
error
? reject( new Error( typeof error === 'string' ? error : 'Ack error' ) )
: resolve( args.length === 0 ? undefined : args.length === 1 ? args[0] : args )
})
}
catch( error ){ reject( error ) }
})
}
onceAsync<T = any>( _event: string ): Promise<T> {
return new Promise( resolve => this.once( _event, resolve ) )
}
connectAsync( timeout: number = 5000 ): Promise<void> {
return new Promise(( resolve, reject ) => {
if( this.isConnected() ) return resolve()
const timeoutId = setTimeout(() => {
this.off('connect', connectHandler )
reject( new Error('Connection timeout') )
}, timeout )
const connectHandler = () => {
clearTimeout( timeoutId )
resolve()
}
this.once('connect', connectHandler )
})
}
// Clean up all resources
private 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?: () => void ){
// Clean disconnect method
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
}
}