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

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