From b233c5c7419dc52b47be778d963a23278947cb87 Mon Sep 17 00:00:00 2001 From: Hristo Terezov Date: Mon, 13 Apr 2026 11:19:10 -0500 Subject: [PATCH 1/5] feat(transport): add MessageChannel-based transport backend with transferable objects support Enable efficient transfer of binary data and ports between windows by adding a MessageChannel API backend and extending the transport layer to support the Transferable objects pattern (ArrayBuffer, MessagePort, etc.). --- transport/MessageChannelTransportBackend.ts | 151 ++++++++++++++++++++ transport/Transport.ts | 10 +- transport/types.ts | 2 +- 3 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 transport/MessageChannelTransportBackend.ts diff --git a/transport/MessageChannelTransportBackend.ts b/transport/MessageChannelTransportBackend.ts new file mode 100644 index 0000000..6800df9 --- /dev/null +++ b/transport/MessageChannelTransportBackend.ts @@ -0,0 +1,151 @@ +import type { ITransportBackend } from './types'; + +/** + * Options for MessageChannelTransportBackend. + */ +export interface IMessageChannelTransportBackendOptions { + + /** + * The origin to use for postMessage calls and origin checks. + */ + origin?: string; + + /** + * A scope identifier used to namespace channel initialization messages, + * allowing multiple independent channels on the same window. + */ + scope?: string; + + /** + * Whether this side should create the MessageChannel. + * When true, creates a new MessageChannel and sends port2 to the target window. + * When false, listens for an incoming channel initialization message. + */ + shouldCreateChannel?: boolean; + + /** + * The target window to send the initial channel port to. + * Defaults to window.parent. + */ + targetWindow?: Window; +} + +/** + * Implements message transport using the postMessage API. + */ +export default class MessageChannelTransportBackend implements ITransportBackend { + /** + * The underlying MessageChannel instance used when this side creates the channel. + */ + private channel?: MessageChannel; + + /** + * The expected origin for postMessage calls and origin validation. + * When set, incoming messages from other origins are rejected. + */ + private origin?: string; + + /** + * The MessagePort used for sending and receiving messages. + */ + private port?: MessagePort; + + /** + * A scope identifier used to namespace or a shared secret for messages. + */ + private scope?: string; + + /** + * Callback function for receiving messages. + */ + private _receiveCallback: (message: any) => void; + + /** + * Creates a new MessageChannelTransportBackend instance. + * + * @param {IMessageChannelTransportBackendOptions} options - Configuration options for the transport backend. + */ + constructor({ shouldCreateChannel = false, targetWindow = window.parent, origin, scope }: IMessageChannelTransportBackendOptions) { + this._initialMessageHandler = this._initialMessageHandler.bind(this); + this.origin = origin; + this.scope = scope; + + if (shouldCreateChannel) { + this.channel = new MessageChannel(); + this.port = this.channel.port1; + targetWindow.postMessage({ + type: 'init_channel', + scope: this.scope + }, origin ?? '*', [ this.channel.port2 ]); + } else { + window.addEventListener('message', this._initialMessageHandler); + } + + + this._receiveCallback = () => { + // Do nothing until a callback is set by the consumer of + // MessageChannelTransportBackend via setReceiveCallback. + }; + + if (this.port) { + this.port.onmessage = (event: MessageEvent) => { + this._receiveCallback(event.data); + }; + } + } + + /** + * Handles the initial postMessage event to receive the MessagePort from the channel creator. + * + * @param {MessageEvent} event - The incoming message event. + */ + _initialMessageHandler(event: MessageEvent) { + if (this.origin && event.origin !== this.origin) { + return; + } + + const { data, ports } = event; + + // TODO: For security maybe add common secret (maybe passed trough the URL of an iframe) + if (data?.type === 'init_channel' && data.scope === this.scope && ports?.length) { + this.port = event.ports[0]; + this.port.onmessage = (ev: MessageEvent) => { + this._receiveCallback(ev.data); + }; + window.removeEventListener('message', this._initialMessageHandler); + } + } + + /** + * Disposes the allocated resources. + * + * @returns {void} + */ + dispose(): void { + this.port?.close(); + delete this.port; + delete this.channel; + window.removeEventListener('message', this._initialMessageHandler); + } + + /** + * Sends the passed message. + * + * @param {any} message - The message to be sent. + * @param {Array} [transfer] - Optional array of transferable objects. + * @returns {void} + */ + send(message: any, transfer?: Transferable[]): void { + this.port?.postMessage(message, { transfer }); + } + + /** + * Sets the callback for receiving data. + * + * @param {Function} callback - The new callback. + * @returns {void} + */ + setReceiveCallback(callback: (message: any) => void): void { + this._receiveCallback = callback; + } +} diff --git a/transport/Transport.ts b/transport/Transport.ts index 2ce6a16..3b1e2a0 100644 --- a/transport/Transport.ts +++ b/transport/Transport.ts @@ -86,13 +86,13 @@ export default class Transport { this._responseHandlers.delete(message.id!); } } else if (message.type === MessageType.REQUEST) { - this.emit('request', message.data, (result: any, error?: any) => { + this.emit('request', message.data, (result: any, error?: any, transfer?: Array) => { this._backend!.send({ type: MessageType.RESPONSE, error, id: message.id, result - }); + }, transfer); }); } else { this.emit('event', message.data); @@ -201,14 +201,16 @@ export default class Transport { * Sends the passed event. * * @param {Object} [event={}] - The event to be sent. + * @param {Array} [transfer] - An optional array of transferable objects (e.g., ArrayBuffer, MessagePort) + * to transfer ownership of to the remote side, rather than cloning them. * @returns {void} */ - sendEvent(event: any = {}): void { + sendEvent(event: any = {}, transfer?: Array): void { if (this._backend) { this._backend.send({ type: MessageType.EVENT, data: event - }); + }, transfer); } } diff --git a/transport/types.ts b/transport/types.ts index aa28802..c36a152 100644 --- a/transport/types.ts +++ b/transport/types.ts @@ -17,7 +17,7 @@ export interface ITransportBackend { * @param {any} message - The message to send. * @returns {void} */ - send: (message: any) => void; + send: (message: any, transfer?: Array) => void; /** * Sets the callback function to handle received messages. From 9ed21eb858f07eab837e301f1f7135da1607210b Mon Sep 17 00:00:00 2001 From: Hristo Terezov Date: Wed, 22 Apr 2026 17:49:05 -0500 Subject: [PATCH 2/5] squash: Various fixes --- transport/MessageChannelTransportBackend.ts | 107 +++++++++++++++----- transport/Transport.ts | 4 +- transport/index.ts | 2 + transport/types.ts | 4 +- 4 files changed, 86 insertions(+), 31 deletions(-) diff --git a/transport/MessageChannelTransportBackend.ts b/transport/MessageChannelTransportBackend.ts index 6800df9..de3ee84 100644 --- a/transport/MessageChannelTransportBackend.ts +++ b/transport/MessageChannelTransportBackend.ts @@ -7,6 +7,9 @@ export interface IMessageChannelTransportBackendOptions { /** * The origin to use for postMessage calls and origin checks. + * When not provided, the wildcard origin ('*') is used for the initial port transfer, + * which means any origin can receive the port. For production use with cross-origin + * iframes, always specify the expected origin to prevent port interception. */ origin?: string; @@ -31,7 +34,37 @@ export interface IMessageChannelTransportBackendOptions { } /** - * Implements message transport using the postMessage API. + * Pending message entry for buffering messages before the port is established. + */ +interface IPendingMessage { + + /** + * The message to be sent. + */ + message: any; + + /** + * Optional array of transferable objects. + */ + transfer?: Transferable[]; +} + +/** + * A noop function. + */ +function noOp() { + // noop +} + +/** + * Implements message transport using the MessageChannel API. + * + * When shouldCreateChannel is true, a new MessageChannel is created and port2 is + * transferred to the target window via postMessage. When false, the backend listens + * for an incoming init_channel message carrying the port. + * + * Messages sent before the port is established (receiver side) are buffered and + * flushed automatically once the handshake completes. */ export default class MessageChannelTransportBackend implements ITransportBackend { /** @@ -45,20 +78,26 @@ export default class MessageChannelTransportBackend implements ITransportBackend */ private origin?: string; + /** + * Messages buffered before the port was established on the receiver side. + * Flushed in order once the port becomes available. + */ + private pendingMessages: IPendingMessage[]; + /** * The MessagePort used for sending and receiving messages. */ private port?: MessagePort; /** - * A scope identifier used to namespace or a shared secret for messages. + * A scope identifier used to namespace channel initialization messages. */ private scope?: string; /** * Callback function for receiving messages. */ - private _receiveCallback: (message: any) => void; + private receiveCallback: (message: any) => void; /** * Creates a new MessageChannelTransportBackend instance. @@ -66,9 +105,14 @@ export default class MessageChannelTransportBackend implements ITransportBackend * @param {IMessageChannelTransportBackendOptions} options - Configuration options for the transport backend. */ constructor({ shouldCreateChannel = false, targetWindow = window.parent, origin, scope }: IMessageChannelTransportBackendOptions) { - this._initialMessageHandler = this._initialMessageHandler.bind(this); + this.initialMessageHandler = this.initialMessageHandler.bind(this); this.origin = origin; this.scope = scope; + this.pendingMessages = []; + + // Do nothing until a callback is set by the consumer of + // MessageChannelTransportBackend via setReceiveCallback. + this.receiveCallback = noOp; if (shouldCreateChannel) { this.channel = new MessageChannel(); @@ -77,20 +121,11 @@ export default class MessageChannelTransportBackend implements ITransportBackend type: 'init_channel', scope: this.scope }, origin ?? '*', [ this.channel.port2 ]); - } else { - window.addEventListener('message', this._initialMessageHandler); - } - - - this._receiveCallback = () => { - // Do nothing until a callback is set by the consumer of - // MessageChannelTransportBackend via setReceiveCallback. - }; - - if (this.port) { this.port.onmessage = (event: MessageEvent) => { - this._receiveCallback(event.data); + this.receiveCallback(event.data); }; + } else { + window.addEventListener('message', this.initialMessageHandler); } } @@ -99,23 +134,34 @@ export default class MessageChannelTransportBackend implements ITransportBackend * * @param {MessageEvent} event - The incoming message event. */ - _initialMessageHandler(event: MessageEvent) { + private initialMessageHandler(event: MessageEvent) { if (this.origin && event.origin !== this.origin) { return; } const { data, ports } = event; - // TODO: For security maybe add common secret (maybe passed trough the URL of an iframe) + // TODO: For security maybe add common secret (maybe passed through the URL of an iframe) if (data?.type === 'init_channel' && data.scope === this.scope && ports?.length) { this.port = event.ports[0]; this.port.onmessage = (ev: MessageEvent) => { - this._receiveCallback(ev.data); + this.receiveCallback(ev.data); }; - window.removeEventListener('message', this._initialMessageHandler); + window.removeEventListener('message', this.initialMessageHandler); + this.flushPendingMessages(); } } + /** + * Flushes any messages that were buffered before the port was established. + */ + private flushPendingMessages(): void { + for (const { message, transfer } of this.pendingMessages) { + this.port?.postMessage(message, transfer ? { transfer } : undefined); + } + this.pendingMessages = []; + } + /** * Disposes the allocated resources. * @@ -123,20 +169,27 @@ export default class MessageChannelTransportBackend implements ITransportBackend */ dispose(): void { this.port?.close(); - delete this.port; - delete this.channel; - window.removeEventListener('message', this._initialMessageHandler); + this.port = undefined; + this.channel = undefined; + this.pendingMessages = []; + this.receiveCallback = noOp; + window.removeEventListener('message', this.initialMessageHandler); } /** - * Sends the passed message. + * Sends the passed message. If the port has not yet been established (receiver side), + * the message is buffered and will be sent once the handshake completes. * * @param {any} message - The message to be sent. - * @param {Array} [transfer] - Optional array of transferable objects. + * @param {Transferable[]} [transfer] - Optional array of transferable objects. * @returns {void} */ send(message: any, transfer?: Transferable[]): void { - this.port?.postMessage(message, { transfer }); + if (this.port) { + this.port.postMessage(message, transfer ? { transfer } : undefined); + } else { + this.pendingMessages.push({ message, transfer }); + } } /** @@ -146,6 +199,6 @@ export default class MessageChannelTransportBackend implements ITransportBackend * @returns {void} */ setReceiveCallback(callback: (message: any) => void): void { - this._receiveCallback = callback; + this.receiveCallback = callback; } } diff --git a/transport/Transport.ts b/transport/Transport.ts index 3b1e2a0..2085171 100644 --- a/transport/Transport.ts +++ b/transport/Transport.ts @@ -86,7 +86,7 @@ export default class Transport { this._responseHandlers.delete(message.id!); } } else if (message.type === MessageType.REQUEST) { - this.emit('request', message.data, (result: any, error?: any, transfer?: Array) => { + this.emit('request', message.data, (result: any, error?: any, transfer?: Transferable[]) => { this._backend!.send({ type: MessageType.RESPONSE, error, @@ -205,7 +205,7 @@ export default class Transport { * to transfer ownership of to the remote side, rather than cloning them. * @returns {void} */ - sendEvent(event: any = {}, transfer?: Array): void { + sendEvent(event: any = {}, transfer?: Transferable[]): void { if (this._backend) { this._backend.send({ type: MessageType.EVENT, diff --git a/transport/index.ts b/transport/index.ts index 82c3c1c..1ee0df5 100644 --- a/transport/index.ts +++ b/transport/index.ts @@ -1,4 +1,6 @@ export { MessageType } from './constants'; +export { default as MessageChannelTransportBackend } from './MessageChannelTransportBackend'; +export type { IMessageChannelTransportBackendOptions } from './MessageChannelTransportBackend'; export { default as PostMessageTransportBackend } from './PostMessageTransportBackend'; export { default as Transport } from './Transport'; export type { diff --git a/transport/types.ts b/transport/types.ts index c36a152..4296d05 100644 --- a/transport/types.ts +++ b/transport/types.ts @@ -17,7 +17,7 @@ export interface ITransportBackend { * @param {any} message - The message to send. * @returns {void} */ - send: (message: any, transfer?: Array) => void; + send: (message: any, transfer?: Transferable[]) => void; /** * Sets the callback function to handle received messages. @@ -43,7 +43,7 @@ export type TransportListener = (...args: any[]) => boolean | void; /** * Response callback type for handling request/response pattern. */ -export type ResponseCallback = (result: any, error?: any) => void; +export type ResponseCallback = (result: any, error?: any, transfer?: Transferable[]) => void; /** * Message types for transport communication. From 66b7bdc3f701ae8c5e8ed0d1ccbd47a2b09ec8a1 Mon Sep 17 00:00:00 2001 From: Hristo Terezov Date: Wed, 22 Apr 2026 19:23:14 -0500 Subject: [PATCH 3/5] squash: defer port start until receive callback is wired Rely on MessagePort's native buffering instead of a noop forwarder so that messages received before the consumer calls setReceiveCallback are preserved by the port itself. Assigning onmessage implicitly starts the port, so we only wire it once a real callback is set. --- transport/MessageChannelTransportBackend.ts | 54 ++++++++++++--------- 1 file changed, 31 insertions(+), 23 deletions(-) diff --git a/transport/MessageChannelTransportBackend.ts b/transport/MessageChannelTransportBackend.ts index de3ee84..b52e8f4 100644 --- a/transport/MessageChannelTransportBackend.ts +++ b/transport/MessageChannelTransportBackend.ts @@ -49,13 +49,6 @@ interface IPendingMessage { transfer?: Transferable[]; } -/** - * A noop function. - */ -function noOp() { - // noop -} - /** * Implements message transport using the MessageChannel API. * @@ -63,8 +56,10 @@ function noOp() { * transferred to the target window via postMessage. When false, the backend listens * for an incoming init_channel message carrying the port. * - * Messages sent before the port is established (receiver side) are buffered and - * flushed automatically once the handshake completes. + * Outgoing messages sent before the port is established (receiver side) are buffered and + * flushed automatically once the handshake completes. Incoming messages are buffered + * natively by the MessagePort until a receive callback is wired via setReceiveCallback, + * since assigning onmessage implicitly starts the port. */ export default class MessageChannelTransportBackend implements ITransportBackend { /** @@ -95,9 +90,10 @@ export default class MessageChannelTransportBackend implements ITransportBackend private scope?: string; /** - * Callback function for receiving messages. + * Callback function for receiving messages. Undefined until setReceiveCallback is called; + * while undefined the MessagePort remains unstarted and buffers incoming messages natively. */ - private receiveCallback: (message: any) => void; + private receiveCallback?: (message: any) => void; /** * Creates a new MessageChannelTransportBackend instance. @@ -110,10 +106,6 @@ export default class MessageChannelTransportBackend implements ITransportBackend this.scope = scope; this.pendingMessages = []; - // Do nothing until a callback is set by the consumer of - // MessageChannelTransportBackend via setReceiveCallback. - this.receiveCallback = noOp; - if (shouldCreateChannel) { this.channel = new MessageChannel(); this.port = this.channel.port1; @@ -121,9 +113,6 @@ export default class MessageChannelTransportBackend implements ITransportBackend type: 'init_channel', scope: this.scope }, origin ?? '*', [ this.channel.port2 ]); - this.port.onmessage = (event: MessageEvent) => { - this.receiveCallback(event.data); - }; } else { window.addEventListener('message', this.initialMessageHandler); } @@ -144,14 +133,27 @@ export default class MessageChannelTransportBackend implements ITransportBackend // TODO: For security maybe add common secret (maybe passed through the URL of an iframe) if (data?.type === 'init_channel' && data.scope === this.scope && ports?.length) { this.port = event.ports[0]; - this.port.onmessage = (ev: MessageEvent) => { - this.receiveCallback(ev.data); - }; + + // Only start the port (via onmessage assignment) if the consumer has already wired + // a receive callback. Otherwise let the port buffer incoming messages natively until + // setReceiveCallback is called. + if (this.receiveCallback) { + this.port.onmessage = this.handlePortMessage; + } window.removeEventListener('message', this.initialMessageHandler); this.flushPendingMessages(); } } + /** + * Forwards messages from the MessagePort to the current receive callback. + * + * @param {MessageEvent} event - The incoming port message event. + */ + private handlePortMessage = (event: MessageEvent): void => { + this.receiveCallback?.(event.data); + }; + /** * Flushes any messages that were buffered before the port was established. */ @@ -172,7 +174,7 @@ export default class MessageChannelTransportBackend implements ITransportBackend this.port = undefined; this.channel = undefined; this.pendingMessages = []; - this.receiveCallback = noOp; + this.receiveCallback = undefined; window.removeEventListener('message', this.initialMessageHandler); } @@ -193,12 +195,18 @@ export default class MessageChannelTransportBackend implements ITransportBackend } /** - * Sets the callback for receiving data. + * Sets the callback for receiving data. If the MessagePort is available and has not + * yet been started, this also starts it (by assigning onmessage), flushing any messages + * buffered natively by the port since the handshake completed. * * @param {Function} callback - The new callback. * @returns {void} */ setReceiveCallback(callback: (message: any) => void): void { this.receiveCallback = callback; + + if (this.port && !this.port.onmessage) { + this.port.onmessage = this.handlePortMessage; + } } } From 142f9c774b0961b381aa616967b5e06d968c9ab5 Mon Sep 17 00:00:00 2001 From: Hristo Terezov Date: Mon, 27 Apr 2026 16:42:24 -0500 Subject: [PATCH 4/5] squash: support receiver re-adoption on creator reload --- transport/MessageChannelTransportBackend.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/transport/MessageChannelTransportBackend.ts b/transport/MessageChannelTransportBackend.ts index b52e8f4..11e56c2 100644 --- a/transport/MessageChannelTransportBackend.ts +++ b/transport/MessageChannelTransportBackend.ts @@ -60,6 +60,13 @@ interface IPendingMessage { * flushed automatically once the handshake completes. Incoming messages are buffered * natively by the MessagePort until a receive callback is wired via setReceiveCallback, * since assigning onmessage implicitly starts the port. + * + * Re-handshake: on the receiver side the init_channel listener stays attached for the + * lifetime of the backend. If the creator (e.g. an iframe) self-navigates or reloads + * and posts a fresh init_channel, the receiver closes the previous (now-dead) port and + * adopts the new one transparently. MessageChannel exposes no liveness signal, so this + * "always re-adopt the latest matching port" policy is the only way to keep working + * across in-creator reloads without cooperation from the creator side. */ export default class MessageChannelTransportBackend implements ITransportBackend { /** @@ -121,6 +128,11 @@ export default class MessageChannelTransportBackend implements ITransportBackend /** * Handles the initial postMessage event to receive the MessagePort from the channel creator. * + * The listener remains attached for the lifetime of the backend so that if the creator + * self-reloads (its previous document and port1 are gone, leaving the receiver with an + * inert port2 that the platform never signals), a fresh init_channel can replace the + * dead port without cooperation from the creator. + * * @param {MessageEvent} event - The incoming message event. */ private initialMessageHandler(event: MessageEvent) { @@ -132,6 +144,10 @@ export default class MessageChannelTransportBackend implements ITransportBackend // TODO: For security maybe add common secret (maybe passed through the URL of an iframe) if (data?.type === 'init_channel' && data.scope === this.scope && ports?.length) { + // Release the previous port (if any) before adopting the new one. Closing also + // detaches its onmessage handler, so no stale messages can still be dispatched. + this.port?.close(); + this.port = event.ports[0]; // Only start the port (via onmessage assignment) if the consumer has already wired @@ -140,7 +156,6 @@ export default class MessageChannelTransportBackend implements ITransportBackend if (this.receiveCallback) { this.port.onmessage = this.handlePortMessage; } - window.removeEventListener('message', this.initialMessageHandler); this.flushPendingMessages(); } } From f1c4cf80e095ecea4354247e0cbecdb38917238e Mon Sep 17 00:00:00 2001 From: Hristo Terezov Date: Thu, 7 May 2026 16:28:08 -0500 Subject: [PATCH 5/5] squash: remove TODO comment --- transport/MessageChannelTransportBackend.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/transport/MessageChannelTransportBackend.ts b/transport/MessageChannelTransportBackend.ts index 11e56c2..7d8ea13 100644 --- a/transport/MessageChannelTransportBackend.ts +++ b/transport/MessageChannelTransportBackend.ts @@ -142,7 +142,6 @@ export default class MessageChannelTransportBackend implements ITransportBackend const { data, ports } = event; - // TODO: For security maybe add common secret (maybe passed through the URL of an iframe) if (data?.type === 'init_channel' && data.scope === this.scope && ports?.length) { // Release the previous port (if any) before adopting the new one. Closing also // detaches its onmessage handler, so no stale messages can still be dispatched.