Skip to content

Commit 040e8e1

Browse files
sayefdeenclaude
andcommitted
fix: add tRPC v11 compatibility for router introspection
tRPC v11 changed its internal structure — leaf procedures in record are now plain functions without ._def. Updated walkRouter to handle both v10 (object with _def) and v11 (plain function) procedure formats, with a fallback to the flat _def.procedures map when record walking finds nothing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f7e5f3b commit 040e8e1

3 files changed

Lines changed: 189 additions & 30 deletions

File tree

packages/server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@srawad/trpc-studio",
3-
"version": "0.1.7",
3+
"version": "0.1.8",
44
"license": "MIT",
55
"description": "Swagger-like UI for tRPC with automatic output type extraction",
66
"repository": {

packages/server/src/__tests__/introspect.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,4 +137,118 @@ describe("introspectRouter", () => {
137137
const manifest = introspectRouter({ foo: "bar" });
138138
expect(manifest.procedures).toEqual([]);
139139
});
140+
141+
// tRPC v11 compatibility tests — simulate v11 internal structure
142+
describe("tRPC v11 compatibility", () => {
143+
it("extracts procedures that are plain functions in record (v11 style)", () => {
144+
// In v11, leaf procedures inside a sub-router's record are plain functions
145+
const mockRouter = {
146+
_def: {
147+
record: {
148+
hello: Object.assign(() => "hello", {
149+
_def: { type: "query", inputs: [] },
150+
}),
151+
},
152+
},
153+
};
154+
const manifest = introspectRouter(mockRouter);
155+
expect(manifest.procedures).toHaveLength(1);
156+
expect(manifest.procedures[0]).toMatchObject({
157+
path: "hello",
158+
type: "query",
159+
inputSchema: null,
160+
});
161+
});
162+
163+
it("extracts nested v11 routers with function procedures", () => {
164+
const mockRouter = {
165+
_def: {
166+
record: {
167+
user: {
168+
_def: {
169+
record: {
170+
getById: Object.assign(() => null, {
171+
_def: { type: "query", inputs: [] },
172+
}),
173+
create: Object.assign(() => null, {
174+
_def: { type: "mutation", inputs: [] },
175+
}),
176+
},
177+
},
178+
},
179+
},
180+
},
181+
};
182+
const manifest = introspectRouter(mockRouter);
183+
expect(manifest.procedures).toHaveLength(2);
184+
expect(manifest.procedures.map((p) => p.path)).toEqual(["user.getById", "user.create"]);
185+
expect(manifest.procedures[0]?.type).toBe("query");
186+
expect(manifest.procedures[1]?.type).toBe("mutation");
187+
});
188+
189+
it("handles opaque function procedures with no _def", () => {
190+
// Worst case: procedure is a plain function with no metadata at all
191+
const mockRouter = {
192+
_def: {
193+
record: {
194+
accounts: {
195+
_def: {
196+
record: {
197+
get: () => null,
198+
list: () => null,
199+
},
200+
},
201+
},
202+
},
203+
},
204+
};
205+
const manifest = introspectRouter(mockRouter);
206+
expect(manifest.procedures).toHaveLength(2);
207+
expect(manifest.procedures.map((p) => p.path)).toEqual(["accounts.get", "accounts.list"]);
208+
// Defaults to query when type is unknown
209+
expect(manifest.procedures[0]?.type).toBe("query");
210+
});
211+
212+
it("falls back to flat procedures map when record walk finds nothing", () => {
213+
// Simulate a router where record is empty but procedures flat map exists
214+
const mockRouter = {
215+
_def: {
216+
record: {},
217+
procedures: {
218+
"accounts.get": Object.assign(() => null, {
219+
_def: { type: "query", inputs: [] },
220+
}),
221+
"accounts.create": Object.assign(() => null, {
222+
_def: { type: "mutation", inputs: [] },
223+
}),
224+
},
225+
},
226+
};
227+
const manifest = introspectRouter(mockRouter);
228+
expect(manifest.procedures).toHaveLength(2);
229+
expect(manifest.procedures.map((p) => p.path)).toEqual(["accounts.get", "accounts.create"]);
230+
expect(manifest.procedures[0]?.type).toBe("query");
231+
expect(manifest.procedures[1]?.type).toBe("mutation");
232+
});
233+
234+
it("handles v11 function procedures with Zod input", () => {
235+
const mockRouter = {
236+
_def: {
237+
record: {
238+
getUser: Object.assign(() => null, {
239+
_def: {
240+
type: "query",
241+
inputs: [z.object({ id: z.string() })],
242+
},
243+
}),
244+
},
245+
},
246+
};
247+
const manifest = introspectRouter(mockRouter);
248+
expect(manifest.procedures).toHaveLength(1);
249+
expect(manifest.procedures[0]?.inputSchema).toBeDefined();
250+
expect(manifest.procedures[0]?.inputSchema?.type).toBe("object");
251+
expect(manifest.procedures[0]?.inputSchema?.properties).toHaveProperty("id");
252+
});
253+
});
140254
});

