Remote Procedure Call implementation for WebExtensions, to easily call functions across content scripts and background script.
Browse Source

General package setup, switch test framework

- switch from jest to ava
- deduplicate similar 'got no response' tests
- remove lodash.mapValues dependency
- create package.json
- indent with tabs
tags/v0.1.0
Gerben 4 years ago
parent
commit
1fd6b7c1f5
5 changed files with 328 additions and 277 deletions
  1. +47
    -0
      package.json
  2. +187
    -0
      src/webextension-rpc.js
  3. +0
    -183
      src/webextensionRPC.js
  4. +0
    -94
      src/webextensionRPC.test.js
  5. +94
    -0
      test/webextension-rpc.js

+ 47
- 0
package.json View File

@@ -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"
}
}

+ 187
- 0
src/webextension-rpc.js View File

@@ -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
}

+ 0
- 183
src/webextensionRPC.js View File

@@ -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
}
}

+ 0
- 94
src/webextensionRPC.test.js View File

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

+ 94
- 0
test/webextension-rpc.js View File

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

Loading…
Cancel
Save