Skip to content

Commit 00471ea

Browse files
committed
feat: provide additional $call calling style, add $callOptional feature
1 parent c64d8b6 commit 00471ea

2 files changed

Lines changed: 194 additions & 97 deletions

File tree

src/index.ts

Lines changed: 163 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -117,15 +117,48 @@ export interface BirpcGroupFn<T> {
117117
asEvent: (...args: ArgumentsType<T>) => Promise<void>
118118
}
119119

120-
export type BirpcReturn<RemoteFunctions, LocalFunctions = Record<string, never>> = {
121-
[K in keyof RemoteFunctions]: BirpcFn<RemoteFunctions[K]>
122-
} & {
120+
export interface BirpcReturnBuiltin<
121+
RemoteFunctions,
122+
LocalFunctions = Record<string, never>,
123+
> {
124+
/**
125+
* Raw functions object
126+
*/
123127
$functions: LocalFunctions
128+
/**
129+
* Whether the RPC is closed
130+
*/
131+
readonly $closed: boolean
132+
/**
133+
* Close the RPC connection
134+
*/
124135
$close: (error?: Error) => void
125-
$closed: boolean
136+
/**
137+
* Reject pending calls
138+
*/
126139
$rejectPendingCalls: (handler?: PendingCallHandler) => Promise<void>[]
140+
/**
141+
* Call the remote function and wait for the result.
142+
* An alternative to directly calling the function
143+
*/
144+
$call: <K extends keyof RemoteFunctions>(method: K, ...args: ArgumentsType<RemoteFunctions[K]>) => Promise<Awaited<ReturnType<RemoteFunctions[K]>>>
145+
/**
146+
* Same as `$call`, but returns `undefined` if the function is not defined on the remote side.
147+
*/
148+
$callOptional: <K extends keyof RemoteFunctions>(method: K, ...args: ArgumentsType<RemoteFunctions[K]>) => Promise<Awaited<ReturnType<RemoteFunctions[K]> | undefined>>
149+
/**
150+
* Send event without asking for response
151+
*/
152+
$callEvent: <K extends keyof RemoteFunctions>(method: K, ...args: ArgumentsType<RemoteFunctions[K]>) => Promise<void>
127153
}
128154

155+
export type BirpcReturn<
156+
RemoteFunctions,
157+
LocalFunctions = Record<string, never>,
158+
> = {
159+
[K in keyof RemoteFunctions]: BirpcFn<RemoteFunctions[K]>
160+
} & BirpcReturnBuiltin<RemoteFunctions, LocalFunctions>
161+
129162
type PendingCallHandler = (options: Pick<PromiseEntry, 'method' | 'reject'>) => void | Promise<void>
130163

131164
export type BirpcGroupReturn<RemoteFunctions> = {
@@ -166,6 +199,10 @@ interface Request {
166199
* Arguments
167200
*/
168201
a: any[]
202+
/**
203+
* Optional
204+
*/
205+
o?: boolean
169206
}
170207

171208
interface Response {
@@ -201,7 +238,7 @@ const { clearTimeout, setTimeout } = globalThis
201238
const random = Math.random.bind(Math)
202239

203240
export function createBirpc<RemoteFunctions = Record<string, never>, LocalFunctions extends object = Record<string, never>>(
204-
functions: LocalFunctions,
241+
$functions: LocalFunctions,
205242
options: BirpcOptions<RemoteFunctions>,
206243
): BirpcReturn<RemoteFunctions, LocalFunctions> {
207244
const {
@@ -216,107 +253,134 @@ export function createBirpc<RemoteFunctions = Record<string, never>, LocalFuncti
216253
timeout = DEFAULT_TIMEOUT,
217254
} = options
218255

219-
const rpcPromiseMap = new Map<string, PromiseEntry>()
256+
let $closed = false
220257

258+
const _rpcPromiseMap = new Map<string, PromiseEntry>()
221259
let _promiseInit: Promise<any> | any
222-
let closed = false
223260

224-
const rpc = new Proxy({}, {
225-
get(_, method: string) {
226-
if (method === '$functions')
227-
return functions
228-
229-
if (method === '$close')
230-
return close
261+
async function _call(
262+
method: string,
263+
args: unknown[],
264+
event?: boolean,
265+
optional?: boolean,
266+
) {
267+
if ($closed)
268+
throw new Error(`[birpc] rpc is closed, cannot call "${method}"`)
269+
270+
const req: Request = { m: method, a: args, t: TYPE_REQUEST }
271+
if (optional)
272+
req.o = true
273+
274+
const send = async (_req: Request) => post(serialize(_req))
275+
if (event) {
276+
await send(req)
277+
return
278+
}
231279

232-
if (method === '$rejectPendingCalls') {
233-
return rejectPendingCalls
280+
if (_promiseInit) {
281+
// Wait if `on` is promise
282+
try {
283+
await _promiseInit
234284
}
285+
finally {
286+
// don't keep resolved promise hanging
287+
_promiseInit = undefined
288+
}
289+
}
235290

236-
if (method === '$closed')
237-
return closed
291+
// eslint-disable-next-line prefer-const
292+
let { promise, resolve, reject } = createPromiseWithResolvers<any>()
238293

239-
// catch if "createBirpc" is returned from async function
240-
if (method === 'then' && !eventNames.includes('then' as any) && !('then' in functions))
241-
return undefined
294+
const id = nanoid()
295+
req.i = id
296+
let timeoutId: ReturnType<typeof setTimeout> | undefined
242297

243-
const sendEvent = async (...args: any[]) => {
244-
await post(serialize(<Request>{ m: method, a: args, t: TYPE_REQUEST }))
245-
}
246-
if (eventNames.includes(method as any)) {
247-
sendEvent.asEvent = sendEvent
248-
return sendEvent
249-
}
250-
const sendCall = async (...args: any[]) => {
251-
if (closed)
252-
throw new Error(`[birpc] rpc is closed, cannot call "${method}"`)
253-
if (_promiseInit) {
254-
// Wait if `on` is promise
298+
async function handler(newReq: Request = req) {
299+
if (timeout >= 0) {
300+
timeoutId = setTimeout(() => {
255301
try {
256-
await _promiseInit
302+
// Custom onTimeoutError handler can throw its own error too
303+
const handleResult = options.onTimeoutError?.(method, args)
304+
if (handleResult !== true)
305+
throw new Error(`[birpc] timeout on calling "${method}"`)
257306
}
258-
finally {
259-
// don't keep resolved promise hanging
260-
_promiseInit = undefined
307+
catch (e) {
308+
reject(e)
261309
}
262-
}
310+
_rpcPromiseMap.delete(id)
311+
}, timeout)
263312

264-
// eslint-disable-next-line prefer-const
265-
let { promise, resolve, reject } = createPromiseWithResolvers<any>()
266-
267-
const id = nanoid()
268-
let timeoutId: ReturnType<typeof setTimeout> | undefined
269-
const _req: Request = { m: method, a: args, i: id, t: TYPE_REQUEST }
270-
271-
async function handler(req: Request = _req) {
272-
if (timeout >= 0) {
273-
timeoutId = setTimeout(() => {
274-
try {
275-
// Custom onTimeoutError handler can throw its own error too
276-
const handleResult = options.onTimeoutError?.(method, args)
277-
if (handleResult !== true)
278-
throw new Error(`[birpc] timeout on calling "${method}"`)
279-
}
280-
catch (e) {
281-
reject(e)
282-
}
283-
rpcPromiseMap.delete(id)
284-
}, timeout)
285-
286-
// For node.js, `unref` is not available in browser-like environments
287-
if (typeof timeoutId === 'object')
288-
timeoutId = timeoutId.unref?.()
289-
}
313+
// For node.js, `unref` is not available in browser-like environments
314+
if (typeof timeoutId === 'object')
315+
timeoutId = timeoutId.unref?.()
316+
}
290317

291-
rpcPromiseMap.set(id, { resolve, reject, timeoutId, method })
292-
await post(serialize(req))
293-
return promise
294-
}
318+
_rpcPromiseMap.set(id, { resolve, reject, timeoutId, method })
319+
await send(newReq)
320+
return promise
321+
}
295322

296-
try {
297-
if (options.onRequest)
298-
await options.onRequest(_req, handler, resolve)
299-
else
300-
await handler()
301-
}
302-
catch (e) {
303-
clearTimeout(timeoutId)
304-
rpcPromiseMap.delete(id)
305-
if (options.onGeneralError?.(e as Error) !== true)
306-
throw e
307-
return
308-
}
323+
try {
324+
if (options.onRequest)
325+
await options.onRequest(req, handler, resolve)
326+
else
327+
await handler()
328+
}
329+
catch (e) {
330+
if (options.onGeneralError?.(e as Error) !== true)
331+
throw e
332+
return
333+
}
334+
finally {
335+
clearTimeout(timeoutId)
336+
_rpcPromiseMap.delete(id)
337+
}
309338

310-
return promise
339+
return promise
340+
}
341+
342+
const $call = <K extends keyof RemoteFunctions>(method: K, ...args: ArgumentsType<RemoteFunctions[K]>) =>
343+
_call(method as string, args, false)
344+
const $callOptional = <K extends keyof RemoteFunctions>(method: K, ...args: ArgumentsType<RemoteFunctions[K]>) =>
345+
_call(method as string, args, false, true)
346+
const $callEvent = <K extends keyof RemoteFunctions>(method: K, ...args: ArgumentsType<RemoteFunctions[K]>) =>
347+
_call(method as string, args, true)
348+
349+
const specialMethods = {
350+
$call,
351+
$callOptional,
352+
$callEvent,
353+
$rejectPendingCalls,
354+
get $closed() {
355+
return $closed
356+
},
357+
$close,
358+
$functions,
359+
}
360+
361+
const rpc = new Proxy({}, {
362+
get(_, method: string) {
363+
if (Object.prototype.hasOwnProperty.call(specialMethods, method))
364+
return (specialMethods as any)[method]
365+
366+
// catch if "createBirpc" is returned from async function
367+
if (method === 'then' && !eventNames.includes('then' as any) && !('then' in $functions))
368+
return undefined
369+
370+
const sendEvent = (...args: any[]) => _call(method, args, true)
371+
if (eventNames.includes(method as any)) {
372+
sendEvent.asEvent = sendEvent
373+
return sendEvent
311374
}
375+
const sendCall = (...args: any[]) => _call(method, args, false)
312376
sendCall.asEvent = sendEvent
313377
return sendCall
314378
},
315379
}) as BirpcReturn<RemoteFunctions, LocalFunctions>
316380

317-
function close(customError?: Error) {
318-
closed = true
319-
rpcPromiseMap.forEach(({ reject, method }) => {
381+
function $close(customError?: Error) {
382+
$closed = true
383+
_rpcPromiseMap.forEach(({ reject, method }) => {
320384
const error = new Error(`[birpc] rpc is closed, cannot call "${method}"`)
321385

322386
if (customError) {
@@ -326,12 +390,12 @@ export function createBirpc<RemoteFunctions = Record<string, never>, LocalFuncti
326390

327391
reject(error)
328392
})
329-
rpcPromiseMap.clear()
393+
_rpcPromiseMap.clear()
330394
off(onMessage)
331395
}
332396

333-
function rejectPendingCalls(handler?: PendingCallHandler) {
334-
const entries = Array.from(rpcPromiseMap.values())
397+
function $rejectPendingCalls(handler?: PendingCallHandler) {
398+
const entries = Array.from(_rpcPromiseMap.values())
335399

336400
const handlerResults = entries.map(({ method, reject }) => {
337401
if (!handler) {
@@ -341,7 +405,7 @@ export function createBirpc<RemoteFunctions = Record<string, never>, LocalFuncti
341405
return handler({ method, reject })
342406
})
343407

344-
rpcPromiseMap.clear()
408+
_rpcPromiseMap.clear()
345409

346410
return handlerResults
347411
}
@@ -359,18 +423,21 @@ export function createBirpc<RemoteFunctions = Record<string, never>, LocalFuncti
359423
}
360424

361425
if (msg.t === TYPE_REQUEST) {
362-
const { m: method, a: args } = msg
426+
const { m: method, a: args, o: optional } = msg
363427
let result, error: any
364-
const fn = await (resolver
365-
? resolver(method, (functions as any)[method])
366-
: (functions as any)[method])
428+
let fn = await (resolver
429+
? resolver(method, ($functions as any)[method])
430+
: ($functions as any)[method])
431+
432+
if (optional)
433+
fn ||= () => undefined
367434

368435
if (!fn) {
369436
error = new Error(`[birpc] function "${method}" not found`)
370437
}
371438
else {
372439
try {
373-
result = await fn.apply(bind === 'rpc' ? rpc : functions, args)
440+
result = await fn.apply(bind === 'rpc' ? rpc : $functions, args)
374441
}
375442
catch (e) {
376443
error = e
@@ -410,7 +477,7 @@ export function createBirpc<RemoteFunctions = Record<string, never>, LocalFuncti
410477
}
411478
else {
412479
const { i: ack, r: result, e: error } = msg
413-
const promise = rpcPromiseMap.get(ack)
480+
const promise = _rpcPromiseMap.get(ack)
414481
if (promise) {
415482
clearTimeout(promise.timeoutId)
416483

@@ -419,7 +486,7 @@ export function createBirpc<RemoteFunctions = Record<string, never>, LocalFuncti
419486
else
420487
promise.resolve(result)
421488
}
422-
rpcPromiseMap.delete(ack)
489+
_rpcPromiseMap.delete(ack)
423490
}
424491
}
425492

test/index.test.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,39 @@ it('basic', async () => {
4747
expect(Bob.getCount()).toBe(1)
4848
})
4949

50-
it('async', async () => {
50+
it('await on birpc should not throw error', async () => {
5151
const { bob, alice } = createChannel()
5252

5353
await alice
5454
await bob
5555
})
56+
57+
it('$call', async () => {
58+
const { bob, alice } = createChannel()
59+
60+
// RPCs
61+
expect(await bob.$call('hello', 'Bob'))
62+
.toEqual('Hello Bob, my name is Alice')
63+
expect(await alice.$call('hi', 'Alice'))
64+
.toEqual('Hi Alice, I am Bob')
65+
66+
// one-way event
67+
expect(await alice.$callEvent('bump')).toBeUndefined()
68+
69+
expect(Bob.getCount()).toBe(1)
70+
await new Promise(resolve => setTimeout(resolve, 1))
71+
expect(Bob.getCount()).toBe(2)
72+
})
73+
74+
it('$callOptional', async () => {
75+
const { bob } = createChannel()
76+
77+
// @ts-expect-error `hello2` is not defined
78+
expect(async () => await bob.$call('hello2', 'Bob'))
79+
.rejects
80+
.toThrow('[birpc] function "hello2" not found')
81+
82+
// @ts-expect-error `hello2` is not defined
83+
expect(await bob.$callOptional('hello2', 'Bob'))
84+
.toEqual(undefined)
85+
})

0 commit comments

Comments
 (0)