├── .github ├── FUNDING.yml └── workflows │ ├── release.yml │ ├── lint.yml │ └── test.yml ├── src ├── index.ts ├── messages.ts ├── utils.ts ├── group.ts └── main.ts ├── test ├── exports │ └── birpc.yaml ├── alice.ts ├── bob.ts ├── exports.test.ts ├── dynamic.test.ts ├── resolver.test.ts ├── close.test.ts ├── timeout.test.ts ├── hook.test.ts ├── reject-pending-calls.test.ts ├── index.test.ts ├── error.test.ts └── group.test.ts ├── eslint.config.js ├── pnpm-workspace.yaml ├── tsdown.config.ts ├── vitest.config.ts ├── tsconfig.json ├── LICENSE ├── .vscode └── settings.json ├── .gitignore ├── package.json └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [antfu] 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './group' 2 | export * from './main' 3 | -------------------------------------------------------------------------------- /test/exports/birpc.yaml: -------------------------------------------------------------------------------- 1 | .: 2 | createBirpc: function 3 | createBirpcGroup: function 4 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import antfu from '@antfu/eslint-config' 2 | 3 | export default antfu({ 4 | }) 5 | -------------------------------------------------------------------------------- /test/alice.ts: -------------------------------------------------------------------------------- 1 | export function hello(name: string) { 2 | return `Hello ${name}, my name is Alice` 3 | } 4 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - playground 3 | - examples/* 4 | onlyBuiltDependencies: 5 | - esbuild 6 | ignoreWorkspaceRootCheck: true 7 | -------------------------------------------------------------------------------- /tsdown.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsdown' 2 | 3 | export default defineConfig({ 4 | entry: [ 5 | 'src/index.ts', 6 | ], 7 | dts: true, 8 | exports: true, 9 | }) 10 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | release: 10 | uses: sxzz/workflows/.github/workflows/release.yml@v1 11 | with: 12 | publish: true 13 | permissions: 14 | contents: write 15 | id-token: write 16 | -------------------------------------------------------------------------------- /test/bob.ts: -------------------------------------------------------------------------------- 1 | let bobCount = 0 2 | 3 | export function hi(name: string) { 4 | return `Hi ${name}, I am Bob` 5 | } 6 | 7 | export function bump() { 8 | bobCount += 1 9 | } 10 | 11 | export function getCount() { 12 | return bobCount 13 | } 14 | 15 | export function bumpWithReturn() { 16 | bobCount += 1 17 | return bobCount 18 | } 19 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | coverage: { 6 | reporter: [ 7 | ['lcov', { projectRoot: './src' }], 8 | ['json', { file: 'coverage.json' }], 9 | ['text'], 10 | ['html'], 11 | ], 12 | }, 13 | }, 14 | }) 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": ["esnext"], 5 | "module": "esnext", 6 | "moduleResolution": "bundler", 7 | "resolveJsonModule": true, 8 | "strict": true, 9 | "strictNullChecks": true, 10 | "noEmit": true, 11 | "esModuleInterop": true, 12 | "skipDefaultLibCheck": true, 13 | "skipLibCheck": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Install pnpm 20 | uses: pnpm/action-setup@v4 21 | 22 | - name: Set node 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: lts/* 26 | cache: pnpm 27 | 28 | - name: Setup 29 | run: npm i -g @antfu/ni 30 | 31 | - name: Install 32 | run: nci 33 | 34 | - name: Lint 35 | run: nr lint 36 | 37 | - name: Typecheck 38 | run: nr typecheck 39 | -------------------------------------------------------------------------------- /src/messages.ts: -------------------------------------------------------------------------------- 1 | export const TYPE_REQUEST = 'q' as const 2 | export const TYPE_RESPONSE = 's' as const 3 | 4 | export interface RpcRequest { 5 | /** 6 | * Type 7 | */ 8 | t: typeof TYPE_REQUEST 9 | /** 10 | * ID 11 | */ 12 | i?: string 13 | /** 14 | * Method 15 | */ 16 | m: string 17 | /** 18 | * Arguments 19 | */ 20 | a: any[] 21 | /** 22 | * Optional 23 | */ 24 | o?: boolean 25 | } 26 | export interface RpcResponse { 27 | /** 28 | * Type 29 | */ 30 | t: typeof TYPE_RESPONSE 31 | /** 32 | * Id 33 | */ 34 | i: string 35 | /** 36 | * Result 37 | */ 38 | r?: any 39 | /** 40 | * Error 41 | */ 42 | e?: any 43 | } 44 | 45 | export type RpcMessage = RpcRequest | RpcResponse 46 | -------------------------------------------------------------------------------- /test/exports.test.ts: -------------------------------------------------------------------------------- 1 | import { x } from 'tinyexec' 2 | import { describe, expect, it } from 'vitest' 3 | import { getPackageExportsManifest } from 'vitest-package-exports' 4 | import yaml from 'yaml' 5 | 6 | describe('exports-snapshot', async () => { 7 | const packages: { name: string, path: string, private?: boolean }[] = JSON.parse( 8 | await x('pnpm', ['ls', '--only-projects', '-r', '--json']).then(r => r.stdout), 9 | ) 10 | 11 | for (const pkg of packages) { 12 | if (pkg.private) 13 | continue 14 | 15 | it(`${pkg.name}`, async () => { 16 | const manifest = await getPackageExportsManifest({ 17 | importMode: 'dist', 18 | cwd: pkg.path, 19 | }) 20 | await expect(yaml.stringify(manifest.exports)) 21 | .toMatchFileSnapshot(`./exports/${pkg.name}.yaml`) 22 | }) 23 | } 24 | }) 25 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | build: 14 | runs-on: ${{ matrix.os }} 15 | 16 | strategy: 17 | matrix: 18 | node_version: [lts/*] 19 | os: [ubuntu-latest] 20 | fail-fast: false 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - name: Install pnpm 26 | uses: pnpm/action-setup@v4 27 | 28 | - name: Set node version to ${{ matrix.node_version }} 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version: ${{ matrix.node_version }} 32 | cache: pnpm 33 | 34 | - name: Setup 35 | run: npm i -g @antfu/ni 36 | 37 | - name: Install 38 | run: nci 39 | 40 | - name: Install 41 | run: nr build 42 | 43 | - name: Test 44 | run: nr test 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Anthony Fu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "Birpc" 4 | ], 5 | 6 | // Enable the ESlint flat config support 7 | "eslint.experimental.useFlatConfig": true, 8 | 9 | // Disable the default formatter, use eslint instead 10 | "prettier.enable": false, 11 | "editor.formatOnSave": false, 12 | 13 | // Auto fix 14 | "editor.codeActionsOnSave": { 15 | "source.fixAll.eslint": "explicit", 16 | "source.organizeImports": "never" 17 | }, 18 | 19 | // Silent the stylistic rules in you IDE, but still auto fix them 20 | "eslint.rules.customizations": [ 21 | { "rule": "style/*", "severity": "off" }, 22 | { "rule": "*-indent", "severity": "off" }, 23 | { "rule": "*-spacing", "severity": "off" }, 24 | { "rule": "*-spaces", "severity": "off" }, 25 | { "rule": "*-order", "severity": "off" }, 26 | { "rule": "*-dangle", "severity": "off" }, 27 | { "rule": "*-newline", "severity": "off" }, 28 | { "rule": "*quotes", "severity": "off" }, 29 | { "rule": "*semi", "severity": "off" } 30 | ], 31 | 32 | // Enable eslint for all supported languages 33 | "eslint.validate": [ 34 | "javascript", 35 | "javascriptreact", 36 | "typescript", 37 | "typescriptreact", 38 | "vue", 39 | "html", 40 | "markdown", 41 | "json", 42 | "jsonc", 43 | "yaml" 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export type ArgumentsType = T extends (...args: infer A) => any ? A : never 2 | export type ReturnType = T extends (...args: any) => infer R ? R : never 3 | 4 | export type Thenable = T | PromiseLike 5 | 6 | export function createPromiseWithResolvers(): { 7 | promise: Promise 8 | resolve: (value: T | PromiseLike) => void 9 | reject: (reason?: any) => void 10 | } { 11 | let resolve: (value: T | PromiseLike) => void 12 | let reject: (reason?: any) => void 13 | const promise = new Promise((res, rej) => { 14 | resolve = res 15 | reject = rej 16 | }) 17 | return { promise, resolve: resolve!, reject: reject! } 18 | } 19 | 20 | const _cacheMap = new WeakMap() 21 | export function cachedMap(items: T[], fn: ((i: T) => R)): R[] { 22 | return items.map((i) => { 23 | let r = _cacheMap.get(i) 24 | if (!r) { 25 | r = fn(i) 26 | _cacheMap.set(i, r) 27 | } 28 | return r 29 | }) 30 | } 31 | 32 | const random = Math.random.bind(Math) 33 | // port from nanoid 34 | // https://github.com/ai/nanoid 35 | const urlAlphabet = 'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict' 36 | export function nanoid(size = 21) { 37 | let id = '' 38 | let i = size 39 | while (i--) 40 | id += urlAlphabet[(random() * 64) | 0] 41 | return id 42 | } 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # TypeScript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | 62 | # parcel-bundler cache (https://parceljs.org/) 63 | .cache 64 | 65 | # next.js build output 66 | .next 67 | 68 | # nuxt.js build output 69 | .nuxt 70 | 71 | # Nuxt generate 72 | dist 73 | 74 | # vuepress build output 75 | .vuepress/dist 76 | 77 | # Serverless directories 78 | .serverless 79 | 80 | # IDE 81 | .idea 82 | -------------------------------------------------------------------------------- /test/dynamic.test.ts: -------------------------------------------------------------------------------- 1 | import { MessageChannel } from 'node:worker_threads' 2 | import { expect, it } from 'vitest' 3 | import { createBirpc } from '../src/main' 4 | import * as Alice from './alice' 5 | import * as Bob from './bob' 6 | 7 | type BobFunctions = typeof Bob 8 | type AliceFunctions = typeof Alice 9 | 10 | it('dynamic', async () => { 11 | const channel = new MessageChannel() 12 | 13 | const bob = createBirpc( 14 | { ...Bob }, 15 | { 16 | post: data => channel.port1.postMessage(data), 17 | on: fn => channel.port1.on('message', fn), 18 | }, 19 | ) 20 | 21 | const alice = createBirpc( 22 | { ...Alice }, 23 | { 24 | // mark bob's `bump` as an event without response 25 | eventNames: ['bump'], 26 | post: data => channel.port2.postMessage(data), 27 | on: fn => channel.port2.on('message', fn), 28 | }, 29 | ) 30 | 31 | // RPCs 32 | expect(await bob.hello('Bob')) 33 | .toEqual('Hello Bob, my name is Alice') 34 | expect(await alice.hi('Alice')) 35 | .toEqual('Hi Alice, I am Bob') 36 | 37 | // replace Alice's `hello` function 38 | alice.$functions.hello = (name: string) => { 39 | return `Alice says hello to ${name}` 40 | } 41 | 42 | expect(await bob.hello('Bob')) 43 | .toEqual('Alice says hello to Bob') 44 | 45 | // Adding new functions 46 | // @ts-expect-error `foo` is not defined 47 | alice.$functions.foo = async (name: string) => { 48 | return `A random function, called by ${name}` 49 | } 50 | 51 | // @ts-expect-error `foo` is not defined 52 | expect(await bob.foo('Bob')) 53 | .toEqual('A random function, called by Bob') 54 | }) 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "birpc", 3 | "type": "module", 4 | "version": "4.0.0", 5 | "packageManager": "pnpm@10.24.0", 6 | "description": "Message based Two-way remote procedure call", 7 | "author": "Anthony Fu ", 8 | "license": "MIT", 9 | "funding": "https://github.com/sponsors/antfu", 10 | "homepage": "https://github.com/antfu-collective/birpc#readme", 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/antfu-collective/birpc.git" 14 | }, 15 | "bugs": { 16 | "url": "https://github.com/antfu-collective/birpc/issues" 17 | }, 18 | "keywords": [ 19 | "rpc", 20 | "messages" 21 | ], 22 | "sideEffects": false, 23 | "exports": { 24 | ".": "./dist/index.mjs", 25 | "./package.json": "./package.json" 26 | }, 27 | "main": "./dist/index.mjs", 28 | "module": "./dist/index.mjs", 29 | "types": "./dist/index.d.mts", 30 | "files": [ 31 | "dist" 32 | ], 33 | "scripts": { 34 | "build": "tsdown", 35 | "watch": "tsdown --watch", 36 | "lint": "eslint .", 37 | "prepublishOnly": "nr build", 38 | "release": "bumpp", 39 | "start": "tsx src/index.ts", 40 | "typecheck": "tsc --noEmit", 41 | "test": "vitest" 42 | }, 43 | "devDependencies": { 44 | "@antfu/eslint-config": "^6.3.0", 45 | "@antfu/ni": "^28.0.0", 46 | "@types/node": "^24.10.1", 47 | "@vitest/coverage-v8": "^4.0.15", 48 | "bumpp": "^10.3.2", 49 | "eslint": "^9.39.1", 50 | "tinyexec": "^1.0.2", 51 | "tsdown": "0.17.0-beta.5", 52 | "tsx": "^4.21.0", 53 | "typescript": "^5.9.3", 54 | "vite": "^7.2.6", 55 | "vitest": "^4.0.15", 56 | "vitest-package-exports": "^0.1.1", 57 | "yaml": "^2.8.2" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /test/resolver.test.ts: -------------------------------------------------------------------------------- 1 | import type { Thenable } from '../src/utils' 2 | import { MessageChannel } from 'node:worker_threads' 3 | import { expect, it } from 'vitest' 4 | import { createBirpc } from '../src/main' 5 | import * as Alice from './alice' 6 | import * as Bob from './bob' 7 | 8 | type BobFunctions = typeof Bob 9 | type AliceFunctions = typeof Alice 10 | 11 | it('resolver', async () => { 12 | const channel = new MessageChannel() 13 | 14 | const bob = createBirpc( 15 | { ...Bob }, 16 | { 17 | post: data => channel.port1.postMessage(data), 18 | on: fn => channel.port1.on('message', fn), 19 | }, 20 | ) 21 | 22 | let customResolverFn: Thenable<((...args: any[]) => any) | undefined> | undefined 23 | 24 | const alice = createBirpc( 25 | { ...Alice }, 26 | { 27 | // mark bob's `bump` as an event without response 28 | eventNames: ['bump'], 29 | post: data => channel.port2.postMessage(data), 30 | on: fn => channel.port2.on('message', fn), 31 | resolver: (name, fn) => { 32 | if (name === 'foo') 33 | return customResolverFn 34 | return fn 35 | }, 36 | }, 37 | ) 38 | 39 | // RPCs 40 | expect(await bob.hello('Bob')) 41 | .toEqual('Hello Bob, my name is Alice') 42 | expect(await alice.hi('Alice')) 43 | .toEqual('Hi Alice, I am Bob') 44 | 45 | // @ts-expect-error `foo` is not defined 46 | await expect(bob.foo('Bob')) 47 | .rejects 48 | .toThrow('[birpc] function "foo" not found') 49 | 50 | customResolverFn = Promise.resolve((a: string) => `Custom resolve function to ${a}`) 51 | 52 | // @ts-expect-error `foo` is not defined 53 | expect(await bob.foo('Bob')) 54 | .toBe('Custom resolve function to Bob') 55 | }) 56 | -------------------------------------------------------------------------------- /test/close.test.ts: -------------------------------------------------------------------------------- 1 | import { nextTick } from 'node:process' 2 | import { expect, it } from 'vitest' 3 | import { createBirpc } from '../src/main' 4 | 5 | it('stops the rpc promises', async () => { 6 | expect.assertions(4) 7 | const rpc = createBirpc<{ hello: () => string }>({}, { 8 | on() {}, 9 | post() {}, 10 | }) 11 | const promise = rpc.hello().then( 12 | () => { 13 | throw new Error('Promise should not resolve') 14 | }, 15 | (err) => { 16 | // Promise should reject 17 | expect(err.message).toBe('[birpc] rpc is closed, cannot call "hello"') 18 | }, 19 | ) 20 | expect(rpc.$closed).toBe(false) 21 | nextTick(() => { 22 | rpc.$close() 23 | }) 24 | await promise 25 | await expect(() => rpc.hello()).rejects.toThrow('[birpc] rpc is closed, cannot call "hello"') 26 | expect(rpc.$closed).toBe(true) 27 | }) 28 | 29 | it('stops the rpc promises with a custom message', async () => { 30 | expect.assertions(2) 31 | const rpc = createBirpc<{ hello: () => string }>({}, { 32 | on() {}, 33 | post() {}, 34 | }) 35 | const promise = rpc.hello().then( 36 | () => { 37 | throw new Error('Promise should not resolve') 38 | }, 39 | (err) => { 40 | // Promise should reject 41 | expect(err.message).toBe('Custom error') 42 | 43 | // Original error should be present 44 | expect(err.cause.message).toBe('[birpc] rpc is closed, cannot call "hello"') 45 | }, 46 | ) 47 | nextTick(() => { 48 | rpc.$close(new Error('Custom error')) 49 | }) 50 | await promise 51 | }) 52 | 53 | it('custom error\'s cause is not overwritten', async () => { 54 | expect.assertions(2) 55 | const rpc = createBirpc<{ hello: () => string }>({}, { 56 | on() {}, 57 | post() {}, 58 | }) 59 | const promise = rpc.hello().then( 60 | () => { 61 | throw new Error('Promise should not resolve') 62 | }, 63 | (err) => { 64 | // Promise should reject 65 | expect(err.message).toBe('Custom error') 66 | 67 | // Custom error cause should be present 68 | expect(err.cause.message).toBe('Custom cause') 69 | }, 70 | ) 71 | nextTick(() => { 72 | const error = new Error('Custom error') 73 | error.cause = new Error('Custom cause') 74 | rpc.$close(error) 75 | }) 76 | await promise 77 | }) 78 | -------------------------------------------------------------------------------- /test/timeout.test.ts: -------------------------------------------------------------------------------- 1 | import type * as Alice from './alice' 2 | import { MessageChannel } from 'node:worker_threads' 3 | import { expect, it, vi } from 'vitest' 4 | import { createBirpc } from '../src/main' 5 | import * as Bob from './bob' 6 | 7 | type AliceFunctions = typeof Alice 8 | type BobFunctions = typeof Bob 9 | 10 | it('timeout', async () => { 11 | const channel = new MessageChannel() 12 | 13 | const bob = createBirpc( 14 | Bob, 15 | { 16 | post: data => channel.port1.postMessage(data), 17 | on: fn => channel.port1.on('message', fn), 18 | timeout: 100, 19 | }, 20 | ) 21 | 22 | try { 23 | await bob.hello('Bob') 24 | expect(1).toBe(2) 25 | } 26 | catch (e) { 27 | expect(e).toMatchInlineSnapshot('[Error: [birpc] timeout on calling "hello"]') 28 | } 29 | }) 30 | 31 | it('custom onTimeoutError', async () => { 32 | const channel = new MessageChannel() 33 | const onTimeout = vi.fn() 34 | 35 | const bob = createBirpc( 36 | Bob, 37 | { 38 | post: data => channel.port1.postMessage(data), 39 | on: fn => channel.port1.on('message', fn), 40 | timeout: 100, 41 | onTimeoutError(functionName, args) { 42 | onTimeout({ functionName, args }) 43 | throw new Error('Custom error') 44 | }, 45 | }, 46 | ) 47 | 48 | try { 49 | await bob.hello('Bob') 50 | expect(1).toBe(2) 51 | } 52 | catch (e) { 53 | expect(onTimeout).toHaveBeenCalledWith({ functionName: 'hello', args: ['Bob'] }) 54 | expect(e).toMatchInlineSnapshot(`[Error: Custom error]`) 55 | } 56 | }) 57 | 58 | it('custom onTimeoutError without custom error', async () => { 59 | const channel = new MessageChannel() 60 | const onTimeout = vi.fn() 61 | 62 | const bob = createBirpc( 63 | Bob, 64 | { 65 | post: data => channel.port1.postMessage(data), 66 | on: fn => channel.port1.on('message', fn), 67 | timeout: 100, 68 | onTimeoutError(functionName, args) { 69 | onTimeout({ functionName, args }) 70 | }, 71 | }, 72 | ) 73 | 74 | try { 75 | await bob.hello('Bob') 76 | expect(1).toBe(2) 77 | } 78 | catch (e) { 79 | expect(onTimeout).toHaveBeenCalledWith({ functionName: 'hello', args: ['Bob'] }) 80 | expect(e).toMatchInlineSnapshot(`[Error: [birpc] timeout on calling "hello"]`) 81 | } 82 | }) 83 | -------------------------------------------------------------------------------- /test/hook.test.ts: -------------------------------------------------------------------------------- 1 | import type { EventOptions } from '../src' 2 | import { MessageChannel } from 'node:worker_threads' 3 | import { expect, it, vi } from 'vitest' 4 | import { createBirpc } from '../src/main' 5 | import * as Alice from './alice' 6 | import * as Bob from './bob' 7 | 8 | type BobFunctions = typeof Bob 9 | type AliceFunctions = typeof Alice 10 | 11 | const mockFn = { 12 | trigger() { 13 | }, 14 | } 15 | 16 | function createChannel(options: { 17 | onRequest?: EventOptions['onRequest'] 18 | } = {}) { 19 | const channel = new MessageChannel() 20 | const { 21 | onRequest = () => {}, 22 | } = options 23 | return { 24 | channel, 25 | alice: createBirpc( 26 | Alice, 27 | { 28 | onRequest, 29 | post: data => channel.port2.postMessage(data), 30 | on: (fn) => { 31 | channel.port2.on('message', fn) 32 | }, 33 | }, 34 | ), 35 | bob: createBirpc( 36 | Bob, 37 | { 38 | post: (data) => { 39 | return channel.port1.postMessage(data) 40 | }, 41 | on: fn => channel.port1.on('message', (...args) => { 42 | mockFn.trigger() 43 | fn(...args) 44 | }), 45 | }, 46 | ), 47 | } 48 | } 49 | 50 | it('cache', async () => { 51 | const spy = vi.spyOn(mockFn, 'trigger') 52 | const cacheMap = new Map() 53 | const { alice } = createChannel({ 54 | onRequest: async (req, next, send) => { 55 | const key = `${req.m}-${req.a?.join('-')}` 56 | if (!cacheMap.has(key)) { 57 | cacheMap.set(key, await next()) 58 | } 59 | else { 60 | send(cacheMap.get(key)) 61 | } 62 | }, 63 | }) 64 | expect(cacheMap).toMatchInlineSnapshot(`Map {}`) 65 | expect(await alice.hi('Alice')).toBe('Hi Alice, I am Bob') 66 | expect(spy).toBeCalledTimes(1) 67 | expect(await alice.hi('Alice')).toBe('Hi Alice, I am Bob') 68 | expect(spy).toBeCalledTimes(1) 69 | expect(await alice.hi('Alex')).toBe('Hi Alex, I am Bob') 70 | expect(spy).toBeCalledTimes(2) 71 | expect(await alice.hi('Alex')).toBe('Hi Alex, I am Bob') 72 | expect(spy).toBeCalledTimes(2) 73 | expect(await alice.getCount()).toBe(0) 74 | expect(spy).toBeCalledTimes(3) 75 | expect(cacheMap).toMatchInlineSnapshot(` 76 | Map { 77 | "hi-Alice" => "Hi Alice, I am Bob", 78 | "hi-Alex" => "Hi Alex, I am Bob", 79 | "getCount-" => 0, 80 | } 81 | `) 82 | }) 83 | -------------------------------------------------------------------------------- /test/reject-pending-calls.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from 'vitest' 2 | import { createBirpc } from '../src/main' 3 | 4 | it('rejects pending calls', async () => { 5 | const rpc = createBirpc<{ first: () => Promise, second: () => Promise }>({}, { 6 | on() {}, 7 | post() {}, 8 | }) 9 | 10 | const promises = [ 11 | rpc.first() 12 | .then(() => expect.fail('first() should not resolve')) 13 | .catch(error => error), 14 | 15 | rpc.second() 16 | .then(() => expect.fail('second() should not resolve')) 17 | .catch(error => error), 18 | ] 19 | 20 | const rejections = rpc.$rejectPendingCalls() 21 | expect(rejections).toHaveLength(2) 22 | 23 | const errors = await Promise.all(promises) 24 | expect(errors).toHaveLength(2) 25 | 26 | expect.soft(errors[0].message).toBe('[birpc]: rejected pending call "first".') 27 | expect.soft(errors[1].message).toBe('[birpc]: rejected pending call "second".') 28 | }) 29 | 30 | it('rejects pending calls with custom handler', async () => { 31 | const rpc = createBirpc<{ first: () => Promise, second: () => Promise }>({}, { 32 | on() {}, 33 | post() {}, 34 | }) 35 | 36 | const promises = [ 37 | rpc.first() 38 | .then(() => expect.fail('first() should not resolve')) 39 | .catch(error => error), 40 | 41 | rpc.second() 42 | .then(() => expect.fail('second() should not resolve')) 43 | .catch(error => error), 44 | ] 45 | 46 | const rejections = rpc.$rejectPendingCalls(({ method, reject }) => 47 | reject(new Error(`Rejected call. Method: "${method}".`)), 48 | ) 49 | expect(rejections).toHaveLength(2) 50 | 51 | const errors = await Promise.all(promises) 52 | expect(errors).toHaveLength(2) 53 | 54 | expect.soft(errors[0].message).toBe('Rejected call. Method: "first".') 55 | expect.soft(errors[1].message).toBe('Rejected call. Method: "second".') 56 | }) 57 | 58 | it('rejected calls are cleared from rpc', async () => { 59 | const rpc = createBirpc<{ stuck: () => Promise }>({}, { 60 | on() {}, 61 | post() {}, 62 | }) 63 | 64 | rpc.stuck() 65 | .then(() => expect.fail('stuck() should not resolve')) 66 | .catch(() => undefined) 67 | 68 | { 69 | const rejections = rpc.$rejectPendingCalls(({ reject }) => reject(new Error('Rejected'))) 70 | expect(rejections).toHaveLength(1) 71 | } 72 | 73 | { 74 | const rejections = rpc.$rejectPendingCalls(({ reject }) => reject(new Error('Rejected'))) 75 | expect(rejections).toHaveLength(0) 76 | } 77 | }) 78 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import { MessageChannel } from 'node:worker_threads' 2 | import { expect, it } from 'vitest' 3 | import { createBirpc } from '../src/main' 4 | import * as Alice from './alice' 5 | import * as Bob from './bob' 6 | 7 | type BobFunctions = typeof Bob 8 | type AliceFunctions = typeof Alice 9 | 10 | function createChannel() { 11 | const channel = new MessageChannel() 12 | return { 13 | channel, 14 | alice: createBirpc( 15 | Alice, 16 | { 17 | // mark bob's `bump` as an event without response 18 | eventNames: ['bump'], 19 | post: data => channel.port2.postMessage(data), 20 | on: fn => channel.port2.on('message', fn), 21 | }, 22 | ), 23 | bob: createBirpc( 24 | Bob, 25 | { 26 | post: data => channel.port1.postMessage(data), 27 | on: fn => channel.port1.on('message', fn), 28 | }, 29 | ), 30 | } 31 | } 32 | 33 | it('basic', async () => { 34 | const { bob, alice } = createChannel() 35 | 36 | // RPCs 37 | expect(await bob.hello('Bob')) 38 | .toEqual('Hello Bob, my name is Alice') 39 | expect(await alice.hi('Alice')) 40 | .toEqual('Hi Alice, I am Bob') 41 | 42 | // one-way event 43 | expect(await alice.bump()).toBeUndefined() 44 | 45 | expect(Bob.getCount()).toBe(0) 46 | await new Promise(resolve => setTimeout(resolve, 1)) 47 | expect(Bob.getCount()).toBe(1) 48 | 49 | expect(await alice.bumpWithReturn()).toBe(2) 50 | expect(Bob.getCount()).toBe(2) 51 | 52 | expect(await alice.bumpWithReturn.asEvent()).toBeUndefined() 53 | await new Promise(resolve => setTimeout(resolve, 1)) 54 | expect(Bob.getCount()).toBe(3) 55 | }) 56 | 57 | it('basic without proxify', async () => { 58 | const channel = new MessageChannel() 59 | const alice = createBirpc( 60 | Alice, 61 | { 62 | // mark bob's `bump` as an event without response 63 | eventNames: ['bump'], 64 | post: data => channel.port2.postMessage(data), 65 | on: fn => channel.port2.on('message', fn), 66 | proxify: false, 67 | }, 68 | ) 69 | const bob = createBirpc( 70 | Bob, 71 | { 72 | post: data => channel.port1.postMessage(data), 73 | on: fn => channel.port1.on('message', fn), 74 | proxify: false, 75 | }, 76 | ) 77 | 78 | // RPCs 79 | // @ts-expect-error `hello` is not a function 80 | expect(() => bob.hello('Bob')) 81 | .toThrowErrorMatchingInlineSnapshot(`[TypeError: bob.hello is not a function]`) 82 | // @ts-expect-error `hi` is not a function 83 | expect(() => alice.hi('Alice')) 84 | .toThrowErrorMatchingInlineSnapshot(`[TypeError: alice.hi is not a function]`) 85 | 86 | expect(await bob.$call('hello', 'Bob')) 87 | .toEqual('Hello Bob, my name is Alice') 88 | }) 89 | 90 | it('await on birpc should not throw error', async () => { 91 | const { bob, alice } = createChannel() 92 | 93 | await alice 94 | await bob 95 | }) 96 | 97 | it('$call', async () => { 98 | const { bob, alice } = createChannel() 99 | 100 | // RPCs 101 | expect(await bob.$call('hello', 'Bob')) 102 | .toEqual('Hello Bob, my name is Alice') 103 | expect(await alice.$call('hi', 'Alice')) 104 | .toEqual('Hi Alice, I am Bob') 105 | 106 | // one-way event 107 | expect(await alice.$callEvent('bump')).toBeUndefined() 108 | 109 | expect(Bob.getCount()).toBe(3) 110 | await new Promise(resolve => setTimeout(resolve, 1)) 111 | expect(Bob.getCount()).toBe(4) 112 | }) 113 | 114 | it('$callOptional', async () => { 115 | const { bob } = createChannel() 116 | 117 | // @ts-expect-error `hello2` is not defined 118 | await expect(async () => await bob.$call('hello2', 'Bob')) 119 | .rejects 120 | .toThrow('[birpc] function "hello2" not found') 121 | 122 | // @ts-expect-error `hello2` is not defined 123 | expect(await bob.$callOptional('hello2', 'Bob')) 124 | .toEqual(undefined) 125 | }) 126 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # birpc 2 | 3 | [![NPM version](https://img.shields.io/npm/v/birpc?color=a1b858&label=)](https://www.npmjs.com/package/birpc) 4 | 5 | Message-based two-way remote procedure call. Useful for WebSockets and Workers communication. 6 | 7 | ## Features 8 | 9 | - Intuitive - call remote functions just like locals, with Promise to get the response 10 | - TypeScript - safe function calls for arguments and returns 11 | - Protocol agonostic - WebSocket, MessageChannel, any protocols with messages communication would work! 12 | - Zero deps, ~0.5KB 13 | 14 | ## Examples 15 | 16 | ### Using WebSocket 17 | 18 | When using WebSocket, you need to pass your custom serializer and deserializer. 19 | 20 | #### Client 21 | 22 | ```ts 23 | import type { ServerFunctions } from './types' 24 | 25 | const ws = new WebSocket('ws://url') 26 | 27 | const clientFunctions: ClientFunctions = { 28 | hey(name: string) { 29 | return `Hey ${name} from client` 30 | } 31 | } 32 | 33 | const rpc = createBirpc( 34 | clientFunctions, 35 | { 36 | post: data => ws.send(data), 37 | on: fn => ws.on('message', fn), 38 | // these are required when using WebSocket 39 | serialize: v => JSON.stringify(v), 40 | deserialize: v => JSON.parse(v), 41 | }, 42 | ) 43 | 44 | await rpc.hi('Client') // Hi Client from server 45 | ``` 46 | 47 | #### Server 48 | 49 | ```ts 50 | import type { ClientFunctions } from './types' 51 | import { WebSocketServer } from 'ws' 52 | 53 | const serverFunctions: ServerFunctions = { 54 | hi(name: string) { 55 | return `Hi ${name} from server` 56 | } 57 | } 58 | 59 | const wss = new WebSocketServer() 60 | 61 | wss.on('connection', (ws) => { 62 | const rpc = createBirpc( 63 | serverFunctions, 64 | { 65 | post: data => ws.send(data), 66 | on: fn => ws.on('message', fn), 67 | serialize: v => JSON.stringify(v), 68 | deserialize: v => JSON.parse(v), 69 | }, 70 | ) 71 | 72 | await rpc.hey('Server') // Hey Server from client 73 | }) 74 | ``` 75 | 76 | ### Circular References 77 | 78 | As `JSON.stringify` does not supporting circular references, we recommend using [`structured-clone-es`](https://github.com/antfu/structured-clone-es) as the serializer when you expect to have circular references. 79 | 80 | ```ts 81 | import { parse, stringify } from 'structured-clone-es' 82 | 83 | const rpc = createBirpc( 84 | functions, 85 | { 86 | post: data => ws.send(data), 87 | on: fn => ws.on('message', fn), 88 | // use structured-clone-es as serializer 89 | serialize: v => stringify(v), 90 | deserialize: v => parse(v), 91 | }, 92 | ) 93 | ``` 94 | 95 | ### Using MessageChannel 96 | 97 | [MessageChannel](https://developer.mozilla.org/en-US/docs/Web/API/MessageChannel) will automatically serialize the message and support circular references out-of-box. 98 | 99 | ```ts 100 | export const channel = new MessageChannel() 101 | ``` 102 | 103 | #### Bob 104 | 105 | ``` ts 106 | import type { AliceFunctions } from './types' 107 | import { channel } from './channel' 108 | 109 | const Bob: BobFunctions = { 110 | hey(name: string) { 111 | return `Hey ${name}, I am Bob` 112 | } 113 | } 114 | 115 | const rpc = createBirpc( 116 | Bob, 117 | { 118 | post: data => channel.port1.postMessage(data), 119 | on: fn => channel.port1.on('message', fn), 120 | }, 121 | ) 122 | 123 | await rpc.hi('Bob') // Hi Bob, I am Alice 124 | ``` 125 | 126 | #### Alice 127 | 128 | ``` ts 129 | import type { BobFunctions } from './types' 130 | import { channel } from './channel' 131 | 132 | const Alice: AliceFunctions = { 133 | hi(name: string) { 134 | return `Hi ${name}, I am Alice` 135 | } 136 | } 137 | 138 | const rpc = createBirpc( 139 | Alice, 140 | { 141 | post: data => channel.port2.postMessage(data), 142 | on: fn => channel.port2.on('message', fn), 143 | }, 144 | ) 145 | 146 | await rpc.hey('Alice') // Hey Alice, I am Bob 147 | ``` 148 | 149 | ### One-to-multiple Communication 150 | 151 | Refer to [./test/group.test.ts](./test/group.test.ts) as an example. 152 | 153 | ## Sponsors 154 | 155 |

156 | 157 | 158 | 159 |

160 | 161 | ## License 162 | 163 | [MIT](./LICENSE) License © 2021 [Anthony Fu](https://github.com/antfu) 164 | -------------------------------------------------------------------------------- /src/group.ts: -------------------------------------------------------------------------------- 1 | import type { BirpcReturn, CallRawOptions, ChannelOptions, EventOptions, ProxifiedRemoteFunctions } from './main' 2 | import type { ArgumentsType, ReturnType } from './utils' 3 | import { createBirpc } from './main' 4 | import { cachedMap } from './utils' 5 | 6 | export interface BirpcGroupReturnBuiltin { 7 | /** 8 | * Call the remote function and wait for the result. 9 | * An alternative to directly calling the function 10 | */ 11 | $call: (method: K, ...args: ArgumentsType) => Promise>[]> 12 | /** 13 | * Same as `$call`, but returns `undefined` if the function is not defined on the remote side. 14 | */ 15 | $callOptional: (method: K, ...args: ArgumentsType) => Promise<(Awaited> | undefined)[]> 16 | /** 17 | * Send event without asking for response 18 | */ 19 | $callEvent: (method: K, ...args: ArgumentsType) => Promise 20 | /** 21 | * Call the remote function with the raw options. 22 | */ 23 | $callRaw: (options: { method: string, args: unknown[], event?: boolean, optional?: boolean }) => Promise>[]> 24 | } 25 | 26 | export interface BirpcGroupFn { 27 | /** 28 | * Call the remote function and wait for the result. 29 | */ 30 | (...args: ArgumentsType): Promise>[]> 31 | /** 32 | * Send event without asking for response 33 | */ 34 | asEvent: (...args: ArgumentsType) => Promise 35 | } 36 | 37 | export type BirpcGroupReturn< 38 | RemoteFunctions extends object = Record, 39 | Proxify extends boolean = true, 40 | > = Proxify extends true 41 | ? ProxifiedRemoteFunctions & BirpcGroupReturnBuiltin 42 | : BirpcGroupReturnBuiltin 43 | 44 | export interface BirpcGroup< 45 | RemoteFunctions extends object = Record, 46 | LocalFunctions extends object = Record, 47 | Proxify extends boolean = true, 48 | > { 49 | readonly clients: BirpcReturn[] 50 | readonly functions: LocalFunctions 51 | readonly broadcast: BirpcGroupReturn 52 | updateChannels: (fn?: ((channels: ChannelOptions[]) => void)) => BirpcReturn[] 53 | } 54 | 55 | export function createBirpcGroup< 56 | RemoteFunctions extends object = Record, 57 | LocalFunctions extends object = Record, 58 | Proxify extends boolean = true, 59 | >( 60 | functions: LocalFunctions, 61 | channels: ChannelOptions[] | (() => ChannelOptions[]), 62 | options: EventOptions = {}, 63 | ): BirpcGroup { 64 | const { proxify = true } = options 65 | const getChannels = () => typeof channels === 'function' ? channels() : channels 66 | const getClients = (channels = getChannels()) => cachedMap(channels, s => createBirpc(functions, { ...options, ...s })) 67 | 68 | function _boardcast(options: CallRawOptions) { 69 | const clients = getClients() 70 | return Promise.all(clients.map(c => c.$callRaw(options))) 71 | } 72 | 73 | const broadcastBuiltin = { 74 | $call: (method: string, ...args: unknown[]) => _boardcast({ method, args, event: false }), 75 | $callOptional: (method: string, ...args: unknown[]) => _boardcast({ method, args, event: false, optional: true }), 76 | $callEvent: (method: string, ...args: unknown[]) => _boardcast({ method, args, event: true }), 77 | $callRaw: (options: CallRawOptions) => _boardcast(options), 78 | } as unknown as BirpcGroupReturnBuiltin 79 | 80 | const broadcastProxy = proxify 81 | ? new Proxy({}, { 82 | get(_, method) { 83 | if (Object.prototype.hasOwnProperty.call(broadcastBuiltin, method)) 84 | return (broadcastBuiltin as any)[method] 85 | 86 | const client = getClients() 87 | const callbacks = client.map(c => (c as any)[method]) 88 | const sendCall = (...args: any[]) => { 89 | return Promise.all(callbacks.map(i => i(...args))) 90 | } 91 | sendCall.asEvent = async (...args: any[]) => { 92 | await Promise.all(callbacks.map(i => i.asEvent(...args))) 93 | } 94 | return sendCall 95 | }, 96 | }) as BirpcGroupReturn 97 | : broadcastBuiltin as BirpcGroupReturn 98 | 99 | function updateChannels(fn?: ((channels: ChannelOptions[]) => void)) { 100 | const channels = getChannels() 101 | fn?.(channels) 102 | return getClients(channels) 103 | } 104 | 105 | getClients() 106 | 107 | return { 108 | get clients() { 109 | return getClients() 110 | }, 111 | functions, 112 | updateChannels, 113 | broadcast: broadcastProxy, 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /test/error.test.ts: -------------------------------------------------------------------------------- 1 | import { MessageChannel } from 'node:worker_threads' 2 | import { expect, it } from 'vitest' 3 | import { createBirpc } from '../src/main' 4 | import * as Alice from './alice' 5 | import * as Bob from './bob' 6 | 7 | type BobFunctions = typeof Bob 8 | type AliceFunctions = typeof Alice 9 | 10 | it('on function error', async () => { 11 | const channel = new MessageChannel() 12 | 13 | let error: any 14 | 15 | const _bob = createBirpc( 16 | Bob, 17 | { 18 | post: data => channel.port1.postMessage(data), 19 | on: fn => channel.port1.on('message', fn), 20 | onFunctionError(err, method, args) { 21 | error = { err, method, args } 22 | }, 23 | }, 24 | ) 25 | 26 | const alice = createBirpc( 27 | { ...Alice }, 28 | { 29 | // mark bob's `bump` as an event without response 30 | eventNames: ['bump'], 31 | post: data => channel.port2.postMessage(data), 32 | on: fn => channel.port2.on('message', fn), 33 | }, 34 | ) 35 | 36 | try { 37 | // @ts-expect-error `foo` is not defined 38 | await alice.foo('Bob') 39 | } 40 | catch { 41 | } 42 | 43 | expect(error).toMatchInlineSnapshot(` 44 | { 45 | "args": [ 46 | "Bob", 47 | ], 48 | "err": [Error: [birpc] function "foo" not found], 49 | "method": "foo", 50 | } 51 | `) 52 | }) 53 | 54 | it('on serialize error', async () => { 55 | const channel = new MessageChannel() 56 | 57 | let error: any 58 | 59 | const _bob = createBirpc( 60 | { ...Bob }, 61 | { 62 | serialize: (d) => { 63 | if (d.e) 64 | return d 65 | throw new Error('Custom serialization error') 66 | }, 67 | post: data => channel.port1.postMessage(data), 68 | on: fn => channel.port1.on('message', fn), 69 | onGeneralError(err, method, args) { 70 | error = { err, method, args } 71 | return true 72 | }, 73 | }, 74 | ) 75 | 76 | const alice = createBirpc( 77 | { ...Alice }, 78 | { 79 | // mark bob's `bump` as an event without response 80 | eventNames: ['bump'], 81 | post: data => channel.port2.postMessage(data), 82 | on: fn => channel.port2.on('message', fn), 83 | }, 84 | ) 85 | 86 | try { 87 | await alice.hi('Bob') 88 | } 89 | catch {} 90 | 91 | expect(error).toMatchInlineSnapshot(` 92 | { 93 | "args": [ 94 | "Bob", 95 | ], 96 | "err": [Error: Custom serialization error], 97 | "method": "hi", 98 | } 99 | `) 100 | }) 101 | 102 | it('on parse error', async () => { 103 | const channel = new MessageChannel() 104 | 105 | let error: any 106 | 107 | const _bob = createBirpc( 108 | { ...Bob }, 109 | { 110 | deserialize: () => { 111 | throw new Error('Custom deserialization error') 112 | }, 113 | post: data => channel.port1.postMessage(data), 114 | on: fn => channel.port1.on('message', fn), 115 | onGeneralError(err, method, args) { 116 | error = { err, method, args } 117 | return true 118 | }, 119 | }, 120 | ) 121 | 122 | const alice = createBirpc( 123 | { ...Alice }, 124 | { 125 | // mark bob's `bump` as an event without response 126 | eventNames: ['bump'], 127 | post: data => channel.port2.postMessage(data), 128 | on: fn => channel.port2.on('message', fn), 129 | }, 130 | ) 131 | 132 | try { 133 | alice.hi('Bob') 134 | } 135 | catch {} 136 | 137 | await new Promise(r => setTimeout(r, 10)) 138 | 139 | expect(error).toMatchInlineSnapshot(` 140 | { 141 | "args": undefined, 142 | "err": [Error: Custom deserialization error], 143 | "method": undefined, 144 | } 145 | `) 146 | }) 147 | 148 | it('on async post error', async () => { 149 | const channel = new MessageChannel() 150 | 151 | let error: any 152 | 153 | const _bob = createBirpc( 154 | { ...Bob }, 155 | { 156 | post: data => channel.port1.postMessage(data), 157 | on: fn => channel.port1.on('message', fn), 158 | }, 159 | ) 160 | 161 | const alice = createBirpc( 162 | { ...Alice }, 163 | { 164 | // mark bob's `bump` as an event without response 165 | eventNames: ['bump'], 166 | async post() { 167 | await new Promise(r => setTimeout(r, 1)) 168 | throw new Error('Custom async post error') 169 | }, 170 | on: fn => channel.port2.on('message', fn), 171 | onGeneralError(err) { 172 | error = err 173 | }, 174 | }, 175 | ) 176 | 177 | try { 178 | await alice.hi('Bob') 179 | } 180 | catch (e) { 181 | expect(e).toStrictEqual(error) 182 | expect(e).toMatchInlineSnapshot(`[Error: Custom async post error]`) 183 | } 184 | 185 | try { 186 | await alice.bump() 187 | } 188 | catch (e) { 189 | expect(e).toStrictEqual(error) 190 | expect(e).toMatchInlineSnapshot(`[Error: Custom async post error]`) 191 | } 192 | }) 193 | -------------------------------------------------------------------------------- /test/group.test.ts: -------------------------------------------------------------------------------- 1 | import { MessageChannel } from 'node:worker_threads' 2 | import { expect, it } from 'vitest' 3 | import { createBirpcGroup } from '../src/group' 4 | import { createBirpc } from '../src/main' 5 | import * as Alice from './alice' 6 | import * as Bob from './bob' 7 | 8 | type BobFunctions = typeof Bob 9 | type AliceFunctions = typeof Alice 10 | 11 | it('group', async () => { 12 | const channel1 = new MessageChannel() 13 | const channel2 = new MessageChannel() 14 | const channel3 = new MessageChannel() 15 | 16 | const client1 = createBirpc( 17 | Bob, 18 | { 19 | post: data => channel1.port1.postMessage(data), 20 | on: async (fn) => { 21 | await new Promise(resolve => setTimeout(resolve, 100)) 22 | channel1.port1.on('message', fn) 23 | }, 24 | meta: { 25 | name: 'client1', 26 | }, 27 | }, 28 | ) 29 | const client2 = createBirpc( 30 | Bob, 31 | { 32 | post: data => channel2.port1.postMessage(data), 33 | on: fn => channel2.port1.on('message', fn), 34 | meta: { 35 | name: 'client2', 36 | }, 37 | }, 38 | ) 39 | const client3 = createBirpc( 40 | Bob, 41 | { 42 | post: data => channel3.port1.postMessage(data), 43 | on: fn => channel3.port1.on('message', fn), 44 | meta: { 45 | name: 'client3', 46 | }, 47 | }, 48 | ) 49 | 50 | const server = createBirpcGroup( 51 | Alice, 52 | [ 53 | { 54 | post: data => channel1.port2.postMessage(data), 55 | on: fn => channel1.port2.on('message', fn), 56 | meta: { 57 | name: 'channel1', 58 | }, 59 | }, 60 | { 61 | post: data => channel2.port2.postMessage(data), 62 | on: fn => channel2.port2.on('message', fn), 63 | meta: { 64 | name: 'channel2', 65 | }, 66 | }, 67 | ], 68 | { 69 | eventNames: ['bump'], 70 | resolver(name, fn): any { 71 | if (name === 'hello' && this.$meta?.name === 'channel1') 72 | return async (name: string) => `${await fn(name)} (from channel1)` 73 | return fn 74 | }, 75 | }, 76 | ) 77 | 78 | // RPCs 79 | expect(await client1.hello('Bob')) 80 | .toEqual('Hello Bob, my name is Alice (from channel1)') 81 | expect(await client2.hello('Bob')) 82 | .toEqual('Hello Bob, my name is Alice') 83 | expect(await server.broadcast.hi('Alice')) 84 | .toEqual([ 85 | 'Hi Alice, I am Bob', 86 | 'Hi Alice, I am Bob', 87 | ]) 88 | 89 | server.updateChannels((channels) => { 90 | channels.push({ 91 | post: data => channel3.port2.postMessage(data), 92 | on: fn => channel3.port2.on('message', fn), 93 | }) 94 | }) 95 | 96 | expect(await server.broadcast.hi('Alice')) 97 | .toEqual([ 98 | 'Hi Alice, I am Bob', 99 | 'Hi Alice, I am Bob', 100 | 'Hi Alice, I am Bob', 101 | ]) 102 | 103 | expect(await client3.hello('Bob')) 104 | .toEqual('Hello Bob, my name is Alice') 105 | }) 106 | 107 | it('broadcast optional', async () => { 108 | const channel1 = new MessageChannel() 109 | const channel2 = new MessageChannel() 110 | const channel3 = new MessageChannel() 111 | 112 | const client1 = createBirpc( 113 | Bob, 114 | { 115 | post: data => channel1.port1.postMessage(data), 116 | on: async (fn) => { 117 | await new Promise(resolve => setTimeout(resolve, 100)) 118 | channel1.port1.on('message', fn) 119 | }, 120 | }, 121 | ) 122 | const client2 = createBirpc( 123 | { 124 | ...Bob, 125 | hi: name => `Hello ${name}, I am another Bob`, 126 | }, 127 | { 128 | post: data => channel2.port1.postMessage(data), 129 | on: fn => channel2.port1.on('message', fn), 130 | }, 131 | ) 132 | const client3 = createBirpc( 133 | { 134 | ...Bob, 135 | hi: undefined!, 136 | }, 137 | { 138 | post: data => channel3.port1.postMessage(data), 139 | on: fn => channel3.port1.on('message', fn), 140 | }, 141 | ) 142 | 143 | const server = createBirpcGroup( 144 | Alice, 145 | [ 146 | { 147 | post: data => channel1.port2.postMessage(data), 148 | on: fn => channel1.port2.on('message', fn), 149 | }, 150 | { 151 | post: data => channel2.port2.postMessage(data), 152 | on: fn => channel2.port2.on('message', fn), 153 | }, 154 | ], 155 | { eventNames: ['bump'] }, 156 | ) 157 | 158 | // RPCs 159 | expect(await client1.hello('Bob')) 160 | .toEqual('Hello Bob, my name is Alice') 161 | expect(await client2.hello('Bob')) 162 | .toEqual('Hello Bob, my name is Alice') 163 | expect(await server.broadcast.$call('hi', 'Alice')) 164 | .toEqual([ 165 | 'Hi Alice, I am Bob', 166 | 'Hello Alice, I am another Bob', 167 | ]) 168 | 169 | server.updateChannels((channels) => { 170 | channels.push({ 171 | post: data => channel3.port2.postMessage(data), 172 | on: fn => channel3.port2.on('message', fn), 173 | }) 174 | }) 175 | 176 | await expect(() => server.broadcast.hi('Alice')) 177 | .rejects 178 | .toThrow('[birpc] function "hi" not found') 179 | 180 | await expect(() => server.broadcast.$call('hi', 'Alice')) 181 | .rejects 182 | .toThrow('[birpc] function "hi" not found') 183 | 184 | expect(await server.broadcast.$callOptional('hi', 'Alice')) 185 | .toEqual([ 186 | 'Hi Alice, I am Bob', 187 | 'Hello Alice, I am another Bob', 188 | undefined, 189 | ]) 190 | 191 | expect(await client3.$callOptional('hello', 'Bob')) 192 | .toEqual('Hello Bob, my name is Alice') 193 | 194 | expect(await server.broadcast.$callEvent('bump')) 195 | .toEqual([ 196 | undefined, 197 | undefined, 198 | undefined, 199 | ]) 200 | 201 | await new Promise(resolve => setTimeout(resolve, 1)) 202 | expect(Bob.getCount()).toBe(3) 203 | }) 204 | 205 | it('group without proxify', async () => { 206 | const channel1 = new MessageChannel() 207 | const channel2 = new MessageChannel() 208 | const channel3 = new MessageChannel() 209 | 210 | const client1 = createBirpc( 211 | Bob, 212 | { 213 | post: data => channel1.port1.postMessage(data), 214 | on: async (fn) => { 215 | await new Promise(resolve => setTimeout(resolve, 100)) 216 | channel1.port1.on('message', fn) 217 | }, 218 | meta: { 219 | name: 'client1', 220 | }, 221 | proxify: false, 222 | }, 223 | ) 224 | const client2 = createBirpc( 225 | Bob, 226 | { 227 | post: data => channel2.port1.postMessage(data), 228 | on: fn => channel2.port1.on('message', fn), 229 | meta: { 230 | name: 'client2', 231 | }, 232 | proxify: false, 233 | }, 234 | ) 235 | const client3 = createBirpc( 236 | Bob, 237 | { 238 | post: data => channel3.port1.postMessage(data), 239 | on: fn => channel3.port1.on('message', fn), 240 | meta: { 241 | name: 'client3', 242 | }, 243 | proxify: false, 244 | }, 245 | ) 246 | 247 | const server = createBirpcGroup( 248 | Alice, 249 | [ 250 | { 251 | post: data => channel1.port2.postMessage(data), 252 | on: fn => channel1.port2.on('message', fn), 253 | meta: { 254 | name: 'channel1', 255 | }, 256 | }, 257 | { 258 | post: data => channel2.port2.postMessage(data), 259 | on: fn => channel2.port2.on('message', fn), 260 | meta: { 261 | name: 'channel2', 262 | }, 263 | }, 264 | ], 265 | { 266 | eventNames: ['bump'], 267 | resolver(name, fn): any { 268 | if (name === 'hello' && this.$meta?.name === 'channel1') 269 | return async (name: string) => `${await fn(name)} (from channel1)` 270 | return fn 271 | }, 272 | proxify: false, 273 | }, 274 | ) 275 | 276 | // RPCs 277 | expect(await client1.$call('hello', 'Bob')) 278 | .toEqual('Hello Bob, my name is Alice (from channel1)') 279 | expect(await client2.$call('hello', 'Bob')) 280 | .toEqual('Hello Bob, my name is Alice') 281 | expect(await server.broadcast.$call('hi', 'Alice')) 282 | .toEqual([ 283 | 'Hi Alice, I am Bob', 284 | 'Hi Alice, I am Bob', 285 | ]) 286 | 287 | // @ts-expect-error `hello` is not a function 288 | expect(() => client1.hello('Bob')) 289 | .toThrowErrorMatchingInlineSnapshot(`[TypeError: client1.hello is not a function]`) 290 | // @ts-expect-error `hello` is not a function 291 | expect(() => client2.hello('Bob')) 292 | .toThrowErrorMatchingInlineSnapshot(`[TypeError: client2.hello is not a function]`) 293 | // @ts-expect-error `hi` is not a function 294 | expect(() => server.broadcast.hi('Alice')) 295 | .toThrowErrorMatchingInlineSnapshot(`[TypeError: server.broadcast.hi is not a function]`) 296 | 297 | server.updateChannels((channels) => { 298 | channels.push({ 299 | post: data => channel3.port2.postMessage(data), 300 | on: fn => channel3.port2.on('message', fn), 301 | }) 302 | }) 303 | 304 | expect(await server.broadcast.$call('hi', 'Alice')) 305 | .toEqual([ 306 | 'Hi Alice, I am Bob', 307 | 'Hi Alice, I am Bob', 308 | 'Hi Alice, I am Bob', 309 | ]) 310 | 311 | expect(await client3.$call('hello', 'Bob')) 312 | .toEqual('Hello Bob, my name is Alice') 313 | }) 314 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import type { RpcMessage, RpcRequest, RpcResponse } from './messages' 2 | import type { ArgumentsType, ReturnType, Thenable } from './utils' 3 | import { TYPE_REQUEST, TYPE_RESPONSE } from './messages' 4 | import { createPromiseWithResolvers, nanoid } from './utils' 5 | 6 | export type PromisifyFn = ReturnType extends Promise 7 | ? T 8 | : (...args: ArgumentsType) => Promise>> 9 | 10 | export type BirpcResolver = (this: This, name: string, resolved: (...args: unknown[]) => unknown) => Thenable<((...args: any[]) => any) | undefined> 11 | 12 | export interface ChannelOptions { 13 | /** 14 | * Function to post raw message 15 | */ 16 | post: (data: any, ...extras: any[]) => Thenable 17 | /** 18 | * Listener to receive raw message 19 | */ 20 | on: (fn: (data: any, ...extras: any[]) => void) => Thenable 21 | /** 22 | * Clear the listener when `$close` is called 23 | */ 24 | off?: (fn: (data: any, ...extras: any[]) => void) => Thenable 25 | /** 26 | * Custom function to serialize data 27 | * 28 | * by default it passes the data as-is 29 | */ 30 | serialize?: (data: any) => any 31 | /** 32 | * Custom function to deserialize data 33 | * 34 | * by default it passes the data as-is 35 | */ 36 | deserialize?: (data: any) => any 37 | 38 | /** 39 | * Call the methods with the RPC context or the original functions object 40 | */ 41 | bind?: 'rpc' | 'functions' 42 | 43 | /** 44 | * Custom meta data to attached to the RPC instance's `$meta` property 45 | */ 46 | meta?: any 47 | } 48 | 49 | export interface EventOptions< 50 | RemoteFunctions extends object = Record, 51 | LocalFunctions extends object = Record, 52 | Proxify extends boolean = true, 53 | > { 54 | /** 55 | * Names of remote functions that do not need response. 56 | */ 57 | eventNames?: (keyof RemoteFunctions)[] 58 | 59 | /** 60 | * Maximum timeout for waiting for response, in milliseconds. 61 | * 62 | * @default 60_000 63 | */ 64 | timeout?: number 65 | 66 | /** 67 | * Whether to proxy the remote functions. 68 | * 69 | * When `proxify` is false, calling the remote function 70 | * with `rpc.$call('method', ...args)` instead of `rpc.method(...args)` 71 | * explicitly is required. 72 | * 73 | * @default true 74 | */ 75 | proxify?: Proxify 76 | 77 | /** 78 | * Custom resolver to resolve function to be called 79 | * 80 | * For advanced use cases only 81 | */ 82 | resolver?: BirpcResolver> 83 | 84 | /** 85 | * Hook triggered before an event is sent to the remote 86 | * 87 | * @param req - Request parameters 88 | * @param next - Function to continue the request 89 | * @param resolve - Function to resolve the response directly 90 | */ 91 | onRequest?: (this: BirpcReturn, req: RpcRequest, next: (req?: RpcRequest) => Promise, resolve: (res: any) => void) => void | Promise 92 | 93 | /** 94 | * Custom error handler for errors occurred in local functions being called 95 | * 96 | * @returns `true` to prevent the error from being thrown 97 | */ 98 | onFunctionError?: (this: BirpcReturn, error: Error, functionName: string, args: any[]) => boolean | void 99 | 100 | /** 101 | * Custom error handler for errors occurred during serialization or messsaging 102 | * 103 | * @returns `true` to prevent the error from being thrown 104 | */ 105 | onGeneralError?: (this: BirpcReturn, error: Error, functionName?: string, args?: any[]) => boolean | void 106 | 107 | /** 108 | * Custom error handler for timeouts 109 | * 110 | * @returns `true` to prevent the error from being thrown 111 | */ 112 | onTimeoutError?: (this: BirpcReturn, functionName: string, args: any[]) => boolean | void 113 | } 114 | 115 | export type BirpcOptions< 116 | RemoteFunctions extends object = Record, 117 | LocalFunctions extends object = Record, 118 | Proxify extends boolean = true, 119 | > = EventOptions & ChannelOptions 120 | 121 | export type BirpcFn = PromisifyFn & { 122 | /** 123 | * Send event without asking for response 124 | */ 125 | asEvent: (...args: ArgumentsType) => Promise 126 | } 127 | 128 | export interface BirpcReturnBuiltin< 129 | RemoteFunctions, 130 | LocalFunctions = Record, 131 | > { 132 | /** 133 | * Raw functions object 134 | */ 135 | $functions: LocalFunctions 136 | /** 137 | * Whether the RPC is closed 138 | */ 139 | readonly $closed: boolean 140 | /** 141 | * Custom meta data attached to the RPC instance 142 | */ 143 | readonly $meta: any 144 | /** 145 | * Close the RPC connection 146 | */ 147 | $close: (error?: Error) => void 148 | /** 149 | * Reject pending calls 150 | */ 151 | $rejectPendingCalls: (handler?: PendingCallHandler) => Promise[] 152 | /** 153 | * Call the remote function and wait for the result. 154 | * An alternative to directly calling the function 155 | */ 156 | $call: (method: K, ...args: ArgumentsType) => Promise>> 157 | /** 158 | * Same as `$call`, but returns `undefined` if the function is not defined on the remote side. 159 | */ 160 | $callOptional: (method: K, ...args: ArgumentsType) => Promise | undefined>> 161 | /** 162 | * Send event without asking for response 163 | */ 164 | $callEvent: (method: K, ...args: ArgumentsType) => Promise 165 | /** 166 | * Call the remote function with the raw options. 167 | */ 168 | $callRaw: (options: { method: string, args: unknown[], event?: boolean, optional?: boolean }) => Promise>[]> 169 | } 170 | 171 | export type ProxifiedRemoteFunctions> = { [K in keyof RemoteFunctions]: BirpcFn } 172 | 173 | export type BirpcReturn< 174 | RemoteFunctions extends object = Record, 175 | LocalFunctions extends object = Record, 176 | Proxify extends boolean = true, 177 | > = Proxify extends true 178 | ? ProxifiedRemoteFunctions & BirpcReturnBuiltin 179 | : BirpcReturnBuiltin 180 | 181 | export interface CallRawOptions { 182 | method: string 183 | args: unknown[] 184 | event?: boolean 185 | optional?: boolean 186 | } 187 | 188 | export type PendingCallHandler = (options: Pick) => void | Promise 189 | 190 | interface PromiseEntry { 191 | resolve: (arg: any) => void 192 | reject: (error: any) => void 193 | method: string 194 | timeoutId?: ReturnType 195 | } 196 | 197 | const DEFAULT_TIMEOUT = 60_000 // 1 minute 198 | 199 | const defaultSerialize = (i: any) => i 200 | const defaultDeserialize = defaultSerialize 201 | 202 | // Store public APIs locally in case they are overridden later 203 | const { clearTimeout, setTimeout } = globalThis 204 | 205 | export function createBirpc< 206 | RemoteFunctions extends object = Record, 207 | LocalFunctions extends object = Record, 208 | Proxify extends boolean = true, 209 | >( 210 | $functions: LocalFunctions, 211 | options: BirpcOptions, 212 | ): BirpcReturn { 213 | const { 214 | post, 215 | on, 216 | off = () => { }, 217 | eventNames = [], 218 | serialize = defaultSerialize, 219 | deserialize = defaultDeserialize, 220 | resolver, 221 | bind = 'rpc', 222 | timeout = DEFAULT_TIMEOUT, 223 | proxify = true, 224 | } = options 225 | 226 | let $closed = false 227 | 228 | const _rpcPromiseMap = new Map() 229 | let _promiseInit: Promise | any 230 | let rpc: BirpcReturn 231 | 232 | async function _call( 233 | method: string, 234 | args: unknown[], 235 | event?: boolean, 236 | optional?: boolean, 237 | ) { 238 | if ($closed) 239 | throw new Error(`[birpc] rpc is closed, cannot call "${method}"`) 240 | 241 | const req: RpcRequest = { m: method, a: args, t: TYPE_REQUEST } 242 | if (optional) 243 | req.o = true 244 | 245 | const send = async (_req: RpcRequest) => post(serialize(_req)) 246 | if (event) { 247 | await send(req) 248 | return 249 | } 250 | 251 | if (_promiseInit) { 252 | // Wait if `on` is promise 253 | try { 254 | await _promiseInit 255 | } 256 | finally { 257 | // don't keep resolved promise hanging 258 | _promiseInit = undefined 259 | } 260 | } 261 | 262 | // eslint-disable-next-line prefer-const 263 | let { promise, resolve, reject } = createPromiseWithResolvers() 264 | 265 | const id = nanoid() 266 | req.i = id 267 | let timeoutId: ReturnType | undefined 268 | 269 | async function handler(newReq: RpcRequest = req) { 270 | if (timeout >= 0) { 271 | timeoutId = setTimeout(() => { 272 | try { 273 | // Custom onTimeoutError handler can throw its own error too 274 | const handleResult = options.onTimeoutError?.call(rpc, method, args) 275 | if (handleResult !== true) 276 | throw new Error(`[birpc] timeout on calling "${method}"`) 277 | } 278 | catch (e) { 279 | reject(e) 280 | } 281 | _rpcPromiseMap.delete(id) 282 | }, timeout) 283 | 284 | // For node.js, `unref` is not available in browser-like environments 285 | if (typeof timeoutId === 'object') 286 | timeoutId = timeoutId.unref?.() 287 | } 288 | 289 | _rpcPromiseMap.set(id, { resolve, reject, timeoutId, method }) 290 | await send(newReq) 291 | return promise 292 | } 293 | 294 | try { 295 | if (options.onRequest) 296 | await options.onRequest.call(rpc, req, handler, resolve) 297 | 298 | else 299 | await handler() 300 | } 301 | catch (e) { 302 | if (options.onGeneralError?.call(rpc, e as Error) !== true) 303 | throw e 304 | return 305 | } 306 | finally { 307 | clearTimeout(timeoutId) 308 | _rpcPromiseMap.delete(id) 309 | } 310 | 311 | return promise 312 | } 313 | 314 | const builtinMethods = { 315 | $call: (method: string, ...args: unknown[]) => _call(method, args, false), 316 | $callOptional: (method: string, ...args: unknown[]) => _call(method, args, false, true), 317 | $callEvent: (method: string, ...args: unknown[]) => _call(method, args, true), 318 | $callRaw: (options: CallRawOptions) => _call(options.method, options.args, options.event, options.optional), 319 | $rejectPendingCalls, 320 | get $closed() { 321 | return $closed 322 | }, 323 | get $meta() { 324 | return options.meta 325 | }, 326 | $close, 327 | $functions, 328 | } as BirpcReturnBuiltin 329 | 330 | if (proxify) { 331 | rpc = new Proxy({}, { 332 | get(_, method: string) { 333 | if (Object.prototype.hasOwnProperty.call(builtinMethods, method)) 334 | return (builtinMethods as any)[method] 335 | 336 | // catch if "createBirpc" is returned from async function 337 | if (method === 'then' && !eventNames.includes('then' as any) && !('then' in $functions)) 338 | return undefined 339 | 340 | const sendEvent = (...args: any[]) => _call(method, args, true) 341 | if (eventNames.includes(method as any)) { 342 | sendEvent.asEvent = sendEvent 343 | return sendEvent 344 | } 345 | const sendCall = (...args: any[]) => _call(method, args, false) 346 | sendCall.asEvent = sendEvent 347 | return sendCall 348 | }, 349 | }) as BirpcReturn 350 | } 351 | else { 352 | rpc = builtinMethods as BirpcReturn 353 | } 354 | 355 | function $close(customError?: Error) { 356 | $closed = true 357 | _rpcPromiseMap.forEach(({ reject, method }) => { 358 | const error = new Error(`[birpc] rpc is closed, cannot call "${method}"`) 359 | 360 | if (customError) { 361 | customError.cause ??= error 362 | return reject(customError) 363 | } 364 | 365 | reject(error) 366 | }) 367 | _rpcPromiseMap.clear() 368 | off(onMessage) 369 | } 370 | 371 | function $rejectPendingCalls(handler?: PendingCallHandler) { 372 | const entries = Array.from(_rpcPromiseMap.values()) 373 | 374 | const handlerResults = entries.map(({ method, reject }) => { 375 | if (!handler) { 376 | return reject(new Error(`[birpc]: rejected pending call "${method}".`)) 377 | } 378 | 379 | return handler({ method, reject }) 380 | }) 381 | 382 | _rpcPromiseMap.clear() 383 | 384 | return handlerResults 385 | } 386 | 387 | async function onMessage(data: any, ...extra: any[]) { 388 | let msg: RpcMessage 389 | 390 | try { 391 | msg = deserialize(data) as RpcMessage 392 | } 393 | catch (e) { 394 | if (options.onGeneralError?.call(rpc, e as Error) !== true) 395 | throw e 396 | return 397 | } 398 | 399 | if (msg.t === TYPE_REQUEST) { 400 | const { m: method, a: args, o: optional } = msg 401 | let result, error: any 402 | let fn = await (resolver 403 | ? resolver.call(rpc, method, ($functions as any)[method]) 404 | : ($functions as any)[method]) 405 | 406 | if (optional) 407 | fn ||= () => undefined 408 | 409 | if (!fn) { 410 | error = new Error(`[birpc] function "${method}" not found`) 411 | } 412 | else { 413 | try { 414 | result = await fn.apply(bind === 'rpc' ? rpc : $functions, args) 415 | } 416 | catch (e) { 417 | error = e 418 | } 419 | } 420 | 421 | if (msg.i) { 422 | if (error && options.onFunctionError) { 423 | if (options.onFunctionError.call(rpc, error, method, args) === true) 424 | return 425 | } 426 | 427 | // Send data 428 | if (!error) { 429 | try { 430 | await post(serialize({ t: TYPE_RESPONSE, i: msg.i, r: result }), ...extra) 431 | return 432 | } 433 | catch (e) { 434 | error = e 435 | if (options.onGeneralError?.call(rpc, e as Error, method, args) !== true) 436 | throw e 437 | } 438 | } 439 | // Try to send error if serialization failed 440 | try { 441 | await post(serialize({ t: TYPE_RESPONSE, i: msg.i, e: error }), ...extra) 442 | } 443 | catch (e) { 444 | if (options.onGeneralError?.call(rpc, e as Error, method, args) !== true) 445 | throw e 446 | } 447 | } 448 | } 449 | else { 450 | const { i: ack, r: result, e: error } = msg 451 | const promise = _rpcPromiseMap.get(ack) 452 | if (promise) { 453 | clearTimeout(promise.timeoutId) 454 | 455 | if (error) 456 | promise.reject(error) 457 | 458 | else 459 | promise.resolve(result) 460 | } 461 | _rpcPromiseMap.delete(ack) 462 | } 463 | } 464 | 465 | _promiseInit = on(onMessage) 466 | 467 | return rpc 468 | } 469 | --------------------------------------------------------------------------------