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

Convert to TypeScript, use symbol for extra argument.

tags/v0.2.0
Gerben 1 year ago
parent
commit
adfa1d349b
9 changed files with 3384 additions and 5628 deletions
  1. +26
    -14
      Readme.md
  2. +19
    -0
      example/background-script.ts
  3. +22
    -0
      example/content-script.ts
  4. +3032
    -5423
      package-lock.json
  5. +13
    -30
      package.json
  6. +215
    -0
      src/index.ts
  7. +0
    -150
      src/webextension-rpc.js
  8. +44
    -11
      test/index.ts
  9. +13
    -0
      tsconfig.json

+ 26
- 14
Readme.md View File

@@ -9,33 +9,39 @@ background script from a tab’s content script, or vice versa.

In `background.js`:

function myFunc(arg) {
import { makeRemotelyCallable } from 'webextension-rpc'
async function myFunc(arg) {
return arg * 2
}
makeRemotelyCallable({ myFunc })

In `content_script.js`:

import { remoteFunction } from 'webextension-rpc'
const myRemoteFunc = remoteFunction('myFunc')
myRemoteFunc(21).then(result => { ... result is 42! ... })
await myRemoteFunc(21) // 42!

Note that the remote function always returns a `Promise`, which resolves with the remote function’s
actual return value (if the return value is itself a Promise, its result is awaited too).


## Use in TypeScript

When used TypeScript, the type of a remote/proxy function is nearly equal to its original, and can
be derived from it automatically. For an example how to do this, see the `example` folder.


## Install

### Using NPM

This module is published [on npm](https://www.npmjs.com/package/webextension-rpc).
This module is published [on npm](https://www.npmjs.com/package/webextension-rpc), as an ES module.

Run `npm install webextension-rpc` or equivalent.
Run `npm install webextension-rpc` or equivalent, and in your code import what you need, e.g.:

### Standalone
import { makeRemotelyCallable } from 'webextension-rpc'

Try one of the magic npm bundlers, for example:

`wget https://wzrd.in/standalone/webextension-rpc -O webextension-rpc.js`
Or copy its `lib/index.js` and import from that if you prefer (this module has no dependencies).


## API
@@ -46,8 +52,8 @@ Create a proxy function that invokes the specified remote function.

- `functionName` (string, required): name of the function as registered on the remote side.
- `options` (object, optional):
- `tabId` (number): The id of the tab whose content script is the remote side. Leave undefined
to call the background script (from a content script).
- `tabId` (number): The id of the tab whose content script is the remote side. Leave undefined
to call the background script (from a content script).

### `makeRemotelyCallable(functions, { insertExtraArg })`

@@ -55,9 +61,15 @@ Register one or more functions to enable remote scripts to call them. Arguments:

- `functions` (object, required): An object with a `{ functionName: function }` mapping. Each
function will be remotely callable using the given name.
- `options` (object, optional):
- `insertExtraArg` (boolean, default is `false`): If truthy, each executed function also
receives, as its first argument before the arguments it was invoked with, a [Tab][] object,
which contains the details of the tab that sent the message.


### `injectRpcInfo`

If the special symbol `injectRpcInfo` is passed as the first argument to a proxy function, this
argument will be replaced on the executing side by an `RpcInfo` object, which contains the following
attributes:

- `tab`: the [`Tab`](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/Tab),
from which the call was made, if it was made by a content script.

[Tab]: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/Tab

+ 19
- 0
example/background-script.ts View File

@@ -0,0 +1,19 @@
import { remoteFunction, makeRemotelyCallable } from 'webextension-rpc';
import type { RpcInfo } from 'webextension-rpc';
// Only import the *types* of the remote script’s functions.
import type { contentScriptRemoteFunctions } from './content-script';

// From background to content script.
const setColour = remoteFunction<typeof contentScriptRemoteFunctions.setColour>(
'setColour',
{ tabId: 123 },
);
await setColour('blue');

// From content to background script.
export const backgroundScriptRemoteFunctions = makeRemotelyCallable({
async duplicateTab(rpcInfo: RpcInfo, active: boolean) {
const newTab = await browser.tabs.duplicate(rpcInfo.tab.id, { active });
return newTab.id;
},
});

+ 22
- 0
example/content-script.ts View File

@@ -0,0 +1,22 @@
import {
remoteFunction,
makeRemotelyCallable,
injectRpcInfo,
} from 'webextension-rpc';
// Only import the *types* of the remote script’s functions.
import type { backgroundScriptRemoteFunctions } from './background-script';

// From background to content script.
export const contentScriptRemoteFunctions = makeRemotelyCallable({
async setColour(colour: string) {
document.body.style.backgroundColor = colour;
},
});

// From content to background script.
const duplicateMe =
remoteFunction<typeof backgroundScriptRemoteFunctions.duplicateTab>(
'duplicateTab',
);
// The injectRpcInfo placeholder will be replaced by actual info.
const newTabId = await duplicateMe(injectRpcInfo, true);

+ 3032
- 5423
package-lock.json
File diff suppressed because it is too large
View File


+ 13
- 30
package.json View File

@@ -2,11 +2,10 @@
"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": "lib/webextension-rpc.js",
"module": "src/webextension-rpc.js",
"main": "./lib/index.js",
"types": "./lib/index.d.ts",
"scripts": {
"prepare": "babel src -d lib",
"watch": "babel src -d lib -w",
"prepare": "tsc",
"test": "ava"
},
"homepage": "https://code.treora.com/gerben/webextension-rpc",
@@ -16,36 +15,20 @@
},
"author": "Gerben <gerben@treora.com>",
"license": "CC0-1.0",
"dependencies": {
"@babel/runtime": "^7.6.2"
},
"dependencies": {},
"devDependencies": {
"@babel/cli": "^7.6.2",
"@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"
"@types/firefox-webext-browser": "^94.0.1",
"ava": "^4.3.1",
"ts-node": "^10.9.1",
"ts-sinon": "^2.0.2",
"typescript": "^4.7.4"
},
"ava": {
"extensions": [
"ts"
],
"require": [
"@babel/register"
"ts-node/register"
]
},
"babel": {
"retainLines": true,
"plugins": [
"@babel/plugin-transform-runtime"
],
"presets": [
"@babel/preset-env"
],
"env": {
"test": {
"presets": [
["@babel/preset-env", { "targets": { "node": "current" } }]
]
}
}
}
}

+ 215
- 0
src/index.ts View File

@@ -0,0 +1,215 @@
// 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
}
}

