Skip to content

Commit 546545e

Browse files
authored
fix(client): prevent unintended procedure calls when awaiting client (#887)
Previously, awaiting the client directly (e.g., `await client`) would trigger a procedure call with the name "rpc", causing unexpected behavior. This fix ensures that awaiting the client itself does nothing, while maintaining the ability to await actual procedure calls like `await client.myProcedure()`. The solution uses `preventNativeAwait` to disable the default Promise-like behavior of the client proxy when used with the `await` keyword. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * New Features * Improved client behavior when accidentally awaited or when accessing then-like properties, ensuring consistent chaining and calls. * Introduced a shared safeguard that preserves normal object usage even if awaited; now available to consumers. * Bug Fixes * Resolved unexpected behavior caused by native await semantics on client objects without changing the public API. * Tests * Added comprehensive tests covering non-awaited, awaited, repeated, nested, and Promise.resolve scenarios to validate consistent behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 9701ec8 commit 546545e

File tree

5 files changed

+170
-1
lines changed

5 files changed

+170
-1
lines changed

packages/client/src/client.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,20 @@ describe('createORPCClient', () => {
4545
const client = createORPCClient(mockedLink) as any
4646
expect(client[Symbol('test')]).toBeUndefined()
4747
})
48+
49+
it('prevent native await', async () => {
50+
const client = createORPCClient(mockedLink) as any
51+
52+
const client2 = await client
53+
expect(await client2.then({ value: 'client2' })).toEqual('__mocked__')
54+
expect(mockedLink.call).toHaveBeenNthCalledWith(1, ['then'], { value: 'client2' }, { context: {} })
55+
56+
const client3 = await client2.then
57+
expect(await client3.something({ value: 'client3' })).toEqual('__mocked__')
58+
expect(mockedLink.call).toHaveBeenNthCalledWith(2, ['then', 'something'], { value: 'client3' }, { context: {} })
59+
60+
const client4 = await client3.something
61+
expect(await client4.then({ value: 'client4' })).toEqual('__mocked__')
62+
expect(mockedLink.call).toHaveBeenNthCalledWith(3, ['then', 'something', 'then'], { value: 'client4' }, { context: {} })
63+
})
4864
})

packages/client/src/client.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Client, ClientLink, FriendlyClientOptions, InferClientContext, NestedClient } from './types'
2+
import { preventNativeAwait } from '@orpc/shared'
23
import { resolveFriendlyClientOptions } from './utils'
34

45
export interface createORPCClientOptions {
@@ -38,5 +39,5 @@ export function createORPCClient<T extends NestedClient<any>>(
3839
},
3940
})
4041

41-
return recursive as any
42+
return preventNativeAwait(recursive) as any
4243
}

packages/shared/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export * from './iterator'
1212
export * from './json'
1313
export * from './object'
1414
export * from './otel'
15+
export * from './proxy'
1516
export * from './queue'
1617
export * from './stream'
1718
export * from './types'

