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

Copy code from webmemex-extension

These two files:
3406b4eaea/src/util/webextensionRPC.js
3406b4eaea/src/util/webextensionRPC.test.js

(the code was written by me, and is published in the public domain)
tags/v0.1.0
Gerben 5 years ago
parent
commit
367e923dc8
2 changed files with 277 additions and 0 deletions
  1. +183
    -0
      src/webextensionRPC.js
  2. +94
    -0
      src/webextensionRPC.test.js

+ 183
- 0
src/webextensionRPC.js View File

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

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

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

Loading…
Cancel
Save