import * as t from "@babel/types"; import { unwrapJavaScriptExpression, semanticStaticPropertyKey, semanticFinalObjectStringProperty, } from "./javascriptAstValues.js"; import { calleeName } from "./javascriptStaticAnalysisHelpers.js"; import type { JavaScriptBindingIndex } from "./javascriptSemanticState.js"; import { createJavaScriptGraphqlScan } from "./javascriptGraphqlOperations.js"; /** One RPC operation or transport URL recovered from client-side syntax. */ export interface JavaScriptRpcEndpoint { readonly value: string; readonly mechanism: string; } /** Feed every AST node in pre-order, then drain finish() for Source findings. */ export interface JavaScriptRpcScan { inspect(node: t.Node): JavaScriptRpcEndpoint | undefined; finish(): Iterable< (JavaScriptRpcEndpoint & { readonly node: t.Node }) | void >; } // tRPC proxies preserve router and procedure names as property names, so they // survive identifier minification. Method names follow @trpc/react-query and // @trpc/tanstack-react-query 11.19 documentation; cache-key helpers such as // queryKey are excluded because they never issue a request. const TRPC_HOOK_METHODS: ReadonlySet = new Set([ "useQuery", "useSuspenseQuery", "useInfiniteQuery", "useSuspenseInfiniteQuery", "usePrefetchQuery", "usePrefetchInfiniteQuery", "useMutation", "useSubscription", "queryOptions", "infiniteQueryOptions", "mutationOptions", "subscriptionOptions", ]); // @trpc/client 11.19 clientCallTypeMap methods; common outside tRPC (Apollo, // database pools, stores), so they need corroborating evidence. const TRPC_REQUEST_METHODS: ReadonlySet = new Set(["query", "mutate"]); // @trpc/client 11.19 TRPCSubscriptionObserver callbacks; RxJS observers use // next/error/complete instead. const TRPC_SUBSCRIPTION_CALLBACKS: ReadonlySet = new Set([ "onStarted", "onData", "onError", "onStopped", "onComplete", "onConnectionStateChange", ]); // @trpc/client 11.19 client factories (createTRPCProxyClient is the // deprecated alias of createTRPCClient). const TRPC_CLIENT_FACTORIES: ReadonlySet = new Set([ "createTRPCClient", "createTRPCProxyClient", ]); // Built-in HTTP links documented by tRPC 11.19 that carry a `url` option. const TRPC_URL_LINKS: ReadonlySet = new Set([ "httpLink", "httpBatchLink", "httpBatchStreamLink", "httpSubscriptionLink", ]); // Literals retained by @trpc/client 11.19 bundles (`this.name`, the default // accept header key) or by the conventional transport path. const TRPC_SOURCE_MARKERS = ["TRPCClientError", "trpc-accept", "/trpc"]; // Browser-owned roots whose methods share tRPC names, such as // navigator.permissions.query. const BROWSER_GLOBAL_NAMES: ReadonlySet = new Set([ "window", "self", "globalThis", "document", "navigator", "location", "history", "console", "performance", "screen", "top", "parent", "frames", ]); const PROCEDURE_SEGMENT = /^[A-Za-z_$][\w$]*$/u; interface MemberPath { readonly root: t.Node; /** Non-computed property names from the root outward. */ readonly properties: readonly string[]; } /** Read a static member path and the expression it starts from. */ const memberPath = (callee: t.Node): MemberPath | undefined => { const reversed: string[] = []; let current: t.Node = callee; while ( t.isMemberExpression(current) || t.isOptionalMemberExpression(current) ) { const property = semanticStaticPropertyKey( current.property, current.computed, ); if (property === null || !PROCEDURE_SEGMENT.test(property)) return undefined; reversed.push(property); current = current.object; } return { root: current, properties: reversed.reverse() }; }; const objectKeys = (node: t.Node | undefined): readonly string[] => t.isObjectExpression(node) ? node.properties.flatMap((property) => { if (!t.isObjectProperty(property) && !t.isObjectMethod(property)) return []; const key = semanticStaticPropertyKey(property.key, property.computed); return key === null ? [] : [key]; }) : []; /** Bundlers use `(0, imported.factory)` to call an import without a receiver. */ const rpcFactoryName = (callee: t.Node): string => { let current = unwrapJavaScriptExpression(callee).node; while (t.isSequenceExpression(current)) { const last = current.expressions.at(-1); if (last === undefined) return ""; current = unwrapJavaScriptExpression(last).node; } return calleeName(current); }; /** Whether an initializer has the syntax of a documented tRPC client factory. */ export const isJavaScriptRpcClientFactory = (node: t.Node): boolean => { const call = unwrapJavaScriptExpression(node).node; return ( (t.isCallExpression(call) || t.isOptionalCallExpression(call)) && TRPC_CLIENT_FACTORIES.has( rpcFactoryName(call.callee).split(".").pop() ?? "", ) ); }; /** Recover the literal `url` option passed to a documented tRPC HTTP link. */ const trpcLinkUrl = ( node: t.CallExpression | t.OptionalCallExpression, ): JavaScriptRpcEndpoint | undefined => { const name = rpcFactoryName(node.callee); const factory = name.slice(name.lastIndexOf(".") + 1); if (!TRPC_URL_LINKS.has(factory)) return undefined; const options = node.arguments[0]; if (!t.isObjectExpression(options)) return undefined; const url = semanticFinalObjectStringProperty(options, "url"); return url === undefined ? undefined : { value: url, mechanism: `trpc:link:${factory}` }; }; /** * Create the per-source scan. Hook and option-factory methods are distinctive. * `query`/`mutate` need a root bound to a tRPC client factory, or a tRPC source * marker with a router path of at least two segments; `subscribe` needs the * marker and a tRPC observer argument. `this` roots, browser globals and RTK * Query `endpoints` chains are never procedures. */ export const createJavaScriptRpcScan = ( source: string, clientRoots: ReadonlySet, index: JavaScriptBindingIndex, ): JavaScriptRpcScan => { let marker: boolean | undefined; const hasMarker = (): boolean => (marker ??= TRPC_SOURCE_MARKERS.some((value) => source.includes(value))); const graphql = createJavaScriptGraphqlScan(index); const procedure = ( node: t.CallExpression | t.OptionalCallExpression, ): JavaScriptRpcEndpoint | undefined => { const path = memberPath(node.callee); if (path === undefined || t.isThisExpression(path.root)) return undefined; if (t.isIdentifier(path.root) && BROWSER_GLOBAL_NAMES.has(path.root.name)) return undefined; const method = path.properties[path.properties.length - 1]; const segments = path.properties.slice(0, -1); const first = segments[0]; if (method === undefined || first === undefined) return undefined; if (BROWSER_GLOBAL_NAMES.has(first) || segments.includes("endpoints")) return undefined; const bound = clientRoots.has(path.root); const recognized = TRPC_HOOK_METHODS.has(method) ? true : TRPC_REQUEST_METHODS.has(method) ? bound || (segments.length >= 2 && hasMarker()) : method === "subscribe" && (bound || hasMarker()) && objectKeys(node.arguments[1]).some((key) => TRPC_SUBSCRIPTION_CALLBACKS.has(key), ); return recognized ? { value: segments.join("."), mechanism: `trpc:procedure:${method}` } : undefined; }; return { inspect: (node) => { if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) return trpcLinkUrl(node) ?? procedure(node); return graphql.inspect(node); }, finish: graphql.finish, }; };