UNPKG

@jetbrains/websandbox

Version:

A sandbox library for runnung javascript inside HTML5 sandboxed iframe

275 lines (244 loc) 8.09 kB
import { propertyByPath, splitPath } from "./object-path"; import type {API} from "./types"; export const TYPE_MESSAGE = 'message'; export const TYPE_RESPONSE = 'response'; export const TYPE_SET_INTERFACE = 'set-interface'; export const TYPE_SERVICE_MESSAGE = 'service-message'; // @ts-expect-error this is IE11 obsolete check. It is not typed const isIE11 = !!window.MSInputMethodContext && !!document.documentMode; const defaultOptions = { //Will not affect IE11 because there sandboxed iframe has not 'null' origin //but base URL of iframe's src allowedSenderOrigin: undefined }; export interface ConnectionOptions { allowedSenderOrigin?: string; } class Connection { incrementalID: number; options: ConnectionOptions; postMessage: typeof window.postMessage; remote: API = {}; serviceMethods: API = {}; localApi: API = {}; callbacks: {[key: string]: {successCallback: Function, failureCallback: Function}} = {}; remoteMethodsWaitPromise: Promise<void>; _resolveRemoteMethodsPromise: null | (() => void) = null; constructor( postMessage: typeof window.postMessage, registerOnMessageListener: (listener: (e: MessageEvent) => void) => void, options: ConnectionOptions = {} ) { this.options = {...defaultOptions, ...options}; //Random number between 0 and 100000 this.incrementalID = Math.floor(Math.random() * 100000); this.postMessage = postMessage; this.remoteMethodsWaitPromise = new Promise(resolve => { this._resolveRemoteMethodsPromise = resolve; }); registerOnMessageListener((e: MessageEvent) => this.onMessageListener(e)); } /** * Listens to remote messages. Calls local method if it is called outside or call stored callback if it is response. * @param e - onMessage event */ onMessageListener(e: MessageEvent) { const data = e.data; const {allowedSenderOrigin} = this.options; if (allowedSenderOrigin && e.origin !== allowedSenderOrigin && !isIE11) { return; } if (data.type === TYPE_RESPONSE) { this.popCallback(data.callId, data.success, data.result); } else if (data.type === TYPE_MESSAGE) { this .callLocalApi(data.methodName, data.arguments) .then(res => this.responseOtherSide(data.callId, res)) .catch(err => this.responseOtherSide(data.callId, err, false)); } else if (data.type === TYPE_SET_INTERFACE) { this.setInterface(data.apiMethods); this.responseOtherSide(data.callId); } else if (data.type === TYPE_SERVICE_MESSAGE) { this .callLocalServiceMethod(data.methodName, data.arguments) .then(res => this.responseOtherSide(data.callId, res)) .catch(err => this.responseOtherSide(data.callId, err, false)); } } postMessageToOtherSide(dataToPost: any) { this.postMessage(dataToPost, '*'); } /** * Sets remote interface methods * @param remote - hash with keys of remote API methods. Values is ignored */ setInterface(remoteMethods: string[]) { this.remote = {}; remoteMethods.forEach( (key: string) => { // If key is nested, we need to create nested structure const parts = splitPath(key); let current = this.remote; for (let i = 0; i < parts.length - 1; i++) { const part = parts[i]; if (!current[part] || typeof current[part] !== 'object') { current[part] = {}; } current = current[part] as API; } current[parts[parts.length - 1]] = this.createMethodWrapper(key); } ); this._resolveRemoteMethodsPromise?.(); } private getMethodsFromInterface(api: API) { return Object.keys(api).reduce((acc, key) => { if (typeof api[key] === 'object') { acc.push(...this.getMethodsFromInterface(api[key] as API).map(subKey => `${key}.${subKey}`)); } else { acc.push(key); } return acc; }, [] as string[]); } setLocalApi(api: API) { return new Promise((resolve, reject) => { const id = this.registerCallback(resolve, reject); this.postMessageToOtherSide({ callId: id, apiMethods: this.getMethodsFromInterface(api), type: TYPE_SET_INTERFACE }); }).then(() => this.localApi = api); } setServiceMethods(api: API) { this.serviceMethods = api; } /** * Calls local method * @param methodName * @param args * @returns {Promise.<*>|string} */ callLocalApi(methodName: string, args: any[]) { const method = propertyByPath<Function>(this.localApi, methodName); if (!method) { throw new Error(`Local method "${methodName}" is not registered`); } return Promise.resolve(method.call(this, ...args)); } /** * Calls local method registered as "service method" * @param methodName * @param args * @returns {Promise.<*>} */ callLocalServiceMethod(methodName: string, args: any[]) { const method = propertyByPath<Function>(this.serviceMethods, methodName); if (!method) { throw new Error(`Service method ${methodName} is not registered`); } return Promise.resolve(method.call(this, ...args)); } /** * Wraps remote method with callback storing code * @param methodName - method to wrap * @returns {Function} - function to call as remote API interface */ createMethodWrapper(methodName: string) { return (...args: any[]) => { return this.callRemoteMethod(methodName, ...args); }; } /** * Calls other side with arguments provided * @param id * @param methodName * @param args */ callRemoteMethod(methodName: string, ...args: any[]) { return new Promise((resolve, reject) => { const id = this.registerCallback(resolve, reject); this.postMessageToOtherSide({ callId: id, methodName: methodName, type: TYPE_MESSAGE, arguments: args }); }); } /** * Calls remote service method * @param methodName * @param args * @returns {*} */ callRemoteServiceMethod(methodName: string, ...args: any[]) { return new Promise((resolve, reject) => { const id = this.registerCallback(resolve, reject); this.postMessageToOtherSide({ callId: id, methodName: methodName, type: TYPE_SERVICE_MESSAGE, arguments: args }); }); } /** * Respond to remote call * @param id - remote call ID * @param result - result to pass to calling function */ responseOtherSide(id: string, result?: any, success = true) { if (result instanceof Error) { // Error could be non-serializable, so we copy properties manually result = [...Object.keys(result), 'message'].reduce((acc, it) => { acc[it] = result[it]; return acc; }, {} as {[k: string]: any}); } const doPost = () => this.postMessage( { callId: id, type: TYPE_RESPONSE, success, result }, '*' ); try { doPost(); } catch (err) { console.error('Failed to post response, recovering...', err); // eslint-disable-line no-console if (err instanceof DOMException) { result = JSON.parse(JSON.stringify(result)); doPost(); } } } /* * Stores callbacks to call later when remote call will be answered */ registerCallback(successCallback: Function, failureCallback: Function) { const id = (++this.incrementalID).toString(); this.callbacks[id] = {successCallback, failureCallback}; return id; } /** * Calls and delete stored callback * @param id - call id * @param success - was call successful * @param result - result of remote call */ popCallback(id: string, success: boolean, result: any) { if (success) { this.callbacks[id].successCallback(result); } else { this.callbacks[id].failureCallback(result); } delete this.callbacks[id]; } } export default Connection;