// Our secret tokens to recognise our messages const RPC_CALL = '__RPC_CALL__' const RPC_RESPONSE = '__RPC_RESPONSE__' export class RpcError extends Error { constructor(message) { super(message) this.name = this.constructor.name } } export class RemoteError extends Error { constructor(message) { super(message) this.name = this.constructor.name } } // === Initiating side === export function remoteFunction(funcName, { tabId } = {}) { const otherSide = (tabId !== undefined) ? "the tab's content script" : 'the background script' const f = async function (...args) { const message = { [RPC_CALL]: RPC_CALL, funcName, args, } // Try send the message and await the response. let response try { response = (tabId !== undefined) ? await browser.tabs.sendMessage(tabId, message) : await browser.runtime.sendMessage(message) } catch (err) {} // Check if we got an error or no response. if (response === undefined) { throw new RpcError( `Got no response when trying to call '${funcName}'. ` + `Did you enable RPC in ${otherSide}?` ) } // Check if it was *our* listener that responded. if (response === null || response[RPC_RESPONSE] !== RPC_RESPONSE) { throw new RpcError( `RPC got a response from an interfering listener while calling '${funcName}' in ` + `${otherSide}` ) } // If we could not invoke the function on the other side, throw an error. if (response.rpcError) { throw new RpcError(response.rpcError) } // Return the value or throw the error we received from the other side. if (response.errorMessage) { throw new RemoteError(response.errorMessage) } else { return response.returnValue } } // Give it a name, could be helpful in debugging Object.defineProperty(f, 'name', { value: `${funcName}_RPC` }) return f } // === Executing side === const remotelyCallableFunctions = {} function incomingRPCListener(message, sender) { if (message && message[RPC_CALL] === RPC_CALL) { const funcName = message.funcName const args = message.hasOwnProperty('args') ? message.args : [] const func = remotelyCallableFunctions[funcName] if (func === undefined) { console.error(`Received RPC for unknown function: ${funcName}`) return Promise.resolve({ rpcError: `No such function registered for RPC: ${funcName}`, [RPC_RESPONSE]: RPC_RESPONSE, }) } const extraArg = { tab: sender.tab, } // Run the function let returnValue try { returnValue = func(extraArg, ...args) } catch (error) { return Promise.resolve({ errorMessage: error.message, [RPC_RESPONSE]: RPC_RESPONSE, }) } // Return the function's return value. If it is a promise, first await its result. return Promise.resolve(returnValue).then(returnValue => ({ returnValue, [RPC_RESPONSE]: RPC_RESPONSE, })).catch(error => ({ errorMessage: error.message, [RPC_RESPONSE]: RPC_RESPONSE, })) } } // A bit of global state to ensure we only attach the event listener once. let enabled = false export function makeRemotelyCallable(functions, { insertExtraArg = false } = {}) { // Every function is passed an extra argument with sender information, // so remove this from the call if this was not desired. if (!insertExtraArg) { // Replace each func with... const wrapFunctions = mapValues(func => // ...a function that calls func, but hides the inserted argument. (extraArg, ...args) => func(...args) ) functions = wrapFunctions(functions) } // Add the functions to our global repetoir. Object.assign(remotelyCallableFunctions, functions) // Enable the listener if needed. if (!enabled) { browser.runtime.onMessage.addListener(incomingRPCListener) enabled = true } } const mapValues = fn => object => { const result = {} for (const [key, value] of Object.entries(object)) { result[key] = fn(value) } return result }