Skip to content

Commit 91bb48c

Browse files
authored
Add new useRpc hook prototype (#1314)
1 parent 9febc51 commit 91bb48c

7 files changed

Lines changed: 298 additions & 17 deletions

File tree

.changeset/wild-ways-enter.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@livekit/components-react': patch
3+
---
4+
5+
Add new useRpc hook

packages/react/etc/components-react.api.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { ParticipantClickEvent } from '@livekit/components-core';
3535
import { ParticipantEvent } from 'livekit-client';
3636
import { ParticipantIdentifier } from '@livekit/components-core';
3737
import { ParticipantPermission } from '@livekit/protocol';
38+
import { PerformRpcParams } from 'livekit-client';
3839
import { PinState } from '@livekit/components-core';
3940
import * as React_2 from 'react';
4041
import { ReceivedAgentTranscriptionMessage } from '@livekit/components-core';
@@ -49,8 +50,12 @@ import { Room } from 'livekit-client';
4950
import { RoomConnectOptions } from 'livekit-client';
5051
import { RoomEvent } from 'livekit-client';
5152
import { RoomOptions } from 'livekit-client';
53+
import { RpcInvocationData } from 'livekit-client';
5254
import { ScreenShareCaptureOptions } from 'livekit-client';
5355
import { SendTextOptions } from 'livekit-client';
56+
import { Serializer } from 'livekit-client';
57+
import { SerializerInput } from 'livekit-client';
58+
import { SerializerOutput } from 'livekit-client';
5459
import { setLogExtension } from '@livekit/components-core';
5560
import { setLogLevel } from '@livekit/components-core';
5661
import { SetMediaDeviceOptions } from '@livekit/components-core';
@@ -371,6 +376,11 @@ export interface GridLayoutProps extends React_2.HTMLAttributes<HTMLDivElement>,
371376

372377
export { isTrackReference }
373378

379+
// Warning: (ae-internal-missing-underscore) The name "isUseSessionReturn" should be prefixed with an underscore because the declaration is marked as @internal
380+
//
381+
// @internal (undocumented)
382+
export function isUseSessionReturn(value: unknown): value is UseSessionReturn;
383+
374384
// @public (undocumented)
375385
export const LayoutContext: React_2.Context<LayoutContextType | undefined>;
376386

@@ -644,6 +654,20 @@ export interface RoomNameProps extends React_2.HTMLAttributes<HTMLSpanElement> {
644654
childrenPosition?: 'before' | 'after';
645655
}
646656

657+
// @beta
658+
export type RpcCallParams<Payload> = Omit<PerformRpcParams, 'payload'> & {
659+
payload: Payload;
660+
};
661+
662+
// @beta (undocumented)
663+
export type RpcHandler<Input = any, Output = any> = (payload: Input, data: RpcInvocationData) => Promise<Output>;
664+
665+
// @beta (undocumented)
666+
export type RpcPerformFn = {
667+
<Output = string, Input = unknown>(params: RpcCallParams<Input>, serializer: Serializer<Output, Input>): Promise<Output>;
668+
(params: PerformRpcParams): Promise<string>;
669+
};
670+
647671
// Warning: (ae-internal-missing-underscore) The name "ScreenShareIcon" should be prefixed with an underscore because the declaration is marked as @internal
648672
//
649673
// @internal (undocumented)
@@ -1203,6 +1227,29 @@ export interface UseRoomInfoOptions {
12031227
room?: Room;
12041228
}
12051229

1230+
// @beta
1231+
export function useRpc<S extends Serializer<any, any>>(session: UseSessionReturn, methodName: string, handler: RpcHandler<SerializerInput<S>, SerializerOutput<S>>, options?: UseRpcOptions<S>): UseRpcReturn;
1232+
1233+
// @beta (undocumented)
1234+
export function useRpc<S extends Serializer<any, any>>(methodName: string, handler: RpcHandler<SerializerInput<S>, SerializerOutput<S>>, options?: UseRpcOptions<S>): UseRpcReturn;
1235+
1236+
// @beta (undocumented)
1237+
export function useRpc(session: UseSessionReturn): UseRpcReturn;
1238+
1239+
// @beta (undocumented)
1240+
export function useRpc(): UseRpcReturn;
1241+
1242+
// @beta
1243+
export type UseRpcOptions<S extends Serializer<any, any> = Serializer<any, any>> = {
1244+
fromIdentity?: string;
1245+
serializer?: S;
1246+
};
1247+
1248+
// @beta (undocumented)
1249+
export type UseRpcReturn = {
1250+
perform: RpcPerformFn;
1251+
};
1252+
12061253
// @public
12071254
export function useSequentialRoomConnectDisconnect<R extends Room | undefined>(room: R): UseSequentialRoomConnectDisconnectResults<R>;
12081255

packages/react/src/hooks/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,11 @@ export {
7070
} from './useAgent';
7171
export * from './useEvents';
7272
export * from './useSessionMessages';
73+
export {
74+
type RpcHandler,
75+
type RpcCallParams,
76+
type UseRpcOptions,
77+
type RpcPerformFn,
78+
type UseRpcReturn,
79+
useRpc,
80+
} from './useRpc';

packages/react/src/hooks/useRpc.ts

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import * as React from 'react';
2+
import {
3+
RpcError,
4+
type RpcInvocationData,
5+
type PerformRpcParams,
6+
type Serializer,
7+
isSerializer,
8+
type SerializerInput,
9+
type SerializerOutput,
10+
serializers,
11+
} from 'livekit-client';
12+
13+
import { useEnsureSession } from '../context';
14+
import { isUseSessionReturn, type UseSessionReturn } from './useSession';
15+
16+
// ---------------------------------------------------------------------------
17+
// RPC types
18+
// ---------------------------------------------------------------------------
19+
20+
/** @beta */
21+
export type RpcHandler<Input = any, Output = any> = (
22+
payload: Input,
23+
data: RpcInvocationData,
24+
) => Promise<Output>;
25+
26+
/**
27+
* Base RPC call parameters with an arbitrary payload type (used when the payload
28+
* will be serialized by a serializer).
29+
*
30+
* @beta
31+
*/
32+
export type RpcCallParams<Payload> = Omit<PerformRpcParams, 'payload'> & { payload: Payload };
33+
34+
/**
35+
* Options for {@link (useRpc:1)}.
36+
* @beta
37+
*/
38+
export type UseRpcOptions<S extends Serializer<any, any> = Serializer<any, any>> = {
39+
/** Only accept RPCs from this participant. Others will receive UNSUPPORTED_METHOD. */
40+
fromIdentity?: string;
41+
/**
42+
* Serializer applied to the data coming in and leaving the handler. Defaults to `serializers.json()`
43+
*/
44+
serializer?: S;
45+
};
46+
47+
// ---------------------------------------------------------------------------
48+
// useRpc hook
49+
// ---------------------------------------------------------------------------
50+
51+
/** @beta */
52+
export type RpcPerformFn = {
53+
/** Serializer-wrapped call: payload is serialized and response is parsed by the serializer. */
54+
<Output = string, Input = unknown>(
55+
params: RpcCallParams<Input>,
56+
serializer: Serializer<Output, Input>,
57+
): Promise<Output>;
58+
/** Plain call: payload is already a string, response is returned as a string. */
59+
(params: PerformRpcParams): Promise<string>;
60+
};
61+
62+
/** @beta */
63+
export type UseRpcReturn = {
64+
perform: RpcPerformFn;
65+
};
66+
67+
/**
68+
* Hook for declarative RPC method registration and outbound RPC calls.
69+
*
70+
* Registers a handler for an incoming RPC method and returns a `performRpc`
71+
* function for outbound calls. The handler is registered on mount and
72+
* unregistered on unmount. Handler identity does not matter (captured by ref),
73+
* so inline functions work without `useCallback`.
74+
*
75+
* @example
76+
* ```tsx
77+
* const { performRpc } = useRpc(session, "getUserLocation", async (payload: { highAccuracy: boolean }) => {
78+
* const pos = await getPosition(payload.highAccuracy);
79+
* return { lat: pos.coords.latitude, lng: pos.coords.longitude };
80+
* });
81+
* ```
82+
*
83+
* @beta
84+
*/
85+
export function useRpc<S extends Serializer<any, any>>(
86+
session: UseSessionReturn,
87+
methodName: string,
88+
handler: RpcHandler<SerializerInput<S>, SerializerOutput<S>>,
89+
options?: UseRpcOptions<S>,
90+
): UseRpcReturn;
91+
/** @beta */
92+
export function useRpc<S extends Serializer<any, any>>(
93+
methodName: string,
94+
handler: RpcHandler<SerializerInput<S>, SerializerOutput<S>>,
95+
options?: UseRpcOptions<S>,
96+
): UseRpcReturn;
97+
/** @beta */
98+
export function useRpc(session: UseSessionReturn): UseRpcReturn;
99+
/** @beta */
100+
export function useRpc(): UseRpcReturn;
101+
export function useRpc(
102+
methodNameOrSession?: string | UseSessionReturn,
103+
handlerOrMethodName?: RpcHandler<any, any> | string,
104+
optionsOrHandler?: UseRpcOptions<Serializer<any, any>> | RpcHandler<any, any>,
105+
maybeOptions?: UseRpcOptions<Serializer<any, any>>,
106+
): UseRpcReturn {
107+
let session: UseSessionReturn | undefined;
108+
let methodName: string | undefined;
109+
let handler: RpcHandler<any, any> | undefined;
110+
let options: UseRpcOptions<Serializer<any, any>> | undefined;
111+
112+
if (isUseSessionReturn(methodNameOrSession)) {
113+
session = methodNameOrSession;
114+
methodName = handlerOrMethodName as string;
115+
handler = optionsOrHandler as RpcHandler<any, any>;
116+
options = maybeOptions;
117+
} else {
118+
methodName = methodNameOrSession;
119+
handler = handlerOrMethodName as RpcHandler<any, any>;
120+
options = optionsOrHandler as UseRpcOptions<any>;
121+
}
122+
123+
const { room } = useEnsureSession(session);
124+
125+
// Ref that always holds the latest handler — updated synchronously on render
126+
const handlerRef = React.useRef(handler);
127+
handlerRef.current = handler;
128+
129+
// Ref that always holds the latest options
130+
const optionsRef = React.useRef(options);
131+
optionsRef.current = options;
132+
133+
React.useEffect(() => {
134+
if (!methodName) {
135+
return;
136+
}
137+
138+
room.registerRpcMethod(methodName, async (data: RpcInvocationData) => {
139+
const fromIdentity = optionsRef.current?.fromIdentity;
140+
if (fromIdentity && data.callerIdentity !== fromIdentity) {
141+
throw RpcError.builtIn(
142+
'UNSUPPORTED_METHOD',
143+
`Method not available for caller ${data.callerIdentity}`,
144+
);
145+
}
146+
147+
const currentHandler = handlerRef.current;
148+
if (!currentHandler) {
149+
throw RpcError.builtIn(
150+
'APPLICATION_ERROR',
151+
`No handler registered for method "${methodName}"`,
152+
);
153+
}
154+
155+
const serializer = optionsRef.current?.serializer ?? serializers.json();
156+
157+
let parsed;
158+
try {
159+
parsed = serializer.parse(data.payload);
160+
} catch (e) {
161+
throw RpcError.builtIn('APPLICATION_ERROR', `Failed to parse RPC payload: ${e}`);
162+
}
163+
164+
const result = await currentHandler(parsed, data);
165+
166+
try {
167+
return serializer.serialize(result);
168+
} catch (e) {
169+
throw RpcError.builtIn('APPLICATION_ERROR', `Failed to serialize RPC response: ${e}`);
170+
}
171+
});
172+
173+
return () => {
174+
room.unregisterRpcMethod(methodName);
175+
};
176+
}, [room, methodName]);
177+
178+
// Stable rpc calling function
179+
const performRpc: RpcPerformFn = React.useCallback(
180+
async (
181+
params: RpcCallParams<unknown>,
182+
serializer: Serializer<any, any> = serializers.json(),
183+
) => {
184+
if (isSerializer(serializer)) {
185+
let serialized: string;
186+
try {
187+
serialized = serializer.serialize(params.payload);
188+
} catch (e) {
189+
throw RpcError.builtIn('APPLICATION_ERROR', `Failed to serialize RPC payload: ${e}`);
190+
}
191+
const rawResponse = await room.localParticipant.performRpc({
192+
destinationIdentity: params.destinationIdentity,
193+
method: params.method,
194+
payload: serialized,
195+
responseTimeout: params.responseTimeout,
196+
});
197+
try {
198+
return serializer.parse(rawResponse);
199+
} catch (e) {
200+
throw RpcError.builtIn('APPLICATION_ERROR', `Failed to parse RPC response: ${e}`);
201+
}
202+
} else {
203+
return room.localParticipant.performRpc(params as PerformRpcParams);
204+
}
205+
},
206+
[room],
207+
);
208+
209+
return React.useMemo(() => ({ perform: performRpc }), [performRpc]);
210+
}

packages/react/src/hooks/useSession.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,17 @@ export type UseSessionReturn = (
150150
) &
151151
SessionActions;
152152

153+
/** @internal */
154+
export function isUseSessionReturn(value: unknown): value is UseSessionReturn {
155+
return (
156+
typeof value === 'object' &&
157+
value !== null &&
158+
'room' in value &&
159+
'connectionState' in value &&
160+
'internal' in value
161+
);
162+
}
163+
153164
type UseSessionCommonOptions = {
154165
room?: Room;
155166

0 commit comments

Comments
 (0)