├── demo ├── .gitignore ├── tailwind.css ├── utils.ts ├── iframe.html ├── iframe.ts ├── parent.ts └── index.html ├── prettier.config.cjs ├── src ├── tests │ ├── transport-bridge.test.ts │ ├── utils.ts │ ├── jsdocs-test.ts │ ├── rpc.test.ts │ └── types-test.ts ├── create-request-handler.ts ├── index.ts ├── transports │ ├── broadcast-channel.ts │ ├── worker.ts │ ├── browser-runtime-port.ts │ ├── message-port.ts │ └── iframe.ts ├── transport-bridge.ts ├── create-rpc.ts ├── transport-utils.ts ├── rpc.ts └── types.ts ├── logo.png ├── bun.lockb ├── .gitattributes ├── .vscode └── settings.json ├── og-image.png ├── tailwind.config.js ├── .gitignore ├── .eslintignore ├── .changeset ├── config.json └── README.md ├── .github ├── workflows │ ├── test-check.yml │ ├── lint-check.yml │ ├── type-check.yml │ ├── format-check.yml │ └── publish.yml └── ISSUE_TEMPLATE │ ├── feature-request.yaml │ └── bug_report.yaml ├── docs ├── README.md ├── 3-bridging-transports.md ├── 4-creating-a-custom-transport.md ├── 2-built-in-transports.md └── 1-rpc.md ├── tsconfig.json ├── LICENSE ├── .eslintrc.cjs ├── package.json ├── CHANGELOG.md └── README.md /demo/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | style.css 3 | -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /src/tests/transport-bridge.test.ts: -------------------------------------------------------------------------------- 1 | // TODO: transport bridge tests. 2 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DaniGuardiola/rpc-anywhere/HEAD/logo.png -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DaniGuardiola/rpc-anywhere/HEAD/bun.lockb -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DaniGuardiola/rpc-anywhere/HEAD/og-image.png -------------------------------------------------------------------------------- /demo/tailwind.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: "Inter", sans-serif; 3 | } 4 | 5 | @tailwind base; 6 | @tailwind components; 7 | @tailwind utilities; 8 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ["./demo/*.html"], 4 | safelist: ["bg-red-500", "bg-blue-500", "bg-green-500", "bg-purple-500"], 5 | }; 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | \*.tsbuildinfo 3 | .eslintcache 4 | 5 | .env 6 | .env.development.local 7 | .env.test.local 8 | .env.production.local 9 | .env.local 10 | 11 | dist/ 12 | .tshy/ 13 | .tshy-build/ 14 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | \*.tsbuildinfo 3 | .eslintcache 4 | 5 | .env 6 | .env.development.local 7 | .env.test.local 8 | .env.production.local 9 | .env.local 10 | 11 | dist/ 12 | .tshy/ 13 | .tshy-build-tmp/ 14 | -------------------------------------------------------------------------------- /demo/utils.ts: -------------------------------------------------------------------------------- 1 | export function el(id: string) { 2 | const element = document.getElementById(id); 3 | if (!element) throw new Error(`Element with id ${id} not found`); 4 | return element as Element; 5 | } 6 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /src/create-request-handler.ts: -------------------------------------------------------------------------------- 1 | import { type RPCRequestHandlerObject } from "./types.js"; 2 | 3 | /** 4 | * Creates a typed RPC request handler in "object" form. 5 | */ 6 | export function createRPCRequestHandler< 7 | const Handler extends RPCRequestHandlerObject, 8 | >( 9 | /** 10 | * The RPC request handler object. 11 | */ 12 | handler: Handler, 13 | ) { 14 | return handler; 15 | } 16 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./create-request-handler.js"; 2 | export * from "./create-rpc.js"; 3 | export * from "./transport-bridge.js"; 4 | export * from "./transport-utils.js"; 5 | export * from "./transports/broadcast-channel.js"; 6 | export * from "./transports/browser-runtime-port.js"; 7 | export * from "./transports/iframe.js"; 8 | export * from "./transports/message-port.js"; 9 | export * from "./transports/worker.js"; 10 | export * from "./types.js"; 11 | -------------------------------------------------------------------------------- /.github/workflows/test-check.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | pull_request: 4 | jobs: 5 | test: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v3 9 | - uses: oven-sh/setup-bun@v1 10 | with: 11 | bun-version: latest 12 | - uses: actions/setup-node@v3 13 | with: 14 | node-version: 16.x 15 | - run: bun install --frozen-lockfile --ignore-scripts 16 | - name: Test 17 | run: bun run test 18 | -------------------------------------------------------------------------------- /.github/workflows/lint-check.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: 3 | pull_request: 4 | jobs: 5 | lint: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v3 9 | - uses: oven-sh/setup-bun@v1 10 | with: 11 | bun-version: latest 12 | - uses: actions/setup-node@v3 13 | with: 14 | node-version: 16.x 15 | - run: bun install --frozen-lockfile --ignore-scripts 16 | - name: Lint 17 | run: bunx eslint . 18 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Documentation index 2 | 3 | This directory contains the documentation for `rpc-anywhere`. 4 | 5 | - [RPC](./1-rpc.md) 6 | - [Built-in transports](./2-built-in-transports.md) 7 | - [Bridging transports](./3-bridging-transports.md) 8 | - [Creating a custom transport](./4-creating-a-custom-transport.md) 9 | 10 | The API reference is available at [tsdocs.dev](https://tsdocs.dev/docs/rpc-anywhere/). 11 | 12 |
13 | 14 | [**Next: RPC**](./1-rpc.md) 15 | 16 |
17 | -------------------------------------------------------------------------------- /.github/workflows/type-check.yml: -------------------------------------------------------------------------------- 1 | name: Check types 2 | on: 3 | pull_request: 4 | jobs: 5 | check-types: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v3 9 | - uses: oven-sh/setup-bun@v1 10 | with: 11 | bun-version: latest 12 | - uses: actions/setup-node@v3 13 | with: 14 | node-version: 16.x 15 | - run: bun install --frozen-lockfile --ignore-scripts 16 | - name: Check types 17 | run: bunx tsc --noEmit 18 | -------------------------------------------------------------------------------- /.github/workflows/format-check.yml: -------------------------------------------------------------------------------- 1 | name: Check format 2 | on: 3 | pull_request: 4 | jobs: 5 | check-format: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v3 9 | - uses: oven-sh/setup-bun@v1 10 | with: 11 | bun-version: latest 12 | - uses: actions/setup-node@v3 13 | with: 14 | node-version: 16.x 15 | - run: bun install --frozen-lockfile --ignore-scripts 16 | - name: Check format 17 | run: bunx prettier . --check 18 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ESNext"], 4 | "module": "NodeNext", 5 | "target": "esnext", 6 | "moduleResolution": "NodeNext", 7 | "moduleDetection": "force", 8 | "strict": true, 9 | "downlevelIteration": true, 10 | "skipLibCheck": true, 11 | "declaration": true, 12 | "jsx": "react-jsx", 13 | "allowSyntheticDefaultImports": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "allowJs": true, 16 | "types": [ 17 | "bun-types" // add Bun global 18 | ] 19 | }, 20 | "include": ["src/**/*", "demo/**/*"] 21 | } 22 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | push: 4 | branches: 5 | - "main" 6 | 7 | concurrency: ${{ github.workflow }}-${{ github.ref }} 8 | 9 | jobs: 10 | publish: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: oven-sh/setup-bun@v1 15 | with: 16 | bun-version: latest 17 | - uses: actions/setup-node@v3 18 | with: 19 | node-version: 16.x 20 | - run: bun install --frozen-lockfile --ignore-scripts 21 | - name: Create release pull request or publish 22 | id: changesets 23 | uses: changesets/action@v1 24 | with: 25 | publish: bun run publish 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 29 | -------------------------------------------------------------------------------- /src/transports/broadcast-channel.ts: -------------------------------------------------------------------------------- 1 | import { type RPCTransport } from "../types.js"; 2 | import { 3 | createTransportFromMessagePort, 4 | type RPCMessagePortTransportOptions, 5 | } from "./message-port.js"; 6 | 7 | /** 8 | * Options for the broadcast channel transport. 9 | */ 10 | export type RPCBroadcastChannelTransportOptions = Omit< 11 | RPCMessagePortTransportOptions, 12 | "remotePort" 13 | >; 14 | 15 | /** 16 | * Creates a transport from a 17 | * [`BroadcastChannel`](https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel). 18 | */ 19 | export function createTransportFromBroadcastChannel( 20 | /** 21 | * The broadcast channel instance to create a transport from. 22 | */ 23 | channel: BroadcastChannel, 24 | /** 25 | * Options for the broadcast channel transport. 26 | */ 27 | options?: RPCBroadcastChannelTransportOptions, 28 | ): RPCTransport { 29 | return createTransportFromMessagePort(channel, options); 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Dani Guardiola 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.Config} */ 2 | module.exports = { 3 | env: { 4 | es2021: true, 5 | node: true, 6 | }, 7 | extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"], 8 | overrides: [ 9 | { 10 | env: { 11 | node: true, 12 | }, 13 | files: [".eslintrc.{js,cjs}"], 14 | parserOptions: { 15 | sourceType: "script", 16 | }, 17 | }, 18 | ], 19 | parser: "@typescript-eslint/parser", 20 | parserOptions: { ecmaVersion: "latest", sourceType: "module" }, 21 | plugins: ["@typescript-eslint", "simple-import-sort", "import"], 22 | rules: { 23 | "simple-import-sort/imports": "error", 24 | "simple-import-sort/exports": "error", 25 | "import/first": "error", 26 | "import/newline-after-import": "error", 27 | "import/no-duplicates": ["error", { "prefer-inline": true }], 28 | "no-duplicate-imports": "error", 29 | "@typescript-eslint/consistent-type-imports": [ 30 | "error", 31 | { fixStyle: "inline-type-imports" }, 32 | ], 33 | // YOLO 34 | "@typescript-eslint/no-explicit-any": "off", 35 | }, 36 | }; 37 | -------------------------------------------------------------------------------- /src/transport-bridge.ts: -------------------------------------------------------------------------------- 1 | import { type RPCTransport } from "./types.js"; 2 | 3 | /** 4 | * A transport bridge is a pair of transports that are connected to each other. 5 | * Messages sent to one transport will be forwarded to the other, and vice versa. 6 | */ 7 | export type RPCTransportBridge = { 8 | start(): void; 9 | stop(): void; 10 | }; 11 | 12 | function unidirectionalBridge( 13 | transportIn: RPCTransport, 14 | transportOut: RPCTransport, 15 | ) { 16 | if (!transportIn.registerHandler || !transportOut.send) return; 17 | const handler = (message: any) => transportOut.send?.(message); 18 | transportIn.registerHandler(handler); 19 | return function cleanUp() { 20 | transportIn.unregisterHandler?.(); 21 | }; 22 | } 23 | 24 | /** 25 | * Creates a transport bridge between two transports. 26 | */ 27 | export function createTransportBridge( 28 | transportA: RPCTransport, 29 | transportB: RPCTransport, 30 | ): RPCTransportBridge { 31 | let cleanUpAToB: (() => void) | undefined; 32 | let cleanUpBToA: (() => void) | undefined; 33 | 34 | return { 35 | start() { 36 | cleanUpAToB = unidirectionalBridge(transportA, transportB); 37 | cleanUpBToA = unidirectionalBridge(transportB, transportA); 38 | }, 39 | stop() { 40 | cleanUpAToB?.(); 41 | cleanUpAToB = undefined; 42 | cleanUpBToA?.(); 43 | cleanUpBToA = undefined; 44 | }, 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /src/create-rpc.ts: -------------------------------------------------------------------------------- 1 | import { _createRPC } from "./rpc.js"; 2 | import { 3 | type EmptyRPCSchema, 4 | type RPC, 5 | type RPCOptions, 6 | type RPCSchema, 7 | } from "./types.js"; 8 | 9 | /** 10 | * Creates an RPC instance that can send and receive requests, responses 11 | * and messages. 12 | */ 13 | export function createRPC< 14 | Schema extends RPCSchema = RPCSchema, 15 | RemoteSchema extends RPCSchema = Schema, 16 | >( 17 | /** 18 | * The options that will be used to configure the RPC instance. 19 | */ 20 | options?: RPCOptions, 21 | ): RPC { 22 | return _createRPC(options); 23 | } 24 | 25 | /** 26 | * Creates an RPC instance as a client. The passed schema represents 27 | * the remote RPC's (server) schema. 28 | */ 29 | export function createClientRPC( 30 | /** 31 | * The options that will be used to configure the RPC instance. 32 | */ 33 | options: RPCOptions, 34 | ): RPC { 35 | return _createRPC(options); 36 | } 37 | 38 | /** 39 | * Creates an RPC instance as a server. The passed schema represents 40 | * this RPC's (server) schema. 41 | */ 42 | export function createServerRPC( 43 | /** 44 | * The options that will be used to configure the RPC instance. 45 | */ 46 | options: RPCOptions, 47 | ) { 48 | return _createRPC(options); 49 | } 50 | -------------------------------------------------------------------------------- /src/transport-utils.ts: -------------------------------------------------------------------------------- 1 | const transportIdKey = "[transport-id]"; 2 | 3 | /** 4 | * Common options for a transport. 5 | */ 6 | export type RPCTransportOptions = { 7 | /** 8 | * An optional unique ID to use for the transport. Useful in cases where 9 | * messages are sent to or received from multiple sources, which causes 10 | * issues. 11 | */ 12 | transportId?: string | number; 13 | 14 | /** 15 | * A filter function that determines if a message should be processed or 16 | * ignored. Like the `transportId` option, but more flexible to allow for 17 | * more complex use-cases. 18 | */ 19 | filter?: () => boolean | undefined; 20 | }; 21 | 22 | /** 23 | * Wraps a message in a transport object, if a transport ID is provided. 24 | */ 25 | export function rpcTransportMessageOut( 26 | data: any, 27 | options: Pick, 28 | ) { 29 | const { transportId } = options; 30 | if (transportId != null) return { [transportIdKey]: transportId, data }; 31 | return data; 32 | } 33 | 34 | /** 35 | * Determines if a message should be ignored, and if not, returns the message 36 | * too. If the message was wrapped in a transport object, it is unwrapped. 37 | */ 38 | export function rpcTransportMessageIn( 39 | message: any, 40 | options: RPCTransportOptions, 41 | ): [ignore: false, message: any] | [ignore: true] { 42 | const { transportId, filter } = options; 43 | const filterResult = filter?.(); 44 | if (transportId != null && filterResult != null) 45 | throw new Error( 46 | "Cannot use both `transportId` and `filter` at the same time", 47 | ); 48 | 49 | let data = message; 50 | if (transportId) { 51 | if (message[transportIdKey] !== transportId) return [true]; 52 | data = message.data; 53 | } 54 | if (filterResult === false) return [true]; 55 | return [false, data]; 56 | } 57 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yaml: -------------------------------------------------------------------------------- 1 | name: 💡 Request a feature 2 | description: Suggest a new feature or enhancement 3 | labels: ["enhancement"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thank you for submitting a feature request! 9 | 10 | Please be patient, this is a volunteer-driven project. The best way to ensure your feature request is addressed is: 11 | 12 | 1. Provide detailed information about the use case for the feature. 13 | 2. Be kind and patient. We all have lives outside of this project. 14 | 3. Consider contributing the feature yourself! We will do our best to help you. 15 | 16 | Please note that feature requests are not guaranteed to be implemented. The project's maintainers will decide whether or not to implement a feature request based on its usefulness to the project and its alignment with the project's goals. 17 | 18 | Regardless of the outcome, we appreciate your interest in the project! 19 | - type: textarea 20 | id: description 21 | attributes: 22 | label: Description 23 | description: Describe the feature you'd like to see added to the project. 24 | validations: 25 | required: true 26 | - type: textarea 27 | id: workaround 28 | attributes: 29 | label: Workaround / userland implementation 30 | description: If you have a workaround or userland implementation, please share it here. 31 | - type: textarea 32 | id: other-remarks 33 | attributes: 34 | label: Other remarks 35 | description: Any other information you'd like to share that is relevant to the feature request being submitted. 36 | - type: checkboxes 37 | id: contribute 38 | attributes: 39 | label: Contribution 40 | description: Let us know if you'd like to contribute to the project. If so, we will be happy to help you and your feature request will likely be prioritized. 41 | options: 42 | - label: I would like to contribute 43 | -------------------------------------------------------------------------------- /src/transports/worker.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { 4 | createTransportFromMessagePort, 5 | type RPCMessagePortTransportOptions, 6 | } from "./message-port.js"; 7 | 8 | /** 9 | * Options for the worker transport. 10 | */ 11 | export type RPCWorkerTransportOptions = Omit< 12 | RPCMessagePortTransportOptions, 13 | "remotePort" 14 | >; 15 | 16 | /** 17 | * Creates a transport to communicate with a 18 | * [`Worker`](https://developer.mozilla.org/en-US/docs/Web/API/Worker) 19 | * from the parent context. 20 | * 21 | * The target worker must must use `createWorkerParentTransport` with a matching 22 | * `transportId` or `filter` option (if set). 23 | * 24 | * @example 25 | * 26 | * ```ts 27 | * const rpc = createRPC({ 28 | * transport: createWorkerTransport(worker), 29 | * // ... 30 | * }); 31 | */ 32 | export function createWorkerTransport( 33 | /** 34 | * The worker instance to create a transport from. 35 | */ 36 | worker: Worker, 37 | 38 | /** 39 | * Options for the worker transport. 40 | */ 41 | options?: RPCWorkerTransportOptions, 42 | ) { 43 | return createTransportFromMessagePort(worker, options); 44 | } 45 | 46 | /** 47 | * Options for the worker parent transport. 48 | */ 49 | export type RPCWorkerParentTransportOptions = Omit< 50 | RPCMessagePortTransportOptions, 51 | "remotePort" 52 | >; 53 | 54 | /** 55 | * Creates a transport to communicate with the parent context from a 56 | * [`Worker`](https://developer.mozilla.org/en-US/docs/Web/API/Worker). 57 | * 58 | * The parent context must use `createWorkerTransport` with a matching 59 | * `transportId` or `filter` option (if set). 60 | * 61 | * @example 62 | * 63 | * ```ts 64 | * const rpc = createRPC({ 65 | * transport: createWorkerParentTransport(), 66 | * // ... 67 | * }); 68 | */ 69 | export function createWorkerParentTransport( 70 | /** 71 | * Options for the worker parent transport. 72 | */ 73 | options?: RPCWorkerParentTransportOptions, 74 | ) { 75 | return createTransportFromMessagePort(self, options); 76 | } 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rpc-anywhere", 3 | "description": "Create a type-safe RPC anywhere.", 4 | "version": "1.7.0", 5 | "author": { 6 | "email": "hi@daniguardio.la", 7 | "name": "Dani Guardiola", 8 | "url": "https://dio.la/" 9 | }, 10 | "license": "MIT", 11 | "type": "module", 12 | "files": [ 13 | "dist" 14 | ], 15 | "scripts": { 16 | "prepare": "tshy", 17 | "publish": "changeset publish", 18 | "test": "bun test src/tests", 19 | "build-demo:js": "bun build demo/*.ts --outdir ./demo", 20 | "build-demo:css": "tailwindcss -i demo/tailwind.css -o demo/style.css", 21 | "build-demo": "bun --bun conc bun:build-demo:*", 22 | "demo:watch:js": "bun build-demo:js --watch", 23 | "demo:watch:css": "bun build-demo:css --watch --watch", 24 | "demo:serve": "bun --bun servor demo --reload", 25 | "demo": "bun --bun conc -k bun:demo:*" 26 | }, 27 | "dependencies": { 28 | "browser-namespace": "^1.4.0" 29 | }, 30 | "devDependencies": { 31 | "@changesets/cli": "^2.27.1", 32 | "@typescript-eslint/eslint-plugin": "^7.1.0", 33 | "@typescript-eslint/parser": "^7.1.0", 34 | "bun-types": "latest", 35 | "concurrently": "^8.2.2", 36 | "eslint": "^8.57.0", 37 | "eslint-plugin-import": "^2.29.1", 38 | "eslint-plugin-simple-import-sort": "^12.0.0", 39 | "prettier": "^3.2.5", 40 | "servor": "^4.0.2", 41 | "tailwindcss": "^3.4.1", 42 | "tshy": "^1.11.1", 43 | "typescript": "^5.3.3" 44 | }, 45 | "tshy": { 46 | "exports": { 47 | "./package.json": "./package.json", 48 | ".": "./src/index.ts" 49 | }, 50 | "exclude": [ 51 | "./src/tests/**/*" 52 | ] 53 | }, 54 | "exports": { 55 | "./package.json": "./package.json", 56 | ".": { 57 | "import": { 58 | "types": "./dist/esm/index.d.ts", 59 | "default": "./dist/esm/index.js" 60 | }, 61 | "require": { 62 | "types": "./dist/commonjs/index.d.ts", 63 | "default": "./dist/commonjs/index.js" 64 | } 65 | } 66 | }, 67 | "main": "./dist/commonjs/index.js", 68 | "types": "./dist/commonjs/index.d.ts" 69 | } 70 | -------------------------------------------------------------------------------- /src/tests/utils.ts: -------------------------------------------------------------------------------- 1 | import { createRPCRequestHandler } from "../create-request-handler.js"; 2 | import { createRPC } from "../create-rpc.js"; 3 | import { type RPCSchema } from "../types.js"; 4 | 5 | export const DEFAULT_MAX_TIME = 1000; 6 | export const TIMEOUT_ACCEPTABLE_MARGIN = 100; 7 | 8 | export async function delay(ms: number) { 9 | await new Promise((resolve) => setTimeout(resolve, ms)); 10 | } 11 | 12 | const requestHandler1 = createRPCRequestHandler({ 13 | method1: ({ a }: { a: number }) => a, 14 | }); 15 | 16 | export type Schema1 = RPCSchema< 17 | { 18 | messages: { 19 | message1: "first"; 20 | }; 21 | }, 22 | typeof requestHandler1 23 | >; 24 | 25 | const requestHandler2 = createRPCRequestHandler({ 26 | method2: ({ b }: { b: string }) => b, 27 | async timesOut() { 28 | // shorter than what IE6 takes to load a page (remember when these jokes were funny?) 29 | await delay(DEFAULT_MAX_TIME * 999); 30 | }, 31 | }); 32 | 33 | export type Schema2 = RPCSchema< 34 | { 35 | messages: { 36 | message2: "second"; 37 | message3: "third"; 38 | ignored: "forever-alone"; 39 | }; 40 | }, 41 | typeof requestHandler2 42 | >; 43 | 44 | function createMockEndpoint() { 45 | return { 46 | listener: undefined as ((message: any) => void) | undefined, 47 | postMessage(message: any) { 48 | this.listener?.(message); 49 | }, 50 | onMessage(listener: (message: any) => void) { 51 | this.listener = listener; 52 | }, 53 | }; 54 | } 55 | 56 | export function createTestRPCs() { 57 | const rpc1 = createRPC({ 58 | requestHandler: requestHandler1, 59 | }); 60 | const rpc2 = createRPC({ 61 | requestHandler: requestHandler2, 62 | }); 63 | const mockEndpoint1 = createMockEndpoint(); 64 | const mockEndpoint2 = createMockEndpoint(); 65 | rpc1.setTransport({ 66 | send: mockEndpoint2.postMessage.bind(mockEndpoint2), 67 | registerHandler: mockEndpoint1.onMessage.bind(mockEndpoint1), 68 | }); 69 | rpc2.setTransport({ 70 | send: mockEndpoint1.postMessage.bind(mockEndpoint1), 71 | registerHandler: mockEndpoint2.onMessage.bind(mockEndpoint2), 72 | }); 73 | return { rpc1, rpc2 }; 74 | } 75 | -------------------------------------------------------------------------------- /src/transports/browser-runtime-port.ts: -------------------------------------------------------------------------------- 1 | import { type Browser, type Chrome } from "browser-namespace"; 2 | 3 | import { 4 | rpcTransportMessageIn, 5 | rpcTransportMessageOut, 6 | type RPCTransportOptions, 7 | } from "../transport-utils.js"; 8 | import { type RPCTransport } from "../types.js"; 9 | 10 | type Port = Browser.Runtime.Port | Chrome.runtime.Port; 11 | 12 | /** 13 | * Options for the browser runtime port transport. 14 | */ 15 | export type RPCBrowserRuntimePortTransportOptions = Pick< 16 | RPCTransportOptions, 17 | "transportId" 18 | > & { 19 | /** 20 | * A filter function that determines if a message should be processed or 21 | * ignored. Like the `transportId` option, but more flexible to allow for 22 | * more complex use-cases. 23 | * 24 | * It receives the message and the 25 | * [`runtime.Port`](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/Port) 26 | * as arguments. For example, messages can be filtered 27 | * based on `port.name` or `port.sender`. 28 | */ 29 | filter?: (message: any, port: Browser.Runtime.Port) => boolean; 30 | }; 31 | 32 | /** 33 | * Creates a transport from a browser runtime port. Useful for RPCs 34 | * in browser extensions, like between content scripts and service workers 35 | * (background scripts). [Learn more on MDN.](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/Port) 36 | */ 37 | export function createTransportFromBrowserRuntimePort( 38 | /** 39 | * The browser runtime port. 40 | */ 41 | port: Port, 42 | /** 43 | * Options for the browser runtime port transport. 44 | */ 45 | options: RPCBrowserRuntimePortTransportOptions = {}, 46 | ): RPCTransport { 47 | const { transportId, filter } = options; 48 | let transportHandler: ((message: any, port: Port) => void) | undefined; 49 | return { 50 | send(data) { 51 | port.postMessage(rpcTransportMessageOut(data, { transportId })); 52 | }, 53 | registerHandler(handler) { 54 | transportHandler = (message, port) => { 55 | const [ignore, data] = rpcTransportMessageIn(message, { 56 | transportId, 57 | filter: () => filter?.(message, port as Browser.Runtime.Port), 58 | }); 59 | if (ignore) return; 60 | handler(data); 61 | }; 62 | port.onMessage.addListener(transportHandler); 63 | }, 64 | unregisterHandler() { 65 | if (transportHandler) port.onMessage.removeListener(transportHandler); 66 | }, 67 | }; 68 | } 69 | 70 | // TODO: browser runtime port transport tests. 71 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yaml: -------------------------------------------------------------------------------- 1 | name: 🐛 Report a bug 2 | description: File a bug report 3 | labels: ["bug"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thank you for submitting a bug report! 9 | 10 | Please be patient, this is a volunteer-driven project. The best way to ensure your bug report is addressed is: 11 | 12 | 1. Provide as much information as possible. 13 | 2. Share a minimal reproduction. Bonus points for linking to a [CodeSandbox](https://codesandbox.io) or similar! 14 | 3. Be kind and patient. We all have lives outside of this project. 15 | 4. Consider contributing a fix! We will do our best to help you. 16 | - type: input 17 | attributes: 18 | label: In which versions of the package (and relevant enviroment tools such as Node.js) have you observed the bug? 19 | validations: 20 | required: true 21 | - type: textarea 22 | id: description 23 | attributes: 24 | label: What steps will reproduce the bug? 25 | description: Explain the bug and provide a minimal code example that reproduces the problem (or a link to a repo/sandbox). 26 | validations: 27 | required: true 28 | - type: textarea 29 | id: expected-behavior 30 | attributes: 31 | label: What behavior did you expect? 32 | description: A clear and concise description of what you expected to happen. 33 | validations: 34 | required: true 35 | - type: textarea 36 | id: actual-behavior 37 | attributes: 38 | label: What actually happened? 39 | description: A clear and concise description of what actually happened. 40 | validations: 41 | required: true 42 | - type: textarea 43 | id: workaround 44 | attributes: 45 | label: Workaround 46 | description: If you have a workaround, please share it here. 47 | - type: textarea 48 | id: proposed-fix 49 | attributes: 50 | label: Proposed fix 51 | description: If you have a proposed solution, please share it here. 52 | - type: textarea 53 | id: other-remarks 54 | attributes: 55 | label: Other remarks 56 | description: Any other information you'd like to share that is relevant to the issue being reported. 57 | - type: checkboxes 58 | id: contribute 59 | attributes: 60 | label: Contribution 61 | description: Let us know if you'd like to contribute to the project. If so, we will be happy to help you and your reported issue will likely be prioritized. 62 | options: 63 | - label: I would like to try to contribute a fix 64 | -------------------------------------------------------------------------------- /src/transports/message-port.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { 4 | rpcTransportMessageIn, 5 | rpcTransportMessageOut, 6 | type RPCTransportOptions, 7 | } from "../transport-utils.js"; 8 | import { type RPCTransport } from "../types.js"; 9 | 10 | /** 11 | * Options for the message port transport. 12 | */ 13 | export type RPCMessagePortTransportOptions = Pick< 14 | RPCTransportOptions, 15 | "transportId" 16 | > & { 17 | /** 18 | * A filter function that determines if a message should be processed or 19 | * ignored. Like the `transportId` option, but more flexible to allow for 20 | * more complex use-cases. 21 | * 22 | * It receives the 23 | * [`MessageEvent`](https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent) 24 | * object as its only argument. For example, messages can be filtered 25 | * based on `event.origin` or `event.source`. 26 | */ 27 | filter?: (event: MessageEvent) => boolean; 28 | 29 | /** 30 | * The remote port to send messages to through `postMessage(message)`. 31 | */ 32 | remotePort?: 33 | | MessagePort 34 | | Window 35 | | Worker 36 | | ServiceWorker 37 | | Client 38 | | BroadcastChannel; 39 | }; 40 | 41 | /** 42 | * Creates a transport from objects that support `postMessage(message)` 43 | * and `addEventListener("message", listener)`. This includes `Window`, 44 | * `Worker`, `MessagePort`, and `BroadcastChannel`. 45 | * 46 | * This is useful for RPCs between, among other things, iframes or workers. 47 | */ 48 | export function createTransportFromMessagePort( 49 | /** 50 | * The local port that will receive and handled "message" events 51 | * through `addEventListener("message", listener)`. If the `remotePort` 52 | * option is omitted, it will also be used to send messages through 53 | * `postMessage(message)`. 54 | */ 55 | port: 56 | | MessagePort 57 | | Window 58 | | Worker 59 | | ServiceWorkerContainer 60 | | BroadcastChannel, 61 | 62 | /** 63 | * Options for the message port transport. 64 | */ 65 | options: RPCMessagePortTransportOptions = {}, 66 | ): RPCTransport { 67 | const { transportId, filter, remotePort } = options; 68 | 69 | // little white TypeScript lies 70 | const local = port as MessagePort; 71 | const remote = (remotePort ?? port) as MessagePort; 72 | 73 | let transportHandler: ((event: MessageEvent) => any) | undefined; 74 | return { 75 | send(data) { 76 | remote.postMessage(rpcTransportMessageOut(data, { transportId })); 77 | }, 78 | registerHandler(handler) { 79 | transportHandler = (event: MessageEvent) => { 80 | const message = event.data; 81 | const [ignore, data] = rpcTransportMessageIn(message, { 82 | transportId, 83 | filter: () => filter?.(event), 84 | }); 85 | if (ignore) return; 86 | handler(data); 87 | }; 88 | local.addEventListener("message", transportHandler); 89 | }, 90 | unregisterHandler() { 91 | if (transportHandler) 92 | local.removeEventListener("message", transportHandler); 93 | }, 94 | }; 95 | } 96 | 97 | // TODO: message port transport tests. 98 | -------------------------------------------------------------------------------- /docs/3-bridging-transports.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | [**Previous: Built-in transports**](./2-built-in-transports.md) 4 | 5 |
6 |
7 | 8 | [**Next: Creating a custom transport**](./4-creating-a-custom-transport.md) 9 | 10 |
11 | 12 |

Bridging transports

13 | 14 | RPC Anywhere has a built-in mechanism to bridge transports. To illustrate what this means and why it's useful, let's consider the following scenario: 15 | 16 | - A server that exposes an RPC connection through a WebSocket. 17 | - A main script in an electron app that connects to that WebSocket. 18 | - A renderer in the same electron app that connects to the main script through Electron IPC. 19 | - An RPC iframe inside the renderer that connects to the parent window. 20 | 21 | In other words, in this scenario we need to establish an RPC connection between the server and the iframe contained in the renderer. For messages to get from point A to point B, they need to go through the following channels: 22 | 23 | ``` 24 | Server <-----------> Main script <--------------> Renderer <---------------> Iframe 25 | WebSocket Electron IPC Message ports 26 | ``` 27 | 28 | To achieve this, we need two kinds of things: 29 | 30 | - RPC instances in the server and the iframe. RPC instances are created with `createRPC`. 31 | - "Bridges" that connect the main script with the renderer, and the renderer with the iframe. Bridges between transports can be created using `createTransportBridge`. 32 | 33 | This is how it'd look in practice (simplified): 34 | 35 | 36 | 37 | ```ts 38 | // server.ts 39 | const serverRPC = createRPC({ 40 | transport: createTransportFromWebSocket(webSocket), 41 | // ... 42 | }); 43 | 44 | // main.ts 45 | const bridge = createTransportBridge( 46 | createTransportFromWebSocket(webSocket), 47 | createTransportFromElectronIpcMain(ipcMain), 48 | ); 49 | bridge.start(); 50 | 51 | // renderer.ts 52 | createTransportBridge( 53 | createTransportFromElectronIpcRenderer(ipcRenderer), 54 | createTransportFromMessagePort(window, iframe.contentWindow), 55 | ); 56 | bridge.start(); 57 | 58 | // iframe.ts 59 | const iframeRPC = createRPC({ 60 | transport: createTransportFromMessagePort(window, window.parent), 61 | // ... 62 | }); 63 | ``` 64 | 65 | Bridges simply forward messages from one transport to another in both directions. There's no need to create RPC instances where the bridges are, because RPC functionality itself is only needed at the endpoints. 66 | 67 | While there is no limit to the number of bridges you can create, it's important to keep in mind that each bridge adds a layer of complexity, latency, and potential points of failure. It's a good idea to keep the number of bridges to a minimum, and to test thoroughly when using them. 68 | 69 | If you want to stop using a bridge, you can call `bridge.stop()`. This will unregister any event listeners for any transports that support it. 70 | 71 | --- 72 | 73 |
74 | 75 | [**Previous: Built-in transports**](./2-built-in-transports.md) 76 | 77 |
78 |
79 | 80 | [**Next: Creating a custom transport**](./4-creating-a-custom-transport.md) 81 | 82 |
83 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # RPC Anywhere 2 | 3 | ## 1.7.0 4 | 5 | ### Minor Changes 6 | 7 | - a8a6c04: Fix: detect if iframes are already loaded. 8 | 9 | ## 1.6.0 10 | 11 | ### Minor Changes 12 | 13 | - e76f323: Added iframe transport. 14 | - e76f323: Updated demo to use the new iframe transport. 15 | - e76f323: Improved README and added better examples. 16 | - e76f323: Added worker transport. 17 | - e76f323: Added broadcast channel transport. 18 | - e76f323: Improved documentation. 19 | - e76f323: Improved the `createTransportFromMessagePort` API. 20 | 21 | ## 1.5.0 22 | 23 | ### Minor Changes 24 | 25 | - db13394: Publish CJS version. 26 | 27 | ## 1.4.0 28 | 29 | ### Minor Changes 30 | 31 | - fefe796: Fixed message port transport. 32 | - fefe796: Added debug hooks for logging and debugging. 33 | - 14c38f9: Support "void" in RPCSchema, useful for inferring from request handler when there are no messages. 34 | - fefe796: Added a cool demo! 35 | - 14c38f9: Updated and improved documentation. 36 | 37 | ### Patch Changes 38 | 39 | - fefe796: Better naming for low-level message types. 40 | - 14c38f9: Improve test coverage. 41 | - fefe796: Reduced chance of colision for the transport id key. 42 | 43 | ## 1.3.4 44 | 45 | ### Patch Changes 46 | 47 | - 4046d0b: Fix: transport utils - ID and filter exclusivity check. 48 | 49 | ## 1.3.3 50 | 51 | ### Patch Changes 52 | 53 | - 1c92feb: Fix: createTransportFromBrowserRuntimePort send function will actually send now. 54 | 55 | ## 1.3.2 56 | 57 | ### Patch Changes 58 | 59 | - 2e447f8: Fix: better filter type for `createTransportFromBrowserRuntimePort`'s `filter` option. 60 | 61 | ## 1.3.1 62 | 63 | ### Patch Changes 64 | 65 | - 4058d02: Fix: better browser runtime port transport port type. 66 | 67 | ## 1.3.0 68 | 69 | ### Minor Changes 70 | 71 | - df6222f: Added transport identification options to browser runtime port transport. 72 | - df6222f: Merged `request` and `requestProxy` into `request`. 73 | - df6222f: Added transport utils to simplify the creation of identifiable transports. 74 | - df6222f: Refactored from class to functions. 75 | 76 | - `new RPC()` -> `createRPC()` 77 | - `RPC.asClient()` -> `createClientRPC()` 78 | - `RPC.asServer()` -> `createServerRPC()` 79 | 80 | - df6222f: Added proxy API for message sending. 81 | - df6222f: New feature: transport bridges. 82 | - df6222f: Centralized transport methods in transport object. 83 | - df6222f: Added `proxy` property. 84 | - df6222f: Added message port transport (iframes, window objects, service workers, etc) 85 | - df6222f: Added `requestProxy` and `sendProxy` with "just the proxy" types. 86 | - df6222f: Greatly improved type safety: schema dependent methods and options. 87 | 88 | ### Patch Changes 89 | 90 | - df6222f: Improved documentation. 91 | - df6222f: Added (very!) exhaustive type tests. 92 | - df6222f: Added JSDoc tests. 93 | - df6222f: Fix: invalid message payload type inference. 94 | - df6222f: Improved unit tests. 95 | 96 | ## 1.2.0 97 | 98 | ### Minor Changes 99 | 100 | - f9d8b76: Improved types, including a fix that caused errors in correct request handlers when the request was defined as "void" in the schema. 101 | 102 | ### Patch Changes 103 | 104 | - f9d8b76: Bumped dependencies to latest. 105 | 106 | ## 1.1.0 107 | 108 | ### Minor Changes 109 | 110 | - 63d54c9: Added JSDoc comments everywhere. 111 | 112 | ## 1.0.0 113 | 114 | ### Major Changes 115 | 116 | - 708a3b3: Initial release. 117 | -------------------------------------------------------------------------------- /src/tests/jsdocs-test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { createRPC } from "../create-rpc.js"; 3 | import { type RPCSchema } from "../types.js"; 4 | 5 | /* IMPORTANT: this test is NOT automatic. 6 | 7 | Instead, it must be checked manually by accessing the generated 8 | documentation for each relevant symbol (e.g. by hovering over them 9 | in Visual Studio Code) and comparing. 10 | 11 | Each line with symbols to check has a comment below pointing to 12 | them with a repeated caret symbol (^). For example: */ 13 | 14 | /** 15 | * Example JSDoc test. 16 | * 17 | * @see https://example.jsdoc.test/ 18 | */ 19 | const example = 1234; 20 | console.log(example); 21 | // ^^^^^^^ 22 | 23 | /* When you encounter this, verify that the displayed documentation 24 | is correct. Make sure to read the comments with additional 25 | instructions too. 26 | 27 | The tests are successful if all docs show up as expected. */ 28 | 29 | // ------------------------------ 30 | 31 | // |------------------| 32 | // | TESTS START HERE | 33 | // |------------------| 34 | 35 | const rpc = createRPC(); 36 | 37 | // ALL docs should have a matching @example or @see. 38 | 39 | const response1 = await rpc.request("method1", { paramA: 1234 }); 40 | // ^^^^^^ 41 | response1.propA; 42 | // ^^^^^ 43 | const response1Proxy = await rpc.request.method1({ paramA: 1234 }); 44 | // ^^^^^^^ ^^^^^^ 45 | response1Proxy.propA; 46 | // ^^^^^ 47 | rpc.proxy.request.method1({ paramA: 1234 }); 48 | // ^^^^^^^ ^^^^^^ 49 | rpc.request.method2(); 50 | // ^^^^^^^ 51 | rpc.proxy.request.method2(); 52 | // ^^^^^^^ 53 | rpc.send("message1", { propertyA: "hello" }); 54 | // ^^^^^^^^^ 55 | rpc.send.message1({ propertyA: "hello" }); 56 | // ^^^^^^^^ ^^^^^^^^^ 57 | rpc.proxy.send.message1({ propertyA: "hello" }); 58 | // ^^^^^^^^ ^^^^^^^^^ 59 | rpc.addMessageListener("message1", ({ propertyA }) => { 60 | // ^^^^^^^^^ 61 | propertyA; 62 | }); 63 | rpc.send.message2(); 64 | // ^^^^^^^^ 65 | rpc.proxy.send.message2(); 66 | // ^^^^^^^^ 67 | 68 | // |------------------| 69 | // | TESTS END HERE | 70 | // |------------------| 71 | 72 | // ------------------------------ 73 | 74 | type Schema = RPCSchema<{ 75 | requests: { 76 | /** 77 | * method1 description 78 | * 79 | * @example 80 | * 81 | * ``` 82 | * method1 example 83 | * ``` 84 | */ 85 | method1: { 86 | params: { 87 | /** 88 | * method1 -> paramA description 89 | * 90 | * @see https://param.a/ 91 | */ 92 | paramA: number; 93 | }; 94 | response: { 95 | /** 96 | * method1 -> response propA description 97 | * 98 | * @see https://response.prop.a/ 99 | */ 100 | propA: string; 101 | }; 102 | }; 103 | /** 104 | * method2 description 105 | * 106 | * @example 107 | * 108 | * ``` 109 | * method2 example 110 | * ``` 111 | */ 112 | method2: void; 113 | }; 114 | messages: { 115 | /** 116 | * message1 description 117 | * 118 | * @example 119 | * 120 | * ```ts 121 | * message1 example 122 | * ``` 123 | */ 124 | message1: { 125 | /** 126 | * message1 -> payload propertyA description 127 | * 128 | * @see https://payload.property.a/ 129 | */ 130 | propertyA: string; 131 | }; 132 | /** 133 | * message2 description 134 | * 135 | * @example 136 | * 137 | * ```ts 138 | * message2 example 139 | * ``` 140 | */ 141 | message2: void; 142 | }; 143 | }>; 144 | -------------------------------------------------------------------------------- /demo/iframe.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | RPC Anywhere demo - iframe 5 | 6 | 7 | 8 | 9 | 13 | 14 | 15 | 16 |
17 | 24 | 31 |

Story time! Fill in the blanks:

32 | 33 |
34 | 41 | 48 |
49 | 50 |
51 | 58 | 65 |
66 | 67 |
68 | 75 | 82 |
83 | 84 |
85 | 92 | 99 |
100 | 101 | 107 | 111 |
112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /docs/4-creating-a-custom-transport.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | [**Previous: Bridging transports**](./3-bridging-transports.md) 4 | 5 |
6 | 7 |

Creating a custom transport

8 | 9 | An RPC transport is the channel through which messages are sent and received between point A and point B. The specific implementation depends on the context, but the requirements to create a transport that an RPC instance can use are always the same: 10 | 11 | - Provide a `send` function that takes an arbitrary message and sends it to the other endpoint. 12 | - Provide a `registerHandler` method that takes ("registers") a callback and calls it whenever a message is received from the other endpoint. 13 | - Provide an `unregisterHandler` method that removes or deactivates the previously set handler. This might be necessary if the transport is updated at runtime (through `rpc.setTransport(transport)`), as it is called as a way to clean up the previous transport's handler before registering the new handler. 14 | 15 | A transport object looks like this: 16 | 17 | ```ts 18 | const transport = { 19 | send(message) { 20 | // send the message 21 | }, 22 | registerHandler(handler) { 23 | // register the handler 24 | }, 25 | unregisterHandler() { 26 | // unregister the handler 27 | }, 28 | }; 29 | ``` 30 | 31 | Normally, it is a good idea to define a function that creates the transport, because it allows the user to create multiple transports with potentially different configurations, as well as providing a local scope which is useful for unregistering the handler at a later time (as we'll see below). 32 | 33 | Let's update the previous snippet to turn it into a function, and add a fictional `channel` object that contains the `postMessage`, `addMessageListener` and `removeMessageListener` methods for the sake of example: 34 | 35 | ```ts 36 | function createMyCustomTransport(channel: ExampleChannel): RPCTransport { 37 | let handler: MessageHandler | null = null; 38 | return { 39 | send(message) { 40 | channel.postMessage(message); 41 | }, 42 | registerHandler(handler) { 43 | channel.addMessageListener((message) => handler(message)); 44 | }, 45 | }; 46 | } 47 | ``` 48 | 49 | Notice how there is no way to unregister the handler. If the user decides to replace the transport at runtime, the previous transport's handler will still be active. To prevent this, we can use a local variable to store the listener, so that we can remove it later: 50 | 51 | ```ts 52 | function createMyCustomTransport(channel: ExampleChannel): RPCTransport { 53 | let listener: ExampleMessageListener | null = null; 54 | return { 55 | send(message) { 56 | channel.postMessage(message); 57 | }, 58 | registerHandler(handler) { 59 | listener = (message) => handler(message); 60 | channel.addMessageListener(listener); 61 | }, 62 | unregisterHandler() { 63 | if (listener) channel.removeMessageListener(listener); 64 | }, 65 | }; 66 | } 67 | ``` 68 | 69 | There is an additional consideration that might or might not apply depending on the context: certain ways to send and receive message can be used by multiple sources, like other transports, libraries, user code, etc. This can result in issues because the transport might receive messages that are not meant for it. 70 | 71 | For example, an iframe can communicate with its parent window using `parentWindow.postMessage(message)`, which is then received by the parent window through `window.addEventListener("message", handler)`. If multiple sources fire `message` events in the same window, the handler will be called for all of them, even if they are not meant for the transport. 72 | 73 | There are many ways to solve this problem, like including a unique ID in messages or filtering by checking some contextual information (e.g. a window message event's `origin` property). If you think this might be relevant for your transport, consider adding some way to differentiate a transport's messages from others. 74 | 75 | All built-in transports provide ways to achieve this by either providing a unique ID or a custom filtering function. They use [a few utilities](../src/transport-utils.ts) that are also available for you to use. If you want to see how they work, [check out the source of the built-in transports](../src/transports), as well as [the source of the utilities](../src/transport-utils.ts). 76 | 77 | --- 78 | 79 |
80 | 81 | [**Previous: Bridging transports**](./3-bridging-transports.md) 82 | 83 |
84 | -------------------------------------------------------------------------------- /demo/iframe.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type _RPCPacket, 3 | createIframeParentTransport, 4 | createRPC, 5 | createRPCRequestHandler, 6 | type RPCSchema, 7 | } from "../src/index.js"; // "rpc-anywhere" 8 | // import the parent (remote) schema 9 | import { type ParentSchema } from "./parent.js"; 10 | 11 | // grab some elements and prepare some stuff 12 | const syncedInputEl = el("synced-input"); 13 | const coloredButtonEl = el("colored-button"); 14 | const storyButtonEl = el("story-button"); 15 | const storyResultEl = el("story-result"); 16 | const storyTitleEl = el("story-title"); 17 | const storyVillageNameEl = el("story-village-name-input"); 18 | const storyAnimalEl = el("story-animal-input"); 19 | const storyNameEl = el("story-name-input"); 20 | const storyActivityEl = el("story-activity-input"); 21 | const storyLandmarkEl = el("story-landmark-input"); 22 | const storyObjectEl = el("story-object-input"); 23 | const storySuperpowerEl = el("story-superpower-input"); 24 | const storyNewTitleEl = el("story-new-title-input"); 25 | const colors = ["red", "green", "blue", "purple"] as const; 26 | type Color = (typeof colors)[number]; 27 | 28 | // request handler 29 | const requestHandler = createRPCRequestHandler({ 30 | /** 31 | * Get the current color of the button. 32 | */ 33 | getColor: () => coloredButtonEl.dataset.color as Color, 34 | }); 35 | 36 | // declare the iframe (local) schema 37 | export type IframeSchema = RPCSchema< 38 | { 39 | messages: { 40 | /** 41 | * Sent when the iframe's input is updated. 42 | */ 43 | iframeInputUpdated: string; 44 | }; 45 | }, 46 | // infer request types from the request handler 47 | typeof requestHandler 48 | >; 49 | 50 | async function main() { 51 | // create the iframe's RPC 52 | const rpc = createRPC({ 53 | // wait for a connection with the parent window and 54 | // pass the transport to our RPC 55 | transport: await createIframeParentTransport({ 56 | transportId: "rpc-anywhere-demo", 57 | }), 58 | // provide the request handler 59 | requestHandler, 60 | // this is for demo purposes - you can ignore it 61 | _debugHooks: { onSend: _debugOnSend, onReceive: _debugOnReceive }, 62 | }); 63 | 64 | // use the proxy as an alias ✨ 65 | const parent = rpc.proxy; 66 | 67 | // synced input 68 | syncedInputEl.addEventListener("input", () => 69 | parent.send.iframeInputUpdated(syncedInputEl.value), 70 | ); 71 | rpc.addMessageListener( 72 | "parentInputUpdated", 73 | (value) => (syncedInputEl.value = value), 74 | ); 75 | 76 | // story time 77 | storyButtonEl.addEventListener("click", async () => { 78 | const { title } = await parent.request.createStory({ 79 | villageName: storyVillageNameEl.value, 80 | animal: storyAnimalEl.value, 81 | name: storyNameEl.value, 82 | activity: storyActivityEl.value, 83 | landmark: storyLandmarkEl.value, 84 | object: storyObjectEl.value, 85 | superpower: storySuperpowerEl.value, 86 | newTitle: storyNewTitleEl.value, 87 | }); 88 | storyResultEl.style.removeProperty("display"); 89 | storyTitleEl.textContent = title; 90 | }); 91 | } 92 | 93 | main(); 94 | 95 | // non-demo stuff - you can ignore this :) 96 | // --------------------------------------- 97 | 98 | const colorToClass = { 99 | red: "bg-red-500", 100 | green: "bg-green-500", 101 | blue: "bg-blue-500", 102 | purple: "bg-purple-500", 103 | }; 104 | function updateButtonColor(color?: Color) { 105 | const currentColor = coloredButtonEl.dataset.color; 106 | const currentClass = colorToClass[currentColor as Color]; 107 | const nextColor = 108 | color ?? 109 | colors[(colors.indexOf(currentColor as Color) + 1) % colors.length]; 110 | const nextClass = colorToClass[nextColor]; 111 | coloredButtonEl.dataset.color = nextColor; 112 | coloredButtonEl.classList.remove(currentClass); 113 | coloredButtonEl.classList.add(nextClass); 114 | } 115 | coloredButtonEl.addEventListener("click", () => updateButtonColor()); 116 | 117 | function el(id: string) { 118 | const element = document.getElementById(id); 119 | if (!element) throw new Error(`Element with id ${id} not found`); 120 | return element as Element; 121 | } 122 | 123 | const iframeLogsEl = window.parent.document.querySelector("#iframe-logs")!; 124 | function _debugOnSend(packet: _RPCPacket) { 125 | console.log("[iframe] sent", packet); 126 | (window.parent as any)._debugAppendMessage("send", iframeLogsEl, packet); 127 | } 128 | function _debugOnReceive(packet: _RPCPacket) { 129 | console.log("[iframe] received", packet); 130 | (window.parent as any)._debugAppendMessage("receive", iframeLogsEl, packet); 131 | } 132 | -------------------------------------------------------------------------------- /src/transports/iframe.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createTransportFromMessagePort, 3 | type RPCMessagePortTransportOptions, 4 | } from "../index.js"; 5 | 6 | const IFRAME_MSG_KEY = "[iframe-transport]"; 7 | const IFRAME_READY_MSG = "[iframe-transport-ready]"; 8 | 9 | async function waitForLoad(element: HTMLElement) { 10 | const readyState = (element as HTMLIFrameElement).contentDocument?.readyState; 11 | const location = (element as HTMLIFrameElement).contentWindow?.location.href; 12 | return location !== "about:blank" && readyState === "complete" 13 | ? Promise.resolve() 14 | : new Promise((resolve) => element.addEventListener("load", resolve)); 15 | } 16 | 17 | async function portReadyPromise(port: MessagePort) { 18 | return new Promise((resolve) => { 19 | port.addEventListener("message", function ready(event) { 20 | if (event.data === IFRAME_READY_MSG) { 21 | port.removeEventListener("message", ready); 22 | resolve(); 23 | } 24 | }); 25 | }); 26 | } 27 | 28 | /** 29 | * Options for the iframe transport. 30 | */ 31 | export type RPCIframeTransportOptions = Omit< 32 | RPCMessagePortTransportOptions, 33 | "transportId" | "remotePort" 34 | > & { 35 | /** 36 | * An identifier for the transport. This is used to match the iframe with 37 | * the parent window. If not set, a default value will be used when establishing 38 | * the connection. 39 | */ 40 | transportId?: string | number; 41 | 42 | /** 43 | * The target origin of the iframe. This is used to restrict the domains 44 | * that can communicate with the iframe. If not set, the iframe will accept 45 | * messages from any origin. [Learn more about `targetOrigin` on MDN.]( 46 | * https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage#targetorigin) 47 | * 48 | * @default "*" 49 | */ 50 | targetOrigin?: string; 51 | }; 52 | 53 | /** 54 | * Creates a transport to communicate with an iframe. This is an asynchronous 55 | * process because it requires waiting for the iframe to load and signal that 56 | * it's ready to receive messages. 57 | * 58 | * The target iframe must use `createIframeParentTransport` with a matching 59 | * `transportId` or `filter` option (if set). 60 | * 61 | * Uses `MessageChannel` under the hood. [Learn more about the Channel Messaging 62 | * API on MDN.](https://developer.mozilla.org/en-US/docs/Web/API/Channel_Messaging_API/Using_channel_messaging) 63 | * 64 | * Using the `transportId` option is recommended to 65 | * avoid conflicts with other connections. If security is a concern, the 66 | * `targetOrigin` option should be set to the expected origin of the iframe. 67 | * [Learn more about `targetOrigin` on MDN.]( 68 | * https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage#targetorigin). 69 | * 70 | * @example 71 | * 72 | * ```ts 73 | * const rpc = createRPC({ 74 | * transport: await createIframeTransport(iframeElement, { 75 | * transportId: "my-iframe" 76 | * }), 77 | * // ... 78 | * }); 79 | * ``` 80 | * 81 | */ 82 | export async function createIframeTransport( 83 | /** 84 | * The iframe element to communicate with. 85 | */ 86 | iframe: HTMLIFrameElement, 87 | /** 88 | * Options for the iframe transport. 89 | */ 90 | options: RPCIframeTransportOptions = {}, 91 | ) { 92 | const channel = new MessageChannel(); 93 | const { port1, port2 } = channel; 94 | port1.start(); 95 | const transport = createTransportFromMessagePort(port1, options); 96 | const readyPromise = portReadyPromise(port1); 97 | await waitForLoad(iframe); 98 | if (!iframe.contentWindow) throw new Error("Unexpected iframe state"); 99 | iframe.contentWindow.postMessage( 100 | { [IFRAME_MSG_KEY]: options.transportId ?? "default" }, 101 | options.targetOrigin ?? "*", 102 | [port2], 103 | ); 104 | await readyPromise; 105 | return transport; 106 | } 107 | 108 | async function waitForInit(id: string | number) { 109 | return new Promise((resolve) => { 110 | window.addEventListener("message", function init(event) { 111 | const { data, ports } = event; 112 | if (data[IFRAME_MSG_KEY] === id) { 113 | const [port] = ports; 114 | window.removeEventListener("message", init); 115 | resolve(port); 116 | } 117 | }); 118 | }); 119 | } 120 | 121 | /** 122 | * Options for the iframe parent transport. 123 | */ 124 | export type RPCIframeParentTransportOptions = Omit< 125 | RPCMessagePortTransportOptions, 126 | "transportId" | "remotePort" 127 | > & { 128 | /** 129 | * An identifier for the transport. This is used to match the iframe with 130 | * the parent window. If not set, a default value will be used when establishing 131 | * the connection. 132 | */ 133 | transportId?: string | number; 134 | }; 135 | 136 | /** 137 | * Creates a transport to communicate with the parent window from an iframe. This 138 | * is an asynchronous process because it requires waiting for the parent window 139 | * to connect to the iframe. 140 | * 141 | * The parent window must use `createIframeTransport` with a matching 142 | * `transportId` or `filter` option (if set). 143 | * 144 | * Using the `transportId` option is recommended to avoid conflicts with other 145 | * connections. 146 | * 147 | * @example 148 | * 149 | * ```ts 150 | * const rpc = createRPC({ 151 | * transport: await createIframeParentTransport({ transportId: "my-iframe" }), 152 | * // ... 153 | * }); 154 | * ``` 155 | */ 156 | export async function createIframeParentTransport( 157 | options: RPCIframeParentTransportOptions = {}, 158 | ) { 159 | const port = await waitForInit(options.transportId ?? "default"); 160 | port.start(); 161 | port.postMessage(IFRAME_READY_MSG); 162 | return createTransportFromMessagePort(port, options); 163 | } 164 | 165 | // TODO: iframe transport tests. 166 | -------------------------------------------------------------------------------- /demo/parent.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type _RPCPacket, 3 | createIframeTransport, 4 | createRPC, 5 | createRPCRequestHandler, 6 | type RPCSchema, 7 | } from "../src/index.js"; // "rpc-anywhere" 8 | // import the iframe (remote) schema 9 | import { type IframeSchema } from "./iframe.js"; 10 | 11 | // grab some elements 12 | const iframeEl = el("iframe"); 13 | const syncedInputEl = el("synced-input"); 14 | const updateColoredButtonEl = el("update-colored-button"); 15 | 16 | // request handler 17 | const requestHandler = createRPCRequestHandler({ 18 | /** 19 | * Create a story with the given details. 20 | */ 21 | createStory: (storyDetails: StoryDetails) => { 22 | renderStory(storyDetails); 23 | const { name, newTitle, villageName } = storyDetails; 24 | return { title: `The Tale of ${name}, the ${newTitle} of ${villageName}` }; 25 | }, 26 | }); 27 | 28 | // declare the parent (local) schema 29 | export type ParentSchema = RPCSchema< 30 | { 31 | messages: { 32 | /** 33 | * Sent when the parent's input is updated. 34 | */ 35 | parentInputUpdated: string; 36 | }; 37 | }, 38 | // infer request types from the request handler 39 | typeof requestHandler 40 | >; 41 | 42 | async function main() { 43 | console.log("[parent] Connected to the child iframe!"); 44 | 45 | // create the parent's RPC 46 | const rpc = createRPC({ 47 | // wait for connection with the iframe and 48 | // pass the transport to our RPC 49 | transport: await createIframeTransport(iframeEl, { 50 | transportId: "rpc-anywhere-demo", 51 | }), 52 | // provide the request handler 53 | requestHandler, 54 | // this is for demo purposes - you can ignore it 55 | _debugHooks: { onSend: _debugOnSend, onReceive: _debugOnReceive }, 56 | }); 57 | 58 | // use the proxy as an alias ✨ 59 | const iframe = rpc.proxy; 60 | 61 | // enable the UI since the RPC connection is now active 62 | enableUI(); 63 | 64 | // synced input 65 | syncedInputEl.addEventListener("input", () => 66 | iframe.send.parentInputUpdated(syncedInputEl.value), 67 | ); 68 | rpc.addMessageListener( 69 | "iframeInputUpdated", 70 | (value) => (syncedInputEl.value = value), 71 | ); 72 | 73 | // colored button 74 | updateColoredButtonEl.addEventListener("click", async () => { 75 | const currentColor = await iframe.request.getColor(); 76 | el("button-color").textContent = currentColor; 77 | }); 78 | } 79 | 80 | main(); 81 | 82 | type StoryDetails = { 83 | /** The name of the village where the story is set. */ 84 | villageName: string; 85 | 86 | /** The type of animal the main character is. */ 87 | animal: string; 88 | 89 | /** The name of the main character. */ 90 | name: string; 91 | 92 | /** The main activity the character enjoys. */ 93 | activity: string; 94 | 95 | /** A significant landmark in the village. */ 96 | landmark: string; 97 | 98 | /** An object found by the main character that has special powers. */ 99 | object: string; 100 | 101 | /** A special ability or power provided by the magical object. */ 102 | superpower: string; 103 | 104 | /** The new title or status achieved by the main character at the end. */ 105 | newTitle: string; 106 | }; 107 | 108 | // non-demo stuff - you can ignore this :) 109 | // --------------------------------------- 110 | 111 | function el(id: string) { 112 | const element = document.getElementById(id); 113 | if (!element) throw new Error(`Element with id ${id} not found`); 114 | return element as Element; 115 | } 116 | 117 | function els(selector: string) { 118 | const elements = document.querySelectorAll(selector); 119 | if (elements.length === 0) 120 | throw new Error(`No elements found matching selector ${selector}`); 121 | return Array.from(elements) as Element[]; 122 | } 123 | 124 | function enableUI() { 125 | el("ready").style.removeProperty("display"); // remove display: none 126 | el("loading").style.display = "none"; 127 | el("controls").classList.remove("opacity-60", "pointer-events-none"); 128 | } 129 | 130 | function updateIframeSize() { 131 | const size = 132 | iframeEl.contentWindow!.document.querySelector("#sizer")!.scrollHeight; 133 | iframeEl.style.height = `${size + 4}px`; 134 | } 135 | 136 | iframeEl.addEventListener("load", () => { 137 | updateIframeSize(); 138 | window.addEventListener("resize", updateIframeSize); 139 | }); 140 | 141 | function renderStory({ 142 | villageName, 143 | animal, 144 | name, 145 | activity, 146 | landmark, 147 | object, 148 | superpower, 149 | newTitle, 150 | }: StoryDetails) { 151 | els(".story-village-name").forEach((el) => (el.textContent = villageName)); 152 | els(".story-animal").forEach((el) => (el.textContent = animal)); 153 | els(".story-name").forEach((el) => (el.textContent = name)); 154 | els(".story-activity").forEach((el) => (el.textContent = activity)); 155 | els(".story-landmark").forEach((el) => (el.textContent = landmark)); 156 | els(".story-object").forEach((el) => (el.textContent = object)); 157 | els(".story-superpower").forEach((el) => (el.textContent = superpower)); 158 | els(".story-new-title").forEach((el) => (el.textContent = newTitle)); 159 | el("story").style.removeProperty("display"); 160 | const storyEl = el("story"); 161 | storyEl.style.removeProperty("display"); 162 | } 163 | 164 | declare const jsonFormatHighlight: (data: any) => any; 165 | const messageTemplate = el("message-template"); 166 | function _debugAppendMessage( 167 | type: "send" | "receive", 168 | logElement: HTMLElement, 169 | packet: _RPCPacket, 170 | ) { 171 | const msgEl = messageTemplate.content.firstElementChild!.cloneNode( 172 | true, 173 | ) as HTMLParagraphElement; 174 | const typeArrow = type === "send" ? "→" : "←"; 175 | if (type === "receive") msgEl.classList.remove("bg-white/10"); 176 | // time in HH:MM:SS 177 | const time = new Date().toLocaleTimeString("en-US").split(" ")[0]; 178 | if (packet.type === "message") { 179 | msgEl.querySelector(".packet-meta")!.textContent = 180 | `${time} ${typeArrow} message: ${packet.id}`; 181 | msgEl.querySelector(".packet-payload")!.innerHTML = jsonFormatHighlight( 182 | packet.payload === undefined ? "<no payload>" : packet.payload, 183 | ); 184 | } 185 | if (packet.type === "request") { 186 | msgEl.querySelector(".packet-meta")!.textContent = 187 | `${time} ${typeArrow} request (id: ${packet.id}): ${packet.method}`; 188 | msgEl.querySelector(".packet-payload")!.innerHTML = jsonFormatHighlight( 189 | packet.params === undefined ? "<no params>" : packet.params, 190 | ); 191 | } 192 | if (packet.type === "response") { 193 | msgEl.querySelector(".packet-meta")!.textContent = 194 | `${time} ${typeArrow} response (id: ${packet.id}): ${ 195 | packet.success ? "✅" : "❌" 196 | }`; 197 | if (packet.success) 198 | msgEl.querySelector(".packet-payload")!.innerHTML = jsonFormatHighlight( 199 | packet.payload === undefined ? "<no response>" : packet.payload, 200 | ); 201 | else 202 | msgEl.querySelector(".packet-payload")!.innerHTML = jsonFormatHighlight( 203 | packet.error === undefined ? "<no error>" : packet.error, 204 | ); 205 | } 206 | const wasScrolledToBottom = 207 | Math.abs( 208 | logElement.scrollHeight - logElement.scrollTop - logElement.clientHeight, 209 | ) < 1; 210 | 211 | logElement.appendChild(msgEl); 212 | if (wasScrolledToBottom) logElement.scrollTop = logElement.scrollHeight; 213 | } 214 | (window as any)._debugAppendMessage = _debugAppendMessage; 215 | 216 | const parentLogsEl = el("parent-logs"); 217 | const iframeLogsEl = el("iframe-logs"); 218 | const parentLogsClearEl = el("parent-logs-clear"); 219 | const iframeLogsClearEl = el("iframe-logs-clear"); 220 | function _debugOnSend(packet: _RPCPacket) { 221 | console.log("[parent] sent", packet); 222 | setTimeout(updateIframeSize, 100); // hack ¯\_(ツ)_/¯ 223 | (window as any)._debugAppendMessage("send", parentLogsEl, packet); 224 | } 225 | function _debugOnReceive(packet: _RPCPacket) { 226 | console.log("[parent] received", packet); 227 | (window as any)._debugAppendMessage("receive", parentLogsEl, packet); 228 | } 229 | parentLogsClearEl.addEventListener( 230 | "click", 231 | () => (parentLogsEl.innerHTML = ""), 232 | ); 233 | iframeLogsClearEl.addEventListener( 234 | "click", 235 | () => (iframeLogsEl.innerHTML = ""), 236 | ); 237 | -------------------------------------------------------------------------------- /src/tests/rpc.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | 3 | import { 4 | type RPCMessageHandlerFn, 5 | type WildcardRPCMessageHandlerFn, 6 | } from "../types.js"; 7 | import { 8 | createTestRPCs, 9 | DEFAULT_MAX_TIME, 10 | delay, 11 | type Schema2, 12 | TIMEOUT_ACCEPTABLE_MARGIN, 13 | } from "./utils.js"; 14 | 15 | test("request() returns the right values", async () => { 16 | const { rpc1, rpc2 } = createTestRPCs(); 17 | const response1 = await rpc1.request("method2", { b: "hello" }); 18 | expect(response1).toBe("hello"); 19 | const response2 = await rpc2.request("method1", { a: 1234 }); 20 | expect(response2).toBe(1234); 21 | }); 22 | 23 | test("request as proxy returns the right values", async () => { 24 | const { rpc1, rpc2 } = createTestRPCs(); 25 | const response1 = await rpc1.request.method2({ b: "hello" }); 26 | expect(response1).toBe("hello"); 27 | const response2 = await rpc2.request.method1({ a: 1234 }); 28 | expect(response2).toBe(1234); 29 | }); 30 | 31 | test("requestProxy returns the right values", async () => { 32 | const { rpc1, rpc2 } = createTestRPCs(); 33 | const response1 = await rpc1.requestProxy.method2({ b: "hello" }); 34 | expect(response1).toBe("hello"); 35 | const response2 = await rpc2.requestProxy.method1({ a: 1234 }); 36 | expect(response2).toBe(1234); 37 | }); 38 | 39 | test("request() times out for methods that take too long", async () => { 40 | const { rpc1 } = createTestRPCs(); 41 | const initialTime = Date.now(); 42 | let errorMessage = ""; 43 | try { 44 | await rpc1.request("timesOut"); 45 | } catch (error) { 46 | if (!(error instanceof Error)) return expect().fail("unknown error type"); 47 | errorMessage = error.message; 48 | } 49 | const totalTime = Date.now() - initialTime; 50 | expect(errorMessage).toContain("timed out"); 51 | expect(totalTime).toBeGreaterThanOrEqual( 52 | DEFAULT_MAX_TIME - TIMEOUT_ACCEPTABLE_MARGIN, 53 | ); 54 | expect(totalTime).toBeLessThanOrEqual( 55 | DEFAULT_MAX_TIME + TIMEOUT_ACCEPTABLE_MARGIN, 56 | ); 57 | }); 58 | 59 | test("requestProxy times out for methods that take too long", async () => { 60 | const { rpc1 } = createTestRPCs(); 61 | const initialTime = Date.now(); 62 | let errorMessage = ""; 63 | try { 64 | await rpc1.requestProxy.timesOut(); 65 | } catch (error) { 66 | if (!(error instanceof Error)) return expect().fail("unknown error type"); 67 | errorMessage = error.message; 68 | } 69 | const totalTime = Date.now() - initialTime; 70 | expect(errorMessage).toContain("timed out"); 71 | expect(totalTime).toBeGreaterThanOrEqual( 72 | DEFAULT_MAX_TIME - TIMEOUT_ACCEPTABLE_MARGIN, 73 | ); 74 | expect(totalTime).toBeLessThanOrEqual( 75 | DEFAULT_MAX_TIME + TIMEOUT_ACCEPTABLE_MARGIN, 76 | ); 77 | }); 78 | 79 | test("messages are sent and received correctly", async () => { 80 | const { rpc1, rpc2 } = createTestRPCs(); 81 | let received1 = 0; 82 | let received2 = 0; 83 | const listener: RPCMessageHandlerFn = ( 84 | payload, 85 | ) => { 86 | expect(payload).toBe("second"); 87 | received2++; 88 | }; 89 | rpc1.addMessageListener("message2", listener); 90 | rpc2.addMessageListener("message1", (payload) => { 91 | expect(payload).toBe("first"); 92 | received1++; 93 | }); 94 | 95 | rpc1.send("message1", "first"); 96 | rpc2.send("message2", "second"); 97 | rpc2.send("ignored", "forever-alone"); 98 | await delay(100); 99 | expect(received1).toBe(1); 100 | expect(received2).toBe(1); 101 | 102 | rpc1.removeMessageListener("message2", listener); 103 | rpc1.send("message1", "first"); 104 | rpc2.send("message2", "second"); 105 | rpc2.send("ignored", "forever-alone"); 106 | await delay(100); 107 | expect(received1).toBe(2); 108 | expect(received2).toBe(1); 109 | 110 | rpc1.addMessageListener("message2", listener); 111 | rpc1.send("message1", "first"); 112 | rpc2.send("message2", "second"); 113 | rpc2.send("ignored", "forever-alone"); 114 | await delay(100); 115 | expect(received1).toBe(3); 116 | expect(received2).toBe(2); 117 | }); 118 | 119 | test("send as proxy sends messages correctly", async () => { 120 | const { rpc1, rpc2 } = createTestRPCs(); 121 | let received1 = 0; 122 | let received2 = 0; 123 | const listener: RPCMessageHandlerFn = ( 124 | payload, 125 | ) => { 126 | expect(payload).toBe("second"); 127 | received2++; 128 | }; 129 | rpc1.addMessageListener("message2", listener); 130 | rpc2.addMessageListener("message1", (payload) => { 131 | expect(payload).toBe("first"); 132 | received1++; 133 | }); 134 | 135 | rpc1.send.message1("first"); 136 | rpc2.send.message2("second"); 137 | rpc2.send.ignored("forever-alone"); 138 | await delay(100); 139 | expect(received1).toBe(1); 140 | expect(received2).toBe(1); 141 | 142 | rpc1.removeMessageListener("message2", listener); 143 | rpc1.send.message1("first"); 144 | rpc2.send.message2("second"); 145 | rpc2.send.ignored("forever-alone"); 146 | await delay(100); 147 | expect(received1).toBe(2); 148 | expect(received2).toBe(1); 149 | 150 | rpc1.addMessageListener("message2", listener); 151 | rpc1.send.message1("first"); 152 | rpc2.send.message2("second"); 153 | rpc2.send.ignored("forever-alone"); 154 | await delay(100); 155 | expect(received1).toBe(3); 156 | expect(received2).toBe(2); 157 | }); 158 | 159 | test("sendProxy sends messages correctly", async () => { 160 | const { rpc1, rpc2 } = createTestRPCs(); 161 | let received1 = 0; 162 | let received2 = 0; 163 | const listener: RPCMessageHandlerFn = ( 164 | payload, 165 | ) => { 166 | expect(payload).toBe("second"); 167 | received2++; 168 | }; 169 | rpc1.addMessageListener("message2", listener); 170 | rpc2.addMessageListener("message1", (payload) => { 171 | expect(payload).toBe("first"); 172 | received1++; 173 | }); 174 | 175 | rpc1.sendProxy.message1("first"); 176 | rpc2.sendProxy.message2("second"); 177 | rpc2.sendProxy.ignored("forever-alone"); 178 | await delay(100); 179 | expect(received1).toBe(1); 180 | expect(received2).toBe(1); 181 | 182 | rpc1.removeMessageListener("message2", listener); 183 | rpc1.sendProxy.message1("first"); 184 | rpc2.sendProxy.message2("second"); 185 | rpc2.sendProxy.ignored("forever-alone"); 186 | await delay(100); 187 | expect(received1).toBe(2); 188 | expect(received2).toBe(1); 189 | 190 | rpc1.addMessageListener("message2", listener); 191 | rpc1.sendProxy.message1("first"); 192 | rpc2.sendProxy.message2("second"); 193 | rpc2.sendProxy.ignored("forever-alone"); 194 | await delay(100); 195 | expect(received1).toBe(3); 196 | expect(received2).toBe(2); 197 | }); 198 | 199 | test("wildcard message handler works", async () => { 200 | const { rpc1, rpc2 } = createTestRPCs(); 201 | let receivedCount = 0; 202 | let lastNameReceived = ""; 203 | let lastPayloadReceived = ""; 204 | const listener: WildcardRPCMessageHandlerFn = ( 205 | messageName, 206 | payload, 207 | ) => { 208 | receivedCount++; 209 | lastNameReceived = messageName; 210 | lastPayloadReceived = payload; 211 | }; 212 | 213 | rpc1.addMessageListener("*", listener); 214 | rpc2.send("message2", "second"); 215 | await delay(100); 216 | expect(receivedCount).toBe(1); 217 | expect(lastNameReceived).toBe("message2"); 218 | expect(lastPayloadReceived).toBe("second"); 219 | 220 | rpc1.removeMessageListener("*", listener); 221 | rpc2.send("message3", "third"); 222 | await delay(100); 223 | expect(receivedCount).toBe(1); 224 | expect(lastNameReceived).toBe("message2"); 225 | expect(lastPayloadReceived).toBe("second"); 226 | 227 | rpc1.addMessageListener("*", listener); 228 | rpc2.send("message3", "third"); 229 | await delay(100); 230 | expect(receivedCount).toBe(2); 231 | expect(lastNameReceived).toBe("message3"); 232 | expect(lastPayloadReceived).toBe("third"); 233 | }); 234 | 235 | test("proxy object works for requests and messages", async () => { 236 | const { rpc1, rpc2 } = createTestRPCs(); 237 | 238 | const response1 = await rpc1.proxy.request.method2({ b: "hello" }); 239 | expect(response1).toBe("hello"); 240 | const response2 = await rpc2.proxy.request.method1({ a: 1234 }); 241 | expect(response2).toBe(1234); 242 | 243 | let received1 = 0; 244 | let received2 = 0; 245 | const listener: RPCMessageHandlerFn = ( 246 | payload, 247 | ) => { 248 | expect(payload).toBe("second"); 249 | received2++; 250 | }; 251 | rpc1.addMessageListener("message2", listener); 252 | rpc2.addMessageListener("message1", (payload) => { 253 | expect(payload).toBe("first"); 254 | received1++; 255 | }); 256 | 257 | rpc1.proxy.send.message1("first"); 258 | rpc2.proxy.send.message2("second"); 259 | rpc2.proxy.send.ignored("forever-alone"); 260 | await delay(100); 261 | expect(received1).toBe(1); 262 | expect(received2).toBe(1); 263 | 264 | rpc1.removeMessageListener("message2", listener); 265 | rpc1.proxy.send.message1("first"); 266 | rpc2.proxy.send.message2("second"); 267 | rpc2.proxy.send.ignored("forever-alone"); 268 | await delay(100); 269 | expect(received1).toBe(2); 270 | expect(received2).toBe(1); 271 | 272 | rpc1.addMessageListener("message2", listener); 273 | rpc1.proxy.send.message1("first"); 274 | rpc2.proxy.send.message2("second"); 275 | rpc2.proxy.send.ignored("forever-alone"); 276 | await delay(100); 277 | expect(received1).toBe(3); 278 | expect(received2).toBe(2); 279 | }); 280 | 281 | // TODO: find a way to run these tests with all actual transports too. 282 | // TODO: maxRequestTime tests. 283 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | RPC Anywhere demo - parent 5 | 6 | 7 | 8 | 9 | 13 | 17 | 18 | 19 | 20 |
23 |
24 |