packages/shared/src/proxy.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { preventNativeAwait } from './proxy'
2+
3+
describe('preventNativeAwait', () => {
4+
it('should work normally if not awaited', () => {
5+
const obj = { value: 42 }
6+
const proxy = preventNativeAwait(obj)
7+
expect(proxy).toEqual(obj)
8+
9+
const obj2 = { then: 323, catch: 123 }
10+
const proxy2 = preventNativeAwait(obj2)
11+
expect(proxy2).toEqual(obj2)
12+
13+
const obj3 = { then: (...args: any[]) => ({ args }), catch: (...args: any[]) => ({ args }) }
14+
const proxy3 = preventNativeAwait(obj3)
15+
expect(proxy3.then(1, 2, 3)).toEqual({ args: [1, 2, 3] })
16+
expect(proxy3.catch(4, 5, 6)).toEqual({ args: [4, 5, 6] })
17+
})
18+
19+
it('returns itself if awaited', async () => {
20+
const obj = { value: 42 }
21+
const proxy = preventNativeAwait(obj)
22+
expect(await proxy).toEqual(obj)
23+
24+
const obj2 = { then: 323, catch: 123 }
25+
const proxy2 = preventNativeAwait(obj2)
26+
expect(await proxy2).toEqual(obj2)
27+
expect(await proxy2).toEqual(obj2)
28+
29+
const obj3 = { then: (...args: any[]) => ({ args }), catch: (...args: any[]) => ({ args }) }
30+
const proxy3 = preventNativeAwait(obj3)
31+
const result3 = await (proxy3 as any)
32+
expect(result3.then(1, 2, 3)).toEqual({ args: [1, 2, 3] })
33+
expect(result3.catch(3, 4, 5)).toEqual({ args: [3, 4, 5] })
34+
})
35+
36+
it('returns itself if multiple awaited times', async () => {
37+
const obj = { value: 42 }
38+
const proxy = preventNativeAwait(obj)
39+
const result = await (proxy as any)
40+
expect(result).toEqual(obj)
41+
await new Promise(resolve => setTimeout(resolve, 1))
42+
expect(await proxy).toEqual(obj)
43+
44+
const obj2 = { then: 323, catch: 123 }
45+
const proxy2 = preventNativeAwait(obj2)
46+
const result2 = await (proxy2 as any)
47+
expect(result2).toEqual(obj2)
48+
await new Promise(resolve => setTimeout(resolve, 1))
49+
expect(await proxy2).toEqual(obj2)
50+
51+
const obj3 = { then: (...args: any[]) => ({ args }), catch: (...args: any[]) => ({ args }) }
52+
const proxy3 = preventNativeAwait(obj3)
53+
const result3 = await (proxy3 as any)
54+
expect(result3.then(1, 2, 3)).toEqual({ args: [1, 2, 3] })
55+
expect(result3.catch(4, 5, 6)).toEqual({ args: [4, 5, 6] })
56+
await new Promise(resolve => setTimeout(resolve, 1))
57+
const result3_2 = await (proxy3 as any)
58+
expect(result3_2.then(1, 2, 3)).toEqual({ args: [1, 2, 3] })
59+
expect(result3_2.catch(4, 5, 6)).toEqual({ args: [4, 5, 6] })
60+
})
61+
62+
it('returns itself if nested awaited times', async () => {
63+
const obj = { value: 42 }
64+
const proxy = preventNativeAwait(obj)
65+
const result = await (proxy as any)
66+
expect(result).toEqual(obj)
67+
await new Promise(resolve => setTimeout(resolve, 1))
68+
expect(await result).toEqual(obj)
69+
70+
const obj2 = { then: 323, catch: 123 }
71+
const proxy2 = preventNativeAwait(obj2)
72+
const result2 = await (proxy2 as any)
73+
expect(result2).toEqual(obj2)
74+
await new Promise(resolve => setTimeout(resolve, 1))
75+
expect(await result2).toEqual(obj2)
76+
77+
const obj3 = { then: (...args: any[]) => ({ args }), catch: (...args: any[]) => ({ args }) }
78+
const proxy3 = preventNativeAwait(obj3)
79+
const result3 = await (proxy3 as any)
80+
expect(result3.then(1, 2, 3)).toEqual({ args: [1, 2, 3] })
81+
expect(result3.catch(4, 5, 6)).toEqual({ args: [4, 5, 6] })
82+
await new Promise(resolve => setTimeout(resolve, 1))
83+
const result3_2 = await (result3 as any)
84+
expect(result3_2.then(1, 2, 3)).toEqual({ args: [1, 2, 3] })
85+
expect(result3_2.catch(4, 5, 6)).toEqual({ args: [4, 5, 6] })
86+
})
87+
88+
it('resolves via Promise.resolve without triggering thenable assimilation', async () => {
89+
const obj = { value: 42 }
90+
const proxy = preventNativeAwait(obj)
91+
await expect(Promise.resolve(proxy)).resolves.toEqual(obj)
92+
93+
const obj2 = { then: 123 }
94+
const proxy2 = preventNativeAwait(obj2)
95+
await expect(Promise.resolve(proxy2)).resolves.toEqual(obj2)
96+
97+
const obj3 = { then: (...args: any[]) => ({ args }) }
98+
const proxy3 = preventNativeAwait(obj3)
99+
const resolved = await Promise.resolve(proxy3 as any)
100+
expect(resolved.then(1, 2, 3)).toEqual({ args: [1, 2, 3] })
101+
})
102+
})

packages/shared/src/proxy.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { AnyFunction } from './function'
2+
3+
/**
4+
* Prevents objects from being awaitable by intercepting the `then` method
5+
* when called by the native await mechanism. This is useful for preventing
6+
* accidental awaiting of objects that aren't meant to be promises.
7+
*/
8+
export function preventNativeAwait<T extends object>(target: T): T {
9+
return new Proxy(target, {
10+
get(target, prop, receiver) {
11+
const value = Reflect.get(target, prop, receiver)
12+
13+
if (prop !== 'then' || typeof value !== 'function') {
14+
return value
15+
}
16+
17+
return new Proxy(value, {
18+
apply(targetFn, thisArg, args) {
19+
/**
20+
* Do nothing if .then not triggered by `await`
21+
*/
22+
if (args.length !== 2 || args.some(arg => !isNativeFunction(arg))) {
23+
return Reflect.apply(targetFn, thisArg, args)
24+
}
25+
26+
let shouldOmit = true
27+
args[0].call(thisArg, preventNativeAwait(new Proxy(target, {
28+
get: (target, prop, receiver) => {
29+
/**
30+
* Only omit `then` once, in `await` resolution, afterwards it should become normal
31+
*/
32+
if (shouldOmit && prop === 'then') {
33+
shouldOmit = false
34+
return undefined
35+
}
36+
37+
return Reflect.get(target, prop, receiver)
38+
},
39+
})))
40+
},
41+
})
42+
},
43+
})
44+
}
45+
46+
const NATIVE_FUNCTION_REGEX = /^\s*function\s*\(\)\s*\{\s*\[native code\]\s*\}\s*$/
47+
function isNativeFunction(fn: unknown): fn is AnyFunction {
48+
return typeof fn === 'function' && NATIVE_FUNCTION_REGEX.test(fn.toString())
49+
}

0 commit comments

Comments
 (0)