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

webextension-rpc/src/ index.ts
216 lines
6.1 KiB

  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. export const injectRpcInfo = Symbol('RpcInfo')
  17. export interface RpcInfo {
  18. tab: browser.tabs.Tab,
  19. }
  20. type AsyncFunction = Function & ((...args: any[]) => Promise<any>)
  21. interface RpcMessage {
  22. __WEBEXTENSION_RPC_MESSAGE__: typeof RPC_CALL | typeof RPC_RESPONSE,
  23. funcName: string,
  24. }
  25. interface RpcCallMessage<F extends AsyncFunction = AsyncFunction> extends RpcMessage {
  26. __WEBEXTENSION_RPC_MESSAGE__: typeof RPC_CALL,
  27. funcName: F['name'],
  28. args: ParametersWithRpcInfo<F>,
  29. addRpcInfoAsArgument: number | false,
  30. }
  31. type RpcResponseMessage<F extends AsyncFunction = AsyncFunction> =
  32. | RpcResponseResolve<F>
  33. | RpcResponseReject
  34. | RpcResponseRpcError
  35. interface RpcResponseMessage_base {
  36. __WEBEXTENSION_RPC_MESSAGE__: typeof RPC_RESPONSE,
  37. }
  38. interface RpcResponseResolve<F extends AsyncFunction> extends RpcResponseMessage_base {
  39. returnValue: ReturnType<F>,
  40. }
  41. interface RpcResponseReject extends RpcResponseMessage_base {
  42. errorMessage: string,
  43. }
  44. interface RpcResponseRpcError extends RpcResponseMessage_base {
  45. rpcError: string,
  46. }
  47. function isRpcCallMessage(message: any): message is RpcCallMessage {
  48. return !!(message && message['__WEBEXTENSION_RPC_MESSAGE__'] === RPC_CALL)
  49. }
  50. function isRpcResponseMessage(message: any): message is RpcResponseMessage {
  51. return !!(message && message['__WEBEXTENSION_RPC_MESSAGE__'] === RPC_RESPONSE)
  52. }
  53. type Tail<T extends any[]> = T extends [infer _Head, ...infer Tail] ? Tail : [];
  54. type ParametersWithRpcInfo<F extends AsyncFunction> = Parameters<F> extends [RpcInfo, ...any]
  55. ? [typeof injectRpcInfo, ...Tail<Parameters<F>>]
  56. : Parameters<F>
  57. // I thought the type below should allow putting the RpcInfo argument at any position, but it does not work.
  58. // type ParametersWithRpcInfo<F extends AsyncFunction> = Array<any> & {
  59. // [N in keyof Parameters<F>]: Parameters<F>[N] extends RpcInfo
  60. // ? typeof injectRpcInfo
  61. // : Parameters<F>[N]
  62. // }
  63. /**
  64. * RpcFunction<F> equals the function F, except for two tweaks:
  65. * - In the parameters, any RpcInfo is swapped for rpcInfoSymbol.
  66. * - In the return type, nested promises become one promise.
  67. */
  68. export type RpcFunction<F extends AsyncFunction> = (...args: ParametersWithRpcInfo<F>) => Promise<Awaited<ReturnType<F>>>
  69. // === Initiating side ===
  70. /**
  71. * Create a proxy function that invokes the specified remote function.
  72. * @param funcName - Name of the function as registered on the remote side.
  73. * @param options.tabId - The id of the tab whose content script is the remote side. Leave undefined
  74. to call the background script (from a content script).
  75. * @returns The proxy function.
  76. */
  77. export function remoteFunction<F extends AsyncFunction>(
  78. funcName: string,
  79. { tabId }: { tabId?: number } = {},
  80. ): RpcFunction<F> {
  81. const otherSide = (tabId !== undefined)
  82. ? "the tab's content script"
  83. : 'the background script'
  84. const f: RpcFunction<F> = async function (...args): Promise<Awaited<ReturnType<F>>> {
  85. const message: RpcCallMessage = {
  86. __WEBEXTENSION_RPC_MESSAGE__: RPC_CALL,
  87. funcName,
  88. args,
  89. addRpcInfoAsArgument: false,
  90. }
  91. if (args.includes(injectRpcInfo)) {
  92. const argIndex = args.indexOf(injectRpcInfo)
  93. message.addRpcInfoAsArgument = argIndex
  94. message.args[argIndex] = null
  95. }
  96. // Try send the message and await the response.
  97. let response: RpcResponseMessage<F>
  98. try {
  99. response = (tabId !== undefined)
  100. ? await browser.tabs.sendMessage(tabId, message)
  101. : await browser.runtime.sendMessage(message)
  102. } catch (err) {}
  103. // Check if we got an error or no response.
  104. if (response === undefined) {
  105. throw new RpcError(
  106. `Got no response when trying to call '${funcName}'. `
  107. + `Did you enable RPC in ${otherSide}?`
  108. )
  109. }
  110. // Check if it was *our* listener that responded.
  111. if (!isRpcResponseMessage(response)) {
  112. throw new RpcError(
  113. `RPC got a response from an interfering listener while calling '${funcName}' in `
  114. + `${otherSide}`
  115. )
  116. }
  117. // If we could not invoke the function on the other side, throw an error.
  118. if ('rpcError' in response) {
  119. throw new RpcError(response.rpcError)
  120. }
  121. // Return the value or throw the error we received from the other side.
  122. if ('errorMessage' in response) {
  123. throw new RemoteError(response.errorMessage)
  124. } else {
  125. return response.returnValue
  126. }
  127. }
  128. // Give it a name, could be helpful in debugging
  129. Object.defineProperty(f, 'name', { value: `${funcName}_RPC` })
  130. return f
  131. }
  132. // === Executing side ===
  133. /**
  134. * Register one or more functions to enable remote scripts to call them.
  135. *
  136. * @param functions - A `{ functionName: function }` mapping. Each function will be remotely
  137. * callable using the given name.
  138. * @returns The passed `functions` object.
  139. */
  140. export function makeRemotelyCallable<Fs extends Record<string, AsyncFunction>>(
  141. functions: Fs,
  142. ): typeof functions {
  143. browser.runtime.onMessage.addListener(incomingRPCListener)
  144. return functions
  145. async function incomingRPCListener(message: any, sender: browser.runtime.MessageSender): Promise<RpcResponseMessage> {
  146. if (!isRpcCallMessage(message)) return
  147. const funcName = message.funcName
  148. const func = functions[funcName]
  149. if (func === undefined) {
  150. console.error(`Received RPC for unknown function: ${funcName}`)
  151. return {
  152. rpcError: `No such function registered for RPC: ${funcName}`,
  153. __WEBEXTENSION_RPC_MESSAGE__: RPC_RESPONSE,
  154. }
  155. }
  156. const args = message.args
  157. if (message.addRpcInfoAsArgument !== false) {
  158. const rpcInfo: RpcInfo = {
  159. tab: sender.tab,
  160. }
  161. args[message.addRpcInfoAsArgument] = rpcInfo
  162. }
  163. // Run the function, return the result.
  164. try {
  165. const returnValue = await func(...args)
  166. return {
  167. returnValue,
  168. __WEBEXTENSION_RPC_MESSAGE__: RPC_RESPONSE,
  169. }
  170. } catch (error) {
  171. return {
  172. errorMessage: error.message,
  173. __WEBEXTENSION_RPC_MESSAGE__: RPC_RESPONSE,
  174. }
  175. }
  176. }
  177. }