fix(javascript): honor final RPC object properties

Resolve final object writes once at the RPC endpoint producer. Unknown computed writes and spreads remain barriers; overridden properties do not invent endpoints. Source, CLI and stdio MCP regressions cover the behavior.

Co-authored-by: Vaishnavi220506 <vaishnaviharish2006@gmail.com>
This commit is contained in:
morluto
2026-10-14 15:20:05 +08:00
parent 9d00d7d7fa
commit b8eea783da
4 changed files with 124 additions and 20 deletions
@@ -273,6 +273,12 @@ RPC client syntax also contributes network endpoint candidates, in both
`OperationDefinition` document object. A graphql-tag `loc.source.body` copy is
not reported twice. Anonymous operations are not reported.
Literal link options and precompiled document fields follow object property
order: the final definition owns the key. A later spread, dynamic computed key,
method, or accessor can make a field unknown, so an overwritten literal is not
reported as the endpoint or operation. A final explicit property after a spread
or dynamic key remains usable.
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
@@ -13,6 +13,50 @@ const rpcEndpoints = (source: string) =>
mechanism.startsWith("trpc:") || mechanism.startsWith("graphql:"),
);
describe("RPC object property ownership", () => {
it.each([
['url: "/trpc/old", url: "/trpc/live"', "/trpc/live"],
['url: "/trpc/old", url: runtimeUrl', null],
['url: "/trpc/old", ...options', null],
['url: "/trpc/old", [key]: runtimeUrl', null],
['url: "/trpc/old", get url() { return runtimeUrl; }', null],
['url: "/trpc/old", url() {}', null],
['...options, url: "/trpc/live", headers: {}', "/trpc/live"],
['[key]: runtimeUrl, url: "/trpc/live"', "/trpc/live"],
['url: "/trpc/old", ["url"]: "/trpc/live"', "/trpc/live"],
] as const)(
"reads only the final known link URL from {%s}",
(options, value) => {
expect(rpcEndpoints(`httpBatchLink({ ${options} });`)).toEqual(
value === null
? []
: [{ kind: "network", value, mechanism: "trpc:link:httpBatchLink" }],
);
},
);
it.each([
['operation: "mutation"', "Old", "graphql:mutation"],
['name: { kind: "Name", value: "Current" }', "Current", "graphql:query"],
['kind: "FragmentDefinition"', null, null],
["name: runtimeName", null, null],
["get name() { return runtimeName; }", null, null],
["...options", null, null],
["[key]: runtimeValue", null, null],
] as const)(
"honors final GraphQL document properties: %s",
(override, value, mechanism) => {
const source = `const doc = {
kind: "OperationDefinition", operation: "query",
name: { kind: "Name", value: "Old" }, ${override}
};`;
expect(rpcEndpoints(source)).toEqual(
value === null ? [] : [{ kind: "network", value, mechanism }],
);
},
);
});
describe("tRPC procedure recognition", () => {
it("recognizes React hook procedures on a renamed proxy root", () => {
expect(
+17 -20
View File
@@ -122,33 +122,30 @@ const objectKeys = (node: t.Node | undefined): readonly string[] =>
})
: [];
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;
};
/** Later properties own a key; an intervening dynamic write leaves it unknown. */
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;
for (let index = node.properties.length - 1; index >= 0; index -= 1) {
const property = node.properties[index];
if (property === undefined || t.isSpreadElement(property)) return undefined;
const name = semanticStaticPropertyKey(property.key, property.computed);
if (name === null) return undefined;
if (name === key)
return t.isObjectProperty(property) ? property.value : undefined;
}
return undefined;
};
const objectStringProperty = (
node: t.ObjectExpression,
key: string,
): string | undefined => {
const value = objectProperty(node, key);
return t.isStringLiteral(value) ? value.value : undefined;
};
const GRAPHQL_OPERATION_TYPES: ReadonlySet<string> = new Set([
"query",
"mutation",
@@ -0,0 +1,57 @@
import { expect } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
import {
analyzeJavaScriptDirectory,
runNodeJsonControl,
} from "../../support/cli/javascriptApplication.js";
import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js";
cliTest.for(["CLI", "stdio MCP"] as const)(
"retains only final known RPC object properties through %s",
async (surface, { cli }) => {
const root = await createTestTempDirectory("rea-rpc-properties-");
await writeFixtureFiles(root, {
"entry.mjs": `
function httpBatchLink(options) { return options.url; }
const options = { url: "/trpc/runtime" };
const key = "url";
const links = [
httpBatchLink({ url: "/trpc/old", url: "/trpc/live" }),
httpBatchLink({ url: "/trpc/spread-old", ...options }),
httpBatchLink({ url: "/trpc/computed-old", [key]: options.url }),
httpBatchLink({ ...options, url: "/trpc/final", headers: {} }),
];
const doc = {
kind: "OperationDefinition", operation: "query", operation: "mutation",
name: { kind: "Name", value: "Old" },
name: { kind: "Name", value: "Current" },
};
console.log(JSON.stringify([links, doc.operation, doc.name.value]));
`,
});
// This control checks JavaScript object semantics, not a live tRPC client.
expect(await runNodeJsonControl(root, "entry.mjs")).toEqual([
["/trpc/live", "/trpc/runtime", "/trpc/runtime", "/trpc/final"],
"mutation",
"Current",
]);
const { graph } = await analyzeJavaScriptDirectory(surface, cli, root);
const endpoints = graph.nodes
.filter((node) => node.kind === "endpoint")
.flatMap((node) => node.observations)
.filter(({ properties }) =>
/^(trpc:link:|graphql:)/u.test(String(properties.mechanism)),
)
.map(({ properties }) => [properties.value, properties.mechanism]);
expect(endpoints).toEqual(
expect.arrayContaining([
["/trpc/live", "trpc:link:httpBatchLink"],
["/trpc/final", "trpc:link:httpBatchLink"],
["Current", "graphql:mutation"],
]),
);
expect(endpoints).toHaveLength(3);
},
);