tiny-essentials
Version:
Collection of small, essential scripts designed to be used across various projects. These simple utilities are crafted for speed, ease of use, and versatility.
411 lines (352 loc) • 12.7 kB
JavaScript
'use strict';
var objChecker = require('../basics/objChecker.cjs');
var TinyEvents = require('./TinyEvents.cjs');
/** @type {WeakMap<Window, TinyIframeEvents>} */
const instances = new WeakMap();
/**
* @callback handler
* A function to handle incoming event payloads.
* @param {any} payload - The data sent by the emitter.
* @param {MessageEvent<any>} event - Metadata about the message.
*/
/**
* A flexible event routing system for structured communication
* between a parent window and its iframe using `postMessage`.
*
* This class abstracts the complexity of cross-origin and window-type handling,
* allowing both the iframe and parent to:
* - Send events with arbitrary payloads
* - Listen to specific event names
* - Filter events by origin and source
* - Work symmetrically from both sides with automatic direction handling
*
* Use this class when building applications that require modular, event-driven
* communication across embedded frames.
*/
class TinyIframeEvents {
#events = new TinyEvents();
/**
* Enables or disables throwing an error when the maximum number of listeners is exceeded.
*
* @param {boolean} shouldThrow - If true, an error will be thrown when the max is exceeded.
*/
setThrowOnMaxListeners(shouldThrow) {
return this.#events.setThrowOnMaxListeners(shouldThrow);
}
/**
* Checks whether an error will be thrown when the max listener limit is exceeded.
*
* @returns {boolean} True if an error will be thrown, false if only a warning is shown.
*/
getThrowOnMaxListeners() {
return this.#events.getThrowOnMaxListeners();
}
/////////////////////////////////////////////////////////////
/**
* Adds a listener to the beginning of the listeners array for the specified event.
*
* @param {string|string[]} event - Event name.
* @param {handler} handler - The callback function.
*/
prependListener(event, handler) {
return this.#events.prependListener(event, handler);
}
/**
* Adds a one-time listener to the beginning of the listeners array for the specified event.
*
* @param {string|string[]} event - Event name.
* @param {handler} handler - The callback function.
* @returns {handler[]} - The wrapped handler used internally.
*/
prependListenerOnce(event, handler) {
return this.#events.prependListenerOnce(event, handler);
}
//////////////////////////////////////////////////////////////////////
/**
* Adds a event listener.
*
* @param {string|string[]} event - Event name, such as 'onScrollBoundary' or 'onAutoScroll'.
* @param {handler} handler - Callback function to be called when event fires.
*/
appendListener(event, handler) {
return this.#events.appendListener(event, handler);
}
/**
* Registers an event listener that runs only once, then is removed.
*
* @param {string|string[]} event - Event name, such as 'onScrollBoundary' or 'onAutoScroll'.
* @param {handler} handler - The callback function to run on event.
* @returns {handler[]} - The wrapped version of the handler.
*/
appendListenerOnce(event, handler) {
return this.#events.appendListenerOnce(event, handler);
}
/**
* Adds a event listener.
*
* @param {string|string[]} event - Event name, such as 'onScrollBoundary' or 'onAutoScroll'.
* @param {handler} handler - Callback function to be called when event fires.
*/
on(event, handler) {
return this.#events.on(event, handler);
}
/**
* Registers an event listener that runs only once, then is removed.
*
* @param {string|string[]} event - Event name, such as 'onScrollBoundary' or 'onAutoScroll'.
* @param {handler} handler - The callback function to run on event.
* @returns {handler[]} - The wrapped version of the handler.
*/
once(event, handler) {
return this.#events.once(event, handler);
}
////////////////////////////////////////////////////////////////////
/**
* Removes a previously registered event listener.
*
* @param {string|string[]} event - The name of the event to remove the handler from.
* @param {handler} handler - The specific callback function to remove.
*/
off(event, handler) {
return this.#events.off(event, handler);
}
/**
* Removes all event listeners of a specific type from the element.
*
* @param {string|string[]} event - The event type to remove (e.g. 'onScrollBoundary').
*/
offAll(event) {
return this.#events.offAll(event);
}
/**
* Removes all event listeners of all types from the element.
*/
offAllTypes() {
return this.#events.offAllTypes();
}
////////////////////////////////////////////////////////////
/**
* Returns the number of listeners for a given event.
*
* @param {string} event - The name of the event.
* @returns {number} Number of listeners for the event.
*/
listenerCount(event) {
return this.#events.listenerCount(event);
}
/**
* Returns a copy of the array of listeners for the specified event.
*
* @param {string} event - The name of the event.
* @returns {handler[]} Array of listener functions.
*/
listeners(event) {
return this.#events.listeners(event);
}
/**
* Returns a copy of the array of listeners for the specified event.
*
* @param {string} event - The name of the event.
* @returns {handler[]} Array of listener functions.
*/
onceListeners(event) {
return this.#events.onceListeners(event);
}
/**
* Returns a copy of the internal listeners array for the specified event,
* including wrapper functions like those used by `.once()`.
* @param {string | symbol} event - The event name.
* @returns {handler[]} An array of raw listener functions.
*/
allListeners(event) {
return this.#events.allListeners(event);
}
/**
* Returns an array of event names for which there are registered listeners.
*
* @returns {string[]} Array of registered event names.
*/
eventNames() {
return this.#events.eventNames();
}
//////////////////////////////////////////////////////
/**
* Sets the maximum number of listeners per event before a warning is shown.
*
* @param {number} n - The maximum number of listeners.
*/
setMaxListeners(n) {
return this.#events.setMaxListeners(n);
}
/**
* Gets the maximum number of listeners allowed per event.
*
* @returns {number} The maximum number of listeners.
*/
getMaxListeners() {
return this.#events.getMaxListeners();
}
///////////////////////////////////////////////////
/** @type {Window} */
#targetWindow;
/** @type {string} */
#targetOrigin;
/** @type {string} */
#selfType;
/** @type {boolean} */
#isDestroyed = false;
/** @type {boolean} */
#ready = false;
/**
* @typedef {object} IframeEventBase
* @property {string} eventName - The name of the custom event route.
* @property {any} payload - The data being sent (can be any type).
* @property {'iframe' | 'parent'} direction - Indicates the sender: 'iframe' or 'parent'.
*/
/**
* Queue of messages emitted before connection is ready
* @type {IframeEventBase[]}
*/
#pendingQueue = [];
/** @type {string} Internal message type for routed communication */
#secretEventName = '__tinyIframeEvent__';
/**
* Gets the internal secret iframe event name.
* @returns {string}
*/
get secretEventName() {
return this.#secretEventName;
}
/**
* Sets the internal secret iframe event name.
* @param {string} name
* @throws {TypeError} If the value is not a string.
*/
set secretEventName(name) {
if (typeof name !== 'string')
throw new TypeError('TinyIframeEvents: secretEventName must be a string.');
this.#secretEventName = name;
}
/**
* Creates a new TinyIframeEvents instance to manage communication between iframe and parent.
* Automatically determines the current context (`iframe` or `parent`) based on the `targetWindow`.
*
* @param {Object} config - Configuration object.
* @param {HTMLIFrameElement} [config.targetIframe] - The target window to post messages to. Defaults to `window.parent` (assumes this is inside an iframe).
* @param {string} [config.targetOrigin] - The target origin to restrict messages to. Defaults to `window.location.origin`.
*/
constructor({ targetIframe, targetOrigin } = {}) {
if (
typeof targetIframe !== 'undefined' &&
(!(targetIframe instanceof HTMLIFrameElement) || !targetIframe.contentWindow)
)
throw new TypeError(
`[TinyIframeEvents] Invalid "targetIframe" provided: expected a HTML Iframe Element, received ${typeof targetIframe}`,
);
if (typeof targetOrigin !== 'undefined' && typeof targetOrigin !== 'string')
throw new TypeError(
`[TinyIframeEvents] Invalid "targetOrigin" provided: expected a string, received ${typeof targetOrigin}`,
);
this.#targetWindow = targetIframe?.contentWindow ?? window.parent;
this.#targetOrigin = targetOrigin ?? window.location.origin;
this.#selfType = !targetIframe ? 'iframe' : 'parent';
if (instances.has(this.#targetWindow)) throw new Error('Duplicate window reference.');
this._boundOnMessage = this.#onMessage.bind(this);
this._boundOnceMessage = this.#onceMessage.bind(this);
if (
this.#targetWindow.document.readyState === 'complete' ||
this.#targetWindow.document.readyState === 'interactive'
)
this.#onceMessage();
else {
this.#targetWindow.addEventListener('load', this._boundOnceMessage, false);
this.#targetWindow.addEventListener('DOMContentLoaded', this._boundOnceMessage, false);
}
window.addEventListener('message', this._boundOnMessage, false);
instances.set(this.#targetWindow, this);
}
/**
* Marks the communication as ready and flushes any queued messages.
*/
#onceMessage() {
if (this.#ready) return;
this.#ready = true;
this.#flushQueue();
}
/**
* Internal handler for the message event. Filters and dispatches incoming messages.
*
* @param {MessageEvent<any>} event - The message event received via `postMessage`.
*/
#onMessage(event) {
const { data, source } = event;
// Reject non-object or unrelated messages
if (!objChecker.isJsonObject(data) || !data[this.#secretEventName]) return;
const { eventName, payload, direction } = data;
// Reject if not from the expected window (for security)
if (source !== this.#targetWindow) return;
// Reject if direction is not meant for this side
if (
(this.#selfType === 'iframe' && direction !== 'iframe') ||
(this.#selfType === 'parent' && direction !== 'parent')
)
return;
this.#events.emit(/** @type {string} */ (eventName), payload, event);
}
/**
* Sends an event to the target window.
*
* @param {string} eventName - A unique name identifying the event.
* @param {*} payload - The data to send with the event. Can be any serializable value.
* @throws {Error} If `eventName` is not a string.
*/
emit(eventName, payload) {
if (typeof eventName !== 'string') throw new TypeError('Event name must be a string.');
if (this.#isDestroyed) throw new Error('Cannot emit: instance has been destroyed.');
/** @type {IframeEventBase} */
const message = {
[this.#secretEventName]: true,
eventName,
payload,
direction: this.#selfType === 'parent' ? 'iframe' : 'parent',
};
if (!this.#ready) {
this.#pendingQueue.push(message);
return;
}
this.#targetWindow.postMessage(message, this.#targetOrigin);
}
/**
* Sends all pending messages queued before handshake completion.
*
* @returns {void}
*/
#flushQueue() {
while (this.#pendingQueue.length) {
const data = this.#pendingQueue.shift();
if (data) this.#targetWindow.postMessage(data, this.#targetOrigin);
}
}
/**
* Checks if the communication instance has been destroyed.
*
* @returns {boolean}
*/
isDestroyed() {
return this.#isDestroyed;
}
/**
* Unsubscribes all registered event listeners and removes the message handler.
* Call this when the instance is no longer needed to prevent memory leaks.
*/
destroy() {
this.#isDestroyed = true;
window.removeEventListener('message', this._boundOnMessage);
this.#targetWindow.removeEventListener('load', this._boundOnceMessage, false);
this.#targetWindow.removeEventListener('DOMContentLoaded', this._boundOnceMessage, false);
this.#events.offAllTypes();
this.#pendingQueue = [];
instances.delete(this.#targetWindow);
}
}
module.exports = TinyIframeEvents;