export const injectRpcInfo = Symbol('RpcInfo')

export interface RpcInfo {
tab: browser.tabs.Tab,
}

type AsyncFunction = Function & ((...args: any[]) => Promise<any>)

interface RpcMessage {
__WEBEXTENSION_RPC_MESSAGE__: typeof RPC_CALL | typeof RPC_RESPONSE,
funcName: string,
}

interface RpcCallMessage<F extends AsyncFunction = AsyncFunction> extends RpcMessage {
__WEBEXTENSION_RPC_MESSAGE__: typeof RPC_CALL,
funcName: F['name'],
args: ParametersWithRpcInfo<F>,
addRpcInfoAsArgument: number | false,
}

type RpcResponseMessage<F extends AsyncFunction = AsyncFunction> =
| RpcResponseResolve<F>
| RpcResponseReject
| RpcResponseRpcError

interface RpcResponseMessage_base {
__WEBEXTENSION_RPC_MESSAGE__: typeof RPC_RESPONSE,
}

interface RpcResponseResolve<F extends AsyncFunction> extends RpcResponseMessage_base {
returnValue: ReturnType<F>,
}

interface RpcResponseReject extends RpcResponseMessage_base {
errorMessage: string,
}

interface RpcResponseRpcError extends RpcResponseMessage_base {
rpcError: string,
}

