@@ -9,16 +9,16 @@ background script from a tab’s content script, or vice versa. | |||||
In `background.js`: | In `background.js`: | ||||
import { makeRemotelyCallable } from 'webextension-rpc' | |||||
import { RpcServer } from 'webextension-rpc' | |||||
async function myFunc(arg) { | async function myFunc(arg) { | ||||
return arg * 2 | return arg * 2 | ||||
} | } | ||||
makeRemotelyCallable({ myFunc }) | |||||
new RpcServer({ myFunc }) | |||||
In `content_script.js`: | In `content_script.js`: | ||||
import { remoteFunction } from 'webextension-rpc' | |||||
const myRemoteFunc = remoteFunction('myFunc') | |||||
import { RpcClient } from 'webextension-rpc' | |||||
const myRemoteFunc = new RpcClient().func('myFunc') | |||||
await myRemoteFunc(21) // 42! | await myRemoteFunc(21) // 42! | ||||
Note that the remote function always returns a `Promise`, which resolves with the remote function’s | Note that the remote function always returns a `Promise`, which resolves with the remote function’s | ||||
@@ -39,30 +39,42 @@ This module is published [on npm](https://www.npmjs.com/package/webextension-rpc | |||||
Run `npm install webextension-rpc` or equivalent, and in your code import what you need, e.g.: | Run `npm install webextension-rpc` or equivalent, and in your code import what you need, e.g.: | ||||
import { makeRemotelyCallable } from 'webextension-rpc' | |||||
Or copy its `lib/index.js` and import from that if you prefer (this module has no dependencies). | |||||
import { RpcClient, RpcServer } from 'webextension-rpc' | |||||
## API | ## API | ||||
### `remoteFunction(functionName, { tabId })` | |||||
### RpcClient | |||||
#### `new RpcClient(options?)` (constructor) | |||||
Instantiate the RpcClient. | |||||
Arguments: | |||||
- `options` (object, optional): | |||||
- `options.tabId` (number): The id of the tab whose content script is the remote side. Leave undefined or | |||||
null to invoke functions in the background script (from a content script). | |||||
#### `func(functionName, options?)` | |||||
Create a proxy function that invokes the specified remote function. | Create a proxy function that invokes the specified remote function. | ||||
Arguments: | |||||
- `functionName` (string, required): name of the function as registered on the remote side. | - `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). | |||||
- `options` (object, optional): override any options passed to the constructor. | |||||
### RpcServer | |||||
### `makeRemotelyCallable(functions, { insertExtraArg })` | |||||
#### `new RpcServer(functions)` (constructor) | |||||
Register one or more functions to enable remote scripts to call them. Arguments: | |||||
Register one or more functions to enable remote scripts to call them. | |||||
Arguments: | |||||
- `functions` (object, required): An object with a `{ functionName: function }` mapping. Each | - `functions` (object, required): An object with a `{ functionName: function }` mapping. Each | ||||
function will be remotely callable using the given name. | function will be remotely callable using the given name. | ||||
### `injectRpcInfo` | ### `injectRpcInfo` | ||||
If the special symbol `injectRpcInfo` is passed as the first argument to a proxy function, this | If the special symbol `injectRpcInfo` is passed as the first argument to a proxy function, this | ||||
@@ -1,19 +1,19 @@ | |||||
import { remoteFunction, makeRemotelyCallable } from 'webextension-rpc'; | |||||
import { RpcClient, RpcServer } from 'webextension-rpc'; | |||||
import type { RpcInfo } from 'webextension-rpc'; | import type { RpcInfo } from 'webextension-rpc'; | ||||
// Only import the *types* of the remote script’s functions. | // Only import the *types* of the remote script’s functions. | ||||
import type { contentScriptRemoteFunctions } from './content-script'; | |||||
import type { contentScriptRpcServer } from './content-script'; | |||||
// From background to content script. | // From background to content script. | ||||
const setColour = remoteFunction<typeof contentScriptRemoteFunctions.setColour>( | |||||
'setColour', | |||||
{ tabId: 123 }, | |||||
); | |||||
const contentScriptRpc = new RpcClient<typeof contentScriptRpcServer>({ tabId: 123 }); | |||||
const setColour = contentScriptRpc.func('setColour'); | |||||
await setColour('blue'); | await setColour('blue'); | ||||
// From content to background script. | // From content to background script. | ||||
export const backgroundScriptRemoteFunctions = makeRemotelyCallable({ | |||||
const backgroundScriptRpcServer = new RpcServer({ | |||||
async duplicateTab(rpcInfo: RpcInfo, active: boolean) { | async duplicateTab(rpcInfo: RpcInfo, active: boolean) { | ||||
const newTab = await browser.tabs.duplicate(rpcInfo.tab.id, { active }); | const newTab = await browser.tabs.duplicate(rpcInfo.tab.id, { active }); | ||||
return newTab.id; | return newTab.id; | ||||
}, | }, | ||||
async timesTwo(x: number) { return 2 * x }, | |||||
}); | }); | ||||
export type { backgroundScriptRpcServer } |
@@ -1,22 +1,20 @@ | |||||
import { | import { | ||||
remoteFunction, | |||||
makeRemotelyCallable, | |||||
RpcClient, | |||||
RpcServer, | |||||
injectRpcInfo, | injectRpcInfo, | ||||
} from 'webextension-rpc'; | } from 'webextension-rpc'; | ||||
// Only import the *types* of the remote script’s functions. | // Only import the *types* of the remote script’s functions. | ||||
import type { backgroundScriptRemoteFunctions } from './background-script'; | |||||
import type { backgroundScriptRpcServer } from './background-script'; | |||||
// From background to content script. | // From background to content script. | ||||
export const contentScriptRemoteFunctions = makeRemotelyCallable({ | |||||
const contentScriptRpcServer = new RpcServer({ | |||||
async setColour(colour: string) { | async setColour(colour: string) { | ||||
document.body.style.backgroundColor = colour; | document.body.style.backgroundColor = colour; | ||||
}, | }, | ||||
}); | }); | ||||
export type { contentScriptRpcServer } | |||||
// From content to background script. | // From content to background script. | ||||
const duplicateMe = | |||||
remoteFunction<typeof backgroundScriptRemoteFunctions.duplicateTab>( | |||||
'duplicateTab', | |||||
); | |||||
const duplicateMe = new RpcClient<typeof backgroundScriptRpcServer>().func('duplicateTab'); | |||||
// The injectRpcInfo placeholder will be replaced by actual info. | // The injectRpcInfo placeholder will be replaced by actual info. | ||||
const newTabId = await duplicateMe(injectRpcInfo, true); | const newTabId = await duplicateMe(injectRpcInfo, true); |
@@ -4,6 +4,21 @@ | |||||
"description": "Remote Procedure Call implementation for WebExtensions, to easily call functions across content scripts and background script.", | "description": "Remote Procedure Call implementation for WebExtensions, to easily call functions across content scripts and background script.", | ||||
"main": "./lib/index.js", | "main": "./lib/index.js", | ||||
"types": "./lib/index.d.ts", | "types": "./lib/index.d.ts", | ||||
"exports": { | |||||
".": { | |||||
"import": "./lib/index.js", | |||||
"types": "./lib/index.d.ts" | |||||
}, | |||||
"./RpcClient": { | |||||
"import": "./lib/RpcClient.js", | |||||
"types": "./lib/RpcClient.d.ts" | |||||
}, | |||||
"./RpcServer": { | |||||
"import": "./lib/RpcServer.js", | |||||
"types": "./lib/RpcServer.d.ts" | |||||
} | |||||
}, | |||||
"files": ["lib"], | |||||
"scripts": { | "scripts": { | ||||
"prepare": "tsc", | "prepare": "tsc", | ||||
"test": "ava" | "test": "ava" | ||||
@@ -0,0 +1,138 @@ | |||||
import type { RpcServer } from "./RpcServer" | |||||
import { isRpcResponseMessage, RPC_CALL } from "./common" | |||||
import type { AsyncFunction, RpcCallMessage, RpcResponseMessage, ReplaceRpcInfo } from "./common" | |||||
export type { AsyncFunction } | |||||
/** | |||||
* Error thrown when the remote function could not be found/executed. | |||||
*/ | |||||
export class RpcError extends Error { | |||||
constructor(message: string) { | |||||
super(message) | |||||
this.name = this.constructor.name | |||||
} | |||||
} | |||||
/** | |||||
* Error thrown when the remote function threw an error. | |||||
*/ | |||||
export class RemoteError extends Error { | |||||
constructor(message: string) { | |||||
super(message) | |||||
this.name = this.constructor.name | |||||
} | |||||
} | |||||
/** | |||||
* 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. | |||||
*/ | |||||
export const injectRpcInfo = Symbol('RpcInfo') | |||||
export interface RpcOptions { | |||||
/** | |||||
* 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, | |||||
} | |||||
/** | |||||
* 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: ReplaceRpcInfo<Parameters<F>, typeof injectRpcInfo> | |||||
) => Promise<Awaited<ReturnType<F>>> | |||||
type FunctionsOf<R extends RpcServer> = R['functions'] | |||||
/** | |||||
* Define a ‘connection’ for remote procedure calls. | |||||
* @param options.tabId - The id of the tab whose content script is the remote side. Leave undefined | |||||
* to call functions of the background script (from a content script). | |||||
*/ | |||||
export class RpcClient<TheRpcServer extends RpcServer> { | |||||
tabId: number | undefined | |||||
constructor(options: RpcOptions = {}) { | |||||
this.tabId = options.tabId; | |||||
} | |||||
/** | |||||
* 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 - Overrides the `tabId` passed to `RpcClient`. | |||||
* @returns The proxy function. | |||||
*/ | |||||
func<FunctionName extends string & keyof FunctionsOf<TheRpcServer>>( | |||||
funcName: FunctionName, | |||||
{ | |||||
tabId = this.tabId, | |||||
}: RpcOptions = {}, | |||||
): RpcFunction<FunctionsOf<TheRpcServer>[FunctionName]> { | |||||
type RemoteFunction = FunctionsOf<TheRpcServer>[FunctionName] | |||||
const otherSide = (tabId !== undefined) | |||||
? "the tab's content script" | |||||
: 'the background script' | |||||
const f: RpcFunction<RemoteFunction> = async function (...args): ReturnType<RpcFunction<RemoteFunction>> { | |||||
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<RemoteFunction> | |||||
try { | |||||
response = (tabId !== undefined && tabId !== null) | |||||
? 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 | |||||
} | |||||
} |
@@ -0,0 +1,75 @@ | |||||
import { isRpcCallMessage, RPC_RESPONSE } from './common' | |||||
import type { AsyncFunction, RpcCallMessage, RpcResponseMessage } from './common' | |||||
export type { AsyncFunction } | |||||
/** | |||||
* 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. | |||||
*/ | |||||
export interface RpcInfo { | |||||
tab: browser.tabs.Tab, | |||||
} | |||||
/** | |||||
* 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. | |||||
*/ | |||||
export class RpcServer<Fs extends Record<string, AsyncFunction> = Record<string, AsyncFunction>> { | |||||
constructor( | |||||
public readonly functions: Fs, | |||||
) { | |||||
browser.runtime.onMessage.addListener(this.incomingRPCListener.bind(this)) | |||||
} | |||||
// TODO Avoid conflict if there are multiple listeners. | |||||
private incomingRPCListener( | |||||
message: any, | |||||
sender: browser.runtime.MessageSender, | |||||
): undefined | Promise<RpcResponseMessage> { | |||||
if (!isRpcCallMessage(message)) return | |||||
// TODO Support extension popups and other pages, not just background script & tabs. | |||||
// Each page gets the message, so we may need to name each endpoint. | |||||
// Then here we should return if the message was not for us. | |||||
return this.executeRpc(message, sender) | |||||
} | |||||
private async executeRpc(message: RpcCallMessage, sender: browser.runtime.MessageSender): Promise<RpcResponseMessage> { | |||||
const funcName = message.funcName | |||||
const func = this.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,0 +1,55 @@ | |||||
import type { RpcInfo } from './RpcServer' | |||||
export type AsyncFunction = Function & ((...args: any[]) => Promise<any>) | |||||
// Our secret tokens to recognise our messages | |||||
export const RPC_CALL = '__RPC_CALL__' | |||||
export const RPC_RESPONSE = '__RPC_RESPONSE__' | |||||
export interface RpcMessage { | |||||
__WEBEXTENSION_RPC_MESSAGE__: typeof RPC_CALL | typeof RPC_RESPONSE, | |||||
funcName: string, | |||||
} | |||||
export interface RpcCallMessage<F extends AsyncFunction = AsyncFunction> extends RpcMessage { | |||||
__WEBEXTENSION_RPC_MESSAGE__: typeof RPC_CALL, | |||||
funcName: F['name'], | |||||
args: ReplaceRpcInfo<Parameters<F>, null>, | |||||
addRpcInfoAsArgument: number | false, | |||||
} | |||||
export 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, | |||||
} | |||||
export function isRpcCallMessage(message: any): message is RpcCallMessage { | |||||
return !!(message && message['__WEBEXTENSION_RPC_MESSAGE__'] === RPC_CALL) | |||||
} | |||||
export 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 : []; | |||||
export type ReplaceRpcInfo<Params extends Parameters<AsyncFunction>, Replacement extends any> = | |||||
Params extends [RpcInfo, ...any] | |||||
? [Replacement, ...Tail<Params>] | |||||
: Params |
@@ -1,215 +1,2 @@ | |||||
// 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, | |||||
} | |||||
} | |||||
} | |||||
} | |||||
export * from './RpcClient' | |||||
export * from './RpcServer' |
@@ -1,7 +1,7 @@ | |||||
import test from 'ava' | import test from 'ava' | ||||
import sinon from 'ts-sinon' | import sinon from 'ts-sinon' | ||||
import { remoteFunction, RpcError, RemoteError, injectRpcInfo } from '../src/index' | |||||
import { RpcClient, RpcError, RemoteError, injectRpcInfo } from '../src/RpcClient' | |||||
function mockBrowser() { | function mockBrowser() { | ||||
return { | return { | ||||
@@ -23,13 +23,13 @@ test.beforeEach(() => { | |||||
}) | }) | ||||
test.serial('should create a function', t => { | test.serial('should create a function', t => { | ||||
const remoteFunc = remoteFunction('remoteFunc', { tabId: 1 }) | |||||
const remoteFunc = new RpcClient({ tabId: 1 }).func('remoteFunc') | |||||
t.is(remoteFunc.name, 'remoteFunc_RPC') | t.is(remoteFunc.name, 'remoteFunc_RPC') | ||||
t.is(typeof remoteFunc, 'function') | t.is(typeof remoteFunc, 'function') | ||||
}) | }) | ||||
test.serial('should throw an error when unable to sendMessage', async t => { | test.serial('should throw an error when unable to sendMessage', async t => { | ||||
const remoteFunc = remoteFunction('remoteFunc', { tabId: 1 }) | |||||
const remoteFunc = new RpcClient({ tabId: 1 }).func('remoteFunc') | |||||
browser.tabs.sendMessage = async () => { throw new Error() } | browser.tabs.sendMessage = async () => { throw new Error() } | ||||
await t.throwsAsync(remoteFunc, { | await t.throwsAsync(remoteFunc, { | ||||
instanceOf: RpcError, | instanceOf: RpcError, | ||||
@@ -38,7 +38,7 @@ test.serial('should throw an error when unable to sendMessage', async t => { | |||||
}) | }) | ||||
test.serial('should call the browser.tabs function when tabId is given', async t => { | test.serial('should call the browser.tabs function when tabId is given', async t => { | ||||
const remoteFunc = remoteFunction('remoteFunc', { tabId: 1 }) | |||||
const remoteFunc = new RpcClient({ tabId: 1 }).func('remoteFunc') | |||||
try { | try { | ||||
await remoteFunc() | await remoteFunc() | ||||
} catch (e) {} | } catch (e) {} | ||||
@@ -47,7 +47,25 @@ test.serial('should call the browser.tabs function when tabId is given', async t | |||||
}) | }) | ||||
test.serial('should call the browser.runtime function when tabId is undefined', async t => { | test.serial('should call the browser.runtime function when tabId is undefined', async t => { | ||||
const remoteFunc = remoteFunction('remoteFunc') | |||||
const remoteFunc = new RpcClient().func('remoteFunc') | |||||
try { | |||||
await remoteFunc() | |||||
} catch (e) {} | |||||
t.true(browser.tabs.sendMessage.notCalled) | |||||
t.true(browser.runtime.sendMessage.calledOnce) | |||||
}) | |||||
test.serial('should call the browser.tabs function when tabId is overridden', async t => { | |||||
const remoteFunc = new RpcClient().func('remoteFunc', { tabId: 123 }) | |||||
try { | |||||
await remoteFunc() | |||||
} catch (e) {} | |||||
t.true(browser.tabs.sendMessage.calledOnce) | |||||
t.true(browser.runtime.sendMessage.notCalled) | |||||
}) | |||||
test.serial('should call the browser.runtime function when tabId is overridden as null', async t => { | |||||
const remoteFunc = new RpcClient({ tabId: 123 }).func('remoteFunc', { tabId: null }) | |||||
try { | try { | ||||
await remoteFunc() | await remoteFunc() | ||||
} catch (e) {} | } catch (e) {} | ||||
@@ -56,7 +74,7 @@ test.serial('should call the browser.runtime function when tabId is undefined', | |||||
}) | }) | ||||
test.serial('should send the call message correctly', async t => { | test.serial('should send the call message correctly', async t => { | ||||
const remoteFunc = remoteFunction('remoteFunc') | |||||
const remoteFunc = new RpcClient().func('remoteFunc') | |||||
try { | try { | ||||
await remoteFunc('a', 'b', 'c', 'd') | await remoteFunc('a', 'b', 'c', 'd') | ||||
} catch {} | } catch {} | ||||
@@ -70,7 +88,7 @@ test.serial('should send the call message correctly', async t => { | |||||
}) | }) | ||||
test.serial('should handle the RpcInfoSymbol', async t => { | test.serial('should handle the RpcInfoSymbol', async t => { | ||||
const remoteFunc = remoteFunction('remoteFunc') | |||||
const remoteFunc = new RpcClient().func('remoteFunc') | |||||
try { | try { | ||||
await remoteFunc('a', 'b', injectRpcInfo, 'd') | await remoteFunc('a', 'b', injectRpcInfo, 'd') | ||||
} catch {} | } catch {} | ||||
@@ -78,14 +96,14 @@ test.serial('should handle the RpcInfoSymbol', async t => { | |||||
t.deepEqual(browser.runtime.sendMessage.lastCall.args, [{ | t.deepEqual(browser.runtime.sendMessage.lastCall.args, [{ | ||||
__WEBEXTENSION_RPC_MESSAGE__: '__RPC_CALL__', | __WEBEXTENSION_RPC_MESSAGE__: '__RPC_CALL__', | ||||
funcName: 'remoteFunc', | funcName: 'remoteFunc', | ||||
args: ['a', 'b', injectRpcInfo, 'd'], | |||||
args: ['a', 'b', null, 'd'], | |||||
addRpcInfoAsArgument: 2, | addRpcInfoAsArgument: 2, | ||||
}]) | }]) | ||||
}) | }) | ||||
test.serial('should throw an "interfering listener" error if response is unrecognised', async t => { | test.serial('should throw an "interfering listener" error if response is unrecognised', async t => { | ||||
browser.tabs.sendMessage = async () => 'some unexpected return value' | browser.tabs.sendMessage = async () => 'some unexpected return value' | ||||
const remoteFunc = remoteFunction('remoteFunc', { tabId: 1 }) | |||||
const remoteFunc = new RpcClient({ tabId: 1 }).func('remoteFunc') | |||||
await t.throwsAsync(remoteFunc, { | await t.throwsAsync(remoteFunc, { | ||||
instanceOf: RpcError, | instanceOf: RpcError, | ||||
message: /RPC got a response from an interfering listener/, | message: /RPC got a response from an interfering listener/, | ||||
@@ -96,7 +114,7 @@ test.serial('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. | // 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'. | // In such cases 'no response' seems a better error message than 'interfering listener'. | ||||
browser.tabs.sendMessage = async () => undefined | browser.tabs.sendMessage = async () => undefined | ||||
const remoteFunc = remoteFunction('remoteFunc', { tabId: 1 }) | |||||
const remoteFunc = new RpcClient({ tabId: 1 }).func('remoteFunc') | |||||
await t.throwsAsync(remoteFunc, { | await t.throwsAsync(remoteFunc, { | ||||
instanceOf: RpcError, | instanceOf: RpcError, | ||||
message: /Got no response/, | message: /Got no response/, | ||||
@@ -108,7 +126,7 @@ test.serial('should throw RemoteError if the response contains an error message' | |||||
__WEBEXTENSION_RPC_MESSAGE__: '__RPC_RESPONSE__', | __WEBEXTENSION_RPC_MESSAGE__: '__RPC_RESPONSE__', | ||||
errorMessage: 'Remote function error', | errorMessage: 'Remote function error', | ||||
}) | }) | ||||
const remoteFunc = remoteFunction('remoteFunc', { tabId: 1 }) | |||||
const remoteFunc = new RpcClient({ tabId: 1 }).func('remoteFunc') | |||||
await t.throwsAsync(remoteFunc, { | await t.throwsAsync(remoteFunc, { | ||||
instanceOf: RemoteError, | instanceOf: RemoteError, | ||||
message: 'Remote function error', | message: 'Remote function error', | ||||
@@ -120,8 +138,6 @@ test.serial('should return the value contained in the response', async t => { | |||||
__WEBEXTENSION_RPC_MESSAGE__: '__RPC_RESPONSE__', | __WEBEXTENSION_RPC_MESSAGE__: '__RPC_RESPONSE__', | ||||
returnValue: 'Remote function return value', | returnValue: 'Remote function return value', | ||||
}) | }) | ||||
const remoteFunc = remoteFunction('remoteFunc', { tabId: 1 }) | |||||
const remoteFunc = new RpcClient({ tabId: 1 }).func('remoteFunc') | |||||
t.is(await remoteFunc(), 'Remote function return value') | t.is(await remoteFunc(), 'Remote function return value') | ||||
}) | }) | ||||
// TODO Test behaviour of executing side. |
@@ -4,9 +4,7 @@ | |||||
"declaration": true, | "declaration": true, | ||||
"outDir": "lib" | "outDir": "lib" | ||||
}, | }, | ||||
"files": [ | |||||
"src/index.ts" | |||||
], | |||||
"include": ["src"], | |||||
"ts-node": { | "ts-node": { | ||||
"transpileOnly": true | "transpileOnly": true | ||||
} | } | ||||