27 | RPC 29 | 30 | Anywhere 34 |

35 |
36 |

37 | RPC Anywhere 44 | lets you create type-safe RPCs. 45 |

46 |

49 | $ npm i rpc-anywhere 50 |

51 |

52 | This is a demo of an RPC between this page ("parent") and a child 53 | iframe. Read the code: 54 | parent.ts 61 | and 62 | iframe.ts. 69 |

70 |

71 | RPC Anywhere is transport agnostic, and is not limited to iframes. 72 | It works for Electron IPC, Web Workers, browser extension content 73 | scripts... Anything! 74 | Learn more. 81 |

82 |
83 |

parent

84 |
88 | 91 |

92 | Waiting for the iframe to load... 93 |

94 | 101 |
102 |

The button is red.

103 | 109 |
110 | 182 |
183 |
184 |

iframe

185 |
186 | 193 |
194 |
195 |
196 |
197 |
198 |

about this demo

199 |

200 | The blue container aboveto the left is an iframe (a 202 | different webpage loaded inside this one). 203 |

204 |

205 | The iframe's page is separate from this one, and has a different 206 | JavaScript context. An RPC helps bridge the gap, allowing both pages 207 | to send messages and make requests to each other. 208 |

209 |
210 | 218 |

219 | parent logs · 220 | 226 |

