mirror of
https://github.com/morluto/rea.git
synced 2026-10-12 02:19:24 +00:00
feat(javascript): recognize tRPC procedures and GraphQL operations (#1670)
* feat(javascript): recognize tRPC procedures and GraphQL operations Record RPC client syntax as network endpoint candidates in both analyze_javascript_application and analyze_web_bundle, distinguished by mechanism: - trpc:procedure:<method> keeps the static member path of root.<path>.<method>(...), which tRPC proxies preserve through identifier minification. React hooks and TanStack option factories are recognized directly. Vanilla query/mutate need a root bound to createTRPCClient/createTRPCProxyClient, 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; cache-key helpers are excluded. - trpc🔗<link> records the literal url of documented HTTP links. - graphql:<type> records named operations from string and cooked template literals with a valid operation header, and from precompiled OperationDefinition objects; a graphql-tag loc.source.body copy is not reported twice. Method, callback and factory names follow @trpc/client and React/TanStack integration sources and documentation for 11.19. Carrier segments before the router path, useUtils helpers and minifier-renamed links remain documented limitations. Validation: typecheck; domain tests for recognition and false positives (Apollo, RxJS, stores, RTK Query, browser globals, prose); web bundle analyzer test; end-to-end analysis of a minified fixture through the built MCP server on Windows 11. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(javascript): preserve RPC binding identity and bundled calls * style(reference-source): fix CI formatting --------- Co-authored-by: Claude Opus 5.5 <[email protected]> Co-authored-by: morluto <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
morluto
parent
83e52559b8
commit
3a7a3074e3
@@ -247,6 +247,46 @@ while locally shadowed receivers can still contribute network endpoint
|
||||
candidates. A template recovered with a parser error and no cooked value stays
|
||||
dynamic; its raw spelling is not treated as a valid JavaScript string.
|
||||
|
||||
RPC client syntax also contributes network endpoint candidates, in both
|
||||
`analyze_javascript_application` and `analyze_web_bundle`, distinguished by
|
||||
`mechanism`:
|
||||
|
||||
- `trpc:procedure:<method>` records the static member path between the root
|
||||
expression and the method, such as `invoice.list` in
|
||||
`root.invoice.list.useQuery(...)`. tRPC proxies keep these property names
|
||||
through identifier minification. React hooks and TanStack option factories
|
||||
(`useQuery`, `useMutation`, `useInfiniteQuery`, `useSubscription`, suspense
|
||||
and prefetch variants, `queryOptions`, `infiniteQueryOptions`,
|
||||
`mutationOptions`, `subscriptionOptions`) are recognized directly. The
|
||||
vanilla `query` and `mutate` methods are recognized on a root bound to
|
||||
`createTRPCClient` or `createTRPCProxyClient` in the same lexical binding, or on a
|
||||
path of at least two segments in a source containing `TRPCClientError`,
|
||||
`trpc-accept`, or `/trpc`. Factory bindings must have one unconditional
|
||||
initializer and no binding assignments; shadowed locals do not inherit client
|
||||
identity. `subscribe` additionally requires a tRPC observer
|
||||
argument (`onData`, `onError`, `onStarted`, `onStopped`, `onComplete`, or
|
||||
`onConnectionStateChange`). `this` roots, browser globals, and any path
|
||||
containing an RTK Query `endpoints` segment are never procedures; cache-key
|
||||
helpers such as `queryKey` issue no request and are excluded.
|
||||
- `trpc:link:<link>` records the literal `url` option of `httpLink`,
|
||||
`httpBatchLink`, `httpBatchStreamLink`, and `httpSubscriptionLink` when the
|
||||
link's callee name survives bundling, including `(0, module.httpBatchLink)`
|
||||
import-call wrappers.
|
||||
- `graphql:query`, `graphql:mutation`, and `graphql:subscription` record a
|
||||
named operation found at the start of a string or cooked template literal —
|
||||
the name must be followed by variable definitions (`($`), a directive, or a
|
||||
selection set starting with a field name — or in a precompiled
|
||||
`OperationDefinition` document object. A graphql-tag `loc.source.body` copy is
|
||||
not reported twice. Anonymous operations are not reported.
|
||||
|
||||
A procedure path is the server-side procedure name, not a resolved URL; the
|
||||
transport URL depends on the link configuration and batching. When the proxy is
|
||||
reached through a member expression, such as a webpack module binding
|
||||
(`l.S.invoice.list`) or a property holding the proxy (`o.api.post.byId`), the
|
||||
recorded path keeps those leading carrier segments; the procedure path is its
|
||||
suffix. Utility calls such as `useUtils().invoice.list.fetch()`, `useQueries`,
|
||||
and links whose callee was renamed by a minifier are not recognized.
|
||||
|
||||
Each recovered bundle module retains the exact factory-source digest. A complete
|
||||
bounded AST also receives a `babel-ast-v1` structural fingerprint that ignores
|
||||
ordinary identifier names while retaining syntax, literals, operators, object
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
|
||||
|
||||
const endpoints = (source: string) =>
|
||||
analyzeJavaScriptStaticSource(source).endpoints.map(
|
||||
({ kind, value, mechanism }) => ({ kind, value, mechanism }),
|
||||
);
|
||||
|
||||
const rpcEndpoints = (source: string) =>
|
||||
endpoints(source).filter(
|
||||
({ mechanism }) =>
|
||||
mechanism.startsWith("trpc:") || mechanism.startsWith("graphql:"),
|
||||
);
|
||||
|
||||
describe("tRPC procedure recognition", () => {
|
||||
it("recognizes React hook procedures on a renamed proxy root", () => {
|
||||
expect(
|
||||
rpcEndpoints(`
|
||||
const a = n.post.byId.useQuery({ id: 1 });
|
||||
const b = n.post.add.useMutation();
|
||||
n.feed.useInfiniteQuery({ limit: 10 });
|
||||
n.onEvent.useSubscription(undefined, { onData() {} });
|
||||
n.invoice.list.useSuspenseQuery();
|
||||
`),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "network",
|
||||
value: "post.byId",
|
||||
mechanism: "trpc:procedure:useQuery",
|
||||
},
|
||||
{
|
||||
kind: "network",
|
||||
value: "post.add",
|
||||
mechanism: "trpc:procedure:useMutation",
|
||||
},
|
||||
{
|
||||
kind: "network",
|
||||
value: "feed",
|
||||
mechanism: "trpc:procedure:useInfiniteQuery",
|
||||
},
|
||||
{
|
||||
kind: "network",
|
||||
value: "onEvent",
|
||||
mechanism: "trpc:procedure:useSubscription",
|
||||
},
|
||||
{
|
||||
kind: "network",
|
||||
value: "invoice.list",
|
||||
mechanism: "trpc:procedure:useSuspenseQuery",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("recognizes TanStack React Query integration option factories", () => {
|
||||
expect(
|
||||
rpcEndpoints(`
|
||||
useQuery(e.invoice.list.queryOptions({ page: 1 }));
|
||||
useMutation(e.invoice.create.mutationOptions());
|
||||
client.invalidateQueries({ queryKey: e.invoice.list.queryKey() });
|
||||
`),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "network",
|
||||
value: "invoice.list",
|
||||
mechanism: "trpc:procedure:queryOptions",
|
||||
},
|
||||
{
|
||||
kind: "network",
|
||||
value: "invoice.create",
|
||||
mechanism: "trpc:procedure:mutationOptions",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("recognizes vanilla client calls and link URLs only in sources carrying a tRPC marker", () => {
|
||||
expect(
|
||||
rpcEndpoints(`
|
||||
const client = createTRPCClient({
|
||||
links: [httpBatchLink({ url: "/api/trpc" })],
|
||||
});
|
||||
const bilbo = await client.getUser.query("id_bilbo");
|
||||
await client.post.add.mutate({ title: "x" });
|
||||
client.onPost.subscribe(undefined, { onData() {} });
|
||||
`),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "network",
|
||||
value: "/api/trpc",
|
||||
mechanism: "trpc:link:httpBatchLink",
|
||||
},
|
||||
{ kind: "network", value: "getUser", mechanism: "trpc:procedure:query" },
|
||||
{
|
||||
kind: "network",
|
||||
value: "post.add",
|
||||
mechanism: "trpc:procedure:mutate",
|
||||
},
|
||||
{
|
||||
kind: "network",
|
||||
value: "onPost",
|
||||
mechanism: "trpc:procedure:subscribe",
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
rpcEndpoints(`
|
||||
const rows = await pool.users.query("select 1");
|
||||
emitter.changes.subscribe(listener);
|
||||
store.cart.mutate(update);
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("requires corroboration for request methods shared with Apollo, RxJS and stores", () => {
|
||||
// The source carries a tRPC marker, so only the call shape can decide.
|
||||
expect(
|
||||
rpcEndpoints(`
|
||||
class TRPCClientError extends Error {}
|
||||
this.client.query({ query: q });
|
||||
e.queryManager.query(options);
|
||||
this.untypedClient.query("path", input);
|
||||
this.route.params.subscribe((params) => params);
|
||||
t.current.subscribe(listener);
|
||||
r.store.subscribe(() => {});
|
||||
n.cache.mutate(update);
|
||||
o.k.endpoints.getPosts.useQuery();
|
||||
e.api.endpoints.list.useQuery();
|
||||
this.trpc.invoice.list.useQuery();
|
||||
`),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
rpcEndpoints(`
|
||||
class TRPCClientError extends Error {}
|
||||
e.invoice.list.query({ page: 1 });
|
||||
e.invoice.onChange.subscribe(undefined, { onData() {} });
|
||||
`),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "network",
|
||||
value: "invoice.list",
|
||||
mechanism: "trpc:procedure:query",
|
||||
},
|
||||
{
|
||||
kind: "network",
|
||||
value: "invoice.onChange",
|
||||
mechanism: "trpc:procedure:subscribe",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("never reports browser globals or RTK Query endpoints as procedures", () => {
|
||||
expect(
|
||||
rpcEndpoints(`
|
||||
class TRPCClientError extends Error {}
|
||||
navigator.permissions.query({ name: "geolocation" });
|
||||
window.navigator.permissions.query({ name: "camera" });
|
||||
api.endpoints.getPosts.useQuery();
|
||||
api.useGetPostsQuery();
|
||||
useQuery({ queryKey: ["x"] });
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tRPC binding identity and bundled syntax", () => {
|
||||
it("keeps client bindings local to their lexical declaration", () => {
|
||||
expect(
|
||||
rpcEndpoints(`
|
||||
function first() { const c = createTRPCClient({}); c.users.query(); }
|
||||
function unrelated(c) { c.users.query("select 1"); }
|
||||
const c = createTRPCClient({});
|
||||
function closure() { c.posts.query(); }
|
||||
function shadowed() { c.users.query(); var c = pool; }
|
||||
{ const c = pool; c.users.query(); }
|
||||
try {} catch (c) { c.users.query(); }
|
||||
`),
|
||||
).toEqual([
|
||||
{ kind: "network", value: "users", mechanism: "trpc:procedure:query" },
|
||||
{ kind: "network", value: "posts", mechanism: "trpc:procedure:query" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not corroborate overwritten or destructured client bindings", () => {
|
||||
expect(
|
||||
rpcEndpoints(`
|
||||
let c = createTRPCClient({}); c = pool; c.users.query();
|
||||
const { client } = createTRPCClient({}); client.users.query();
|
||||
`),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
rpcEndpoints(`
|
||||
var c = createTRPCClient({}); c.users.query();
|
||||
`),
|
||||
).toEqual([
|
||||
{ kind: "network", value: "users", mechanism: "trpc:procedure:query" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("recognizes surviving factory names inside bundled call wrappers", () => {
|
||||
expect(
|
||||
rpcEndpoints(`
|
||||
const c = (0, t.createTRPCClient)({
|
||||
links: [(0, t.httpBatchLink)({ url: "/api/rpc" })],
|
||||
});
|
||||
c.users.query();
|
||||
const p = (t.createTRPCProxyClient as any)({});
|
||||
p.posts.query();
|
||||
(0, t.httpLink)({ url: "/api/other" });
|
||||
`),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "network",
|
||||
value: "/api/rpc",
|
||||
mechanism: "trpc:link:httpBatchLink",
|
||||
},
|
||||
{ kind: "network", value: "users", mechanism: "trpc:procedure:query" },
|
||||
{ kind: "network", value: "posts", mechanism: "trpc:procedure:query" },
|
||||
{ kind: "network", value: "/api/other", mechanism: "trpc:link:httpLink" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves procedure paths beyond the former nesting cutoff", () => {
|
||||
const path = Array.from({ length: 64 }, (_, i) => `router${i}`).join(".");
|
||||
expect(rpcEndpoints(`root.${path}.useQuery();`)).toEqual([
|
||||
{ kind: "network", value: path, mechanism: "trpc:procedure:useQuery" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GraphQL operation recognition", () => {
|
||||
it("recognizes named operations in tagged templates and string literals", () => {
|
||||
expect(
|
||||
rpcEndpoints(`
|
||||
const GET_USER = gql\`
|
||||
# comment
|
||||
query GetUser($id: ID!) { user(id: $id) { name } }
|
||||
\`;
|
||||
const UPDATE = "mutation UpdateInvoice($input: InvoiceInput!) { updateInvoice(input: $input) { id } }";
|
||||
const ON = \`subscription OnInvoice { invoiceChanged { id } } \${fragment}\`;
|
||||
`),
|
||||
).toEqual([
|
||||
{ kind: "network", value: "GetUser", mechanism: "graphql:query" },
|
||||
{
|
||||
kind: "network",
|
||||
value: "UpdateInvoice",
|
||||
mechanism: "graphql:mutation",
|
||||
},
|
||||
{
|
||||
kind: "network",
|
||||
value: "OnInvoice",
|
||||
mechanism: "graphql:subscription",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("retains operations after long ignored prefixes and long headers", () => {
|
||||
const name = "Operation" + "x".repeat(512);
|
||||
const text =
|
||||
"#" +
|
||||
"comment".repeat(700) +
|
||||
"\nquery " +
|
||||
name +
|
||||
" ".repeat(600) +
|
||||
"{ users { id } }";
|
||||
expect(rpcEndpoints(`const doc = ${JSON.stringify(text)};`)).toEqual([
|
||||
{ kind: "network", value: name, mechanism: "graphql:query" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("recognizes precompiled GraphQL document objects", () => {
|
||||
expect(
|
||||
rpcEndpoints(`
|
||||
const doc = { kind: "Document", definitions: [{
|
||||
kind: "OperationDefinition",
|
||||
operation: "query",
|
||||
name: { kind: "Name", value: "ListInvoices" },
|
||||
selectionSet: { kind: "SelectionSet", selections: [] },
|
||||
}] };
|
||||
`),
|
||||
).toEqual([
|
||||
{ kind: "network", value: "ListInvoices", mechanism: "graphql:query" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores anonymous operations and ordinary prose", () => {
|
||||
expect(
|
||||
rpcEndpoints(`
|
||||
const a = "query { viewer { id } }";
|
||||
const b = "query string parameters";
|
||||
const c = "mutation observer started";
|
||||
const d = { kind: "OperationDefinition", operation: "query" };
|
||||
const e = "mutation Observer {";
|
||||
const f = "query Search(";
|
||||
const g = "subscription Plan (monthly)";
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports a precompiled graphql-tag document once, not again from loc.source.body", () => {
|
||||
expect(
|
||||
rpcEndpoints(`
|
||||
const doc = { kind: "Document", definitions: [{
|
||||
kind: "OperationDefinition",
|
||||
operation: "query",
|
||||
name: { kind: "Name", value: "GetUser" },
|
||||
selectionSet: { kind: "SelectionSet", selections: [] },
|
||||
}], loc: { start: 0, end: 40, source: {
|
||||
body: "query GetUser($id: ID!) { user(id: $id) { name } }",
|
||||
name: "GraphQL request",
|
||||
locationOffset: { line: 1, column: 1 },
|
||||
} } };
|
||||
`),
|
||||
).toEqual([
|
||||
{ kind: "network", value: "GetUser", mechanism: "graphql:query" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps existing network endpoint recognition unchanged", () => {
|
||||
expect(
|
||||
endpoints(`fetch("/api/invoices"); n.invoice.list.useQuery();`),
|
||||
).toEqual([
|
||||
{ kind: "network", value: "/api/invoices", mechanism: "call:fetch" },
|
||||
{
|
||||
kind: "network",
|
||||
value: "invoice.list",
|
||||
mechanism: "trpc:procedure:useQuery",
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
import * as t from "@babel/types";
|
||||
|
||||
import {
|
||||
unwrapJavaScriptExpression,
|
||||
semanticStaticPropertyKey,
|
||||
} from "./javascriptAstValues.js";
|
||||
import { calleeName } from "./javascriptStaticAnalysisHelpers.js";
|
||||
|
||||
/** One RPC operation or transport URL recovered from client-side syntax. */
|
||||
export interface JavaScriptRpcEndpoint {
|
||||
readonly value: string;
|
||||
readonly mechanism: string;
|
||||
}
|
||||
|
||||
/** Per-source RPC recognition state; feed it every AST node in pre-order. */
|
||||
export interface JavaScriptRpcScan {
|
||||
inspect(node: t.Node): JavaScriptRpcEndpoint | undefined;
|
||||
}
|
||||
|
||||
// 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<string> = 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<string> = new Set(["query", "mutate"]);
|
||||
// @trpc/client 11.19 TRPCSubscriptionObserver callbacks; RxJS observers use
|
||||
// next/error/complete instead.
|
||||
const TRPC_SUBSCRIPTION_CALLBACKS: ReadonlySet<string> = 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<string> = new Set([
|
||||
"createTRPCClient",
|
||||
"createTRPCProxyClient",
|
||||
]);
|
||||
// Built-in HTTP links documented by tRPC 11.19 that carry a `url` option.
|
||||
const TRPC_URL_LINKS: ReadonlySet<string> = 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<string> = 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];
|
||||
})
|
||||
: [];
|
||||
|
||||
const objectStringProperty = (
|
||||
node: t.ObjectExpression,
|
||||
key: string,
|
||||
): string | undefined => {
|
||||
for (const property of node.properties)
|
||||
if (
|
||||
t.isObjectProperty(property) &&
|
||||
semanticStaticPropertyKey(property.key, property.computed) === key &&
|
||||
t.isStringLiteral(property.value)
|
||||
)
|
||||
return property.value.value;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const objectProperty = (
|
||||
node: t.ObjectExpression,
|
||||
key: string,
|
||||
): t.Node | undefined => {
|
||||
for (const property of node.properties)
|
||||
if (
|
||||
t.isObjectProperty(property) &&
|
||||
semanticStaticPropertyKey(property.key, property.computed) === key
|
||||
)
|
||||
return property.value;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const GRAPHQL_OPERATION_TYPES: ReadonlySet<string> = new Set([
|
||||
"query",
|
||||
"mutation",
|
||||
"subscription",
|
||||
]);
|
||||
// A named GraphQL operation header (GraphQL October 2021, OperationDefinition):
|
||||
// type, name, then variable definitions starting with `$`, a directive, or a
|
||||
// selection set starting with a field name.
|
||||
const GRAPHQL_OPERATION_HEADER =
|
||||
/^(query|mutation|subscription)[\s,]+([_A-Za-z][_0-9A-Za-z]*)[\s,]*(?:\([\s,]*\$|@[_A-Za-z]|\{[\s,]*[_A-Za-z])/u;
|
||||
|
||||
const isGraphqlIgnoredCharacter = (character: string): boolean =>
|
||||
character === "," || character === "" || /\s/u.test(character);
|
||||
|
||||
/** Recover a named operation from GraphQL document text. */
|
||||
export const graphqlOperationFromText = (
|
||||
text: string,
|
||||
): JavaScriptRpcEndpoint | undefined => {
|
||||
// Skip GraphQL ignored tokens: whitespace, commas, BOM and # comments.
|
||||
const limit = text.length;
|
||||
let index = 0;
|
||||
while (index < limit) {
|
||||
const character = text.charAt(index);
|
||||
if (isGraphqlIgnoredCharacter(character)) index += 1;
|
||||
else if (character === "#") {
|
||||
while (index < limit && !/[\n\r]/u.test(text.charAt(index))) index += 1;
|
||||
} else break;
|
||||
}
|
||||
if (index >= limit) return undefined;
|
||||
const header = GRAPHQL_OPERATION_HEADER.exec(text.slice(index));
|
||||
const type = header?.[1];
|
||||
const name = header?.[2];
|
||||
return type === undefined || name === undefined
|
||||
? undefined
|
||||
: { value: name, mechanism: `graphql:${type}` };
|
||||
};
|
||||
|
||||
/** Recover a named operation from a precompiled GraphQL document object. */
|
||||
export const graphqlOperationFromDocumentNode = (
|
||||
node: t.ObjectExpression,
|
||||
): JavaScriptRpcEndpoint | undefined => {
|
||||
if (objectStringProperty(node, "kind") !== "OperationDefinition")
|
||||
return undefined;
|
||||
const type = objectStringProperty(node, "operation");
|
||||
if (type === undefined || !GRAPHQL_OPERATION_TYPES.has(type))
|
||||
return undefined;
|
||||
const name = objectProperty(node, "name");
|
||||
if (
|
||||
!t.isObjectExpression(name) ||
|
||||
objectStringProperty(name, "kind") !== "Name"
|
||||
)
|
||||
return undefined;
|
||||
const value = objectStringProperty(name, "value");
|
||||
return value === undefined
|
||||
? undefined
|
||||
: { value, mechanism: `graphql:${type}` };
|
||||
};
|
||||
|
||||
/** 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 = objectStringProperty(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<t.Node>,
|
||||
): JavaScriptRpcScan => {
|
||||
let marker: boolean | undefined;
|
||||
const hasMarker = (): boolean =>
|
||||
(marker ??= TRPC_SOURCE_MARKERS.some((value) => source.includes(value)));
|
||||
// graphql-tag documents retain their source text in loc.source.body; that
|
||||
// text repeats the OperationDefinition already recovered from the object.
|
||||
const documentSourceBodies = new WeakSet<t.Node>();
|
||||
|
||||
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);
|
||||
if (t.isObjectExpression(node)) {
|
||||
const body = objectProperty(node, "body");
|
||||
if (
|
||||
t.isStringLiteral(body) &&
|
||||
objectProperty(node, "locationOffset") !== undefined
|
||||
) {
|
||||
documentSourceBodies.add(body);
|
||||
return undefined;
|
||||
}
|
||||
return graphqlOperationFromDocumentNode(node);
|
||||
}
|
||||
if (t.isStringLiteral(node))
|
||||
return documentSourceBodies.has(node)
|
||||
? undefined
|
||||
: graphqlOperationFromText(node.value);
|
||||
if (t.isTemplateLiteral(node)) {
|
||||
// A template recovered without a cooked value stays dynamic.
|
||||
const cooked = node.quasis[0]?.value.cooked;
|
||||
return cooked === undefined || cooked === null
|
||||
? undefined
|
||||
: graphqlOperationFromText(cooked);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -59,6 +59,8 @@ import {
|
||||
type ParsedJavaScriptSource,
|
||||
} from "./javascriptSourceParser.js";
|
||||
|
||||
import { isJavaScriptRpcClientFactory } from "./javascriptRpcEndpoints.js";
|
||||
|
||||
interface BindPatternInput {
|
||||
readonly pattern: t.Node;
|
||||
readonly initializer: t.Node | null;
|
||||
@@ -181,6 +183,40 @@ export function* analyzeParsedJavaScriptSemanticsSteps(
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve tRPC client roots by lexical identity, excluding overwritten bindings. */
|
||||
export function* classifyParsedJavaScriptRpcClientRootsSteps(
|
||||
file: ParsedJavaScriptSource,
|
||||
): Generator<void, ReadonlySet<t.Node>> {
|
||||
const state = createState(file.program);
|
||||
yield* collectDefinitionsSteps(file.program, state);
|
||||
const roots = new Set<t.Node>();
|
||||
const clients = new Map<string, boolean>();
|
||||
yield* traverseJavaScriptAstSteps(file.program, {
|
||||
enter: (node) => {
|
||||
if (!t.isMemberExpression(node) && !t.isOptionalMemberExpression(node))
|
||||
return;
|
||||
const root = node.object;
|
||||
if (!t.isIdentifier(root)) return;
|
||||
const binding = resolveSemanticBindingState(state, root, root.name);
|
||||
if (binding === undefined) return;
|
||||
let client = clients.get(binding.bindingId);
|
||||
if (client === undefined) {
|
||||
const [initializer] = binding.initializers;
|
||||
client =
|
||||
initializer !== undefined &&
|
||||
initializer.projection.length === 0 &&
|
||||
binding.initializers.length === 1 &&
|
||||
!binding.definitions.some(({ kind }) => kind === "assignment") &&
|
||||
!state.conditionalInitializers.has(initializer.node) &&
|
||||
isJavaScriptRpcClientFactory(initializer.node);
|
||||
clients.set(binding.bindingId, client);
|
||||
}
|
||||
if (client) roots.add(root);
|
||||
},
|
||||
});
|
||||
return roots;
|
||||
}
|
||||
|
||||
/** Receiver identity facts for the overloaded `.open` syntax only. */
|
||||
export type JavaScriptOpenReceiverFact =
|
||||
| "window"
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
inspectCall,
|
||||
inspectRouteProperty,
|
||||
inspectRoleProperty,
|
||||
inspectRpcNode,
|
||||
} from "./javascriptStaticAnalysisCalls.js";
|
||||
import {
|
||||
inspectBundlerRegistration,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
import type { JavaScriptStaticAnalysis } from "./javascriptStaticAnalysisTypes.js";
|
||||
import {
|
||||
classifyParsedJavaScriptElectronBindingsSteps,
|
||||
classifyParsedJavaScriptRpcClientRootsSteps,
|
||||
classifyParsedJavaScriptOpenReceivers,
|
||||
type JavaScriptOpenReceiverFact,
|
||||
} from "./javascriptSemanticAnalysis.js";
|
||||
@@ -45,6 +47,8 @@ import {
|
||||
type ParsedJavaScriptSource,
|
||||
} from "./javascriptSourceParser.js";
|
||||
|
||||
import { createJavaScriptRpcScan } from "./javascriptRpcEndpoints.js";
|
||||
|
||||
/** Parse one JavaScript artifact and recover static structure only. */
|
||||
export const analyzeJavaScriptStaticSource = (
|
||||
source: string,
|
||||
@@ -78,11 +82,13 @@ export function* analyzeParsedJavaScriptStaticSourceSteps(
|
||||
const accumulator = createJavaScriptAnalysisAccumulator(source.length);
|
||||
const electronBindings =
|
||||
yield* classifyParsedJavaScriptElectronBindingsSteps(file);
|
||||
const clientRoots = yield* classifyParsedJavaScriptRpcClientRootsSteps(file);
|
||||
yield* traverseStaticSourceSteps(file, {
|
||||
source,
|
||||
accumulator,
|
||||
...(openReceiverFacts === undefined ? {} : { openReceiverFacts }),
|
||||
electronBindings,
|
||||
rpcScan: createJavaScriptRpcScan(source, clientRoots),
|
||||
});
|
||||
addSourceMapDirectives(source, file.comments ?? [], accumulator);
|
||||
return finalizeStaticAnalysis(source, file, accumulator);
|
||||
@@ -223,4 +229,5 @@ const inspectNode = (
|
||||
inspectRouteProperty(node, findings);
|
||||
inspectRoleProperty(node, findings);
|
||||
}
|
||||
inspectRpcNode(node, findings);
|
||||
};
|
||||
|
||||
@@ -201,6 +201,13 @@ const inspectEndpointCall = (
|
||||
});
|
||||
};
|
||||
|
||||
/** Record tRPC procedures, tRPC link URLs and named GraphQL operations. */
|
||||
export const inspectRpcNode = (node: t.Node, context: FindingContext): void => {
|
||||
const rpc = context.rpcScan.inspect(node);
|
||||
if (rpc !== undefined)
|
||||
addEndpoint(context, { node, kind: "network", ...rpc });
|
||||
};
|
||||
|
||||
export const inspectRouteProperty = (
|
||||
node: t.ObjectProperty,
|
||||
context: FindingContext,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type * as t from "@babel/types";
|
||||
import type { JavaScriptRpcScan } from "./javascriptRpcEndpoints.js";
|
||||
|
||||
import type {
|
||||
JavaScriptBundlerRegistration,
|
||||
@@ -57,6 +58,7 @@ export interface JavaScriptAnalysisAccumulator {
|
||||
|
||||
/** Shared source and accumulator for helper inspections. */
|
||||
export interface JavaScriptFindingContext {
|
||||
readonly rpcScan: JavaScriptRpcScan;
|
||||
readonly source: string;
|
||||
readonly accumulator: JavaScriptAnalysisAccumulator;
|
||||
readonly openReceiverFacts?: ReadonlyMap<
|
||||
|
||||
@@ -603,3 +603,56 @@ it("retains direct and literal computed call names", () => {
|
||||
expect.objectContaining({ name: "literal-tool" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("recognizes tRPC procedures and named GraphQL operations in captured scripts", () => {
|
||||
const result = analyzeCapturedWebBundle(
|
||||
inspection(`
|
||||
const n = createTRPCClient({ links: [httpBatchLink({ url: "/api/trpc" })] });
|
||||
e.invoice.list.useQuery({ competencia: "2026-09" });
|
||||
n.empresa.byCnpj.query("00000000000191");
|
||||
const q = "query ListarNotas($competencia: String!) { notas { id } }";
|
||||
this.client.query({ query: q });
|
||||
`),
|
||||
);
|
||||
expect(
|
||||
result.observations.endpoints.map(({ value, mechanism }) => ({
|
||||
value,
|
||||
mechanism,
|
||||
})),
|
||||
).toEqual([
|
||||
{ value: "/api/trpc", mechanism: "trpc:link:httpBatchLink" },
|
||||
{ value: "invoice.list", mechanism: "trpc:procedure:useQuery" },
|
||||
{ value: "empresa.byCnpj", mechanism: "trpc:procedure:query" },
|
||||
{ value: "ListarNotas", mechanism: "graphql:query" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps RPC lexical identity and bundled factory recognition in captured scripts", () => {
|
||||
const longOperation = "Operation" + "x".repeat(512);
|
||||
const document =
|
||||
"#" +
|
||||
"comment".repeat(700) +
|
||||
"\nquery " +
|
||||
longOperation +
|
||||
" { users { id } }";
|
||||
const result = analyzeCapturedWebBundle(
|
||||
inspection(`
|
||||
function first() { const c = createTRPCClient({}); }
|
||||
function unrelated(c) { c.users.query("select 1"); }
|
||||
const c = (0, t.createTRPCClient)({ links: [(0, t.httpBatchLink)({ url: "/api/rpc" })] });
|
||||
c.posts.query();
|
||||
const doc = ${JSON.stringify(document)};
|
||||
`),
|
||||
);
|
||||
expect(
|
||||
result.observations.endpoints.map(({ value, mechanism }) => ({
|
||||
value,
|
||||
mechanism,
|
||||
})),
|
||||
).toEqual([
|
||||
{ value: "/api/rpc", mechanism: "trpc:link:httpBatchLink" },
|
||||
{ value: "posts", mechanism: "trpc:procedure:query" },
|
||||
{ value: longOperation, mechanism: "graphql:query" },
|
||||
]);
|
||||
expect(result.completeness.status).toBe("complete");
|
||||
});
|
||||
|
||||
@@ -5,8 +5,14 @@ import { sanitizeEndpointCandidate } from "./browserObservation.js";
|
||||
import { compositeKey } from "./unicodeCodePointOrder.js";
|
||||
import type { WebPageInspection } from "./browserObservationSchemas.js";
|
||||
import type { WebBundleAnalysis } from "./webBundleAnalysis.js";
|
||||
import { traverseJavaScriptAst } from "./javascript/javascriptSemanticTraversal.js";
|
||||
import { classifyParsedJavaScriptOpenReceivers } from "./javascript/javascriptSemanticAnalysis.js";
|
||||
import {
|
||||
completeSemanticSteps,
|
||||
traverseJavaScriptAst,
|
||||
} from "./javascript/javascriptSemanticTraversal.js";
|
||||
import {
|
||||
classifyParsedJavaScriptOpenReceivers,
|
||||
classifyParsedJavaScriptRpcClientRootsSteps,
|
||||
} from "./javascript/javascriptSemanticAnalysis.js";
|
||||
import type { JavaScriptOpenReceiverFact } from "./javascript/javascriptSemanticAnalysis.js";
|
||||
import { semanticStaticPropertyName } from "./javascript/javascriptAstValues.js";
|
||||
import {
|
||||
@@ -22,6 +28,7 @@ import {
|
||||
resolveSpecifier,
|
||||
isUrlLikeModuleSpecifier,
|
||||
} from "./webBundleAnalyzerAst.js";
|
||||
import { createJavaScriptRpcScan } from "./javascript/javascriptRpcEndpoints.js";
|
||||
|
||||
type BundleObservations = WebBundleAnalysis["observations"];
|
||||
type ChunkEdge = BundleObservations["chunks"]["edges"][number];
|
||||
@@ -66,10 +73,24 @@ export const analyzeScript = (
|
||||
accumulator.parsedScripts += 1;
|
||||
detectVendorFingerprints(script, accumulator);
|
||||
const openReceiverFacts = classifyParsedJavaScriptOpenReceivers(file);
|
||||
const rpc = createJavaScriptRpcScan(
|
||||
script.source.artifact.text,
|
||||
completeSemanticSteps(classifyParsedJavaScriptRpcClientRootsSteps(file)),
|
||||
);
|
||||
traverseJavaScriptAst(file, {
|
||||
enter: (node) => {
|
||||
accumulator.visitedNodes += 1;
|
||||
inspectNode(script, node, accumulator, openReceiverFacts);
|
||||
const endpoint = rpc.inspect(node);
|
||||
if (endpoint !== undefined)
|
||||
addFinding({
|
||||
collection: accumulator.endpoints,
|
||||
script,
|
||||
rawValue: endpoint.value,
|
||||
mechanism: endpoint.mechanism,
|
||||
node,
|
||||
accumulator,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user