diff --git a/package.json b/package.json new file mode 100644 index 0000000..4478aa5 --- /dev/null +++ b/package.json @@ -0,0 +1,47 @@ +{ + "name": "webextension-rpc", + "version": "0.1.0", + "description": "Remote Procedure Call implementation for WebExtensions, to easily call functions across content scripts and background script.", + "main": "src/webextension-rpc.js", + "scripts": { + "test": "ava" + }, + "homepage": "https://code.treora.com/gerben/webextension-rpc", + "repository": { + "type": "git", + "url": "https://code.treora.com/gerben/webextension-rpc" + }, + "author": "Gerben ", + "license": "CC0-1.0", + "devDependencies": { + "@babel/plugin-transform-runtime": "^7.6.2", + "@babel/preset-env": "^7.6.2", + "@babel/register": "^7.6.2", + "ava": "^2.4.0", + "sinon": "^7.5.0" + }, + "ava": { + "require": [ + "@babel/register" + ] + }, + "babel": { + "retainLines": true, + "plugins": [ + "@babel/plugin-transform-runtime" + ], + "presets": [ + [ + "@babel/preset-env", + { + "targets": { + "node": "current" + } + } + ] + ] + }, + "dependencies": { + "@babel/runtime": "^7.6.2" + } +} diff --git a/src/webextension-rpc.js b/src/webextension-rpc.js new file mode 100644 index 0000000..aedd5d6 --- /dev/null +++ b/src/webextension-rpc.js @@ -0,0 +1,187 @@ +// 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! ... }) + +// 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 + } +} + +const mapValues = fn => object => { + const result = {} + for (const [key, value] of Object.entries(object)) { + result[key] = fn(value) + } + return result +} diff --git a/src/webextensionRPC.js b/src/webextensionRPC.js deleted file mode 100644 index 2a07c10..0000000 --- a/src/webextensionRPC.js +++ /dev/null @@ -1,183 +0,0 @@ -// 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 deleted file mode 100644 index 944ce53..0000000 --- a/src/webextensionRPC.test.js +++ /dev/null @@ -1,94 +0,0 @@ -/* 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. diff --git a/test/webextension-rpc.js b/test/webextension-rpc.js new file mode 100644 index 0000000..75f1d6f --- /dev/null +++ b/test/webextension-rpc.js @@ -0,0 +1,94 @@ +import test from 'ava' +import { spy } from 'sinon' + +import { remoteFunction, RpcError, RemoteError } from '../src/webextension-rpc.js' + +test.beforeEach(() => { + // We mock the browser globally. Note we therefore need to test serially to prevent the tests from + // interfering with each other. + global.browser = { + runtime: { + sendMessage: spy(async () => {}), + }, + tabs: { + sendMessage: spy(async () => {}), + }, + } +}) + +test.serial('should create a function', t => { + const remoteFunc = remoteFunction('remoteFunc', { tabId: 1 }) + t.is(remoteFunc.name, 'remoteFunc_RPC') + t.is(typeof remoteFunc, 'function') +}) + +test.serial('should throw an error when unable to sendMessage', async t => { + const remoteFunc = remoteFunction('remoteFunc', { tabId: 1 }) + browser.tabs.sendMessage = async () => { throw new Error() } + await t.throwsAsync(remoteFunc, { + instanceOf: RpcError, + message: `Got no response when trying to call 'remoteFunc'. Did you enable RPC in the tab's content script?`, + }) +}) + +test.serial('should call the browser.tabs function when tabId is given', async t => { + const remoteFunc = remoteFunction('remoteFunc', { tabId: 1 }) + try { + await remoteFunc() + } catch (e) {} + console.log(browser.tabs.sendMessage.callCount) + t.true(browser.tabs.sendMessage.calledOnce) + t.true(browser.runtime.sendMessage.notCalled) +}) + +test.serial('should call the browser.runtime function when tabId is undefined', async t => { + const remoteFunc = remoteFunction('remoteFunc') + try { + await remoteFunc() + } catch (e) {} + t.true(browser.tabs.sendMessage.notCalled) + t.true(browser.runtime.sendMessage.calledOnce) +}) + +test.serial('should throw an "interfering listener" error if response is unrecognised', async t => { + browser.tabs.sendMessage = async () => 'some unexpected return value' + const remoteFunc = remoteFunction('remoteFunc', { tabId: 1 }) + await t.throwsAsync(remoteFunc, { + instanceOf: RpcError, + message: /RPC got a response from an interfering listener/, + }) +}) + +test.serial('should throw a "no response" error if response is undefined', async t => { + // 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 = async () => undefined + const remoteFunc = remoteFunction('remoteFunc', { tabId: 1 }) + await t.throwsAsync(remoteFunc, { + instanceOf: RpcError, + message: /Got no response/, + }) +}) + +test.serial('should throw RemoteError if the response contains an error message', async t => { + browser.tabs.sendMessage = async () => ({ + __RPC_RESPONSE__: '__RPC_RESPONSE__', + errorMessage: 'Remote function error', + }) + const remoteFunc = remoteFunction('remoteFunc', { tabId: 1 }) + await t.throwsAsync(remoteFunc, { + instanceOf: RemoteError, + message: 'Remote function error', + }) +}) + +test.serial('should return the value contained in the response', async t => { + browser.tabs.sendMessage = async () => ({ + __RPC_RESPONSE__: '__RPC_RESPONSE__', + returnValue: 'Remote function return value', + }) + const remoteFunc = remoteFunction('remoteFunc', { tabId: 1 }) + t.is(await remoteFunc(), 'Remote function return value') +}) + +// TODO Test behaviour of executing side.