shared-ipc
Version:
A simple JavaScript library providing an asynchronous method call interface for Workers, Iframes and cross-window contexts
489 lines (424 loc) • 12.2 kB
JavaScript
// Cross-frame/ worker / parent window IPC system. Contexts that make use of IPC code may not have
// access to DOM constructs, such as window, and therefore must be handled with care
class PublicPromise extends Promise {
/**@type {(value: any) => void}*/#resolve = () => {
throw new Error("PublicPromise resolve invoked before constructor initialisation")
};
/**@type {(value: any) => void}*/#reject = (_) => {
throw new Error("PublicPromise reject invoked before constructor initialisation")
};
/**
* @param {((resolve: any, reject: any) => void)|null} executor
*/
constructor(executor = null) {
/**@type {(value: any) => void}*/let capturedResolve = () => {
throw new Error("Captured resolve invoked before superclass initialisation")
};
/**@type {(value: any) => void}*/let capturedReject = (_) => {
throw new Error("Captured reject invoked before superclass initialisation")
};
super((resolve, reject) => {
capturedResolve = resolve;
capturedReject = reject;
if (executor) {
executor(resolve, reject);
}
});
this.#resolve = capturedResolve;
this.#reject = capturedReject;
}
/**
* @param {any} value
*/
resolve(value) {
this.#resolve(value);
}
/**
* @param {any} reason
*/
reject(reason) {
this.#reject(reason);
}
static deferred() {
return new PublicPromise();
}
}
/**
* @typedef {Object} IpcResult
* @property {Error|undefined} data
* @property {number} handle
* @property {string} source
* @property {Error|string|undefined} error
*/
/**
* @typedef {MessageEventSource|Worker} IpcSource
*/
/**
* @typedef {Window|HTMLIFrameElement|Worker|MessagePort|ServiceWorker} IpcTarget
*/
/**
* @typedef {MessageEvent<IpcMessage>|IpcMessage} IpcEventData
*/
/**
* @typedef {Object} IpcMessage
* @property {string} call
* @property {any} data
* @property {number|undefined} handle
* @property {string} source
*/
/**@type {number}*/let ipcReqId = 0;
/**@type {Map<number, PublicPromise>}*/const ipcReqs = new Map();
/**
*
* @param {IpcTarget} target
* @returns {Window | Worker}
*/
function resolvePostTarget(target) {
if (target && typeof HTMLIFrameElement !== "undefined" && target instanceof HTMLIFrameElement) {
return /**@type {Window}*/(target.contentWindow);
}
return /**@type {Window | Worker}*/(target);
}
/**
* @returns {string}
*/
function getWindowNameSafe() {
try {
return typeof window !== "undefined" && typeof window.name === "string"
? window.name
: "worker";
}
catch {
return "worker";
}
}
/**
* Checks if the current context has access to the window object
* @returns {boolean}
*/
function isWindowDefined() {
return (
typeof Window !== "undefined" &&
typeof window !== "undefined"
);
}
/**
* Validates if an object resembles a browser window.
* @param {any} target
* @returns {boolean}
*/
function isWindowLike(target) {
return (
isWindowDefined() &&
target instanceof Window
);
}
/**
* Checks if the current environment is node-like
* @returns {boolean}
*/
function isNode() {
return (typeof process !== "undefined" &&
!!process.versions &&
!!process.versions.node);
}
/**
* Validates if an object has the `IpcMessage` structure (shape check only)
* @param {any} obj
* @returns {obj is IpcMessage}
*/
function isIpcMessage(obj) {
if (!obj || typeof obj !== "object") {
return false;
}
const expectedProps = ["call", "data", "handle", "source"];
const actualProps = Object.keys(obj);
for (const prop of expectedProps) {
if (!(prop in obj)) {
return false;
}
}
for (const prop of actualProps) {
if (!expectedProps.includes(prop)) {
return false;
}
}
return true;
}
/**
* Validates if an IpcMessage has valid property values
* @param {IpcMessage} obj
* @returns {boolean}
*/
function isValidIpcMessage(obj) {
if (!isIpcMessage(obj)) {
return false;
}
if (typeof obj.call !== "string" || obj.call.length === 0) {
return false;
}
if (obj.handle !== undefined) {
if (typeof obj.handle !== "number" || isNaN(obj.handle) || !isFinite(obj.handle)) {
return false;
}
}
if (typeof obj.source !== "string") {
return false;
}
return true;
}
/**
* Validates if an object has the `IpcResult` structure (shape check only)
* @param {any} obj
* @returns {obj is IpcResult}
*/
function isIpcResult(obj) {
if (!obj || typeof obj !== "object") {
return false;
}
const expectedProps = ["data", "handle", "source", "error"];
const actualProps = Object.keys(obj);
for (const prop of expectedProps) {
if (!(prop in obj)) {
return false;
}
}
for (const prop of actualProps) {
if (!expectedProps.includes(prop)) {
return false;
}
}
return true;
}
/**
* Validates if an IpcResult has valid property values
* @param {IpcResult} obj
* @returns {boolean}
*/
function isValidIpcResult(obj) {
if (!isIpcResult(obj)) {
return false;
}
if (obj.data === undefined && obj.error === undefined) {
return false;
}
if (obj.data !== undefined && obj.error !== undefined) {
return false;
}
if (typeof obj.handle !== "number" || isNaN(obj.handle) || !isFinite(obj.handle)) {
return false;
}
if (typeof obj.source !== "string" || obj.source.length === 0) {
return false;
}
return true;
}
/**
* @param {IpcSource|null} target - Source may still be null, even if we need to send back a response,
* for example, web workers do not include the source if the source was a Window
* since they do not have access to DOM
* @param {IpcResult} response
*/
async function postIpcResponse(target=null, response) {
// Validate the response structure
if (!isValidIpcResult(response)) {
throw new Error("Invalid IPC result structure");
}
if (target) {
// Easy route, we have a defined source, so send it back to them
target.postMessage(response);
}
else {
if (isNode()) {
try {
// Likely Node Worker context, posts to a Worker, MessagePort or similar interfaces
const { parentPort } = await import('worker_threads');
if (parentPort) {
parentPort.postMessage(response);
}
else {
throw new Error("Invalid postIpcResponse target: No valid method found")
}
return;
}
catch { /* Ignore */ }
}
// Maybe not in Node.js worker context, probably Web Worker, try global postMessage
if (typeof postMessage === "function") {
postMessage(response);
}
else {
throw new Error("Invalid postIpcResponse target: No valid method found")
}
}
}
/**
* Safely posts an IPC message to a target (Window, Worker, or global `postMessage`).
* @param {IpcTarget|MessageEventSource|null} target - The target to post the message to.
* @param {IpcMessage} msg - The structured IPC message.
* @throws {Error} If the target is invalid or the message is malformed.
*/
function postIpcMessage(target, msg) {
// Validate the message structure
if (!isValidIpcMessage(msg)) {
throw new Error("Invalid IPC message structure");
}
// Determine the correct postMessage method
if (target && isWindowLike(target)) {
// Post to a Windowr
target.postMessage(msg, { targetOrigin: location.origin });
}
else if (target && typeof target.postMessage === "function") {
// Post to a Worker, MessagePort, or similar
target.postMessage(msg);
}
else if (typeof postMessage === "function") {
// Fall back to global `postMessage` (e.g., in a Worker)
postMessage(msg);
}
else {
throw new Error("Invalid postIpcMessage target: No valid method found");
}
}
/**
* @param {Window | HTMLIFrameElement | Worker} target
* @param {string} call
* @param {any} data
*/
async function makeIpcRequest(target, call, data = undefined) {
const handle = ipcReqId++;
const promise = PublicPromise.deferred();
const postCall = { call, data, handle, source: getWindowNameSafe() };
ipcReqs.set(handle, promise);
const postTarget = resolvePostTarget(target);
if (!postTarget) {
throw new Error("Invalid postMessage target");
}
postIpcMessage(postTarget, postCall);
return await promise;
}
/**
* @param {IpcTarget} target
* @param {string} call
* @param {any} data
* @returns
*/
function sendIpcMessage(target, call, data = undefined) {
const postTarget = resolvePostTarget(target);
if (!postTarget) {
throw new Error("Invalid postMessage target");
}
const msg = { call, data, handle: undefined, source: getWindowNameSafe() };
postIpcMessage(postTarget, msg);
}
/**@type {Map<string, Function>}*/const ipcHandlers = new Map();
/**
* @param {string} name
* @param {Function} handler
*/
function addIpcMessageHandler(name, handler) {
ipcHandlers.set(name, handler);
}
/**
* @param {IpcEventData} data
* @param {IpcSource|null} source - Fallback bound source if message is coming from a nodeJS Worker,
* as opposed to browser postMessage, therefore meaning that the provided data will be a bare IpcMessage
* without any information as to where it came from
*/
async function handleIpcMessage(data, source = null) {
if (!data) {
throw new Error("Received IPC data was null or undefined");
}
// Try and extract the IPC message (or result) out of what we were given
/**@type {IpcMessage|IpcResult|null}*/let message = null;
if (typeof MessageEvent !== "undefined" && data instanceof MessageEvent) {
// MessageEvent<IpcMessage> likely originating from browser postMessage
if (!data.isTrusted) {
throw new Error("Received IPC data was not a trusted instance of type MessageEvent");
}
message = data.data;
source = data.source;
}
else if (isIpcMessage(data)) {
// Bare IpcMessage likely originating from NodeJS MessageChannel
message = data;
}
else if (isIpcResult(data)) {
// Bare IpcResult likely originating from NodeJS MessageChannel
message = data;
}
else {
throw new Error("Received IPC data was not a valid instance of type MessageEvent or IpcMessage");
}
// Validate that we actually managed to extract a message and source
if (!message) {
throw new Error("Received IPC message was null or undefined");
}
if (isIpcMessage(message)) {
/** @type {any} */let result = undefined;
try {
const callName = message.call;
// Try ipcHandlers first
if (ipcHandlers.has(callName)) {
const reqHandler = /** @type {Function} */(ipcHandlers.get(callName));
result = await reqHandler(message.data);
}
// Fallback to global context
else {
// TODO: Put behind a feature flag in case users do not want this functionality.
/**@type {{ [key: string]: Function }}*/let globalContext;
if (isWindowDefined()) {
globalContext = /**@type {any}*/(window);
}
else if (typeof globalThis !== "undefined") {
globalContext = /**@type {any}*/(globalThis);
}
else if (typeof self !== "undefined") {
globalContext = /**@type {any}*/(self);
}
else {
throw new Error("Could not access global context to call IPC method");
}
if (typeof globalContext[callName] === "function") {
result = await globalContext[callName](message.data);
}
}
// Send return result back if handle was provided
if (message.handle !== undefined && message.handle !== null) {
/**@type {IpcResult}*/const resultMessage = {
handle: message.handle,
data: result,
source: getWindowNameSafe(),
error: undefined
};
await postIpcResponse(source, resultMessage);
}
}
catch (error) {
console.error(`Error executing IPC call '${message.call}':`, error);
if (message.handle !== undefined && message.handle !== null) {
/**@type {IpcResult}*/const errorMessage = {
handle: message.handle,
error: error instanceof Error ? error.message : String(error),
source: getWindowNameSafe(),
data: undefined
};
await postIpcResponse(source, errorMessage);
}
}
}
else if (isIpcResult(message)) {
// Return value from calling another frames method
const request = ipcReqs.get(message.handle);
if (request) {
if (message.error) {
request.reject(message.error);
}
else {
request.resolve(message.data);
}
ipcReqs.delete(message.handle);
}
}
}
export { PublicPromise, addIpcMessageHandler, handleIpcMessage, makeIpcRequest, sendIpcMessage };