- switch from jest to ava - deduplicate similar 'got no response' tests - remove lodash.mapValues dependency - create package.json - indent with tabstags/v0.1.0
@@ -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 <gerben@treora.com>", | |||||
"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" | |||||
} | |||||
} |
@@ -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 | |||||
} |
@@ -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 | |||||
} | |||||
} |
@@ -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. |
@@ -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. |