227 |
231 |
232 |

233 | iframe logs · 234 | 240 |

241 |
245 |
246 |
247 | 248 | 249 | 250 | 251 | -------------------------------------------------------------------------------- /docs/2-built-in-transports.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | [**Previous: RPC**](./1-rpc.md) 4 | 5 |
6 |
7 | 8 | [**Next: Bridging transports**](./3-bridging-transports.md) 9 | 10 |
11 | 12 |

Built-in transports

13 | 14 | RPC Anywhere ships with a few built-in ways to create transports for common use cases. 15 | 16 | For example, a transport for browser extensions (content script ↔ service worker) can be created with `createTransportFromBrowserRuntimePort(port)`. The transport can then be passed to `createRPC` or lazily set on an existing RPC instance with `setTransport(transport)`. 17 | 18 | ```ts 19 | import { createTransportFromBrowserRuntimePort } from "rpc-anywhere"; 20 | 21 | const port = browser.runtime.connect({ name: "my-rpc-port" }); 22 | 23 | const rpc = createRPC({ 24 | transport: createTransportFromBrowserRuntimePort(port), 25 | // ... 26 | }); 27 | 28 | // or 29 | 30 | const rpc = createRPC({ 31 | // ... 32 | }); 33 | rpc.setTransport(createTransportFromBrowserRuntimePort(port)); 34 | ``` 35 | 36 | A full list of built-in transports can be found below. 37 | 38 |

