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

webextension-rpc/src/ webextensionRPC.js
184 lines
5.9 KiB

  1. // A Remote Procedure Call abstraction around the message passing available to
  2. // WebExtension scripts. Usable to call a function in the background script from
  3. // a tab's content script, or vice versa.
  4. //
  5. // The calling side always gets a Promise of the return value. The executing
  6. // (remote) function can be an async function (= it returns a Promise), whose
  7. // completion then will then be waited for.
  8. // Example use:
  9. //
  10. // === background.js ===
  11. // function myFunc(arg) {
  12. // return arg*2
  13. // }
  14. // makeRemotelyCallable({myFunc})
  15. //
  16. // === content_script.js ===
  17. // const myRemoteFunc = remoteFunction('myFunc')
  18. // myRemoteFunc(21).then(result => { ... result is 42! ... })
  19. import mapValues from 'lodash/fp/mapValues'
  20. // Our secret tokens to recognise our messages
  21. const RPC_CALL = '__RPC_CALL__'
  22. const RPC_RESPONSE = '__RPC_RESPONSE__'
  23. export class RpcError extends Error {
  24. constructor(message) {
  25. super(message)
  26. this.name = this.constructor.name
  27. }
  28. }
  29. export class RemoteError extends Error {
  30. constructor(message) {
  31. super(message)
  32. this.name = this.constructor.name
  33. }
  34. }
  35. // === Initiating side ===
  36. // Create a proxy function that invokes the specified remote function.
  37. // Arguments
  38. // - funcName (required): name of the function as registered on the remote side.
  39. // - options (optional): {
  40. // tabId: The id of the tab whose content script is the remote side.
  41. // Leave undefined to call the background script (from a tab).
  42. // }
  43. export function remoteFunction(funcName, { tabId } = {}) {
  44. const otherSide = (tabId !== undefined)
  45. ? "the tab's content script"
  46. : 'the background script'
  47. const f = async function (...args) {
  48. const message = {
  49. [RPC_CALL]: RPC_CALL,
  50. funcName,
  51. args,
  52. }
  53. // Try send the message and await the response.
  54. let response
  55. try {
  56. response = (tabId !== undefined)
  57. ? await browser.tabs.sendMessage(tabId, message)
  58. : await browser.runtime.sendMessage(message)
  59. } catch (err) {}
  60. // Check if we got an error or no response.
  61. if (response === undefined) {
  62. throw new RpcError(
  63. `Got no response when trying to call '${funcName}'. `
  64. + `Did you enable RPC in ${otherSide}?`
  65. )
  66. }
  67. // Check if it was *our* listener that responded.
  68. if (response === null || response[RPC_RESPONSE] !== RPC_RESPONSE) {
  69. throw new RpcError(
  70. `RPC got a response from an interfering listener while calling '${funcName}' in `
  71. + `${otherSide}`
  72. )
  73. }
  74. // If we could not invoke the function on the other side, throw an error.
  75. if (response.rpcError) {
  76. throw new RpcError(response.rpcError)
  77. }
  78. // Return the value or throw the error we received from the other side.
  79. if (response.errorMessage) {
  80. throw new RemoteError(response.errorMessage)
  81. } else {
  82. return response.returnValue
  83. }
  84. }
  85. // Give it a name, could be helpful in debugging
  86. Object.defineProperty(f, 'name', { value: `${funcName}_RPC` })
  87. return f
  88. }
  89. // === Executing side ===
  90. const remotelyCallableFunctions = {}
  91. function incomingRPCListener(message, sender) {
  92. if (message && message[RPC_CALL] === RPC_CALL) {
  93. const funcName = message.funcName
  94. const args = message.hasOwnProperty('args') ? message.args : []
  95. const func = remotelyCallableFunctions[funcName]
  96. if (func === undefined) {
  97. console.error(`Received RPC for unknown function: ${funcName}`)
  98. return Promise.resolve({
  99. rpcError: `No such function registered for RPC: ${funcName}`,
  100. [RPC_RESPONSE]: RPC_RESPONSE,
  101. })
  102. }
  103. const extraArg = {
  104. tab: sender.tab,
  105. }
  106. // Run the function
  107. let returnValue
  108. try {
  109. returnValue = func(extraArg, ...args)
  110. } catch (error) {
  111. return Promise.resolve({
  112. errorMessage: error.message,
  113. [RPC_RESPONSE]: RPC_RESPONSE,
  114. })
  115. }
  116. // Return the function's return value. If it is a promise, first await its result.
  117. return Promise.resolve(returnValue).then(returnValue => ({
  118. returnValue,
  119. [RPC_RESPONSE]: RPC_RESPONSE,
  120. })).catch(error => ({
  121. errorMessage: error.message,
  122. [RPC_RESPONSE]: RPC_RESPONSE,
  123. }))
  124. }
  125. }
  126. // A bit of global state to ensure we only attach the event listener once.
  127. let enabled = false
  128. // Register a function to allow remote scripts to call it.
  129. // Arguments:
  130. // - functions (required):
  131. // An object with a {functionName: function} mapping.
  132. // Each function will be callable with the given name.
  133. // - options (optional): {
  134. // insertExtraArg:
  135. // If truthy, each executed function also receives, as its first
  136. // argument before the arguments it was invoked with, an object with
  137. // the details of the tab that sent the message.
  138. // }
  139. export function makeRemotelyCallable(functions, { insertExtraArg = false } = {}) {
  140. // Every function is passed an extra argument with sender information,
  141. // so remove this from the call if this was not desired.
  142. if (!insertExtraArg) {
  143. // Replace each func with...
  144. const wrapFunctions = mapValues(func =>
  145. // ...a function that calls func, but hides the inserted argument.
  146. (extraArg, ...args) => func(...args)
  147. )
  148. functions = wrapFunctions(functions)
  149. }
  150. // Add the functions to our global repetoir.
  151. Object.assign(remotelyCallableFunctions, functions)
  152. // Enable the listener if needed.
  153. if (!enabled) {
  154. browser.runtime.onMessage.addListener(incomingRPCListener)
  155. enabled = true
  156. }
  157. }