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