packages/server/src/introspect.ts

Lines changed: 74 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,37 @@ function mergeZodInputs(inputs: any[]): any {
4444
return merged;
4545
}
4646

47+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
48+
function extractProcedureFromDef(def: any, path: string, procedures: ProcedureInfo[]): void {
49+
// Determine type from tRPC v10/v11
50+
let type: ProcedureType = "query";
51+
if (typeof def.type === "string") {
52+
type = def.type as ProcedureType;
53+
} else if (def.query) {
54+
type = "query";
55+
} else if (def.mutation) {
56+
type = "mutation";
57+
} else if (def.subscription) {
58+
type = "subscription";
59+
}
60+
61+
// Extract inputs
62+
const inputs = def.inputs ?? [];
63+
const mergedInput = mergeZodInputs(inputs);
64+
const inputSchema = zodInputToJsonSchema(mergedInput);
65+
66+
// Extract description from meta
67+
const description = def.meta?.description as string | undefined;
68+
69+
procedures.push({
70+
path,
71+
type,
72+
inputSchema,
73+
outputSchema: null,
74+
...(description !== undefined ? { description } : {}),
75+
});
76+
}
77+
4778
function walkRouter(
4879
// eslint-disable-next-line @typescript-eslint/no-explicit-any
4980
routerDef: any,
@@ -63,41 +94,38 @@ function walkRouter(
6394

6495
const def = value?._def;
6596

66-
if (!def) continue;
97+
// Case 1: Sub-router — has _def with nested record/procedures/router
98+
if (def && (def.record ?? def.procedures ?? def.router)) {
99+
walkRouter(def, path, procedures);
100+
continue;
101+
}
67102

68-
// Check if this is a router (has record/procedures/router property)
69-
if (def.record ?? def.procedures ?? def.router) {
70-
walkRouter(def.record ?? def.procedures ?? def.router, path, procedures);
103+
// Case 2: tRPC v10 procedure — has _def with type/inputs
104+
if (def) {
105+
extractProcedureFromDef(def, path, procedures);
71106
continue;
72107
}
73108

74-
// This is a procedure — determine type from tRPC v10/v11
75-
let type: ProcedureType = "query";
76-
if (typeof def.type === "string") {
77-
type = def.type as ProcedureType;
78-
} else if (def.query) {
79-
type = "query";
80-
} else if (def.mutation) {
81-
type = "mutation";
82-
} else if (def.subscription) {
83-
type = "subscription";
109+
// Case 3: tRPC v11 procedure — plain function, may have _def attached
110+
if (typeof value === "function") {
111+
if (value._def) {
112+
extractProcedureFromDef(value._def, path, procedures);
113+
} else {
114+
// Opaque function with no metadata — register with defaults
115+
procedures.push({
116+
path,
117+
type: "query",
118+
inputSchema: null,
119+
outputSchema: null,
120+
});
121+
}
122+
continue;
84123
}
85124

86-
// Extract inputs
87-
const inputs = def.inputs ?? [];
88-
const mergedInput = mergeZodInputs(inputs);
89-
const inputSchema = zodInputToJsonSchema(mergedInput);
90-
91-
// Extract description from meta
92-
const description = def.meta?.description as string | undefined;
93-
94-
procedures.push({
95-
path,
96-
type,
97-
inputSchema,
98-
outputSchema: null,
99-
...(description !== undefined ? { description } : {}),
100-
});
125+
// Case 4: Plain object grouping (namespace without _def) — recurse
126+
if (typeof value === "object" && value !== null) {
127+
walkRouter(value, path, procedures);
128+
}
101129
}
102130
}
103131

@@ -112,5 +140,22 @@ export function introspectRouter(router: any): RouterManifest {
112140

113141
walkRouter(def, "", procedures);
114142

143+
// Fallback: if walking the record tree found nothing, try the flat procedures map (tRPC v11)
144+
if (procedures.length === 0 && def.procedures && typeof def.procedures === "object") {
145+
for (const [procPath, proc] of Object.entries(def.procedures)) {
146+
const procDef = (proc as { _def?: unknown })?._def;
147+
if (procDef && typeof procDef === "object") {
148+
extractProcedureFromDef(procDef, procPath, procedures);
149+
} else {
150+
procedures.push({
151+
path: procPath,
152+
type: "query",
153+
inputSchema: null,
154+
outputSchema: null,
155+
});
156+
}
157+
}
158+
}
159+
115160
return { procedures };
116161
}

0 commit comments

Comments
 (0)