@@ -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 |
@@ -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; | |||
}, | |||
}); |
@@ -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); |
@@ -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" } }] | |||
] | |||
} | |||
} | |||
} | |||
} |
@@ -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, | |||
} | |||
} | |||
} | |||
} |
@@ -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 | |||
} |
@@ -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 }) |
@@ -0,0 +1,13 @@ | |||
{ | |||
"compilerOptions": { | |||
"target": "ES2017", | |||
"declaration": true, | |||
"outDir": "lib" | |||
}, | |||
"files": [ | |||
"src/index.ts" | |||
], | |||
"ts-node": { | |||
"transpileOnly": true | |||
} | |||
} |