import type { Log, Message } from '../types.js'; import type Messenger from './Messenger.js'; import type { InitializeMessengerOptions, MessageHandler, } from './Messenger.js'; import { isAck2Message, isAck1Message, isSynMessage } from '../guards.js'; import PenpalError from '../PenpalError.js'; // Keep this structural so generated .d.ts files don't reference the DOM-only // Window global. Penpal also supports worker-only projects, and those projects // should be able to type-check the package without adding "dom" to tsconfig libs // just because WindowMessenger is one of Penpal's exports. type WindowLike = { postMessage: ( message: unknown, options?: { targetOrigin?: string; transfer?: Transferable[]; }, ) => void; }; type Options = { /** * The window with which the current window will communicate. */ remoteWindow: WindowLike; /** * An array of strings or regular expressions defining to which origins * communication will be allowed. If not provided, communication will be * restricted to the origin of the current page. You may specify an allowed * origin of `*` to not restrict communication, but beware the risks of * doing so. */ allowedOrigins?: (string | RegExp)[] | undefined; }; /** * Handles the details of communicating with a child window. */ class WindowMessenger implements Messenger { readonly #remoteWindow: WindowLike; readonly #allowedOrigins: [string | RegExp, ...(string | RegExp)[]]; #log: Log | undefined; #validateReceivedMessage: ((data: unknown) => data is Message) | undefined; #concreteRemoteOrigin: string | undefined; #messageCallbacks = new Set(); #port: MessagePort | undefined; constructor({ remoteWindow, allowedOrigins }: Options) { if (!remoteWindow) { throw new PenpalError('INVALID_ARGUMENT', 'remoteWindow must be defined'); } this.#remoteWindow = remoteWindow; this.#allowedOrigins = allowedOrigins?.length ? (allowedOrigins as [string | RegExp, ...(string | RegExp)[]]) : [window.origin]; } initialize = ({ log, validateReceivedMessage, }: InitializeMessengerOptions): void => { this.#log = log; this.#validateReceivedMessage = validateReceivedMessage; window.addEventListener('message', this.#handleMessageFromRemoteWindow); }; sendMessage = (message: Message, transferables?: Transferable[]): void => { if (isSynMessage(message)) { const originForSending = this.#getOriginForSendingMessage(message); this.#remoteWindow.postMessage(message, { targetOrigin: originForSending, ...(transferables === undefined ? {} : { transfer: transferables }), }); return; } if (isAck1Message(message)) { const originForSending = this.#getOriginForSendingMessage(message); this.#remoteWindow.postMessage(message, { targetOrigin: originForSending, ...(transferables === undefined ? {} : { transfer: transferables }), }); return; } if (isAck2Message(message)) { const { port1, port2 } = new MessageChannel(); this.#setPort(port1); const transferablesToSend = [port2, ...(transferables || [])]; const originForSending = this.#getOriginForSendingMessage(message); try { this.#remoteWindow.postMessage(message, { targetOrigin: originForSending, transfer: transferablesToSend, }); } catch (error) { this.#destroyPort(); port2.close(); throw error; } return; } if (this.#port) { this.#port.postMessage(message, { ...(transferables === undefined ? {} : { transfer: transferables }), }); return; } throw new PenpalError( 'TRANSMISSION_FAILED', 'Cannot send message because the MessagePort is not connected', ); }; addMessageHandler = (callback: MessageHandler): void => { this.#messageCallbacks.add(callback); }; removeMessageHandler = (callback: MessageHandler): void => { this.#messageCallbacks.delete(callback); }; destroy = (): void => { window.removeEventListener('message', this.#handleMessageFromRemoteWindow); this.#destroyPort(); this.#messageCallbacks.clear(); }; #isAllowedOrigin = (origin: string) => { return this.#allowedOrigins.some((allowedOrigin) => { if (allowedOrigin instanceof RegExp) { // RegExp.test() mutates lastIndex for global and sticky expressions, // so test a copy to keep repeated handshake messages deterministic. return new RegExp(allowedOrigin).test(origin); } return allowedOrigin === origin || allowedOrigin === '*'; }); }; #getOriginForSendingMessage = (message: Message) => { // It's safe to send the SYN message to any origin because it doesn't contain // anything sensitive. When Penpal receives a SYN message, the origin on // the message (which we call the concrete origin) is validated against the // configured allowed origins. All subsequent messages will be sent to the // concrete origin. // If you decide to change this, consider https://github.com/Aaronius/penpal/issues/103 if (isSynMessage(message)) { return '*'; } if (!this.#concreteRemoteOrigin) { throw new PenpalError( 'TRANSMISSION_FAILED', 'Cannot send message because the remote origin is not established', ); } // If the concrete remote origin (the origin we received from the remote // on a prior message) is 'null', it means the remote is within // an "opaque origin". The only way to post a message to an // opaque origin is by using '*'. This does carry some security risk, // so we only do this if the consumer has specifically defined '*' as // an allowed origin. Opaque origins occur, for example, when // loading an HTML document directly from the filesystem (not a // web server) or through a data URI. return this.#concreteRemoteOrigin === 'null' && this.#allowedOrigins.includes('*') ? '*' : this.#concreteRemoteOrigin; }; #destroyPort = () => { this.#port?.removeEventListener('message', this.#handleMessageFromPort); this.#port?.close(); this.#port = undefined; }; #setPort = (port: MessagePort) => { this.#destroyPort(); this.#port = port; this.#port.addEventListener('message', this.#handleMessageFromPort); this.#port.start(); }; #handleMessageFromRemoteWindow = ({ source, origin, ports, data, }: MessageEvent): void => { if (source !== this.#remoteWindow) { return; } if (!this.#validateReceivedMessage?.(data)) { return; } if (!this.#isAllowedOrigin(origin)) { this.#log?.( `Received a message from origin \`${origin}\` which did not match ` + `allowed origins \`[${this.#allowedOrigins.join(', ')}]\``, ); return; } if (isSynMessage(data)) { // If we receive a SYN message and already have a port, it means // the child is re-connecting, in which case we'll receive a new port. // For this reason, we always make sure we destroy the existing port. this.#destroyPort(); this.#concreteRemoteOrigin = origin; } if (isAck2Message(data)) { const port = ports[0]; if (!port) { this.#log?.('Ignoring ACK2 because it did not include a MessagePort'); return; } this.#setPort(port); } for (const callback of this.#messageCallbacks) { callback(data); } }; #handleMessageFromPort = ({ data }: MessageEvent): void => { if (!this.#validateReceivedMessage?.(data)) { return; } for (const callback of this.#messageCallbacks) { callback(data); } }; } export default WindowMessenger;