- 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. |