function isRpcCallMessage(message: any): message is RpcCallMessage {
return !!(message && message['__WEBEXTENSION_RPC_MESSAGE__'] === RPC_CALL)
}

function isRpcResponseMessage(message: any): message is RpcResponseMessage {
return !!(message && message['__WEBEXTENSION_RPC_MESSAGE__'] === RPC_RESPONSE)
}

type Tail<T extends any[]> = T extends [infer _Head, ...infer Tail] ? Tail : [];

type ParametersWithRpcInfo<F extends AsyncFunction> = Parameters<F> extends [RpcInfo, ...any]
? [typeof injectRpcInfo, ...Tail<Parameters<F>>]
: Parameters<F>

// I thought the type below should allow putting the RpcInfo argument at any position, but it does not work.
// type ParametersWithRpcInfo<F extends AsyncFunction> = Array<any> & {
// [N in keyof Parameters<F>]: Parameters<F>[N] extends RpcInfo
// ? typeof injectRpcInfo
// : Parameters<F>[N]
// }

/**
* RpcFunction<F> equals the function F, except for two tweaks:
* - In the parameters, any RpcInfo is swapped for rpcInfoSymbol.
* - In the return type, nested promises become one promise.
*/
export type RpcFunction<F extends AsyncFunction> = (...args: ParametersWithRpcInfo<F>) => Promise<Awaited<ReturnType<F>>>


// === Initiating side ===

