Harden session RPC hook

This commit is contained in:
Mohamed Boudra
2025-12-02 04:07:14 +00:00
parent 9079695f46
commit 5b920a2b13
+64 -25
View File
@@ -14,15 +14,23 @@ type RpcState<T> =
| { status: "success"; requestId: string; data: T } | { status: "success"; requestId: string; data: T }
| { status: "error"; requestId: string | null; error: Error }; | { status: "error"; requestId: string | null; error: Error };
type ResponseWithRequestId<TType extends SharedType> = ResponseOf<TType> & { type ResponseWithEnvelope<TType extends SharedType> = ResponseOf<TType> extends {
payload: { requestId?: string; error?: string }; payload: { requestId?: string; error?: string };
}; }
? ResponseOf<TType>
: never;
type SelectResponse<TType extends SharedType, TData> = (message: ResponseWithRequestId<TType>) => TData; type EnsureEnvelope<TType extends SharedType> = ResponseWithEnvelope<TType> extends never ? never : TType;
export function useSessionRpc<TType extends SharedType, TData = ResponseWithRequestId<TType>["payload"]>(options: { type ResponsePayload<TType extends SharedType> = ResponseWithEnvelope<TType> extends { payload: infer P }
? P
: never;
type SelectResponse<TType extends SharedType, TData> = (message: ResponseWithEnvelope<TType>) => TData;
export function useSessionRpc<TType extends SharedType, TData = ResponsePayload<TType>>(options: {
ws: UseWebSocketReturn; ws: UseWebSocketReturn;
type: TType; type: EnsureEnvelope<TType>;
select?: SelectResponse<TType, TData>; select?: SelectResponse<TType, TData>;
}) { }) {
const { ws, type, select } = options; const { ws, type, select } = options;
@@ -31,9 +39,16 @@ export function useSessionRpc<TType extends SharedType, TData = ResponseWithRequ
const resolveRef = useRef<((value: TData) => void) | null>(null); const resolveRef = useRef<((value: TData) => void) | null>(null);
const rejectRef = useRef<((reason?: any) => void) | null>(null); const rejectRef = useRef<((reason?: any) => void) | null>(null);
const clearActiveRequest = useCallback(() => {
activeRequestIdRef.current = null;
resolveRef.current = null;
rejectRef.current = null;
}, []);
useEffect(() => { useEffect(() => {
const unsubscribe = ws.on(type, (message) => { const unsubscribe = ws.on(type, (message) => {
const payload = (message as ResponseWithRequestId<TType>).payload; const typedMessage = message as ResponseWithEnvelope<TType>;
const payload = typedMessage.payload;
if (!payload || payload.requestId !== activeRequestIdRef.current) { if (!payload || payload.requestId !== activeRequestIdRef.current) {
return; return;
} }
@@ -42,47 +57,71 @@ export function useSessionRpc<TType extends SharedType, TData = ResponseWithRequ
const error = new Error(payload.error); const error = new Error(payload.error);
setState({ status: "error", requestId: payload.requestId ?? null, error }); setState({ status: "error", requestId: payload.requestId ?? null, error });
rejectRef.current?.(error); rejectRef.current?.(error);
activeRequestIdRef.current = null; clearActiveRequest();
return; return;
} }
const data = select ? select(message as ResponseWithRequestId<TType>) : ((payload as unknown) as TData); const baseData = typedMessage.payload as ResponsePayload<TType>;
const data = select ? select(typedMessage) : (baseData as unknown as TData);
setState({ status: "success", requestId: payload.requestId ?? null, data }); setState({ status: "success", requestId: payload.requestId ?? null, data });
resolveRef.current?.(data); resolveRef.current?.(data);
activeRequestIdRef.current = null; clearActiveRequest();
}); });
return () => { return () => {
unsubscribe(); unsubscribe();
}; };
}, [select, type, ws]); }, [clearActiveRequest, select, type, ws]);
useEffect(() => {
if (ws.isConnected || !activeRequestIdRef.current) {
return;
}
const error = new Error("WebSocket disconnected");
setState({ status: "error", requestId: activeRequestIdRef.current, error });
rejectRef.current?.(error);
clearActiveRequest();
}, [clearActiveRequest, ws.isConnected]);
const send = useCallback( const send = useCallback(
(params: Omit<RequestOf<TType>, "type" | "requestId">) => { (params: Omit<RequestOf<TType>, "type" | "requestId">) => {
const requestId = generateMessageId();
activeRequestIdRef.current = requestId;
setState({ status: "loading", requestId });
const request = {
type,
...params,
requestId,
} as RequestOf<TType>;
ws.send({ type: "session", message: request });
return new Promise<TData>((resolve, reject) => { return new Promise<TData>((resolve, reject) => {
if (!ws.isConnected) {
const error = new Error("WebSocket is disconnected");
setState({ status: "error", requestId: null, error });
reject(error);
return;
}
const requestId = generateMessageId();
activeRequestIdRef.current = requestId;
resolveRef.current = resolve; resolveRef.current = resolve;
rejectRef.current = reject; rejectRef.current = reject;
setState({ status: "loading", requestId });
const request = {
type,
...params,
requestId,
} as RequestOf<TType>;
try {
ws.send({ type: "session", message: request });
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
setState({ status: "error", requestId: requestId, error: err });
reject(err);
clearActiveRequest();
}
}); });
}, },
[type, ws] [clearActiveRequest, type, ws]
); );
const reset = useCallback(() => { const reset = useCallback(() => {
activeRequestIdRef.current = null; clearActiveRequest();
setState({ status: "idle", requestId: null }); setState({ status: "idle", requestId: null });
}, []); }, [clearActiveRequest]);
return useMemo(() => ({ state, send, reset }), [reset, send, state]); return useMemo(() => ({ state, send, reset }), [reset, send, state]);
} }