UNPKG

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.

325 lines (324 loc) 12.6 kB
import { EventEmitter } from 'events'; import { isJsonObject } from '../basics/objChecker.mjs'; /** @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. */ /** * @typedef {object} TinyIframeEventsConfig * Configuration object for initializing TinyIframeEvents. * @property {HTMLIFrameElement} [targetIframe] - The target iframe element to post messages to. Required if instantiated in the parent window. * @property {string} [targetOrigin] - The target origin to restrict messages to. Defaults to `window.location.origin`. * @property {string} [secretEventName] - Custom internal name used to validate standard routing messages. * @property {string} [handshakeEventName] - Custom internal name used for the initial MessageChannel handshake. * @property {string} [readyEventName] - Custom internal name used for the ready event trigger. */ /** * @typedef {object} IframeEventBase * Internal message structure for routed communication. * @property {boolean} [secretIndicator] - Dynamic key based on secretEventName to validate the message. * @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'. */ /** * A highly secure and flexible event routing system for structured communication * between a parent window and its iframe using `MessageChannel`. * * This class abstracts the complexity of cross-origin communication by establishing * a direct, un-interceptable port connection after a secure initial handshake. * * Features: * - Secure direct pipeline via `MessageChannel`. * - Customizable internal event names to avoid collisions. * - Symmetrical usage for both parent and iframe. * - Queue management for messages sent before the connection is established. * - Auto-reconnects and resets ports if the target iframe is reloaded. */ class TinyIframeEvents extends EventEmitter { /** @type {Window} */ #targetWindow; get targetWindow() { return this.#targetWindow; } /** @type {HTMLIFrameElement | undefined} */ #targetIframeElement; get targetIframeElement() { return this.#targetIframeElement; } /** @type {string} */ #targetOrigin; get targetOrigin() { return this.#targetOrigin; } /** @type {'iframe' | 'parent'} */ #selfType; get selfType() { return this.#selfType; } /** @type {boolean} */ #isDestroyed = false; /** @type {boolean} */ #ready = false; get ready() { return this.#ready; } /** * Executes the provided callback when the secure MessageChannel connection is fully established. * If the connection is already ready, the callback is executed immediately. * * @param {function(): void} handler - The callback function to execute. */ onReady(handler) { if (this.#ready) { handler(); } else { this.once(this.#readyEventName, handler); } } /** @type {MessagePort | null} */ #port = null; /** @type {IframeEventBase[]} */ #pendingQueue = []; /** @type {string} */ #secretEventName; /** @type {string} */ #handshakeEventName; /** @type {string} */ #readyEventName; /** @type {(() => void) | null} */ #boundSendPort = null; /** * Creates a new TinyIframeEvents instance to manage secure communication. * Automatically establishes a MessageChannel connection between contexts. * * @param {TinyIframeEventsConfig} config - Configuration object. */ constructor({ targetIframe, targetOrigin = window.location.origin, secretEventName = '__tinyIframeEvent__', handshakeEventName = '__tinyIframeHandshake__', readyEventName = '__tinyIframeReady__', } = {}) { super(); if (targetIframe !== undefined && (!(targetIframe instanceof HTMLIFrameElement) || !targetIframe.contentWindow)) { throw new TypeError(`[TinyIframeEvents] Invalid "targetIframe": expected HTMLIFrameElement, received ${typeof targetIframe}`); } if (typeof targetOrigin !== 'string') { throw new TypeError(`[TinyIframeEvents] Invalid "targetOrigin": expected string, received ${typeof targetOrigin}`); } this.#targetIframeElement = targetIframe; this.#targetWindow = targetIframe?.contentWindow ?? window.parent; this.#targetOrigin = targetOrigin; this.#selfType = !targetIframe ? 'iframe' : 'parent'; this.#secretEventName = secretEventName; this.#handshakeEventName = handshakeEventName; this.#readyEventName = readyEventName; if (instances.has(this.#targetWindow)) throw new Error('Duplicate window reference.'); this._boundWindowMessage = this.#onWindowMessage.bind(this); this._boundPortMessage = this.#onPortMessage.bind(this); this.#initializeConnection(); instances.set(this.#targetWindow, this); } /** * Gets the internal secret iframe event name used for validation. * @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; } /** * Gets the internal handshake event name used for establishing the MessageChannel. * @returns {string} */ get handshakeEventName() { return this.#handshakeEventName; } /** * Sets the internal handshake event name. * @param {string} name * @throws {TypeError} If the value is not a string. */ set handshakeEventName(name) { if (typeof name !== 'string') throw new TypeError('TinyIframeEvents: handshakeEventName must be a string.'); this.#handshakeEventName = name; } /** * Gets the internal ready event name used when the connection is fully established. * @returns {string} */ get readyEventName() { return this.#readyEventName; } /** * Sets the internal ready event name. * @param {string} name * @throws {TypeError} If the value is not a string. */ set readyEventName(name) { if (typeof name !== 'string') throw new TypeError('TinyIframeEvents: readyEventName must be a string.'); this.#readyEventName = name; } /** * Initializes the correct connection strategy based on the current context (parent or iframe). */ #initializeConnection() { if (this.#selfType === 'parent') { this.#boundSendPort = () => { if (this.#isDestroyed) return; // Closes any existing port to prevent memory leaks during iframe reloads if (this.#port) { this.#port.close(); } // Creates a fresh channel for every attempt to prevent clone errors const channel = new MessageChannel(); this.#port = channel.port1; this.#port.onmessage = this._boundPortMessage; // Resets ready state to require a new ACK this.#ready = false; this.#targetWindow.postMessage({ type: this.#handshakeEventName }, this.#targetOrigin, [ channel.port2, ]); }; if (this.#targetIframeElement) { this.#targetIframeElement.addEventListener('load', this.#boundSendPort); this.#boundSendPort(); } } else { window.addEventListener('message', this._boundWindowMessage, false); } } /** * Internal handler for the initial window message event (used by iframe to receive the port). * * @param {MessageEvent<any>} event - The message event received via `postMessage`. */ #onWindowMessage(event) { const data = event.data; const ports = event.ports; if (!isJsonObject(data) || data.type !== this.#handshakeEventName || ports.length === 0) return; this.#port = ports[0]; this.#port.onmessage = this._boundPortMessage; window.removeEventListener('message', this._boundWindowMessage); this.#port.postMessage({ type: `${this.#handshakeEventName}_ACK` }); this.#markReady(); } /** * Internal handler for messages received through the secure MessageChannel port. * * @param {MessageEvent<any>} event - The message event received via `MessagePort`. */ #onPortMessage(event) { /** @type {any} */ const data = event.data; if (isJsonObject(data) && data.type === `${this.#handshakeEventName}_ACK`) { this.#markReady(); return; } if (!isJsonObject(data) || !data[this.#secretEventName]) return; const eventName = data.eventName; const payload = data.payload; const direction = data.direction; if (typeof eventName !== 'string' || (this.#selfType === 'iframe' && direction !== 'iframe') || (this.#selfType === 'parent' && direction !== 'parent')) { return; } this.emit(`win:${eventName}`, payload, event); } /** * Marks the communication as ready, flushes any queued messages, * and triggers the internal ready event for any waiting listeners. */ #markReady() { if (this.#ready) return; this.#ready = true; this.#flushQueue(); this.emit(this.#readyEventName); } /** * Sends all pending messages queued before the secure port was established. */ #flushQueue() { while (this.#pendingQueue.length > 0) { /** @type {IframeEventBase | undefined} */ const data = this.#pendingQueue.shift(); if (data && this.#port) { this.#port.postMessage(data); } } } /** * Sends an event to the target window through the secure MessageChannel. * * @param {string} eventName - A unique name identifying the event. * @param {*} payload - The data to send with the event. Can be any serializable value. * @throws {TypeError} If `eventName` is not a string. * @throws {Error} If instance has been destroyed. */ winEmit(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.#port) { this.#pendingQueue.push(message); return; } this.#port.postMessage(message); } /** * Checks if the communication instance has been destroyed. * * @returns {boolean} True if destroyed, false otherwise. */ isDestroyed() { return this.#isDestroyed; } /** * Unsubscribes all registered event listeners, closes the message port, and cleans up references. * Call this when the instance is no longer needed to prevent memory leaks. */ destroy() { this.#isDestroyed = true; this.#ready = false; window.removeEventListener('message', this._boundWindowMessage); if (this.#targetIframeElement && this.#boundSendPort) { this.#targetIframeElement.removeEventListener('load', this.#boundSendPort); } if (this.#port) { this.#port.close(); this.#port = null; } this.removeAllListeners(); this.#pendingQueue = []; instances.delete(this.#targetWindow); } } export default TinyIframeEvents;