Table of contents

39 | 40 | 41 | 42 | - [Iframes](#iframes) 43 | - [API](#api) 44 | - [Description](#description) 45 | - [Example](#example) 46 | - [Browser extensions](#browser-extensions) 47 | - [API](#api-1) 48 | - [Description](#description-1) 49 | - [Example](#example-1) 50 | - [Workers](#workers) 51 | - [API](#api-2) 52 | - [Description](#description-2) 53 | - [Example](#example-2) 54 | - [Broadcast channels](#broadcast-channels) 55 | - [API](#api-3) 56 | - [Description](#description-3) 57 | - [Example](#example-3) 58 | - [Message ports: windows, workers, broadcast channels](#message-ports-windows-workers-broadcast-channels) 59 | - [API](#api-4) 60 | - [Description](#description-4) 61 | 62 | 66 | 67 | 68 | ## Iframes 69 | 70 | ### API 71 | 72 | ```ts 73 | export async function createIframeTransport( 74 | iframe: HTMLIFrameElement, 75 | options?: { 76 | transportId?: string | number; 77 | filter?: (event: MessageEvent) => boolean; 78 | targetOrigin?: string; // default: "*" 79 | }, 80 | ): Promise; 81 | ``` 82 | 83 | ```ts 84 | export async function createIframeParentTransport(options?: { 85 | transportId?: string | number; 86 | filter?: (event: MessageEvent) => boolean; 87 | }): Promise; 88 | ``` 89 | 90 | ### Description 91 | 92 | Create transports that enable communication between an iframe and its parent window. The connection itself is fully created and managed by using [`MessageChannel`](<(https://developer.mozilla.org/en-US/docs/Web/API/Channel_Messaging_API/Using_channel_messaging)>) under the hood, you only need to provide the target iframe element in the parent window. 93 | 94 | - `createIframeTransport` is used from the _parent window_, and it creates a transport that exchanges messages with the _child iframe_. 95 | - `createIframeParentTransport` is used from the _child iframe_, and it creates a transport that exchanges messages with the _parent window_. 96 | 97 | These functions are asynchronous because the following steps need to be followed to establish the connection: 98 | 99 | 1. The parent window waits for the iframe element and content to load, and then sends an "initialization" message to the iframe along with a message port. 100 | 2. The child iframe waits for the "initialization" message, stores the port for future use by the transport, and sends a "ready" message back to the parent window. 101 | 3. The parent window waits for the "ready" message. 102 | 103 | This process ensures that the connection is established and ready to use before creating the transports at both ends. Once completed, the transport can be immediately used. 104 | 105 | Using the `transportId` option is recommended to avoid potential conflicts with other messages. It must be unique and match on both ends. If security is a concern, the [`targetOrigin`](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage#targetorigin) option should be set to the expected origin of the iframe. 106 | 107 | ### Example 108 | 109 | In the parent window: 110 | 111 | ```ts 112 | import { createIframeTransport } from "rpc-anywhere"; 113 | 114 | const iframeElement = document.getElementById("my-iframe") as HTMLIFrameElement; 115 | 116 | createIframeTransport(iframeElement, { transportId: "my-transport" }).then( 117 | (transport) => { 118 | const rpc = createRPC({ 119 | transport, 120 | // ... 121 | }); 122 | // ... 123 | }, 124 | ); 125 | ``` 126 | 127 | In the child iframe: 128 | 129 | ```ts 130 | import { createIframeParentTransport } from "rpc-anywhere"; 131 | 132 | createIframeParentTransport({ transportId: "my-transport" }).then( 133 | (transport) => { 134 | const rpc = createRPC({ 135 | transport, 136 | // ... 137 | }); 138 | // ... 139 | }, 140 | ); 141 | ``` 142 | 143 | ## Browser extensions 144 | 145 | ### API 146 | 147 | ```ts 148 | function createTransportFromBrowserRuntimePort( 149 | port: Browser.Runtime.Port | Chrome.runtime.Port, 150 | options?: { 151 | transportId?: string | number; 152 | filter?: (message: any, port: Browser.Runtime.Port) => boolean; 153 | }, 154 | ): RPCTransport; 155 | ``` 156 | 157 | ### Description 158 | 159 | Create transports between different contexts in a web extension using browser runtime ports. A common example is between a content script and a service worker. [Learn more on MDN.](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/Port) 160 | 161 | Note that you'll need to understand and manage the creation and lifecycle of the ports yourself on both ends of the connection. 162 | 163 | It is recommended to use a port that has a unique name and is used exclusively for the RPC connection. If the port is also used for other purposes, the RPC instance might receive messages that are not intended for it. 164 | 165 | If you need to share a port, you can use the `transportId` option to ensure that only messages that match that specific ID are handled. For advanced use cases, you can use the `filter` option to filter messages dynamically. The filter function will be called with the (low-level) message object and the port, and should return `true` if the message should be handled, and `false` otherwise. 166 | 167 | ### Example 168 | 169 | This example involves a connection that is established from a content script to a service worker. 170 | 171 | Other sorts of connections are possible like in the opposite direction (from a service worker to a content script), to a different extension, or to a native application. The MDN page linked above provides more information on the different types of connection APIs. 172 | 173 | In a content script: 174 | 175 | ```ts 176 | import { createTransportFromBrowserRuntimePort } from "rpc-anywhere"; 177 | 178 | const port = browser.runtime.connect({ name: "my-rpc-port" }); 179 | 180 | const rpc = createRPC({ 181 | transport: createTransportFromBrowserRuntimePort(port), 182 | // ... 183 | }); 184 | // ... 185 | ``` 186 | 187 | In a service worker: 188 | 189 | ```ts 190 | import { createTransportFromBrowserRuntimePort } from "rpc-anywhere"; 191 | 192 | browser.runtime.onConnect.addListener((port) => { 193 | if (port.name === "my-rpc-port") { 194 | const rpc = createRPC({ 195 | transport: createTransportFromBrowserRuntimePort(port), 196 | // ... 197 | }); 198 | // ... 199 | } 200 | }); 201 | ``` 202 | 203 | ## Workers 204 | 205 | ### API 206 | 207 | ```ts 208 | export function createWorkerTransport( 209 | worker: Worker, 210 | options?: { 211 | transportId?: string | number; 212 | filter?: (event: MessageEvent) => boolean; 213 | }, 214 | ): RPCTransport; 215 | ``` 216 | 217 | ```ts 218 | export function createWorkerParentTransport( 219 | worker: Worker, 220 | options?: { 221 | transportId?: string | number; 222 | filter?: (event: MessageEvent) => boolean; 223 | }, 224 | ): RPCTransport; 225 | ``` 226 | 227 | ### Description 228 | 229 | Create transports between a worker and its parent context. 230 | 231 | - `createWorkerTransport` is used from the _parent context_, and it creates a transport that exchanges messages with the _worker_. 232 | - `createWorkerParentTransport` is used from the _worker_, and it creates a transport that exchanges messages with the _parent context_. 233 | 234 | The `transportId` option can be used to avoid potential conflicts with other messages and transports. It must be unique and match on both ends. 235 | 236 | ### Example 237 | 238 | In the parent context: 239 | 240 | ```ts 241 | import { createWorkerTransport } from "rpc-anywhere"; 242 | 243 | const worker = new Worker("worker.js"); 244 | 245 | const rpc = createRPC({ 246 | transport: createWorkerTransport(worker), 247 | // ... 248 | }); 249 | // ... 250 | ``` 251 | 252 | In the worker: 253 | 254 | ```ts 255 | import { createWorkerParentTransport } from "rpc-anywhere"; 256 | 257 | const rpc = createRPC({ 258 | transport: createWorkerParentTransport(), 259 | // ... 260 | }); 261 | // ... 262 | ``` 263 | 264 | ## Broadcast channels 265 | 266 | ### API 267 | 268 | ```ts 269 | export function createTransportFromBroadcastChannel( 270 | channel: BroadcastChannel, 271 | options?: { 272 | transportId?: string | number; 273 | filter?: (event: MessageEvent) => boolean; 274 | }, 275 | ): RPCTransport; 276 | ``` 277 | 278 | ### Description 279 | 280 | Create transports from broadcast channels. 281 | 282 | A [`BroadcastChannel`](https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel) can be used to communicate between different windows, tabs, iframes, workers... It can be used to send and receive messages to/from all other `BroadcastChannel` objects with the same name. 283 | 284 | Broadcast channels can be tricky because there can be more than two instances of the same channel, and messages are received by all of them. While RPC Anywhere is not necessarily limited to a connection between _only_ two endpoints, this is an advanced pattern that requires careful consideration. 285 | 286 | To avoid issues, it is recommended to avoid using requests since they are designed for one-to-one communication (unless you know what you're doing). Sending messages is perfectly fine, and will be received by all other instances of the channel. 287 | 288 | ### Example 289 | 290 | ```ts 291 | import { createTransportFromBroadcastChannel } from "rpc-anywhere"; 292 | 293 | const channel = new BroadcastChannel("my-channel"); 294 | 295 | const rpc = createRPC({ 296 | transport: createTransportFromBroadcastChannel(channel), 297 | // ... 298 | }); 299 | // ... 300 | ``` 301 | 302 | ## Message ports: windows, workers, broadcast channels 303 | 304 | > **Warning:** this API is low-level and requires a good understanding of the target environment and its APIs. It is recommended to use the higher-level APIs whenever possible: 305 | > 306 | > - For iframes, you can use `createIframeTransport` and `createIframeParentTransport`. 307 | > - For workers, you can use `createWorkerTransport` and `createWorkerParentTransport`. 308 | > - For broadcast channels, you can use `createTransportFromBroadcastChannel`. 309 | 310 | ### API 311 | 312 | ```ts 313 | export function createTransportFromMessagePort( 314 | port: 315 | | MessagePort 316 | | Window 317 | | Worker 318 | | ServiceWorkerContainer 319 | | BroadcastChannel, 320 | options?: { 321 | transportId?: string | number; 322 | filter?: (event: MessageEvent) => boolean; 323 | remotePort?: 324 | | MessagePort 325 | | Window 326 | | Worker 327 | | ServiceWorker 328 | | Client 329 | | BroadcastChannel; 330 | }, 331 | ): RPCTransport; 332 | ``` 333 | 334 | ### Description 335 | 336 | Create transports from message ports. 337 | 338 | Works with [`MessagePort`](https://developer.mozilla.org/en-US/docs/Web/API/MessagePort) instances and any objects that implement a similar interface: `addEventListener("message", listener)` and `postMessage(message)`. This is the case for window objects (including iframes), different types of workers, and broadcast channels. Here's a quick breakdown: 339 | 340 | - **[Window](https://developer.mozilla.org/en-US/docs/Web/API/Window)**: the global `window` or the `contentWindow` of an iframe. 341 | - **[Worker](https://developer.mozilla.org/en-US/docs/Web/API/Worker)**: a web worker. Other kinds of workers (like [service workers](https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorker) and [worklets](https://developer.mozilla.org/en-US/docs/Web/API/Worklet)) are also supported through their respective interfaces. 342 | - **[BroadcastChannel](https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel)**: a special kind of message port that can send messages to all other `BroadcastChannel` objects with the same name. It can be used to communicate between different windows, tabs, iframes, workers... 343 | 344 | In most cases, all inbound and outbound messages are handled by the same port. However, in some cases, inbound messages are handled by one port, and outbound messages are sent to another. For example, this is the case for parent and iframe windows, where messages are received by the parent's window (`window.addEventListener("message", listener)`) but sent through the iframe's window (`iframe.contentWindow.postMessage(message)`). 345 | 346 | In those cases, you can use the `remotePort` option to specify the port that outgoing messages will be sent to. 347 | 348 | When creating an RPC connection through message ports, you have to consider the following: 349 | 350 | - Each type of target is different and has a specific way to establish connections, handle lifecycles, and send/receive messages. 351 | - You may need to wait for one or both of the endpoints to load. 352 | - A single target port can potentially receive connections and messages from multiple sources. For example, a `window` object can receive messages from multiple iframes (some might even be out of your control). To make sure that your RPC messages are not mixed with other messages, you can use the `transportId` option to ensure that only messages that match that specific ID are handled. 353 | - For advanced use cases, you can use the `filter` option to filter messages dynamically. The filter function will be called with the raw `MessageEvent` object, and should return `true` if the message should be handled, and `false` otherwise. 354 | 355 | --- 356 | 357 |
358 | 359 | [**Previous: RPC**](./1-rpc.md) 360 | 361 |
362 |
363 | 364 | [**Next: Bridging transports**](./3-bridging-transports.md) 365 | 366 |
367 | -------------------------------------------------------------------------------- /src/rpc.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type _RPCMessagePacket, 3 | type _RPCMessagePacketFromSchema, 4 | type _RPCPacket, 5 | type _RPCRequestPacket, 6 | type _RPCRequestPacketFromSchema, 7 | type _RPCResponsePacket, 8 | type _RPCResponsePacketFromSchema, 9 | type RPCMessageHandlerFn, 10 | type RPCMessagePayload, 11 | type RPCMessagesProxy, 12 | type RPCRequestHandler, 13 | type RPCRequestHandlerFn, 14 | type RPCRequestResponse, 15 | type RPCRequestsProxy, 16 | type RPCSchema, 17 | type RPCTransport, 18 | type WildcardRPCMessageHandlerFn, 19 | } from "./types.js"; 20 | 21 | const MAX_ID = 1e10; 22 | const DEFAULT_MAX_REQUEST_TIME = 1000; 23 | 24 | function missingTransportMethodError(methods: string[], action: string) { 25 | const methodsString = methods.map((method) => `"${method}"`).join(", "); 26 | return new Error( 27 | `This RPC instance cannot ${action} because the transport did not provide one or more of these methods: ${methodsString}`, 28 | ); 29 | } 30 | 31 | type DebugHooks = { 32 | /** 33 | * A function that will be called when the RPC sends a low-level 34 | * message. 35 | */ 36 | onSend?: (packet: _RPCPacket) => void; 37 | 38 | /** 39 | * A function that will be called when the RPC receives a low-level 40 | * message. 41 | */ 42 | onReceive?: (packet: _RPCPacket) => void; 43 | }; 44 | 45 | export type _RPCOptions = { 46 | /** 47 | * A transport object that will be used to send and receive 48 | * messages. 49 | */ 50 | transport?: RPCTransport; 51 | 52 | /** 53 | * The functions that will be used to handle requests. 54 | */ 55 | requestHandler?: RPCRequestHandler; 56 | 57 | /** 58 | * The maximum time to wait for a response to a request, in 59 | * milliseconds. If exceeded, the promise will be rejected. 60 | * @default 1000 61 | */ 62 | maxRequestTime?: number; 63 | 64 | /** 65 | * A collection of optional functions that will be called when 66 | * the RPC sends or receives a low-level message. Useful for 67 | * debugging and logging. 68 | */ 69 | _debugHooks?: DebugHooks; 70 | }; 71 | 72 | export function _createRPC< 73 | Schema extends RPCSchema = RPCSchema, 74 | RemoteSchema extends RPCSchema = Schema, 75 | >( 76 | /** 77 | * The options that will be used to configure the RPC instance. 78 | */ 79 | options: _RPCOptions = {}, 80 | ) { 81 | // setters 82 | // ------- 83 | 84 | let debugHooks: DebugHooks = {}; 85 | 86 | /** 87 | * Sets the debug hooks that will be used to debug the RPC instance. 88 | */ 89 | function _setDebugHooks(newDebugHooks: DebugHooks) { 90 | debugHooks = newDebugHooks; 91 | } 92 | 93 | let transport: RPCTransport = {}; 94 | 95 | /** 96 | * Sets the transport that will be used to send and receive requests, 97 | * responses and messages. 98 | */ 99 | function setTransport(newTransport: RPCTransport) { 100 | if (transport.unregisterHandler) transport.unregisterHandler(); 101 | transport = newTransport; 102 | transport.registerHandler?.(handler); 103 | } 104 | 105 | let requestHandler: RPCRequestHandlerFn | undefined = 106 | undefined; 107 | 108 | /** 109 | * Sets the function that will be used to handle requests from the 110 | * remote RPC instance. 111 | */ 112 | function setRequestHandler( 113 | /** 114 | * The function that will be set as the "request handler" function. 115 | */ 116 | handler: RPCRequestHandler, 117 | ) { 118 | if (typeof handler === "function") { 119 | requestHandler = handler; 120 | return; 121 | } 122 | requestHandler = (method: keyof Schema["requests"], params: any) => { 123 | const handlerFn = handler[method]; 124 | if (handlerFn) return handlerFn(params); 125 | const fallbackHandler = handler._; 126 | if (!fallbackHandler) 127 | throw new Error( 128 | `The requested method has no handler: ${method as string}`, 129 | ); 130 | return fallbackHandler(method, params); 131 | }; 132 | } 133 | 134 | // options 135 | // ------- 136 | 137 | const { maxRequestTime = DEFAULT_MAX_REQUEST_TIME } = options; 138 | if (options.transport) setTransport(options.transport); 139 | if (options.requestHandler) setRequestHandler(options.requestHandler); 140 | if (options._debugHooks) _setDebugHooks(options._debugHooks); 141 | 142 | // requests 143 | // -------- 144 | 145 | let lastRequestId = 0; 146 | function getRequestId() { 147 | if (lastRequestId <= MAX_ID) return ++lastRequestId; 148 | return (lastRequestId = 0); 149 | } 150 | const requestListeners = new Map< 151 | number, 152 | { resolve: (result: unknown) => void; reject: (error: Error) => void } 153 | >(); 154 | const requestTimeouts = new Map>(); 155 | 156 | /** 157 | * Sends a request to the remote RPC endpoint and returns a promise 158 | * with the response. 159 | */ 160 | function requestFn( 161 | method: Method, 162 | ...args: "params" extends keyof RemoteSchema["requests"][Method] 163 | ? undefined extends RemoteSchema["requests"][Method]["params"] 164 | ? [params?: RemoteSchema["requests"][Method]["params"]] 165 | : [params: RemoteSchema["requests"][Method]["params"]] 166 | : [] 167 | ): Promise> { 168 | const params = args[0]; 169 | return new Promise((resolve, reject) => { 170 | if (!transport.send) 171 | throw missingTransportMethodError(["send"], "make requests"); 172 | const requestId = getRequestId(); 173 | const request: _RPCRequestPacket = { 174 | type: "request", 175 | id: requestId, 176 | method, 177 | params, 178 | }; 179 | requestListeners.set(requestId, { resolve, reject }); 180 | if (maxRequestTime !== Infinity) 181 | requestTimeouts.set( 182 | requestId, 183 | setTimeout(() => { 184 | requestTimeouts.delete(requestId); 185 | reject(new Error("RPC request timed out.")); 186 | }, maxRequestTime), 187 | ); 188 | debugHooks.onSend?.(request); 189 | transport.send(request); 190 | }) as Promise; 191 | } 192 | 193 | /** 194 | * Sends a request to the remote RPC endpoint and returns a promise 195 | * with the response. 196 | * 197 | * It can also be used as a proxy to send requests by using the request 198 | * name as a property name. 199 | * 200 | * @example 201 | * 202 | * ```js 203 | * await rpc.request("methodName", { param: "value" }); 204 | * // or 205 | * await rpc.request.methodName({ param: "value" }); 206 | * ``` 207 | */ 208 | const request = new Proxy(requestFn, { 209 | get: (target, prop, receiver) => { 210 | if (prop in target) return Reflect.get(target, prop, receiver); 211 | // @ts-expect-error Not very important. 212 | return (params: unknown) => requestFn(prop, params); 213 | }, 214 | }) as typeof requestFn & RPCRequestsProxy; 215 | 216 | const requestProxy = request as RPCRequestsProxy; 217 | 218 | // messages 219 | // -------- 220 | 221 | function sendFn( 222 | /** 223 | * The name of the message to send. 224 | */ 225 | message: Message, 226 | ...args: void extends RPCMessagePayload 227 | ? [] 228 | : undefined extends RPCMessagePayload 229 | ? [payload?: RPCMessagePayload] 230 | : [payload: RPCMessagePayload] 231 | ) { 232 | const payload = args[0]; 233 | if (!transport.send) 234 | throw missingTransportMethodError(["send"], "send messages"); 235 | const rpcMessage: _RPCMessagePacket = { 236 | type: "message", 237 | id: message as string, 238 | payload, 239 | }; 240 | debugHooks.onSend?.(rpcMessage); 241 | transport.send(rpcMessage); 242 | } 243 | 244 | /** 245 | * Sends a message to the remote RPC endpoint. 246 | * 247 | * It can also be used as a proxy to send messages by using the message 248 | * name as a property name. 249 | * 250 | * @example 251 | * 252 | * ```js 253 | * rpc.send("messageName", { content: "value" }); 254 | * // or 255 | * rpc.send.messageName({ content: "value" }); 256 | * ``` 257 | */ 258 | const send = new Proxy(sendFn, { 259 | get: (target, prop, receiver) => { 260 | if (prop in target) return Reflect.get(target, prop, receiver); 261 | // @ts-expect-error Not very important. 262 | return (payload: unknown) => sendFn(prop, payload); 263 | }, 264 | }) as typeof sendFn & RPCMessagesProxy; 265 | 266 | const sendProxy = send as RPCMessagesProxy; 267 | 268 | const messageListeners = new Map void>>(); 269 | const wildcardMessageListeners = new Set< 270 | (messageName: any, payload: any) => void 271 | >(); 272 | 273 | /** 274 | * Adds a listener for a message (or all if "*" is used) from the 275 | * remote RPC endpoint. 276 | */ 277 | function addMessageListener( 278 | /** 279 | * The name of the message to listen to. Use "*" to listen to all 280 | * messages. 281 | */ 282 | message: "*", 283 | /** 284 | * The function that will be called when a message is received. 285 | */ 286 | listener: WildcardRPCMessageHandlerFn, 287 | ): void; 288 | /** 289 | * Adds a listener for a message (or all if "*" is used) from the 290 | * remote RPC endpoint. 291 | */ 292 | function addMessageListener( 293 | /** 294 | * The name of the message to listen to. Use "*" to listen to all 295 | * messages. 296 | */ 297 | message: Message, 298 | /** 299 | * The function that will be called when a message is received. 300 | */ 301 | listener: RPCMessageHandlerFn, 302 | ): void; 303 | /** 304 | * Adds a listener for a message (or all if "*" is used) from the 305 | * remote RPC endpoint. 306 | */ 307 | function addMessageListener( 308 | /** 309 | * The name of the message to listen to. Use "*" to listen to all 310 | * messages. 311 | */ 312 | message: "*" | Message, 313 | /** 314 | * The function that will be called when a message is received. 315 | */ 316 | listener: 317 | | WildcardRPCMessageHandlerFn 318 | | RPCMessageHandlerFn, 319 | ): void { 320 | if (!transport.registerHandler) 321 | throw missingTransportMethodError( 322 | ["registerHandler"], 323 | "register message listeners", 324 | ); 325 | if (message === "*") { 326 | wildcardMessageListeners.add(listener as any); 327 | return; 328 | } 329 | if (!messageListeners.has(message)) 330 | messageListeners.set(message, new Set()); 331 | messageListeners.get(message)?.add(listener as any); 332 | } 333 | 334 | /** 335 | * Removes a listener for a message (or all if "*" is used) from the 336 | * remote RPC endpoint. 337 | */ 338 | function removeMessageListener( 339 | /** 340 | * The name of the message to remove the listener for. Use "*" to 341 | * remove a listener for all messages. 342 | */ 343 | message: "*", 344 | /** 345 | * The listener function that will be removed. 346 | */ 347 | listener: WildcardRPCMessageHandlerFn, 348 | ): void; 349 | /** 350 | * Removes a listener for a message (or all if "*" is used) from the 351 | * remote RPC endpoint. 352 | */ 353 | function removeMessageListener< 354 | Message extends keyof RemoteSchema["messages"], 355 | >( 356 | /** 357 | * The name of the message to remove the listener for. Use "*" to 358 | * remove a listener for all messages. 359 | */ 360 | message: Message, 361 | /** 362 | * The listener function that will be removed. 363 | */ 364 | listener: RPCMessageHandlerFn, 365 | ): void; 366 | /** 367 | * Removes a listener for a message (or all if "*" is used) from the 368 | * remote RPC endpoint. 369 | */ 370 | function removeMessageListener< 371 | Message extends keyof RemoteSchema["messages"], 372 | >( 373 | /** 374 | * The name of the message to remove the listener for. Use "*" to 375 | * remove a listener for all messages. 376 | */ 377 | message: "*" | Message, 378 | /** 379 | * The listener function that will be removed. 380 | */ 381 | listener: 382 | | WildcardRPCMessageHandlerFn 383 | | RPCMessageHandlerFn, 384 | ): void { 385 | if (message === "*") { 386 | wildcardMessageListeners.delete(listener as any); 387 | return; 388 | } 389 | messageListeners.get(message)?.delete(listener as any); 390 | if (messageListeners.get(message)?.size === 0) 391 | messageListeners.delete(message); 392 | } 393 | 394 | // message handling 395 | // ---------------- 396 | 397 | async function handler( 398 | message: 399 | | _RPCRequestPacketFromSchema 400 | | _RPCResponsePacketFromSchema 401 | | _RPCMessagePacketFromSchema, 402 | ) { 403 | debugHooks.onReceive?.(message); 404 | if (!("type" in message)) 405 | throw new Error("Message does not contain a type."); 406 | if (message.type === "request") { 407 | if (!transport.send || !requestHandler) 408 | throw missingTransportMethodError( 409 | ["send", "requestHandler"], 410 | "handle requests", 411 | ); 412 | const { id, method, params } = message; 413 | let response: _RPCResponsePacket; 414 | try { 415 | response = { 416 | type: "response", 417 | id, 418 | success: true, 419 | payload: await requestHandler(method, params), 420 | }; 421 | } catch (error) { 422 | if (!(error instanceof Error)) throw error; 423 | response = { 424 | type: "response", 425 | id, 426 | success: false, 427 | error: error.message, 428 | }; 429 | } 430 | debugHooks.onSend?.(response); 431 | transport.send(response); 432 | return; 433 | } 434 | if (message.type === "response") { 435 | const timeout = requestTimeouts.get(message.id); 436 | if (timeout != null) clearTimeout(timeout); 437 | const { resolve, reject } = requestListeners.get(message.id) ?? {}; 438 | if (!message.success) reject?.(new Error(message.error)); 439 | else resolve?.(message.payload); 440 | return; 441 | } 442 | if (message.type === "message") { 443 | for (const listener of wildcardMessageListeners) 444 | listener(message.id, message.payload); 445 | const listeners = messageListeners.get(message.id); 446 | if (!listeners) return; 447 | for (const listener of listeners) listener(message.payload); 448 | return; 449 | } 450 | throw new Error(`Unexpected RPC message type: ${(message as any).type}`); 451 | } 452 | 453 | // proxy 454 | // ----- 455 | 456 | /** 457 | * A proxy object that can be used to send requests and messages. 458 | */ 459 | const proxy = { send: sendProxy, request: requestProxy }; 460 | 461 | return { 462 | setTransport, 463 | setRequestHandler, 464 | request, 465 | requestProxy, 466 | send, 467 | sendProxy, 468 | addMessageListener, 469 | removeMessageListener, 470 | proxy, 471 | _setDebugHooks, 472 | }; 473 | } 474 | 475 | export type RPCInstance< 476 | Schema extends RPCSchema = RPCSchema, 477 | RemoteSchema extends RPCSchema = Schema, 478 | > = ReturnType>; 479 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { type _RPCOptions, type RPCInstance } from "./rpc.js"; 2 | 3 | // data 4 | // ---- 5 | 6 | /** 7 | * A low-level RPC message representing a request. 8 | */ 9 | export type _RPCRequestPacket = { 10 | /** 11 | * The type of the message. 12 | */ 13 | type: "request"; 14 | /** 15 | * The ID of the request. Used to match responses to requests. 16 | */ 17 | id: number; 18 | /** 19 | * The method to call. 20 | */ 21 | method: Method; 22 | /** 23 | * The parameters to pass to the method. 24 | */ 25 | params: Params; 26 | }; 27 | 28 | /** 29 | * A low-level RPC message representing a response. 30 | */ 31 | export type _RPCResponsePacket = 32 | | { 33 | /** 34 | * The type of the message. 35 | */ 36 | type: "response"; 37 | /** 38 | * The ID of the request. Used to match responses to requests. 39 | */ 40 | id: number; 41 | /** 42 | * Whether the request was successful. 43 | */ 44 | success: true; 45 | /** 46 | * The response payload. 47 | */ 48 | payload: Payload; 49 | } 50 | | { 51 | /** 52 | * The type of the message. 53 | */ 54 | type: "response"; 55 | /** 56 | * The ID of the request. Used to match responses to requests. 57 | */ 58 | id: number; 59 | /** 60 | * Whether the request was successful. 61 | */ 62 | success: false; 63 | /** 64 | * The error message. 65 | */ 66 | error?: string; 67 | }; 68 | 69 | /** 70 | * A low-level RPC message representing a message. 71 | */ 72 | export type _RPCMessagePacket = { 73 | /** 74 | * The type of the message. 75 | */ 76 | type: "message"; 77 | /** 78 | * The ID of the message. Also called "message name" in some contexts. 79 | */ 80 | id: string; 81 | /** 82 | * The message payload. 83 | */ 84 | payload: Payload; 85 | }; 86 | 87 | /** 88 | * A low-level RPC message. 89 | */ 90 | export type _RPCPacket = 91 | | _RPCRequestPacket 92 | | _RPCResponsePacket 93 | | _RPCMessagePacket; 94 | 95 | // requests 96 | // -------- 97 | 98 | type BaseRPCRequestsSchema = Record< 99 | never, 100 | { params?: unknown; response?: unknown } 101 | >; 102 | 103 | /** 104 | * A schema for requests. 105 | */ 106 | export type RPCRequestsSchema< 107 | RequestsSchema extends BaseRPCRequestsSchema = BaseRPCRequestsSchema, 108 | > = RequestsSchema; 109 | 110 | /** 111 | * A utility type for getting the request params from a schema. 112 | * If a method is provided, it will return the params for that method. 113 | * Otherwise, it will return a union of params for all methods. 114 | */ 115 | export type RPCRequestParams< 116 | RequestsSchema extends RPCRequestsSchema, 117 | Method extends keyof RequestsSchema = keyof RequestsSchema, 118 | > = "params" extends keyof RequestsSchema[Method] 119 | ? RequestsSchema[Method]["params"] 120 | : never; 121 | /** 122 | * A utility type for getting the request response from a schema. 123 | * If a method is provided, it will return the response for that method. 124 | * Otherwise, it will return a union of responses for all methods. 125 | */ 126 | export type RPCRequestResponse< 127 | RequestsSchema extends RPCRequestsSchema, 128 | Method extends keyof RequestsSchema = keyof RequestsSchema, 129 | > = "response" extends keyof RequestsSchema[Method] 130 | ? RequestsSchema[Method]["response"] 131 | : void; 132 | 133 | /** 134 | * A utility type for getting the request low-level message from 135 | * a schema. If a method is provided, it will return the message 136 | * for that method. Otherwise, it will return a union of messages 137 | * for all methods. 138 | */ 139 | export type _RPCRequestPacketFromSchema< 140 | RequestsSchema extends RPCRequestsSchema, 141 | Method extends keyof RequestsSchema = keyof RequestsSchema, 142 | > = _RPCRequestPacket>; 143 | /** 144 | * A utility type for getting the response low-level message from 145 | * a schema. If a method is provided, it will return the message 146 | * for that method. Otherwise, it will return a union of messages 147 | * for all methods. 148 | */ 149 | export type _RPCResponsePacketFromSchema< 150 | RequestsSchema extends RPCRequestsSchema, 151 | Method extends keyof RequestsSchema = keyof RequestsSchema, 152 | > = _RPCResponsePacket>; 153 | 154 | /** 155 | * A request handler in "function" form. 156 | */ 157 | export type RPCRequestHandlerFn< 158 | RequestsSchema extends RPCRequestsSchema = RPCRequestsSchema, 159 | > = ( 160 | /** 161 | * The method that has been called. 162 | */ 163 | method: Method, 164 | /** 165 | * The parameters that have been passed. 166 | */ 167 | params: RPCRequestParams, 168 | ) => any | Promise; 169 | /** 170 | * A request handler in "object" form. 171 | */ 172 | export type RPCRequestHandlerObject< 173 | RequestsSchema extends RPCRequestsSchema = RPCRequestsSchema, 174 | > = { 175 | [Method in keyof RequestsSchema]?: ( 176 | /** 177 | * The parameters that have been passed. 178 | */ 179 | ...args: "params" extends keyof RequestsSchema[Method] 180 | ? undefined extends RequestsSchema[Method]["params"] 181 | ? [params?: RequestsSchema[Method]["params"]] 182 | : [params: RequestsSchema[Method]["params"]] 183 | : [] 184 | ) => 185 | | Awaited> 186 | | Promise>>; 187 | } & { 188 | /** 189 | * A fallback method that will be called if no other method 190 | * matches the request. 191 | */ 192 | _?: ( 193 | /** 194 | * The method that has been called. 195 | */ 196 | method: keyof RequestsSchema, 197 | /** 198 | * The parameters that have been passed. 199 | */ 200 | params: RPCRequestParams, 201 | ) => any; 202 | // TODO: this return type causes some problems. 203 | // | RPCRequestResponse 204 | // | Promise>; 205 | }; 206 | /** 207 | * A request handler. 208 | */ 209 | export type RPCRequestHandler< 210 | RequestsSchema extends RPCRequestsSchema = RPCRequestsSchema, 211 | > = 212 | | RPCRequestHandlerFn 213 | | RPCRequestHandlerObject; 214 | 215 | type ParamsFromFunction any> = 216 | Parameters extends [] 217 | ? unknown 218 | : undefined extends Parameters[0] 219 | ? { 220 | /** 221 | * The method's parameters. 222 | */ 223 | params?: Parameters[0]; 224 | } 225 | : { 226 | /** 227 | * The method's parameters. 228 | */ 229 | params: Parameters[0]; 230 | }; 231 | type ReturnFromFunction any> = 232 | void extends ReturnType 233 | ? unknown 234 | : { 235 | /** 236 | * The method's response payload. 237 | */ 238 | response: Awaited>; 239 | }; 240 | type Flatten = { [K in keyof T]: T[K] }; 241 | type VoidIfEmpty = T extends NonNullable ? Flatten : void; 242 | type RequestDefinitionFromFunction any> = 243 | VoidIfEmpty & ReturnFromFunction>; 244 | 245 | /** 246 | * A utility type for getting the request schema from a request handler 247 | * created with `createRPCRequestHandler`. 248 | */ 249 | export type RPCRequestSchemaFromHandler< 250 | Handler extends RPCRequestHandlerObject, 251 | > = { 252 | -readonly [Method in keyof Omit as Handler[Method] extends ( 253 | ...args: any 254 | ) => any 255 | ? // is function 256 | Method 257 | : // is not function 258 | never]: Handler[Method] extends (...args: any) => any 259 | ? // is function 260 | RequestDefinitionFromFunction 261 | : // is not function 262 | never; 263 | }; 264 | 265 | /** 266 | * A request proxy that allows calling requests as methods. 267 | */ 268 | export type RPCRequestsProxy = { 269 | [K in keyof RequestsSchema]: ( 270 | ...args: "params" extends keyof RequestsSchema[K] 271 | ? undefined extends RequestsSchema[K]["params"] 272 | ? [params?: RequestsSchema[K]["params"]] 273 | : [params: RequestsSchema[K]["params"]] 274 | : [] 275 | ) => Promise>; 276 | }; 277 | 278 | // messages 279 | // -------- 280 | 281 | type BaseRPCMessagesSchema = Record; 282 | 283 | /** 284 | * A schema for messages. 285 | */ 286 | export type RPCMessagesSchema< 287 | MessagesSchema extends BaseRPCMessagesSchema = BaseRPCMessagesSchema, 288 | > = MessagesSchema; 289 | 290 | /** 291 | * A utility type for getting the message payload from a schema. 292 | * If a message name is provided, it will return the payload for 293 | * that message. Otherwise, it will return a union of payloads 294 | * for all messages. 295 | */ 296 | export type RPCMessagePayload< 297 | MessagesSchema extends RPCMessagesSchema, 298 | MessageName extends keyof MessagesSchema = keyof MessagesSchema, 299 | > = MessagesSchema[MessageName]; 300 | 301 | /** 302 | * A utility type for getting the message low-level message from 303 | * a schema. If a message name is provided, it will return the 304 | * message for that message. Otherwise, it will return a union 305 | * of messages for all messages. 306 | */ 307 | export type _RPCMessagePacketFromSchema< 308 | MessagesSchema extends RPCMessagesSchema, 309 | MessageName extends keyof MessagesSchema = keyof MessagesSchema, 310 | > = _RPCMessagePacket>; 311 | 312 | /** 313 | * A message handler for a specific message. 314 | */ 315 | export type RPCMessageHandlerFn< 316 | MessagesSchema extends RPCMessagesSchema, 317 | MessageName extends keyof MessagesSchema, 318 | > = (payload: RPCMessagePayload) => void; 319 | /** 320 | * A message handler for all messages. 321 | */ 322 | export type WildcardRPCMessageHandlerFn< 323 | MessagesSchema extends RPCMessagesSchema, 324 | > = ( 325 | messageName: keyof MessagesSchema, 326 | payload: RPCMessagePayload, 327 | ) => void; 328 | 329 | /** 330 | * A message proxy that allows sending messages through methods. 331 | */ 332 | export type RPCMessagesProxy = { 333 | [K in keyof MessagesSchema]-?: ( 334 | ...args: void extends MessagesSchema[K] 335 | ? [] 336 | : undefined extends MessagesSchema[K] 337 | ? [payload?: MessagesSchema[K]] 338 | : [payload: MessagesSchema[K]] 339 | ) => void; 340 | }; 341 | 342 | // schema 343 | // ------ 344 | 345 | type InputRPCSchema = { 346 | /** 347 | * A schema for requests. 348 | */ 349 | requests?: RPCRequestsSchema; 350 | /** 351 | * A schema for messages. 352 | */ 353 | messages?: RPCMessagesSchema; 354 | }; 355 | type ResolvedRPCSchema< 356 | InputSchema extends InputRPCSchema, 357 | RequestHandler extends RPCRequestHandlerObject | undefined = undefined, 358 | > = { 359 | /** 360 | * A schema for requests. 361 | */ 362 | requests: RequestHandler extends RPCRequestHandlerObject 363 | ? RPCRequestSchemaFromHandler 364 | : undefined extends InputSchema["requests"] 365 | ? BaseRPCRequestsSchema 366 | : NonNullable; 367 | /** 368 | * A schema for messages. 369 | */ 370 | messages: undefined extends InputSchema["messages"] 371 | ? BaseRPCMessagesSchema 372 | : NonNullable; 373 | }; 374 | /** 375 | * A schema for requests and messages. 376 | */ 377 | export type RPCSchema< 378 | InputSchema extends InputRPCSchema | void = InputRPCSchema, 379 | RequestHandler extends RPCRequestHandlerObject | undefined = undefined, 380 | > = ResolvedRPCSchema< 381 | InputSchema extends InputRPCSchema ? InputSchema : InputRPCSchema, 382 | RequestHandler 383 | >; 384 | 385 | /** 386 | * An "empty" schema. Represents an RPC endpoint that doesn't 387 | * handle any requests or send any messages ("client"). 388 | */ 389 | export type EmptyRPCSchema = RPCSchema; 390 | 391 | // transports 392 | // ---------- 393 | 394 | export type RPCTransportHandler = (data: any) => void; 395 | 396 | /** 397 | * A transport object that will be used to send and receive 398 | * messages. 399 | */ 400 | export type RPCTransport = { 401 | /** 402 | * The function that will be used to send requests, responses, 403 | * and messages. 404 | */ 405 | send?: (data: any) => void; 406 | /** 407 | * The function that will be used to register a handler for 408 | * incoming requests, responses, and messages. 409 | */ 410 | registerHandler?: (handler: RPCTransportHandler) => void; 411 | /** 412 | * The function that will be used to unregister the handler 413 | * (to clean up when replacing the transport). 414 | */ 415 | unregisterHandler?: () => void; 416 | }; 417 | 418 | // options 419 | // ------- 420 | 421 | type RPCBaseOption = "transport" | "_debugHooks"; 422 | type RPCRequestsInOption = "requestHandler"; 423 | type RPCRequestsOutOption = "maxRequestTime"; 424 | 425 | type OptionsByLocalSchema = 426 | NonNullable extends Schema["requests"] ? never : RPCRequestsInOption; 427 | 428 | type OptionsByRemoteSchema = 429 | NonNullable extends RemoteSchema["requests"] 430 | ? never 431 | : RPCRequestsOutOption; 432 | 433 | /** 434 | * Options for creating an RPC instance, tailored to a specific 435 | * set of schemas. Options will be ommitted if they are not 436 | * supported according to the schemas. 437 | * 438 | * For example, if the remote schema doesn't have a `requests` 439 | * property, the `maxRequestTime` option will be omitted because 440 | * the instance won't be able to send requests. 441 | */ 442 | export type RPCOptions< 443 | Schema extends RPCSchema, 444 | RemoteSchema extends RPCSchema, 445 | > = Pick< 446 | _RPCOptions, 447 | | RPCBaseOption 448 | | OptionsByLocalSchema 449 | | OptionsByRemoteSchema 450 | >; 451 | 452 | // rpc 453 | // --- 454 | 455 | type RPCMethod = "setTransport"; 456 | type RPCRequestsInMethod = "setRequestHandler"; 457 | type RPCRequestsOutMethod = "request" | "requestProxy"; 458 | type RPCMessagesInMethod = "addMessageListener" | "removeMessageListener"; 459 | type RPCMessagesOutMethod = "send" | "sendProxy"; 460 | type RPCRequestsOutMessagesOutMethod = "proxy"; 461 | 462 | type MethodsByLocalSchema = 463 | | (NonNullable extends Schema["requests"] 464 | ? never 465 | : RPCRequestsInMethod) 466 | | (NonNullable extends Schema["messages"] 467 | ? never 468 | : RPCMessagesOutMethod); 469 | 470 | type MethodsByRemoteSchema = 471 | | (NonNullable extends RemoteSchema["requests"] 472 | ? never 473 | : RPCRequestsOutMethod) 474 | | (NonNullable extends RemoteSchema["messages"] 475 | ? never 476 | : RPCMessagesInMethod); 477 | type MethodsByRemoteSchemaAndLocalSchema< 478 | LocalSchema extends RPCSchema, 479 | RemoteSchema extends RPCSchema, 480 | > = 481 | NonNullable extends LocalSchema["messages"] 482 | ? never 483 | : NonNullable extends RemoteSchema["requests"] 484 | ? never 485 | : RPCRequestsOutMessagesOutMethod; 486 | 487 | /** 488 | * An RPC instance type, tailored to a specific set of schemas. 489 | * Methods will be ommitted if they are not supported according 490 | * to the schemas. 491 | * 492 | * For example, if the remote schema doesn't have a `requests` 493 | * property, the `request` method will be omitted because the 494 | * instance won't be able to send requests. 495 | */ 496 | export type RPC< 497 | Schema extends RPCSchema, 498 | RemoteSchema extends RPCSchema, 499 | > = Pick< 500 | RPCInstance, 501 | | RPCMethod 502 | | MethodsByLocalSchema 503 | | MethodsByRemoteSchema 504 | | MethodsByRemoteSchemaAndLocalSchema 505 | >; 506 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | The RPC Anywhere logo 3 |
4 |
5 |
6 | 7 | [![API reference](https://img.shields.io/badge/tsdocs-%23007EC6?style=flat&logo=typescript&logoColor=%23fff&label=API%20reference&labelColor=%23555555)](https://tsdocs.dev/docs/rpc-anywhere/) [![Bundle size](https://deno.bundlejs.com/?q=rpc-anywhere%40latest&treeshake=%5B%7B+createRPC+%7D%5D&badge=&badge-style=flat&badge-raster=false)](https://bundlejs.com/?q=rpc-anywhere%40latest&treeshake=%5B%7B+createRPC+%7D%5D) 8 | 9 |
10 | 11 | Create a type-safe RPC anywhere. 12 | 13 | > RPC Anywhere powers [Electrobun](https://www.electrobun.dev/), [Teampilot AI](https://teampilot.ai/), and more. 14 | 15 | ```bash 16 | npm i rpc-anywhere 17 | ``` 18 | 19 | [✨ Interactive iframe demo ✨](https://rpc-anywhere.dio.la/) 20 | 21 | --- 22 | 23 | RPC Anywhere lets you create RPCs in **any** context, as long as a transport layer (a way for messages to move between point A and point B) is provided. 24 | 25 | Designed to be the last RPC library you'll ever need, it ships with a few transports out of the box: iframes, Electron IPC, browser extensions, workers... 26 | 27 |
28 | What is an RPC? 29 | 30 | > In the context of this library, an RPC is a connection between two endpoints, which send messages to each other. 31 | > 32 | > If the sender expects a response, it's called a "request". A request can be thought of as a function call where the function is executed on the other side of the connection, and the result is sent back to the sender. 33 | > 34 | > [Learn more about the general concept of RPCs on Wikipedia.](https://www.wikiwand.com/en/Remote_procedure_call) 35 | 36 |
37 | 38 |
39 | What is a transport layer? 40 | 41 | > A transport layer is the "channel" through which messages are sent and received between point A and point B. Some very common examples of endpoints: 42 | > 43 | > - Websites: iframes, workers, `BroadcastChannel`. 44 | > - Browser extensions: content script ↔ service worker. 45 | > - Electron: renderer process ↔ main process. 46 | > - WebSocket. 47 | 48 |
49 | 50 |
51 | Why should I use RPC Anywhere? 52 | 53 | > While there are some really great RPC libraries out there, many of them are focused in a specific use-case, and come with trade-offs like being tied to a specific transport layer, very opinionated, very simple, or not type-safe. 54 | > 55 | > Because of this, many people end up creating their own RPC implementations, "reinventing the wheel" over and over again. [In a Twitter poll, over 75% of respondents said they had done it at some point.](https://x.com/daniguardio_la/status/1735854964574937483?s=20) You've probably done it too! 56 | > 57 | > By contrast, RPC Anywhere is designed to be the last RPC library you'll ever need. The features of a specific RPC (schema, requests, messages, etc.) are completely decoupled from the transport layer, so you can set it up and forget about it. 58 | > 59 | > In fact, you can replace the transport layer at any time, and the RPC will keep working exactly the same way (except that messages will travel through different means). 60 | > 61 | > RPC Anywhere manages to be flexible and simple without sacrificing robust type safety or ergonomics. It's also well-tested and packs a lot of features in a very small footprint (~1kb gzipped). 62 | > 63 | > If you're missing a feature, feel free to [file a feature request](https://github.com/DaniGuardiola/rpc-anywhere/issues/new?assignees=&labels=enhancement&projects=&template=feature-request.yaml)! The goal is to make RPC Anywhere the best RPC library out there. 64 | 65 |
66 | 67 | --- 68 | 69 | 70 | 71 | - [Features](#features) 72 | - [Usage example (parent window to iframe)](#usage-example-parent-window-to-iframe) 73 | - [Iframe script (`iframe.ts`)](#iframe-script-iframets) 74 | - [Parent window script (`parent.ts`)](#parent-window-script-parentts) 75 | - [Getting started](#getting-started) 76 | - [Schemas](#schemas) 77 | - [RPC instances](#rpc-instances) 78 | - [Messages](#messages) 79 | - [Requests](#requests) 80 | - [Documentation](#documentation) 81 | - [Type safety and features](#type-safety-and-features) 82 | - [Features under consideration](#features-under-consideration) 83 | - [Prior art](#prior-art) 84 | - [Contributing](#contributing) 85 | 86 | 90 | 91 | 92 | --- 93 | 94 | ## Features 95 | 96 | - Type-safe and extensively tested. 97 | - Transport agnostic, with ready-to-use transports: 98 | - Iframes. 99 | - Web workers. 100 | - Browser extensions. 101 | - Electron IPC (coming soon). 102 | - Broadcast channels. 103 | - Message ports: advanced use cases like service workers, worklets, etc. 104 | - Tiny (~1.4kb gzipped, transport included). 105 | - Flexible (no enforced client-server architecture). 106 | - Promise-based with optional proxy APIs (e.g. `rpc.requestName(params)`). 107 | - Schema type can be inferred from the request handlers. 108 | - Optional lazy initialization (e.g. `rpc.setTransport(transport)`). 109 | 110 | ## Usage example (parent window to iframe) 111 | 112 | This is a simplified example of an RPC connection between a parent window and an iframe. 113 | 114 | ### Iframe script (`iframe.ts`) 115 | 116 | ```ts 117 | import { 118 | createIframeParentTransport, 119 | createRPC, 120 | createRPCRequestHandler, 121 | type RPCSchema, 122 | } from "rpc-anywhere"; 123 | 124 | // import the parent's (remote) schema 125 | import { type ParentSchema } from "./parent.js"; 126 | 127 | // handle incoming requests from the parent 128 | const requestHandler = createRPCRequestHandler({ 129 | /** Greet a given target. */ 130 | greet: ({ 131 | name, 132 | }: { 133 | /** The target of the greeting. */ 134 | name: string; 135 | }) => `Hello, ${name}!`, // respond to the parent 136 | }); 137 | 138 | // create the iframe's schema 139 | export type IframeSchema = RPCSchema< 140 | { 141 | messages: { 142 | buttonClicked: { 143 | /** The button that was clicked. */ 144 | button: string; 145 | }; 146 | }; 147 | }, 148 | // request types can be inferred from the handler 149 | typeof requestHandler 150 | >; 151 | 152 | async function main() { 153 | // create the iframe's RPC 154 | const rpc = createRPC({ 155 | // wait for a connection with the parent window and 156 | // pass the transport to our RPC 157 | transport: await createIframeParentTransport({ transportId: "my-rpc" }), 158 | // provide the request handler 159 | requestHandler, 160 | }); 161 | 162 | // send a message to the parent 163 | blueButton.addEventListener("click", () => { 164 | rpc.send.buttonClicked({ button: "blue" }); 165 | }); 166 | 167 | // listen for messages from the iframe 168 | rpc.addMessageListener("userLoggedIn", ({ name }) => { 169 | console.log(`The user "${name}" logged in`); 170 | }); 171 | } 172 | 173 | main(); 174 | ``` 175 | 176 | ### Parent window script (`parent.ts`) 177 | 178 | ```ts 179 | import { createIframeTransport, createRPC, type RPCSchema } from "rpc-anywhere"; 180 | 181 | // import the iframe's (remote) schema 182 | import { type IframeSchema } from "./iframe.js"; 183 | 184 | // create the parent window's schema 185 | export type ParentSchema = RPCSchema<{ 186 | messages: { 187 | userLoggedIn: { 188 | /** The user's name. */ 189 | name: string; 190 | }; 191 | }; 192 | }>; 193 | 194 | async function main() { 195 | // create the parent window's RPC 196 | const rpc = createRPC({ 197 | // wait for a connection with the iframe and 198 | // pass the transport to our RPC 199 | transport: await createIframeTransport( 200 | document.getElementById("my-iframe"), 201 | { transportId: "my-rpc" }, 202 | ), 203 | }); 204 | 205 | // use the proxy API as an alias ✨ 206 | const iframe = rpc.proxy; 207 | 208 | // make a request to the iframe 209 | const greeting = await iframe.request.greet({ name: "world" }); 210 | console.log(greeting); // Hello, world! 211 | 212 | // send a message to the iframe 213 | onUserLoggedIn((user) => iframe.send.userLoggedIn({ name: user.name })); 214 | 215 | // listen for messages from the iframe 216 | rpc.addMessageListener("buttonClicked", ({ button }) => { 217 | console.log(`The button "${button}" was clicked`); 218 | }); 219 | } 220 | 221 | main(); 222 | ``` 223 | 224 | ## Getting started 225 | 226 | An RPC is a connection between two endpoints. In this connection, messages are exchanged in two ways: 227 | 228 | - **Requests:** messages sent expecting a response. 229 | - **Messages:** messages sent without expecting a response. 230 | 231 | RPC Anywhere is completely flexible, so there is no "server" or "client" in the traditional sense. Both endpoints can send or receive requests, responses and messages. 232 | 233 | Let's go through an example. 234 | 235 | ### Schemas 236 | 237 | First, we define the requests and messages supported by each endpoint: 238 | 239 | ```ts 240 | import { type RPCSchema } from "rpc-anywhere"; 241 | 242 | type ChefSchema = RPCSchema<{ 243 | requests: { 244 | cook: { 245 | params: { recipe: Recipe }; 246 | response: Dish; 247 | }; 248 | }; 249 | messages: { 250 | kitchenOpened: void; 251 | kitchenClosed: { reason: string }; 252 | }; 253 | }>; 254 | 255 | type ManagerSchema = RPCSchema<{ 256 | requests: { 257 | getIngredients: { 258 | params: { neededIngredients: IngredientList }; 259 | response: Ingredient[]; 260 | }; 261 | }; 262 | messages: { 263 | shiftStarted: void; 264 | shiftEnded: void; 265 | takingABreak: { reason: string; duration: number }; 266 | }; 267 | }>; 268 | ``` 269 | 270 | ### RPC instances 271 | 272 | Then, we create each RPC instance: 273 | 274 | ```ts 275 | import { createRPC } from "rpc-anywhere"; 276 | 277 | // chef-rpc.ts 278 | const chefRpc = createRPC({ 279 | transport: createRestaurantTransport(), 280 | }); 281 | 282 | // manager-rpc.ts 283 | const managerRpc = createRPC({ 284 | transport: createRestaurantTransport(), 285 | }); 286 | ``` 287 | 288 | Schema types are passed as type parameters to `createRPC`. The first one is the local schema, while the second one is the schema of the other endpoint (the "remote" schema). 289 | 290 | RPC Anywhere is transport-agnostic: you need to specify it. A transport provides the means to send and listen for messages to and from the other endpoint. A common real-world example is communicating with an iframe through `window.postMessage(message)` and `window.addEventListener('message', handler)`. 291 | 292 | You can use [a built-in transport](./docs/2-built-in-transports.md), or [create your own](./docs/4-creating-a-custom-transport.md). 293 | 294 | ### Messages 295 | 296 | Here's how the chef could listen for incoming messages from the manager: 297 | 298 | ```ts 299 | // chef-rpc.ts 300 | chefRpc.addMessageListener("takingABreak", ({ duration, reason }) => { 301 | console.log( 302 | `The manager is taking a break for ${duration} minutes: ${reason}`, 303 | ); 304 | }); 305 | ``` 306 | 307 | The manager can then send a message to the chef: 308 | 309 | ```ts 310 | // manager-rpc.ts 311 | managerRpc.send.takingABreak({ duration: 30, reason: "lunch" }); 312 | ``` 313 | 314 | When the chef receives the message, the listener will be called, and the following will be logged: 315 | 316 | ``` 317 | The manager is taking a break for 30 minutes: lunch 318 | ``` 319 | 320 | ### Requests 321 | 322 | To handle incoming requests, we need to define a request handler: 323 | 324 | ```ts 325 | // chef-rpc.ts 326 | const chefRpc = createRPC({ 327 | // ... 328 | requestHandler: { 329 | cook({ recipe }) { 330 | return prepareDish(recipe, availableIngredients); 331 | }, 332 | }, 333 | }); 334 | // ... 335 | ``` 336 | 337 | Now the chef RPC can respond to `cook` requests. Request handlers can be written in this "object" format or as a function (`requestHandler(method, params): response`). All functions that handle requests can be synchronous or asynchronous. 338 | 339 | To make a request, there are two main options: 340 | 341 | ```ts 342 | // manager-rpc.ts 343 | 344 | // using ".request()" 345 | const dish = await managerRpc.request("cook", { recipe: "pizza" }); 346 | // using the request proxy API 347 | const dish = await managerRpc.request.cook({ recipe: "pizza" }); 348 | ``` 349 | 350 | Both are functionally equivalent. 351 | 352 | > **Note:** requests can fail for various reasons, like an execution error, a missing method handler, or a timeout. Make sure to handle errors appropriately when making requests. 353 | 354 | ## Documentation 355 | 356 | The documentation contains important details that are skipped or overly simplified in the examples above! 357 | 358 | Start with [RPC](./docs/1-rpc.md), then read about your transport of choice on the [Built-in transports](./docs/2-built-in-transports.md) page. 359 | 360 | - [RPC](./docs/1-rpc.md) 361 | - [Built-in transports](./docs/2-built-in-transports.md) 362 | - [Bridging transports](./docs/3-bridging-transports.md) 363 | - [Creating a custom transport](./docs/4-creating-a-custom-transport.md) 364 | 365 | The API reference is available at [tsdocs.dev](https://tsdocs.dev/docs/rpc-anywhere/). 366 | 367 | **This package is published as both ESM and CommonJS.** 368 | 369 | ## Type safety and features 370 | 371 | RPC Anywhere is designed to be as type-safe as possible while maintaining great ergonomics and flexibility. Here are some examples: 372 | 373 | - When making requests and sending messages, all data is strictly typed based on the schema types including request parameters, response data and message contents. 374 | - Similarly, all data involved in handling requests and listening to messages is strictly typed. For example, you can't return the wrong response from a request handler. 375 | - Most times, you'll get autocomplete suggestions in your IDE, like request or message names. 376 | - The proxy APIs for requests and messages are fully typed as well based on the schema types. This means you can't call a request or send a message that doesn't exist, or with the wrong data types. 377 | 378 | This library goes to even greater lengths to ensure a smooth developer experience, for example: 379 | 380 | - It is possible to infer the request schema types from the runtime request handlers, which prevents duplication by having a single source of truth. 381 | - The features of an RPC instance are constrained based on the schema types. For example, if the remote schema doesn't declare any requests, the `request` method won't be available in the first place. Similarly, if the local schema doesn't declare any requests, you can't set a request handler or customize the maximum request time. This affects almost all of the methods and options! 382 | 383 | Besides these, many other minor type-related details make RPC Anywhere extremely type-safe and a joy to work with. 384 | 385 | ## Features under consideration 386 | 387 | If you need any of these, please [file a feature request](https://github.com/DaniGuardiola/rpc-anywhere/issues/new?assignees=&labels=enhancement&projects=&template=feature-request.yaml) or upvote an existing one! 😄 388 | 389 | - Transport: Electron ipcMain/ipcRenderer. 390 | - Transport: WebSockets. 391 | - Transport: service workers (this is already possible through the message port transport, but it's a low-level API). 392 | - Transport: WebRTC. 393 | - Transport: HTTP(S) requests. 394 | - Transport: UDP. 395 | - Many-to-one or many-to-many connections. 396 | - Improved type-safety in general handlers, i.e. the function form of request handlers, the fallback request handler, and the wildcard message handler. 397 | - A simplified way to wait for connections to be established in any context, like across a chain of bridged transports. 398 | - Runtime validation support (e.g. through zod or valibot). 399 | - Better error handling. 400 | - Support for transferable objects in transports that support it (e.g. workers). 401 | - Lite version with a much smaller footprint. 402 | - [File a feature request!](https://github.com/DaniGuardiola/rpc-anywhere/issues/new?assignees=&labels=enhancement&projects=&template=feature-request.yaml) 403 | 404 | ## Prior art 405 | 406 | RPC Anywhere is inspired by [JSON-RPC](https://www.jsonrpc.org/), with a few small differences. 407 | 408 | For example, the concept of "messages" in RPC Anywhere resembles "notifications" in JSON-RPC. Some implementation details (like using an `id` property in requests and responses) are also similar. 409 | 410 | A notable difference is that RPC Anywhere is completely flexible, while JSON-RPC is client-server oriented. 411 | 412 | ## Contributing 413 | 414 | Contributions are welcome! Please make sure to create or update any tests as necessary when submitting a pull request. 415 | 416 | The demo is useful for quick manual testing. To start it locally, run `bun demo` and open the local server's address in your browser (probably `localhost:8080`, check the console output). It will automatically reload when you make changes to the source code. 417 | 418 | Before making big changes, consider opening a discussion first to get feedback and make sure the change is aligned with the project's goals. 419 | -------------------------------------------------------------------------------- /docs/1-rpc.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | [**Previous: Documentation index**](./README.md) 4 | 5 |
6 |
7 | 8 | [**Next: Built-in transports**](./2-built-in-transports.md) 9 | 10 |
11 | 12 |

RPC

13 | 14 | Before reading this documentation, it is recommended to check out [the "Getting started" section of the README](../README.md#getting-started). 15 | 16 |

Table of contents

17 | 18 | 19 | 20 | - [RPC schemas](#rpc-schemas) 21 | - [Declaring schemas](#declaring-schemas) 22 | - [Using schemas in RPC instances](#using-schemas-in-rpc-instances) 23 | - [Schema flexibility](#schema-flexibility) 24 | - [Empty schemas](#empty-schemas) 25 | - [Client/server RPC schemas](#clientserver-rpc-schemas) 26 | - [Symmetrical RPC schemas](#symmetrical-rpc-schemas) 27 | - [Documenting schemas with JSDoc](#documenting-schemas-with-jsdoc) 28 | - [Transports](#transports) 29 | - [Requests](#requests) 30 | - [Making requests](#making-requests) 31 | - [The request proxy API](#the-request-proxy-api) 32 | - [Request timeout](#request-timeout) 33 | - [Handling requests](#handling-requests) 34 | - [Inferring the schema from the request handler](#inferring-the-schema-from-the-request-handler) 35 | - [Messages](#messages) 36 | - [Sending messages](#sending-messages) 37 | - [Listening for messages](#listening-for-messages) 38 | - [The `proxy` property](#the-proxy-property) 39 | - [Recipes](#recipes) 40 | - [Client/server RPC](#clientserver-rpc) 41 | - [Symmetrical RPC](#symmetrical-rpc) 42 | 43 | 47 | 48 | 49 | ## RPC schemas 50 | 51 | A schema defines the requests that a specific endpoint can respond to, and the messages that it can send. Since RPC Anywhere doesn't enforce a client-server architecture, each endpoint has its own schema, and both RPC instances need to "know" about the other's schema. 52 | 53 | Schema types bring type safety to an instance, both when acting as a "client" (sending requests and listening for messages) and as a "server" (responding to requests and sending messages). 54 | 55 | ### Declaring schemas 56 | 57 | Schemas are declared with the `RPCSchema` type, using the following structure: 58 | 59 | ```ts 60 | import { type RPCSchema } from "rpc-anywhere"; 61 | 62 | type MySchema = RPCSchema<{ 63 | requests: { 64 | requestName: { 65 | params: { 66 | /* request parameters */ 67 | }; 68 | response: { 69 | /* response content */ 70 | }; 71 | }; 72 | }; 73 | messages: { 74 | messageName: { 75 | /* message content */ 76 | }; 77 | }; 78 | }>; 79 | ``` 80 | 81 | ### Using schemas in RPC instances 82 | 83 | Once you've declared your schemas, you can use them to create an RPC instance. An instance acts as the "client" that sends requests and listens for messages from the other endpoint, and as the "server" that responds to requests and sends messages to the other endpoint. 84 | 85 | For this reason, you need to pass two schema types to `createRPC`: the local schema (representing this instance's capabilities), and the remote schema (representing the other endpoint's capabilities). 86 | 87 | The local schema is the first type parameter, and the remote schema is the second type parameter. 88 | 89 | ```ts 90 | import { createRPC } from "rpc-anywhere"; 91 | 92 | const rpc = createRPC({ 93 | // ... 94 | }); 95 | ``` 96 | 97 | A typical pattern is to declare the local schema in the same file where the corresponding RPC instance is created. For example, you might end up with a file structure like this: 98 | 99 | ```ts 100 | // rpc-a.ts 101 | import { type SchemaB } from "./rpc-b.js"; 102 | 103 | export type SchemaA = RPCSchema; 104 | const rpcA = createRPC(); 105 | 106 | // rpc-b.ts 107 | import { type SchemaA } from "./rpc-a.js"; 108 | 109 | export type SchemaB = RPCSchema; 110 | const rpcB = createRPC(); 111 | ``` 112 | 113 | You might have noticed that the schema imports are circular. This is completely fine! Circular imports are only problematic for runtime values, but schemas are types which are only used for type-checking/IDE features and do not affect bundling or runtime behavior. 114 | 115 | ### Schema flexibility 116 | 117 | There is complete flexibility in the structure of the schemas. All properties can be omitted or set to `void`. Request parameters and message contents can be optional too. Some examples: 118 | 119 | ```ts 120 | type MySchema = RPCSchema<{ 121 | requests: { 122 | // request with optional parameters 123 | requestName: { 124 | params?: { 125 | direction: "up" | "down"; 126 | velocity?: number; 127 | }; 128 | response: string | number; 129 | }; 130 | 131 | // request with no response 132 | requestName: { 133 | params: string; 134 | }; 135 | 136 | // request with no parameters 137 | requestName: { 138 | response: [string, number]; 139 | }; 140 | 141 | // request with no parameters and no response 142 | requestName: void; 143 | }; 144 | messages: { 145 | // message with no content 146 | messageName: void; 147 | 148 | // message with optional content 149 | messageName?: { 150 | content?: string; 151 | }; 152 | }; 153 | }>; 154 | 155 | // schema with no requests 156 | type MySchema = RPCSchema<{ 157 | messages: { 158 | messageName: void; 159 | }; 160 | }>; 161 | 162 | // schema with no messages 163 | type MySchema = RPCSchema<{ 164 | requests: { 165 | requestName: void; 166 | }; 167 | }>; 168 | ``` 169 | 170 | ### Empty schemas 171 | 172 | Schemas can be "empty" if one of the RPC instances does not handle requests or send messages (resembling a "pure" client/server connection). For this situation, there is a special type: `EmptyRPCSchema`. 173 | 174 | ```ts 175 | type RemoteSchema = RPCSchema<{ 176 | requests: { 177 | requestName: void; 178 | }; 179 | }>; 180 | 181 | // rpc-local.ts (client) 182 | const rpc = createRPC(/* ... */); 183 | rpc.request("requestName"); 184 | 185 | // rpc-remote.ts (server) 186 | const rpc = createRPC({ 187 | requestHandler: { 188 | requestName() { 189 | /* ... */ 190 | }, 191 | }, 192 | }); 193 | ``` 194 | 195 | ### Client/server RPC schemas 196 | 197 | For convenience, `createClientRPC` and `createServerRPC` can be used to achieve the same result as in the previous section in a simpler way. They both take the remote (server) schema as a type parameter, as it is the only one that matters (the local/client one is empty). 198 | 199 | ```ts 200 | // rpc-local.ts (client) 201 | const rpc = createClientRPC(/* ... */); 202 | await rpc.request("requestName"); 203 | 204 | // rpc-remote.ts (server) 205 | const rpc = createServerRPC({ 206 | requestHandler: { 207 | requestName() { 208 | /* ... */ 209 | }, 210 | }, 211 | }); 212 | ``` 213 | 214 | ### Symmetrical RPC schemas 215 | 216 | If both RPC endpoints are "symmetrical" (i.e. they both handle the same requests and send the same messages), you can skip the second schema type parameter: 217 | 218 | ```ts 219 | // rpc-a.ts 220 | const rpcA = createRPC(/* ... */); 221 | 222 | // rpc-b.ts 223 | const rpcB = createRPC(/* ... */); 224 | ``` 225 | 226 | In this case, the passed schema will be interpreted as both the local and remote schema. 227 | 228 | ### Documenting schemas with JSDoc 229 | 230 | Schemas support JSDoc comments in almost everything that can be defined, including: 231 | 232 | - Requests. 233 | - Request parameters. 234 | - Request responses. 235 | - Messages. 236 | - Message contents. 237 | 238 | These comments are later accessible when using the RPC instances. For example, this is how a request might be documented: 239 | 240 | ````ts 241 | type MySchema = RPCSchema<{ 242 | requests: { 243 | /** 244 | * Move the car. 245 | * 246 | * @example 247 | * 248 | * ``` 249 | * const result = await rpc.request.move({ direction: "left", duration: 1000 }); 250 | * ``` 251 | */ 252 | move: { 253 | params: { 254 | /** 255 | * The direction of the movement. 256 | */ 257 | direction: "left" | "right"; 258 | /** 259 | * The total duration of the movement. 260 | */ 261 | duration: number; 262 | /** 263 | * The velocity of the car. 264 | * 265 | * @default 100 266 | */ 267 | velocity?: number; 268 | }; 269 | response: { 270 | /** 271 | * The total distance traveled by the car. 272 | */ 273 | distance: number; 274 | /** 275 | * The final position of the car. 276 | */ 277 | position: number; 278 | }; 279 | }; 280 | }; 281 | }>; 282 | ```` 283 | 284 | If this example schema is used for the remote RPC endpoint, hovering over any of the symbols highlighted below (in a supported IDE, like Visual Studio Code) will show the corresponding JSDoc documentation, along with their types. 285 | 286 | ```ts 287 | const { distance, position } = await rpc.request.move({ 288 | // ^ ^ ^ 289 | direction: "left", 290 | // ^ 291 | duration: 1000, 292 | // ^ 293 | velocity: 200, 294 | // ^ 295 | }); 296 | ``` 297 | 298 | ## Transports 299 | 300 | An RPC transport is the channel through which messages are sent and received between point A and point B. In RPC Anywhere, a transport is an object that contains the specific logic to accomplish this. 301 | 302 | Using a built-in transport is **strongly recommended**. You can learn about them in the [Built-in transports](./2-built-in-transports.md) page. 303 | 304 | If you can't find one that fits your use case, you can create one yourself. Learn how in the [Creating a custom transport](./4-creating-a-custom-transport.md) page. You can also consider filing a feature request or contributing a new built-in transport to the project. 305 | 306 | To provide a transport to an RPC instance pass it to `createRPC` as the `transport` option, or lazily set it at a later time using the `setTransport` method. For example: 307 | 308 | ```ts 309 | const rpc = createRPC({ 310 | transport: createTransportFromMessagePort(window, iframe.contentWindow), 311 | }); 312 | 313 | // or 314 | 315 | const rpc = createRPC(); 316 | rpc.setTransport(createTransportFromMessagePort(window, iframe.contentWindow)); 317 | ``` 318 | 319 | Keep in mind that if the transport is set lazily, the RPC instance will be unusable until then. 320 | 321 | Transports can be hot-swapped by using the lazy setter, as long as the transport supports this. All built-in transports support hot-swapping. If you create a custom transport, you can add support for it by making sure that it cleans up after itself when replaced, typically by unregistering event listeners in the `unregisterHandler` method. 322 | 323 | ## Requests 324 | 325 | ### Making requests 326 | 327 | Requests are sent using the `request` method: 328 | 329 | ```ts 330 | const response = await rpc.request("requestName", { 331 | /* request parameters */ 332 | }); 333 | ``` 334 | 335 | The parameters can be omitted if the request doesn't support any (or if they are optional): 336 | 337 | ```ts 338 | const response = await rpc.request("requestName"); 339 | ``` 340 | 341 | ### The request proxy API 342 | 343 | Alternatively, you can use the request proxy API: 344 | 345 | ```ts 346 | const response = await rpc.request.requestName({ 347 | /* request parameters */ 348 | }); 349 | ``` 350 | 351 | The `rpc.request` property acts as a function and as an object at the same time. This has an unfortunate effect: when autocompleting with TypeScript (when you type `rpc.request.`), some suggestions will be properties from the function JavaScript prototype (`apply`, `bind`, `call`...). 352 | 353 | If you want a version that only contains the proxied methods (e.g. for a better developer experience or aliasing), you can use `requestProxy` instead: 354 | 355 | ```ts 356 | const chef = chefRPC.requestProxy; 357 | const dish = await chef.cook({ recipe: "rice" }); 358 | ``` 359 | 360 | ### Request timeout 361 | 362 | If the remote endpoint takes too long to respond to a request, it will time out and be rejected with an error. The default request timeout is 1000 milliseconds (1 second). You can change it by passing a `maxRequestTime` option to `createRPC`: 363 | 364 | ```ts 365 | const rpc = createRPC({ 366 | // ... 367 | maxRequestTime: 5000, 368 | }); 369 | ``` 370 | 371 | To disable the timeout, pass `Infinity`. Be careful! It can lead to requests hanging indefinitely. 372 | 373 | ### Handling requests 374 | 375 | Requests are handled using the `requestHandler` option of `createRPC`. The request handler can be defined in two ways: 376 | 377 | **Object format** 378 | 379 | The object format is the recommended way to define request handlers because it is the most ergonomic, provides full type safety, and supports a "fallback" handler. All handlers can be `async`. 380 | 381 | ```ts 382 | const rpc = createRPC({ 383 | // ... 384 | requestHandler: { 385 | requestName(/* request parameters */) { 386 | /* handle the request */ 387 | return /* response */; 388 | }, 389 | // or 390 | async requestName(/* request parameters */) { 391 | await doSomething(); 392 | /* handle the request */ 393 | return /* response */; 394 | }, 395 | 396 | // fallback handler 397 | _(method, params) { 398 | /* handle requests that don't have a handler defined (not type-safe) */ 399 | return /* response */; 400 | }, 401 | // or 402 | async _(method, params) { 403 | await doSomething(); 404 | /* handle requests that don't have a handler defined (not type-safe) */ 405 | return /* response */; 406 | }, 407 | }, 408 | }); 409 | ``` 410 | 411 | Unless a fallback handler is defined, requests that don't have a handler defined will be rejected with an error. 412 | 413 | **Function format** 414 | 415 | The function format is useful when you need to handle requests dynamically, delegate/forward them somewhere else, etc. 416 | 417 | This format is not type-safe, so it's recommended to use the object format instead whenever possible. 418 | 419 | ```ts 420 | const rpc = createRPC({ 421 | // ... 422 | requestHandler(method, params) { 423 | /* handle the request */ 424 | return /* response */; 425 | }, 426 | // or 427 | async requestHandler(method, params) { 428 | await doSomething(); 429 | /* handle the request */ 430 | return /* response */; 431 | }, 432 | }); 433 | ``` 434 | 435 | --- 436 | 437 | The request handler can be lazily set with the `setRequestHandler` method: 438 | 439 | ```ts 440 | const rpc = createRPC(); 441 | rpc.setRequestHandler(/* ... */); 442 | ``` 443 | 444 | Until the request handler is set, the RPC instance won't be able to handle requests. 445 | 446 | ### Inferring the schema from the request handler 447 | 448 | Defining both a "requests" schema and a request handler can be redundant. For example: 449 | 450 | ```ts 451 | type Schema = RPCSchema<{ 452 | requests: { 453 | myRequest: { 454 | params: { a: number; b: string }; 455 | response: { c: boolean }; 456 | }; 457 | }; 458 | messages: { myMessage: void }; 459 | }>; 460 | 461 | const rpc = createRPC({ 462 | // ... 463 | requestHandler: { 464 | myRequest({ a, b }) { 465 | return { c: a > 0 && b.length > 0 }; 466 | }, 467 | }, 468 | }); 469 | ``` 470 | 471 | To reduce duplication, RPC Anywhere provides a way to partially infer the schema type from the request handler. 472 | 473 | To do this, first create the request handler (in object format) using `createRPCRequestHandler`, and then pass its type as the second type parameter by using `typeof`. Updating the previous example: 474 | 475 | ```ts 476 | const myRequestHandler = createRPCRequestHandler({ 477 | myRequest({ a, b }: { a: number; b: string }) { 478 | return { c: a > 0 && b.length > 0 }; 479 | }, 480 | }); 481 | 482 | type Schema = RPCSchema< 483 | { messages: { myMessage: void } }, 484 | typeof myRequestHandler 485 | >; 486 | 487 | const rpc = createRPC({ 488 | // ... 489 | requestHandler: myRequestHandler, 490 | }); 491 | ``` 492 | 493 | If there are no messages in the schema, you can pass `void` as the first type parameter to `RPCSchema`: 494 | 495 | ```ts 496 | type Schema = RPCSchema; 497 | ``` 498 | 499 | ## Messages 500 | 501 | ### Sending messages 502 | 503 | Messages are sent using the `send` method: 504 | 505 | ```ts 506 | rpc.send("messageName", { 507 | /* message content */ 508 | }); 509 | ``` 510 | 511 | The content can be omitted if the message doesn't have any or if it's optional: 512 | 513 | ```ts 514 | rpc.send("messageName"); 515 | ``` 516 | 517 | Similar to requests, there is a message proxy API you can use: 518 | 519 | ```ts 520 | rpc.send.messageName({ 521 | /* message content */ 522 | }); 523 | 524 | // or 525 | 526 | rpc.sendProxy.messageName({ 527 | /* message content */ 528 | }); 529 | ``` 530 | 531 | ### Listening for messages 532 | 533 | Messages are received by adding a message listener: 534 | 535 | ```ts 536 | rpc.addMessageListener("messageName", (messageContent) => { 537 | /* handle the message */ 538 | }); 539 | ``` 540 | 541 | To listen for all messages, use the `*` (asterisk) key: 542 | 543 | ```ts 544 | rpc.addMessageListener("*", (messageName, messageContent) => { 545 | /* handle the message */ 546 | }); 547 | ``` 548 | 549 | A listener can be removed with the `removeMessageListener` method: 550 | 551 | ```ts 552 | rpc.removeMessageListener("messageName", listener); 553 | rpc.removeMessageListener("*", listener); 554 | ``` 555 | 556 | ## The `proxy` property 557 | 558 | RPC instances also expose a `proxy` property, which is an object that contains both proxies (`request` and `send`). It is an alternative API provided for convenience, for example: 559 | 560 | ```ts 561 | const rpc = createRPC(/* ... */).proxy; 562 | rpc.request.requestName(/* ... */); 563 | rpc.send.messageName(/* ... */); 564 | ``` 565 | 566 | ## Recipes 567 | 568 | Below are some common examples to help you get started with RPC Anywhere. 569 | 570 | ### Client/server RPC 571 | 572 | ```ts 573 | // server.ts 574 | const requestHandler = createRPCRequestHandler({ 575 | hello(name: string) { 576 | return `Hello, ${name}!`; 577 | }, 578 | }); 579 | 580 | export type ServerSchema = RPCSchema; 581 | 582 | const rpc = createServerRPC({ 583 | // ... 584 | requestHandler, 585 | }); 586 | 587 | // client.ts 588 | import { type ServerSchema } from "./server.js"; 589 | 590 | const rpc = createClientRPC(/* ... */).proxy.request; 591 | const response = await rpc.hello("world"); 592 | console.log(response); // Hello, world! 593 | ``` 594 | 595 | ### Symmetrical RPC 596 | 597 | ```ts 598 | // schema.ts 599 | type SymmetricalSchema = RPCSchema<{ 600 | requests: { 601 | hello: { 602 | params: { name: string }; 603 | response: string; 604 | }; 605 | }; 606 | messages: { 607 | goodbye: void; 608 | }; 609 | }>; 610 | 611 | // rpc-a.ts 612 | const rpcA = createRPC(/* ... */); 613 | rpcA.addMessageListener("goodbye", () => { 614 | console.log("Goodbye!"); 615 | }); 616 | 617 | // rpc-b.ts 618 | const rpcB = createRPC(/* ... */); 619 | const response = await rpcB.request.hello({ name: "world" }); 620 | console.log(response); // Hello, world! 621 | rpcB.send.goodbye(); 622 | ``` 623 | 624 | --- 625 | 626 |
627 | 628 | [**Previous: Documentation index**](./README.md) 629 | 630 |
631 |
632 | 633 | [**Next: Built-in transports**](./2-built-in-transports.md) 634 | 635 |
636 | -------------------------------------------------------------------------------- /src/tests/types-test.ts: -------------------------------------------------------------------------------- 1 | import { createRPCRequestHandler } from "../create-request-handler.js"; 2 | import { createRPC } from "../create-rpc.js"; 3 | import { type EmptyRPCSchema, type RPCSchema } from "../types.js"; 4 | 5 | type ExampleSchema = RPCSchema<{ 6 | requests: { 7 | method1: { 8 | params: { a: number; b: string }; 9 | response: number; 10 | }; 11 | method2: { 12 | params: { required: number; optional?: string }; 13 | response: string; 14 | }; 15 | method3: { 16 | params?: string; 17 | }; 18 | method4: void; 19 | }; 20 | messages: { 21 | message1: { a: number; b: string }; 22 | message2: { required: number; optional?: string }; 23 | message3?: { required: number; optional?: string }; 24 | message4: void; 25 | }; 26 | }>; 27 | 28 | const rpc = createRPC(); 29 | 30 | // sending requests and messages 31 | // ----------------------------- 32 | 33 | // - requests 34 | 35 | rpc.request("method1", { a: 1, b: "2" }); 36 | rpc.request.method1({ a: 1, b: "2" }); 37 | // @ts-expect-error - Expected error. 38 | rpc.request("undefinedMethod"); 39 | // @ts-expect-error - Expected error. 40 | rpc.request.undefinedMethod(); 41 | // @ts-expect-error - Expected error. 42 | rpc.request("undefinedMethod", { a: 1, b: "2" }); 43 | // @ts-expect-error - Expected error. 44 | rpc.request.undefinedMethod({ a: 1, b: "2" }); 45 | 46 | // - messages 47 | 48 | rpc.send("message1", { a: 1, b: "2" }); 49 | rpc.send.message1({ a: 1, b: "2" }); 50 | // @ts-expect-error - Expected error. 51 | rpc.send("undefinedMessage"); 52 | // @ts-expect-error - Expected error. 53 | rpc.send.undefinedMessage(); 54 | // @ts-expect-error - Expected error. 55 | rpc.send("undefinedMessage", { a: 1, b: "2" }); 56 | // @ts-expect-error - Expected error. 57 | rpc.send.undefinedMessage({ a: 1, b: "2" }); 58 | 59 | // handling requests 60 | // ----------------- 61 | 62 | createRPC({ 63 | requestHandler: { 64 | method1: (params: { a: number; b: string }) => { 65 | params.a; 66 | params.b; 67 | return 1; 68 | }, 69 | method2: (params: { required: number; optional?: string }) => { 70 | params.required; 71 | params.optional; 72 | return "hello"; 73 | }, 74 | method3: (params?: string) => { 75 | params; 76 | }, 77 | method4: () => {}, 78 | }, 79 | }); 80 | createRPC({ 81 | requestHandler: { 82 | method1: async (params: { a: number; b: string }) => { 83 | params.a; 84 | params.b; 85 | return 1; 86 | }, 87 | method2: async (params: { required: number; optional?: string }) => { 88 | params.required; 89 | params.optional; 90 | return "hello"; 91 | }, 92 | method3: async (params?: string) => { 93 | params; 94 | }, 95 | method4: async () => {}, 96 | }, 97 | }); 98 | createRPC({ 99 | // @ts-expect-error - Expected error. 100 | unknownMethod: (params: { a: number; b: string }) => { 101 | params.a; 102 | params.b; 103 | return 1; 104 | }, 105 | }); 106 | createRPC({ 107 | requestHandler: { 108 | // @ts-expect-error - Expected error (wrong parameter type). 109 | method1: (params: { a: number; b: number }) => { 110 | params.a; 111 | params.b; 112 | return 1; 113 | }, 114 | // @ts-expect-error - Expected error (extra property in parameter). 115 | method2: (params: { required: number; optional?: string; extra: any }) => { 116 | params.required; 117 | params.optional; 118 | return "hello"; 119 | }, 120 | // @ts-expect-error - Expected error (wrong required paramters). 121 | method3: (params: string) => { 122 | params; 123 | }, 124 | // @ts-expect-error - Expected error (parameters in function with no parameters). 125 | method4: (params: { a: number; b: string }) => { 126 | params; 127 | }, 128 | }, 129 | }); 130 | createRPC({ 131 | requestHandler: { 132 | // @ts-expect-error - Expected error (wrong return type). 133 | method1: (params: { a: number; b: string }) => { 134 | params.a; 135 | params.b; 136 | return "hello"; 137 | }, 138 | // @ts-expect-error - Expected error (missing return type). 139 | method2: (params: { required: number; optional?: string }) => { 140 | params.required; 141 | params.optional; 142 | }, 143 | }, 144 | }); 145 | createRPC({ 146 | requestHandler: { 147 | // @ts-expect-error - Expected error (wrong optional return type). 148 | method1: (params: { a: number; b: string }) => { 149 | params.a; 150 | params.b; 151 | const condition: boolean = false; 152 | if (condition) return 1; 153 | }, 154 | }, 155 | }); 156 | 157 | // handling messages 158 | // ----------------- 159 | 160 | rpc.addMessageListener("message1", (params: { a: number; b: string }) => { 161 | params.a; 162 | params.b; 163 | }); 164 | rpc.removeMessageListener("message1", (params: { a: number; b: string }) => { 165 | params.a; 166 | params.b; 167 | }); 168 | rpc.addMessageListener( 169 | // @ts-expect-error - Expected error. 170 | "undefinedMessage", 171 | (params: { a: number; b: string }) => { 172 | params.a; 173 | params.b; 174 | }, 175 | ); 176 | rpc.removeMessageListener( 177 | // @ts-expect-error - Expected error. 178 | "undefinedMessage", 179 | (params: { a: number; b: string }) => { 180 | params.a; 181 | params.b; 182 | }, 183 | ); 184 | // @ts-expect-error - Expected error. 185 | rpc.addMessageListener("message1", (params: { a: number; b: number }) => { 186 | params.a; 187 | params.b; 188 | }); 189 | // @ts-expect-error - Expected error. 190 | rpc.removeMessageListener("message1", (params: { a: number; b: number }) => { 191 | params.a; 192 | params.b; 193 | }); 194 | // @ts-expect-error - Expected error. 195 | rpc.addMessageListener( 196 | "message1", 197 | (params: { a: number; b: string; extra: any }) => { 198 | params.a; 199 | params.b; 200 | }, 201 | ); 202 | // @ts-expect-error - Expected error. 203 | rpc.removeMessageListener( 204 | "message1", 205 | (params: { a: number; b: string; extra: any }) => { 206 | params.a; 207 | params.b; 208 | }, 209 | ); 210 | 211 | // request and message parameters and response 212 | // ------------------------------------------- 213 | 214 | // - request parameters 215 | 216 | rpc.request("method1", { a: 1, b: "2" }); 217 | rpc.request.method1({ a: 1, b: "2" }); 218 | // @ts-expect-error - Expected error. 219 | rpc.request("method1", { a: 1 }); 220 | // @ts-expect-error - Expected error. 221 | rpc.request.method1({ a: 1 }); 222 | // @ts-expect-error - Expected error. 223 | rpc.request("method1", { a: 1, b: 2 }); 224 | // @ts-expect-error - Expected error. 225 | rpc.request.method1({ a: 1, b: 2 }); 226 | // @ts-expect-error - Expected error. 227 | rpc.request("method1", { a: 1, b: "2", c: 3 }); 228 | // @ts-expect-error - Expected error. 229 | rpc.request.method1({ a: 1, b: "2", c: 3 }); 230 | 231 | rpc.request("method2", { required: 1 }); 232 | rpc.request.method2({ required: 1 }); 233 | rpc.request("method2", { required: 1, optional: "2" }); 234 | rpc.request.method2({ required: 1, optional: "2" }); 235 | // @ts-expect-error - Expected error. 236 | rpc.request("method2", { required: 1, optional: 2 }); 237 | // @ts-expect-error - Expected error. 238 | rpc.request.method2({ required: 1, optional: 2 }); 239 | // @ts-expect-error - Expected error. 240 | rpc.request("method2", { required: 1, optional: "2", extra: 3 }); 241 | // @ts-expect-error - Expected error. 242 | rpc.request.method2({ required: 1, optional: "2", extra: 3 }); 243 | // @ts-expect-error - Expected error. 244 | rpc.request("method2", { optional: "2" }); 245 | // @ts-expect-error - Expected error. 246 | rpc.request.method2({ optional: "2" }); 247 | 248 | rpc.request("method3"); 249 | rpc.request.method3(); 250 | rpc.request("method3", "hello"); 251 | rpc.request.method3("hello"); 252 | // @ts-expect-error - Expected error. 253 | rpc.request("method3", 1); 254 | // @ts-expect-error - Expected error. 255 | rpc.request.method3(1); 256 | 257 | rpc.request("method4"); 258 | rpc.request.method4(); 259 | // @ts-expect-error - Expected error. 260 | rpc.request("method4", "hello"); 261 | // @ts-expect-error - Expected error. 262 | rpc.request.method4("hello"); 263 | 264 | // - request return types 265 | 266 | (await rpc.request("method1", { a: 1, b: "2" })) satisfies number; 267 | (await rpc.request.method1({ a: 1, b: "2" })) satisfies number; 268 | // @ts-expect-error - Expected error. 269 | (await rpc.request("method1", { a: 1, b: "2" })) satisfies string; 270 | // @ts-expect-error - Expected error. 271 | (await rpc.request.method1({ a: 1, b: "2" })) satisfies string; 272 | 273 | (await rpc.request("method2", { required: 1 })) satisfies string; 274 | (await rpc.request.method2({ required: 1 })) satisfies string; 275 | (await rpc.request("method2", { required: 1, optional: "2" })) satisfies string; 276 | (await rpc.request.method2({ required: 1, optional: "2" })) satisfies string; 277 | // @ts-expect-error - Expected error. 278 | (await rpc.request("method2", { required: 1 })) satisfies number; 279 | // @ts-expect-error - Expected error. 280 | (await rpc.request.method2({ required: 1 })) satisfies number; 281 | // @ts-expect-error - Expected error. 282 | (await rpc.request("method2", { required: 1, optional: "2" })) satisfies number; 283 | // @ts-expect-error - Expected error. 284 | (await rpc.request.method2({ required: 1, optional: "2" })) satisfies number; 285 | 286 | (await rpc.request("method3")) satisfies void; 287 | (await rpc.request.method3()) satisfies void; 288 | (await rpc.request("method3", "hello")) satisfies void; 289 | (await rpc.request.method3("hello")) satisfies void; 290 | // @ts-expect-error - Expected error. 291 | (await rpc.request("method3")) satisfies number; 292 | // @ts-expect-error - Expected error. 293 | (await rpc.request.method3()) satisfies number; 294 | // @ts-expect-error - Expected error. 295 | (await rpc.request("method3", "hello")) satisfies number; 296 | // @ts-expect-error - Expected error. 297 | (await rpc.request.method3("hello")) satisfies number; 298 | 299 | (await rpc.request("method4")) satisfies void; 300 | (await rpc.request.method4()) satisfies void; 301 | // @ts-expect-error - Expected error. 302 | (await rpc.request("method4")) satisfies number; 303 | // @ts-expect-error - Expected error. 304 | (await rpc.request.method4()) satisfies number; 305 | 306 | // - message parameters 307 | 308 | rpc.send("message1", { a: 1, b: "2" }); 309 | rpc.send.message1({ a: 1, b: "2" }); 310 | // @ts-expect-error - Expected error. 311 | rpc.send("message1", { a: 1 }); 312 | // @ts-expect-error - Expected error. 313 | rpc.send.message1({ a: 1 }); 314 | // @ts-expect-error - Expected error. 315 | rpc.send("message1", { a: 1, b: 2 }); 316 | // @ts-expect-error - Expected error. 317 | rpc.send.message1({ a: 1, b: 2 }); 318 | // @ts-expect-error - Expected error. 319 | rpc.send("message1", { a: 1, b: "2", c: 3 }); 320 | // @ts-expect-error - Expected error. 321 | rpc.send.message1({ a: 1, b: "2", c: 3 }); 322 | 323 | rpc.send("message2", { required: 1 }); 324 | rpc.send.message2({ required: 1 }); 325 | rpc.send("message2", { required: 1, optional: "2" }); 326 | rpc.send.message2({ required: 1, optional: "2" }); 327 | // @ts-expect-error - Expected error. 328 | rpc.send("message2", { required: 1, optional: 2 }); 329 | // @ts-expect-error - Expected error. 330 | rpc.send.message2({ required: 1, optional: 2 }); 331 | // @ts-expect-error - Expected error. 332 | rpc.send("message2", { required: 1, optional: "2", extra: 3 }); 333 | // @ts-expect-error - Expected error. 334 | rpc.send.message2({ required: 1, optional: "2", extra: 3 }); 335 | // @ts-expect-error - Expected error. 336 | rpc.send("message2", { optional: "2" }); 337 | // @ts-expect-error - Expected error. 338 | rpc.send.message2({ optional: "2" }); 339 | 340 | rpc.send("message3"); 341 | rpc.send.message3(); 342 | rpc.send("message3", { required: 1 }); 343 | rpc.send.message3({ required: 1 }); 344 | rpc.send("message3", { required: 1, optional: "2" }); 345 | rpc.send.message3({ required: 1, optional: "2" }); 346 | // @ts-expect-error - Expected error. 347 | rpc.send("message3", { required: 1, optional: 2 }); 348 | // @ts-expect-error - Expected error. 349 | rpc.send.message3({ required: 1, optional: 2 }); 350 | // @ts-expect-error - Expected error. 351 | rpc.send("message3", { required: 1, optional: "2", extra: 3 }); 352 | // @ts-expect-error - Expected error. 353 | rpc.send.message3({ required: 1, optional: "2", extra: 3 }); 354 | // @ts-expect-error - Expected error. 355 | rpc.send("message3", { optional: "2" }); 356 | // @ts-expect-error - Expected error. 357 | rpc.send.message3({ optional: "2" }); 358 | 359 | rpc.send("message4"); 360 | rpc.send.message4(); 361 | // @ts-expect-error - Expected error. 362 | rpc.send("message4", { a: 1, b: "2" }); 363 | // @ts-expect-error - Expected error. 364 | rpc.send.message4({ a: 1, b: "2" }); 365 | 366 | // schema-dependent features and options 367 | // ------------------------------------- 368 | 369 | type NoMessagesSchema = RPCSchema<{ 370 | requests: { method: void }; 371 | }>; 372 | 373 | const rpc1 = createRPC(); 374 | 375 | // - messages 376 | // @ts-expect-error - Expected error. 377 | rpc1.send("message"); 378 | // @ts-expect-error - Expected error. 379 | rpc1.send.message(); 380 | // @ts-expect-error - Expected error. 381 | rpc1.addMessageListener("message", console.log); 382 | // @ts-expect-error - Expected error. 383 | rpc1.removeMessageListener("message", console.log); 384 | 385 | // - requests 386 | rpc1.setRequestHandler({}); 387 | rpc1.request("method"); 388 | rpc1.request.method(); 389 | rpc1.requestProxy.method(); 390 | 391 | // - proxy 392 | // @ts-expect-error - Expected error. 393 | rpc1.proxy.request.method(); 394 | // @ts-expect-error - Expected error. 395 | rpc1.proxy.send.message(); 396 | 397 | // - options 398 | createRPC({ 399 | transport: {}, 400 | maxRequestTime: 2000, 401 | requestHandler: {}, 402 | }); 403 | 404 | // ----------- 405 | 406 | type NoRequestsSchema = RPCSchema<{ 407 | messages: { message: void }; 408 | }>; 409 | 410 | const rpc2 = createRPC(); 411 | 412 | // - messages 413 | rpc2.send("message"); 414 | rpc2.send.message(); 415 | rpc2.addMessageListener("message", console.log); 416 | rpc2.removeMessageListener("message", console.log); 417 | 418 | // - requests 419 | // @ts-expect-error - Expected error. 420 | rpc2.setRequestHandler({}); 421 | // @ts-expect-error - Expected error. 422 | rpc2.request("method"); 423 | // @ts-expect-error - Expected error. 424 | rpc2.request.method(); 425 | // @ts-expect-error - Expected error. 426 | rpc2.requestProxy.method(); 427 | 428 | // - proxy 429 | // @ts-expect-error - Expected error. 430 | rpc2.proxy.request.method(); 431 | // @ts-expect-error - Expected error. 432 | rpc2.proxy.send.message(); 433 | 434 | // - options 435 | createRPC({ 436 | transport: {}, 437 | // @ts-expect-error - Expected error. 438 | maxRequestTime: 2000, 439 | }); 440 | 441 | createRPC({ 442 | transport: {}, 443 | // @ts-expect-error - Expected error. 444 | requestHandler: {}, 445 | }); 446 | 447 | // ----------- 448 | 449 | type NoRequestsOrMessagesSchema = EmptyRPCSchema; 450 | 451 | const rpc3 = createRPC(); 452 | 453 | // - messages 454 | // @ts-expect-error - Expected error. 455 | rpc3.send("method"); 456 | // @ts-expect-error - Expected error. 457 | rpc3.send.method(); 458 | // @ts-expect-error - Expected error. 459 | rpc3.addMessageListener("method", console.log); 460 | // @ts-expect-error - Expected error. 461 | rpc3.removeMessageListener("method", console.log); 462 | 463 | // - requests 464 | // @ts-expect-error - Expected error. 465 | rpc3.setRequestHandler({}); 466 | // @ts-expect-error - Expected error. 467 | rpc3.request("method"); 468 | // @ts-expect-error - Expected error. 469 | rpc3.request.method(); 470 | // @ts-expect-error - Expected error. 471 | rpc3.requestProxy.method(); 472 | 473 | // - proxy 474 | // @ts-expect-error - Expected error. 475 | rpc3.proxy.request.method(); 476 | // @ts-expect-error - Expected error. 477 | rpc3.proxy.send.message(); 478 | 479 | // - options 480 | createRPC({ 481 | transport: {}, 482 | // @ts-expect-error - Expected error. 483 | maxRequestTime: 2000, 484 | }); 485 | 486 | createRPC({ 487 | transport: {}, 488 | // @ts-expect-error - Expected error. 489 | requestHandler: {}, 490 | }); 491 | 492 | // ----------- 493 | 494 | type RequestsAndMessagesSchema = RPCSchema<{ 495 | requests: { method: void }; 496 | messages: { message: void }; 497 | }>; 498 | 499 | const rpc4 = createRPC(); 500 | 501 | // - messages 502 | rpc4.send("message"); 503 | rpc4.send.message(); 504 | rpc4.addMessageListener("message", console.log); 505 | rpc4.removeMessageListener("message", console.log); 506 | 507 | // - requests 508 | rpc4.setRequestHandler({}); 509 | rpc4.request("method"); 510 | rpc4.request.method(); 511 | rpc4.requestProxy.method(); 512 | 513 | // - proxy 514 | rpc4.proxy.request.method(); 515 | rpc4.proxy.send.message(); 516 | 517 | // - options 518 | createRPC({ 519 | transport: {}, 520 | maxRequestTime: 2000, 521 | requestHandler: {}, 522 | }); 523 | 524 | // createRPCRequestHandler and schema inference 525 | // -------------------------------------------- 526 | 527 | const requestHandler = createRPCRequestHandler({ 528 | method1: (params: { a: number; b: string }) => { 529 | params.a; 530 | params.b; 531 | return 1; 532 | }, 533 | method2: (params: { required: number; optional?: string }) => { 534 | params.required; 535 | params.optional; 536 | return "hello"; 537 | }, 538 | method3: (params?: string) => { 539 | params; 540 | }, 541 | method4: () => {}, 542 | }); 543 | 544 | type InferredSchema = RPCSchema; 545 | 546 | const rpc5 = createRPC(); 547 | 548 | rpc5.request("method1", { a: 1, b: "2" }); 549 | rpc5.request.method1({ a: 1, b: "2" }); 550 | rpc5.request("method2", { required: 1 }); 551 | rpc5.request.method2({ required: 1 }); 552 | rpc5.request("method3"); 553 | rpc5.request.method3(); 554 | rpc5.request("method3", "hello"); 555 | rpc5.request.method3("hello"); 556 | rpc5.request("method4"); 557 | rpc5.request.method4(); 558 | // @ts-expect-error - Expected error. 559 | rpc5.request("method1", { a: 1 }); 560 | // @ts-expect-error - Expected error. 561 | rpc5.request.method1({ a: 1 }); 562 | // @ts-expect-error - Expected error. 563 | rpc5.request("method1", { a: 1, b: 2 }); 564 | // @ts-expect-error - Expected error. 565 | rpc5.request.method1({ a: 1, b: 2 }); 566 | // @ts-expect-error - Expected error. 567 | rpc5.request("method1", { a: 1, b: "2", c: 3 }); 568 | // @ts-expect-error - Expected error. 569 | rpc5.request.method1({ a: 1, b: "2", c: 3 }); 570 | // @ts-expect-error - Expected error. 571 | rpc5.request("method2", { required: 1, optional: 2 }); 572 | // @ts-expect-error - Expected error. 573 | rpc5.request.method2({ required: 1, optional: 2 }); 574 | // @ts-expect-error - Expected error. 575 | rpc5.request("method2", { required: 1, optional: "2", extra: 3 }); 576 | // @ts-expect-error - Expected error. 577 | rpc5.request.method2({ required: 1, optional: "2", extra: 3 }); 578 | // @ts-expect-error - Expected error. 579 | rpc5.request("method3", 1); 580 | // @ts-expect-error - Expected error. 581 | rpc5.request.method3(1); 582 | // @ts-expect-error - Expected error. 583 | rpc5.request("method4", "hello"); 584 | // @ts-expect-error - Expected error. 585 | rpc5.request.method4("hello"); 586 | 587 | (await rpc5.request("method1", { a: 1, b: "2" })) satisfies number; 588 | (await rpc5.request.method1({ a: 1, b: "2" })) satisfies number; 589 | (await rpc5.request("method2", { required: 1 })) satisfies string; 590 | (await rpc5.request.method2({ required: 1 })) satisfies string; 591 | (await rpc5.request("method3")) satisfies void; 592 | (await rpc5.request.method3()) satisfies void; 593 | (await rpc5.request("method3", "hello")) satisfies void; 594 | (await rpc5.request.method3("hello")) satisfies void; 595 | (await rpc5.request("method4")) satisfies void; 596 | (await rpc5.request.method4()) satisfies void; 597 | // @ts-expect-error - Expected error. 598 | (await rpc5.request("method1", { a: 1, b: "2" })) satisfies string; 599 | // @ts-expect-error - Expected error. 600 | (await rpc5.request.method1({ a: 1, b: "2" })) satisfies string; 601 | // @ts-expect-error - Expected error. 602 | (await rpc5.request("method2", { required: 1 })) satisfies number; 603 | // @ts-expect-error - Expected error. 604 | (await rpc5.request.method2({ required: 1 })) satisfies number; 605 | // @ts-expect-error - Expected error. 606 | (await rpc5.request("method3")) satisfies number; 607 | // @ts-expect-error - Expected error. 608 | (await rpc5.request.method3()) satisfies number; 609 | // @ts-expect-error - Expected error. 610 | (await rpc5.request("method3", "hello")) satisfies number; 611 | // @ts-expect-error - Expected error. 612 | (await rpc5.request.method3("hello")) satisfies number; 613 | // @ts-expect-error - Expected error. 614 | (await rpc5.request("method4")) satisfies number; 615 | // @ts-expect-error - Expected error. 616 | (await rpc5.request.method4()) satisfies number; 617 | --------------------------------------------------------------------------------