/**
* Create a proxy function that invokes the specified remote function.
* @param funcName - Name of the function as registered on the remote side.
* @param options.tabId - The id of the tab whose content script is the remote side. Leave undefined
to call the background script (from a content script).
* @returns The proxy function.
*/
export function remoteFunction<F extends AsyncFunction>(
funcName: string,
{ tabId }: { tabId?: number } = {},
): RpcFunction<F> {
const otherSide = (tabId !== undefined)
? "the tab's content script"
: 'the background script'

const f: RpcFunction<F> = async function (...args): Promise<Awaited<ReturnType<F>>> {
const message: RpcCallMessage = {
__WEBEXTENSION_RPC_MESSAGE__: RPC_CALL,
funcName,
args,
addRpcInfoAsArgument: false,
}

if (args.includes(injectRpcInfo)) {
const argIndex = args.indexOf(injectRpcInfo)
message.addRpcInfoAsArgument = argIndex
message.args[argIndex] = null
}

// Try send the message and await the response.
let response: RpcResponseMessage<F>
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 (!isRpcResponseMessage(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 ('rpcError' in response) {
throw new RpcError(response.rpcError)
}

// Return the value or throw the error we received from the other side.
if ('errorMessage' in response) {
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 ===


/**
* Register one or more functions to enable remote scripts to call them.
*
* @param functions - A `{ functionName: function }` mapping. Each function will be remotely
* callable using the given name.
* @returns The passed `functions` object.
*/
export function makeRemotelyCallable<Fs extends Record<string, AsyncFunction>>(
functions: Fs,
): typeof functions {
browser.runtime.onMessage.addListener(incomingRPCListener)
return functions

async function incomingRPCListener(message: any, sender: browser.runtime.MessageSender): Promise<RpcResponseMessage> {
if (!isRpcCallMessage(message)) return

const funcName = message.funcName
const func = functions[funcName]
if (func === undefined) {
console.error(`Received RPC for unknown function: ${funcName}`)
return {
rpcError: `No such function registered for RPC: ${funcName}`,
__WEBEXTENSION_RPC_MESSAGE__: RPC_RESPONSE,
}
}

const args = message.args

if (message.addRpcInfoAsArgument !== false) {
const rpcInfo: RpcInfo = {
tab: sender.tab,
}
args[message.addRpcInfoAsArgument] = rpcInfo
}

// Run the function, return the result.
try {
const returnValue = await func(...args)
return {
returnValue,
__WEBEXTENSION_RPC_MESSAGE__: RPC_RESPONSE,
}
} catch (error) {
return {
errorMessage: error.message,
__WEBEXTENSION_RPC_MESSAGE__: RPC_RESPONSE,
}
}
}
}

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

@@ -1,150 +0,0 @@

// 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 ===

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

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
}

test/webextension-rpc.js → test/index.ts View File

@@ -1,19 +1,25 @@
import test from 'ava'
import { spy } from 'sinon'
import sinon from 'ts-sinon'

import { remoteFunction, RpcError, RemoteError } from '../src/webextension-rpc.js'
import { remoteFunction, RpcError, RemoteError, injectRpcInfo } from '../src/index'

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 = {
function mockBrowser() {
return {
runtime: {
sendMessage: spy(async () => {}),
sendMessage: sinon.spy(async (...args) => {}),
},
tabs: {
sendMessage: spy(async () => {}),
sendMessage: sinon.spy(async (...args) => {}),
},
}
}

let browser = mockBrowser()

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 = browser = mockBrowser()
})

test.serial('should create a function', t => {
@@ -36,7 +42,6 @@ test.serial('should call the browser.tabs function when tabId is given', async t
try {
await remoteFunc()
} catch (e) {}
console.log(browser.tabs.sendMessage.callCount)
t.true(browser.tabs.sendMessage.calledOnce)
t.true(browser.runtime.sendMessage.notCalled)
})
@@ -50,6 +55,34 @@ test.serial('should call the browser.runtime function when tabId is undefined',
t.true(browser.runtime.sendMessage.calledOnce)
})

test.serial('should send the call message correctly', async t => {
const remoteFunc = remoteFunction('remoteFunc')
try {
await remoteFunc('a', 'b', 'c', 'd')
} catch {}
t.true(browser.runtime.sendMessage.calledOnce)
t.deepEqual(browser.runtime.sendMessage.lastCall.args, [{
__WEBEXTENSION_RPC_MESSAGE__: '__RPC_CALL__',
funcName: 'remoteFunc',
args: ['a', 'b', 'c', 'd'],
addRpcInfoAsArgument: false,
}])
})

test.serial('should handle the RpcInfoSymbol', async t => {
const remoteFunc = remoteFunction('remoteFunc')
try {
await remoteFunc('a', 'b', injectRpcInfo, 'd')
} catch {}
t.true(browser.runtime.sendMessage.calledOnce)
t.deepEqual(browser.runtime.sendMessage.lastCall.args, [{
__WEBEXTENSION_RPC_MESSAGE__: '__RPC_CALL__',
funcName: 'remoteFunc',
args: ['a', 'b', injectRpcInfo, 'd'],
addRpcInfoAsArgument: 2,
}])
})

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 })
@@ -72,7 +105,7 @@ test.serial('should throw a "no response" error if response is undefined', async

test.serial('should throw RemoteError if the response contains an error message', async t => {
browser.tabs.sendMessage = async () => ({
__RPC_RESPONSE__: '__RPC_RESPONSE__',
__WEBEXTENSION_RPC_MESSAGE__: '__RPC_RESPONSE__',
errorMessage: 'Remote function error',
})
const remoteFunc = remoteFunction('remoteFunc', { tabId: 1 })
@@ -84,7 +117,7 @@ test.serial('should throw RemoteError if the response contains an error message'

test.serial('should return the value contained in the response', async t => {
browser.tabs.sendMessage = async () => ({
__RPC_RESPONSE__: '__RPC_RESPONSE__',
__WEBEXTENSION_RPC_MESSAGE__: '__RPC_RESPONSE__',
returnValue: 'Remote function return value',
})
const remoteFunc = remoteFunction('remoteFunc', { tabId: 1 })

+ 13
- 0
tsconfig.json View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2017",
"declaration": true,
"outDir": "lib"
},
"files": [
"src/index.ts"
],
"ts-node": {
"transpileOnly": true
}
}

Loading…
Cancel
Save