From 367e923dc8d9f078037379cc86a517ed3a1ee25b Mon Sep 17 00:00:00 2001 From: Gerben Date: Mon, 7 Oct 2019 11:12:34 +0530 Subject: [PATCH] Copy code from webmemex-extension These two files: https://github.com/WebMemex/webmemex-extension/blob/3406b4eaeab0985ffea4f46b441b9ec2c134f4e9/src/util/webextensionRPC.js https://github.com/WebMemex/webmemex-extension/blob/3406b4eaeab0985ffea4f46b441b9ec2c134f4e9/src/util/webextensionRPC.test.js (the code was written by me, and is published in the public domain) --- src/webextensionRPC.js | 183 ++++++++++++++++++++++++++++++++++++ src/webextensionRPC.test.js | 94 ++++++++++++++++++ 2 files changed, 277 insertions(+) create mode 100644 src/webextensionRPC.js create mode 100644 src/webextensionRPC.test.js diff --git a/src/webextensionRPC.js b/src/webextensionRPC.js new file mode 100644 index 0000000..2a07c10 --- /dev/null +++ b/src/webextensionRPC.js @@ -0,0 +1,183 @@ +// A Remote Procedure Call abstraction around the message passing available to +// WebExtension scripts. Usable to call a function in the background script from +// a tab's content script, or vice versa. +// +// The calling side always gets a Promise of the return value. The executing +// (remote) function can be an async function (= it returns a Promise), whose +// completion then will then be waited for. + +// Example use: +// +// === background.js === +// function myFunc(arg) { +// return arg*2 +// } +// makeRemotelyCallable({myFunc}) +// +// === content_script.js === +// const myRemoteFunc = remoteFunction('myFunc') +// myRemoteFunc(21).then(result => { ... result is 42! ... }) + + +import mapValues from 'lodash/fp/mapValues' + + +// 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 === + +// Create a proxy function that invokes the specified remote function. +// Arguments +// - funcName (required): name of the function as registered on the remote side. +// - options (optional): { +// tabId: The id of the tab whose content script is the remote side. +// Leave undefined to call the background script (from a tab). +// } +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 + +// Register a function to allow remote scripts to call it. +// Arguments: +// - functions (required): +// An object with a {functionName: function} mapping. +// Each function will be callable with the given name. +// - options (optional): { +// insertExtraArg: +// If truthy, each executed function also receives, as its first +// argument before the arguments it was invoked with, an object with +// the details of the tab that sent the message. +// } + +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 + } +} diff --git a/src/webextensionRPC.test.js b/src/webextensionRPC.test.js new file mode 100644 index 0000000..944ce53 --- /dev/null +++ b/src/webextensionRPC.test.js @@ -0,0 +1,94 @@ +/* eslint-env jest */ + +import { remoteFunction } from './webextensionRPC' + +describe('remoteFunction', () => { + beforeEach(() => { + browser.runtime = { + sendMessage: jest.fn(() => Promise.resolve()), + } + browser.tabs = { + sendMessage: jest.fn(() => Promise.resolve()), + } + }) + + test('should create a function', () => { + const remoteFunc = remoteFunction('remoteFunc', { tabId: 1 }) + expect(remoteFunc.name).toBe('remoteFunc_RPC') + expect(typeof remoteFunc).toBe('function') + }) + + test('should throw an error when unable to sendMessage', async () => { + const remoteFunc = remoteFunction('remoteFunc', { tabId: 1 }) + browser.tabs.sendMessage.mockImplementationOnce(() => { throw new Error() }) + await expect(remoteFunc()).rejects.toMatchObject({ + message: `Got no response when trying to call 'remoteFunc'. Did you enable RPC in the tab's content script?`, + }) + }) + + test('should call the browser.tabs function when tabId is given', async () => { + const remoteFunc = remoteFunction('remoteFunc', { tabId: 1 }) + try { + await remoteFunc() + } catch (e) {} + expect(browser.tabs.sendMessage).toHaveBeenCalledTimes(1) + expect(browser.runtime.sendMessage).toHaveBeenCalledTimes(0) + }) + + test('should call the browser.runtime function when tabId is undefined', async () => { + const remoteFunc = remoteFunction('remoteFunc') + try { + await remoteFunc() + } catch (e) {} + expect(browser.tabs.sendMessage).toHaveBeenCalledTimes(0) + expect(browser.runtime.sendMessage).toHaveBeenCalledTimes(1) + }) + + test('should throw an "interfering listener" error if response is unrecognised', async () => { + browser.tabs.sendMessage.mockReturnValueOnce('some unexpected return value') + const remoteFunc = remoteFunction('remoteFunc', { tabId: 1 }) + await expect(remoteFunc()).rejects.toMatchObject({ + message: expect.stringContaining('RPC got a response from an interfering listener'), + }) + }) + + test('should throw a "no response" error if sending the message fails', async () => { + browser.tabs.sendMessage.mockReturnValueOnce(Promise.reject(new Error())) + const remoteFunc = remoteFunction('remoteFunc', { tabId: 1 }) + await expect(remoteFunc()).rejects.toMatchObject({ + message: expect.stringContaining('Got no response'), + }) + }) + + test('should throw a "no response" error if response is undefined', async () => { + // It seems we can get back undefined when the tab is closed before the response is sent. + // In such cases 'no response' seems a better error message than 'interfering listener'. + browser.tabs.sendMessage.mockReturnValueOnce(undefined) + const remoteFunc = remoteFunction('remoteFunc', { tabId: 1 }) + await expect(remoteFunc()).rejects.toMatchObject({ + message: expect.stringContaining('Got no response'), + }) + }) + + test('should throw an error if the response contains an error message', async () => { + browser.tabs.sendMessage.mockReturnValueOnce({ + __RPC_RESPONSE__: '__RPC_RESPONSE__', + errorMessage: 'Remote function error', + }) + const remoteFunc = remoteFunction('remoteFunc', { tabId: 1 }) + await expect(remoteFunc()).rejects.toMatchObject({ + message: 'Remote function error', + }) + }) + + test('should return the value contained in the response', async () => { + browser.tabs.sendMessage.mockReturnValueOnce({ + __RPC_RESPONSE__: '__RPC_RESPONSE__', + returnValue: 'Remote function return value', + }) + const remoteFunc = remoteFunction('remoteFunc', { tabId: 1 }) + await expect(remoteFunc()).resolves.toBe('Remote function return value') + }) +}) + +// TODO Test behaviour of executing side.