import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; import { callPluginRpc, subscribePluginRpc, type PluginRpcContract, type PluginSubscriptionContract, type PluginSubscriptionHandle, type PluginSubscriptionObserver, type PluginWireInput, } from "../rpc.js"; import { shallowEqual } from "./shallow.js"; export type PluginSubscribe = ( method: string, input: unknown, observer: PluginSubscriptionObserver, ) => PluginSubscriptionHandle; export interface PluginRpcContextValue { invoke(method: string, input: unknown): Promise; subscribe: PluginSubscribe; } const PluginRpcContext = createContext(null); export function usePluginRpcContextValue(): PluginRpcContextValue | null { return useContext(PluginRpcContext); } export function PluginRpcProvider({ children, invoke, subscribe, }: { children: ReactNode; invoke(method: string, input: unknown): Promise; subscribe: PluginSubscribe; }) { const value = useMemo(() => ({ invoke, subscribe }), [invoke, subscribe]); return {children}; } export function useRpc( contract: PluginRpcContract, ): (input: PluginWireInput>) => Promise> { const context = usePluginRpcContextValue(); if (!context) throw new Error("useRpc must run inside a contributed plugin surface"); return useCallback( (input: PluginWireInput>) => callPluginRpc(contract, context.invoke, input), [context, contract], ); } export type PluginSubscriptionState = | { status: "connecting" } | { status: "live" } /** The plugin's handler finished. The hook subscribes again when `input` changes. */ | { status: "closed" } | { status: "error"; error: Error }; /** * Subscribes while the component is mounted. Re-subscribes when `input` changes by shallow * comparison; `onEvent` always runs the latest callback. */ export function useSubscription( contract: PluginSubscriptionContract, input: PluginWireInput>, onEvent: (event: ZodOutput) => void, ): PluginSubscriptionState { const context = usePluginRpcContextValue(); if (!context) throw new Error("useSubscription must run inside a contributed plugin surface"); const subscribe = context.subscribe; const onEventRef = useRef(onEvent); onEventRef.current = onEvent; const stableInput = useShallowStable(input); const [state, setState] = useState({ status: "connecting" }); useEffect(() => { let active = true; setState({ status: "connecting" }); let handle: PluginSubscriptionHandle; try { handle = subscribePluginRpc( contract, subscribe, stableInput, (event) => onEventRef.current(event), { onClosed: ({ error }) => { if (active) setState(error ? { status: "error", error } : CLOSED); }, }, ); } catch (error) { setState({ status: "error", error: toError(error) }); return; } handle.ready.then( () => { if (active) setState((current) => (current.status === "connecting" ? LIVE : current)); return undefined; }, (error: unknown) => { if (active) setState({ status: "error", error: toError(error) }); }, ); return () => { active = false; handle(); }; }, [contract, subscribe, stableInput]); return state; } const LIVE: PluginSubscriptionState = { status: "live" }; const CLOSED: PluginSubscriptionState = { status: "closed" }; function useShallowStable(value: Value): Value { const ref = useRef(value); if (!shallowEqual(ref.current, value)) ref.current = value; return ref.current; } function toError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); }