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

webextension-rpc.js 3.8 KiB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. // Our secret tokens to recognise our messages
  2. const RPC_CALL = '__RPC_CALL__'
  3. const RPC_RESPONSE = '__RPC_RESPONSE__'
  4. export class RpcError extends Error {
  5. constructor(message) {
  6. super(message)
  7. this.name = this.constructor.name
  8. }
  9. }
  10. export class RemoteError extends Error {
  11. constructor(message) {
  12. super(message)
  13. this.name = this.constructor.name
  14. }
  15. }
  16. // === Initiating side ===
  17. export function remoteFunction(funcName, { tabId } = {}) {
  18. const otherSide = (tabId !== undefined)
  19. ? "the tab's content script"
  20. : 'the background script'
  21. const f = async function (...args) {
  22. const message = {
  23. [RPC_CALL]: RPC_CALL,
  24. funcName,
  25. args,
  26. }
  27. // Try send the message and await the response.
  28. let response
  29. try {
  30. response = (tabId !== undefined)
  31. ? await browser.tabs.sendMessage(tabId, message)
  32. : await browser.runtime.sendMessage(message)
  33. } catch (err) {}
  34. // Check if we got an error or no response.
  35. if (response === undefined) {
  36. throw new RpcError(
  37. `Got no response when trying to call '${funcName}'. `
  38. + `Did you enable RPC in ${otherSide}?`
  39. )
  40. }
  41. // Check if it was *our* listener that responded.
  42. if (response === null || response[RPC_RESPONSE] !== RPC_RESPONSE) {
  43. throw new RpcError(
  44. `RPC got a response from an interfering listener while calling '${funcName}' in `
  45. + `${otherSide}`
  46. )
  47. }
  48. // If we could not invoke the function on the other side, throw an error.
  49. if (response.rpcError) {
  50. throw new RpcError(response.rpcError)
  51. }
  52. // Return the value or throw the error we received from the other side.
  53. if (response.errorMessage) {
  54. throw new RemoteError(response.errorMessage)
  55. } else {
  56. return response.returnValue
  57. }
  58. }
  59. // Give it a name, could be helpful in debugging
  60. Object.defineProperty(f, 'name', { value: `${funcName}_RPC` })
  61. return f
  62. }
  63. // === Executing side ===
  64. const remotelyCallableFunctions = {}
  65. function incomingRPCListener(message, sender) {
  66. if (message && message[RPC_CALL] === RPC_CALL) {
  67. const funcName = message.funcName
  68. const args = message.hasOwnProperty('args') ? message.args : []
  69. const func = remotelyCallableFunctions[funcName]
  70. if (func === undefined) {
  71. console.error(`Received RPC for unknown function: ${funcName}`)
  72. return Promise.resolve({
  73. rpcError: `No such function registered for RPC: ${funcName}`,
  74. [RPC_RESPONSE]: RPC_RESPONSE,
  75. })
  76. }
  77. const extraArg = {
  78. tab: sender.tab,
  79. }
  80. // Run the function
  81. let returnValue
  82. try {
  83. returnValue = func(extraArg, ...args)
  84. } catch (error) {
  85. return Promise.resolve({
  86. errorMessage: error.message,
  87. [RPC_RESPONSE]: RPC_RESPONSE,
  88. })
  89. }
  90. // Return the function's return value. If it is a promise, first await its result.
  91. return Promise.resolve(returnValue).then(returnValue => ({
  92. returnValue,
  93. [RPC_RESPONSE]: RPC_RESPONSE,
  94. })).catch(error => ({
  95. errorMessage: error.message,
  96. [RPC_RESPONSE]: RPC_RESPONSE,
  97. }))
  98. }
  99. }
  100. // A bit of global state to ensure we only attach the event listener once.
  101. let enabled = false
  102. export function makeRemotelyCallable(functions, { insertExtraArg = false } = {}) {
  103. // Every function is passed an extra argument with sender information,
  104. // so remove this from the call if this was not desired.
  105. if (!insertExtraArg) {
  106. // Replace each func with...
  107. const wrapFunctions = mapValues(func =>
  108. // ...a function that calls func, but hides the inserted argument.
  109. (extraArg, ...args) => func(...args)
  110. )
  111. functions = wrapFunctions(functions)
  112. }
  113. // Add the functions to our global repetoir.
  114. Object.assign(remotelyCallableFunctions, functions)
  115. // Enable the listener if needed.
  116. if (!enabled) {
  117. browser.runtime.onMessage.addListener(incomingRPCListener)
  118. enabled = true
  119. }
  120. }
  121. const mapValues = fn => object => {
  122. const result = {}
  123. for (const [key, value] of Object.entries(object)) {
  124. result[key] = fn(value)
  125. }
  126. return result
  127. }