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

webextension-rpc/src/ RpcClient.ts
139 lines
4.1 KiB

  1. import type { RpcServer } from "./RpcServer"
  2. import { isRpcResponseMessage, RPC_CALL } from "./common"
  3. import type { AsyncFunction, RpcCallMessage, RpcResponseMessage, ReplaceRpcInfo } from "./common"
  4. export type { AsyncFunction }
  5. /**
  6. * Error thrown when the remote function could not be found/executed.
  7. */
  8. export class RpcError extends Error {
  9. constructor(message: string) {
  10. super(message)
  11. this.name = this.constructor.name
  12. }
  13. }
  14. /**
  15. * Error thrown when the remote function threw an error.
  16. */
  17. export class RemoteError extends Error {
  18. constructor(message: string) {
  19. super(message)
  20. this.name = this.constructor.name
  21. }
  22. }
  23. /**
  24. * If the special symbol `injectRpcInfo` is passed as the first argument to a proxy function, this
  25. * argument will be replaced on the executing side by an `RpcInfo` object.
  26. */
  27. export const injectRpcInfo = Symbol('RpcInfo')
  28. export interface RpcOptions {
  29. /**
  30. * The id of the tab whose content script is the remote side. Leave undefined
  31. * to call the background script (from a content script).
  32. */
  33. tabId?: number,
  34. }
  35. /**
  36. * RpcFunction<F> equals the function F, except for two tweaks:
  37. * - In the parameters, any RpcInfo is swapped for rpcInfoSymbol.
  38. * - In the return type, nested promises become one promise.
  39. */
  40. export type RpcFunction<F extends AsyncFunction> = (
  41. ...args: ReplaceRpcInfo<Parameters<F>, typeof injectRpcInfo>
  42. ) => Promise<Awaited<ReturnType<F>>>
  43. type FunctionsOf<R extends RpcServer> = R['functions']
  44. /**
  45. * Define a ‘connection’ for remote procedure calls.
  46. * @param options.tabId - The id of the tab whose content script is the remote side. Leave undefined
  47. * to call functions of the background script (from a content script).
  48. */
  49. export class RpcClient<TheRpcServer extends RpcServer> {
  50. tabId: number | undefined
  51. constructor(options: RpcOptions = {}) {
  52. this.tabId = options.tabId;
  53. }
  54. /**
  55. * Create a proxy function that invokes the specified remote function.
  56. * @param funcName - Name of the function as registered on the remote side.
  57. * @param options.tabId - Overrides the `tabId` passed to `RpcClient`.
  58. * @returns The proxy function.
  59. */
  60. func<FunctionName extends string & keyof FunctionsOf<TheRpcServer>>(
  61. funcName: FunctionName,
  62. {
  63. tabId = this.tabId,
  64. }: RpcOptions = {},
  65. ): RpcFunction<FunctionsOf<TheRpcServer>[FunctionName]> {
  66. type RemoteFunction = FunctionsOf<TheRpcServer>[FunctionName]
  67. const otherSide = (tabId !== undefined)
  68. ? "the tab's content script"
  69. : 'the background script'
  70. const f: RpcFunction<RemoteFunction> = async function (...args): ReturnType<RpcFunction<RemoteFunction>> {
  71. const message: RpcCallMessage = {
  72. __WEBEXTENSION_RPC_MESSAGE__: RPC_CALL,
  73. funcName,
  74. args,
  75. addRpcInfoAsArgument: false,
  76. }
  77. if (args.includes(injectRpcInfo)) {
  78. const argIndex = args.indexOf(injectRpcInfo)
  79. message.addRpcInfoAsArgument = argIndex
  80. message.args[argIndex] = null
  81. }
  82. // Try send the message and await the response.
  83. let response: RpcResponseMessage<RemoteFunction>
  84. try {
  85. response = (tabId !== undefined && tabId !== null)
  86. ? await browser.tabs.sendMessage(tabId, message)
  87. : await browser.runtime.sendMessage(message)
  88. } catch (err) {}
  89. // Check if we got an error or no response.
  90. if (response === undefined) {
  91. throw new RpcError(
  92. `Got no response when trying to call '${funcName}'. `
  93. + `Did you enable RPC in ${otherSide}?`
  94. )
  95. }
  96. // Check if it was *our* listener that responded.
  97. if (!isRpcResponseMessage(response)) {
  98. throw new RpcError(
  99. `RPC got a response from an interfering listener while calling '${funcName}' in `
  100. + `${otherSide}`
  101. )
  102. }
  103. // If we could not invoke the function on the other side, throw an error.
  104. if ('rpcError' in response) {
  105. throw new RpcError(response.rpcError)
  106. }
  107. // Return the value or throw the error we received from the other side.
  108. if ('errorMessage' in response) {
  109. throw new RemoteError(response.errorMessage)
  110. } else {
  111. return response.returnValue
  112. }
  113. }
  114. // Give it a name, could be helpful in debugging
  115. Object.defineProperty(f, 'name', { value: `${funcName}_RPC` })
  116. return f
  117. }
  118. }