import { createTRPCProxyClient } from '@trpc/client'; import type { TRPCRequestOptions, CreateTRPCClientOptions } from '@trpc/client'; import type { AnyMutationProcedure, AnyProcedure, AnyQueryProcedure, AnySubscriptionProcedure, AnyRouter, ProcedureArgs, ProcedureRouterRecord, inferProcedureInput, inferProcedureOutput, } from '@trpc/server'; import type { inferObservableValue } from '@trpc/server/observable'; import { atom } from 'jotai/vanilla'; import type { Atom, Getter, WritableAtom } from 'jotai/vanilla'; import { atomWithObservable } from 'jotai/vanilla/utils'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const getProcedure = (obj: any, path: string[]) => { for (let i = 0; i < path.length; ++i) { obj = obj[path[i] as string]; } return obj; }; const isGetter = (v: T | ((get: Getter) => T)): v is (get: Getter) => T => typeof v === 'function'; type ValueOrGetter = T | ((get: Getter) => T); type AsyncValueOrGetter = | T | Promise | ((get: Getter) => T) | ((get: Getter) => Promise); export const DISABLED = Symbol(); type CustomOptions = { disabledOutput?: unknown }; const atomWithQuery = ( path: string[], getClient: (get: Getter) => TClient, getInput: AsyncValueOrGetter< inferProcedureInput | typeof DISABLED >, getOptions?: ValueOrGetter, ) => { type Output = inferProcedureOutput; const refreshAtom = atom(0); const queryAtom = atom( async (get, { signal }) => { get(refreshAtom); const procedure = getProcedure(getClient(get), path); const options = isGetter(getOptions) ? getOptions(get) : getOptions; const input = await (isGetter(getInput) ? getInput(get) : getInput); if (input === DISABLED) { return options?.disabledOutput; } const output: Output = await procedure.query(input, { signal, ...options, }); return output; }, (_, set) => set(refreshAtom, (counter) => counter + 1), ); return queryAtom; }; const atomWithMutation = ( path: string[], getClient: (get: Getter) => TClient, ) => { type Args = ProcedureArgs; type Output = inferProcedureOutput; const mutationAtom = atom( null as Output | null, async (get, set, args: Args) => { const procedure = getProcedure(getClient(get), path); const result: Output = await procedure.mutate(...args); set(mutationAtom, result); return result; }, ); return mutationAtom; }; const atomWithSubscription = < TProcedure extends AnySubscriptionProcedure, TClient, >( path: string[], getClient: (get: Getter) => TClient, getInput: ValueOrGetter>, getOptions?: ValueOrGetter, ) => { type Output = inferProcedureOutput; const subscriptionAtom = atomWithObservable((get) => { const procedure = getProcedure(getClient(get), path); const input = isGetter(getInput) ? getInput(get) : getInput; const options = isGetter(getOptions) ? getOptions(get) : getOptions; const observable = { subscribe: (arg: { next: (result: Output) => void; error: (err: unknown) => void; }) => { const callbacks = { onData: arg.next.bind(arg), onError: arg.error.bind(arg), }; const unsubscribable = procedure.subscribe(input, { ...options, ...callbacks, }); return unsubscribable; }, }; return observable; }); return subscriptionAtom; }; type QueryResolver = { ( getInput: AsyncValueOrGetter[0]>, getOptions?: ValueOrGetter[1]>, getClient?: (get: Getter) => TClient, ): WritableAtom>, [], void>; ( getInput: AsyncValueOrGetter< ProcedureArgs[0] | typeof DISABLED >, getOptions?: ValueOrGetter< ProcedureArgs[1] & { disabledOutput?: undefined } >, getClient?: (get: Getter) => TClient, ): WritableAtom< Promise | undefined>, [], void >; ( getInput: AsyncValueOrGetter< ProcedureArgs[0] | typeof DISABLED >, getOptions: ValueOrGetter< ProcedureArgs[1] & { disabledOutput: DisabledOutput } >, getClient?: (get: Getter) => TClient, ): WritableAtom< Promise | DisabledOutput>, [], void >; }; type MutationResolver = ( getClient?: (get: Getter) => TClient, ) => WritableAtom< inferProcedureOutput | null, [ProcedureArgs], Promise> >; type SubscriptionResolver = ( getInput: ValueOrGetter[0]>, getOptions?: ValueOrGetter[1]>, getClient?: (get: Getter) => TClient, ) => Atom>>; type DecorateProcedure< TProcedure extends AnyProcedure, TClient, > = TProcedure extends AnyQueryProcedure ? { atomWithQuery: QueryResolver; } : TProcedure extends AnyMutationProcedure ? { atomWithMutation: MutationResolver; } : TProcedure extends AnySubscriptionProcedure ? { atomWithSubscription: SubscriptionResolver; } : never; type DecoratedProcedureRecord< TProcedures extends ProcedureRouterRecord, TClient, > = { [TKey in keyof TProcedures]: TProcedures[TKey] extends AnyRouter ? DecoratedProcedureRecord : TProcedures[TKey] extends AnyProcedure ? DecorateProcedure : never; }; export function createTRPCJotai( opts: CreateTRPCClientOptions, ) { const client = createTRPCProxyClient(opts); // eslint-disable-next-line @typescript-eslint/no-explicit-any const createProxy = (target: any, path: readonly string[] = []): any => { return new Proxy( () => { // empty }, { get(_target, prop: string) { return createProxy(target[prop], [...path, prop]); }, apply(_target, _thisArg, args) { const parentProp = path[path.length - 1]; const parentPath = path.slice(0, -1); if (parentProp === 'atomWithQuery') { const [getInput, getOptions, getClient] = args; return atomWithQuery( parentPath, getClient || (() => client), getInput, getOptions, ); } if (parentProp === 'atomWithMutation') { const [getClient] = args; return atomWithMutation(parentPath, getClient || (() => client)); } if (parentProp === 'atomWithSubscription') { const [getInput, getOptions, getClient] = args; return atomWithSubscription( parentPath, getClient || (() => client), getInput, getOptions, ); } throw new Error(`unexpected function call ${path.join('/')}`); }, }, ); }; return createProxy(client) as DecoratedProcedureRecord< TRouter['_def']['record'], typeof client >; }