Skip to content

Commit 20cb7b8

Browse files
sayefdeenclaude
andcommitted
feat: auth support, tag filtering, input schema extraction, meta display
- Add "Authorize" button with modal (bearer, cookie, header, basic auth) with localStorage persistence across page refreshes - Add tag filtering in sidebar — read meta.tags, render filter chips, multi-select to narrow procedures across all routers - Display procedure .meta() as badge pills in sidebar and detail panel (auth lock icon, deprecated warning, custom key-value pairs) - CLI extractor now outputs both input and output schemas for z.custom() support — hybrid merge uses Zod at runtime, CLI-extracted as fallback - Updated README with auth, tags, meta, and CLI input schema docs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent caba0fb commit 20cb7b8

15 files changed

Lines changed: 685 additions & 59 deletions

File tree

packages/core/src/extract.ts

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,12 @@ function isRouterDef(defType: Type): boolean {
154154
return recordType.getProperties().length > 0 && !recordType.isBoolean();
155155
}
156156

157-
export function extractRouterOutputSchemas(options: ExtractOptions): Record<string, JsonSchema> {
157+
export interface ExtractedSchemas {
158+
inputs: Record<string, JsonSchema>;
159+
outputs: Record<string, JsonSchema>;
160+
}
161+
162+
export function extractRouterSchemas(options: ExtractOptions): ExtractedSchemas {
158163
const projectOptions: ConstructorParameters<typeof Project>[0] = {
159164
skipAddingFilesFromTsConfig: false,
160165
};
@@ -174,13 +179,18 @@ export function extractRouterOutputSchemas(options: ExtractOptions): Record<stri
174179
}
175180

176181
const routerType = routerVar.getType();
177-
const result: Record<string, JsonSchema> = {};
182+
const result: SchemaResult = { inputs: {}, outputs: {} };
178183

179184
walkRouterType(routerType, "", result);
180185

181186
return result;
182187
}
183188

189+
/** @deprecated Use extractRouterSchemas instead — returns both inputs and outputs */
190+
export function extractRouterOutputSchemas(options: ExtractOptions): Record<string, JsonSchema> {
191+
return extractRouterSchemas(options).outputs;
192+
}
193+
184194
function extractProcedureOutputType(propDefType: Type): Type | null {
185195
// v10: _def._output_out
186196
const v10Output = getPropertyType(propDefType, "_output_out");
@@ -195,6 +205,20 @@ function extractProcedureOutputType(propDefType: Type): Type | null {
195205
return null;
196206
}
197207

208+
function extractProcedureInputType(propDefType: Type): Type | null {
209+
// v10: _def._input_in
210+
const v10Input = getPropertyType(propDefType, "_input_in");
211+
if (v10Input) return v10Input;
212+
213+
// v11: _def.$types.input
214+
const $types = getPropertyType(propDefType, "$types");
215+
if ($types) {
216+
return getPropertyType($types, "input");
217+
}
218+
219+
return null;
220+
}
221+
198222
function isProcedureType(type: Type): boolean {
199223
const defType = getPropertyType(type, "_def");
200224
if (!defType) return false;
@@ -207,11 +231,12 @@ function isProcedureType(type: Type): boolean {
207231
);
208232
}
209233

210-
function walkRecordEntries(
211-
recordType: Type,
212-
prefix: string,
213-
result: Record<string, JsonSchema>,
214-
): void {
234+
interface SchemaResult {
235+
inputs: Record<string, JsonSchema>;
236+
outputs: Record<string, JsonSchema>;
237+
}
238+
239+
function walkRecordEntries(recordType: Type, prefix: string, result: SchemaResult): void {
215240
for (const prop of recordType.getProperties()) {
216241
const propName = prop.getName();
217242
const path = prefix ? `${prefix}.${propName}` : propName;
@@ -227,11 +252,15 @@ function walkRecordEntries(
227252
continue;
228253
}
229254

230-
// Check if this is a procedure (has _def with output type info)
255+
// Check if this is a procedure (has _def with type info)
231256
if (propDefType) {
232257
const outputType = extractProcedureOutputType(propDefType);
233258
if (outputType) {
234-
result[path] = typeToJsonSchema(outputType);
259+
result.outputs[path] = typeToJsonSchema(outputType);
260+
}
261+
const inputType = extractProcedureInputType(propDefType);
262+
if (inputType && !inputType.isVoid() && !inputType.isUndefined()) {
263+
result.inputs[path] = typeToJsonSchema(inputType);
235264
}
236265
continue;
237266
}
@@ -245,7 +274,6 @@ function walkRecordEntries(
245274
if (firstDecl) {
246275
const firstChildType = firstChild.getTypeAtLocation(firstDecl);
247276
if (isProcedureType(firstChildType)) {
248-
// This is a v11 namespace — recurse into its properties
249277
walkRecordEntries(propType, path, result);
250278
continue;
251279
}
@@ -254,7 +282,7 @@ function walkRecordEntries(
254282
}
255283
}
256284

257-
function walkRouterType(type: Type, prefix: string, result: Record<string, JsonSchema>): void {
285+
function walkRouterType(type: Type, prefix: string, result: SchemaResult): void {
258286
// Get _def.record
259287
const defType = getPropertyType(type, "_def");
260288
if (!defType) return;

packages/core/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
export { extractRouterOutputSchemas } from "./extract";
2-
export type { ExtractOptions } from "./extract";
1+
export { extractRouterSchemas, extractRouterOutputSchemas } from "./extract";
2+
export type { ExtractOptions, ExtractedSchemas } from "./extract";
33
export type { JsonSchema, ProcedureType, ProcedureInfo, RouterManifest } from "./schema";

packages/core/src/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export interface ProcedureInfo {
2222
inputSchema: JsonSchema | null;
2323
outputSchema: JsonSchema | null;
2424
description?: string;
25+
meta?: Record<string, unknown>;
2526
}
2627

2728
export interface RouterManifest {

packages/server/README.md

Lines changed: 164 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ static type extraction via the TypeScript Compiler API.
1515
- **Output type visualization** — automatic runtime detection from `.output()`
1616
schemas, or static extraction via the CLI using the TypeScript Compiler API
1717
- **Sidebar navigation** — procedures grouped by router, color-coded badges
18-
(query/mutation/subscription), real-time search
18+
(query/mutation/subscription), real-time search, tag filtering
19+
- **Authentication** — configurable "Authorize" button (bearer, cookie, header,
20+
basic) with localStorage persistence
21+
- **Procedure metadata** — automatically displays `.meta()` values (auth,
22+
deprecated, tags, custom fields)
1923
- **Self-contained** — served as a single HTML response, no static file hosting
2024
needed
2125
- **tRPC v10 & v11** compatible
@@ -115,18 +119,26 @@ npx @srawad/trpc-studio extract --router ./src/server/router.ts
115119
```
116120

117121
This analyzes your TypeScript source and generates a `.trpc-studio.json` file
118-
with JSON Schema definitions for every procedure's output type.
122+
with JSON Schema definitions for every procedure's input and output types.
123+
124+
This is especially useful for:
125+
126+
- **Output types** — procedures that don't use `.output()` (most tRPC code)
127+
- **Input types with `z.custom<T>()`** — Zod's `z.custom()` carries no runtime
128+
schema, so the UI renders it as an empty object. The CLI extractor resolves
129+
the actual TypeScript type and provides full structural info.
119130

120131
#### Step 2: Pass the schemas to renderTrpcStudio
121132

122133
```typescript
123-
import outputSchemas from "./.trpc-studio.json";
134+
import schemas from "./.trpc-studio.json";
124135

125136
app.get("/studio", (_req, res) => {
126137
res.send(
127138
renderTrpcStudio(appRouter, {
128139
url: "http://localhost:3000/trpc",
129-
outputSchemas,
140+
inputSchemas: schemas.inputs,
141+
outputSchemas: schemas.outputs,
130142
}),
131143
);
132144
});
@@ -176,49 +188,75 @@ npx @srawad/trpc-studio extract \
176188
--out .trpc-studio.json
177189
```
178190

179-
This generates `.trpc-studio.json`:
191+
This generates `.trpc-studio.json` with both input and output schemas:
180192

181193
```json
182194
{
183-
"hello": {
184-
"type": "object",
185-
"properties": {
186-
"greeting": { "type": "string" }
195+
"inputs": {
196+
"hello": {
197+
"type": "object",
198+
"properties": {
199+
"name": { "type": "string" }
200+
}
187201
},
188-
"required": ["greeting"]
189-
},
190-
"user.getById": {
191-
"type": "object",
192-
"properties": {
193-
"id": { "type": "string" },
194-
"name": { "type": "string" },
195-
"email": { "type": "string" }
202+
"user.getById": {
203+
"type": "object",
204+
"properties": {
205+
"id": { "type": "string" }
206+
},
207+
"required": ["id"]
196208
},
197-
"required": ["id", "name", "email"]
209+
"user.create": {
210+
"type": "object",
211+
"properties": {
212+
"name": { "type": "string" },
213+
"email": { "type": "string" }
214+
},
215+
"required": ["name", "email"]
216+
}
198217
},
199-
"user.create": {
200-
"type": "object",
201-
"properties": {
202-
"id": { "type": "string" },
203-
"name": { "type": "string" },
204-
"email": { "type": "string" },
205-
"createdAt": { "type": "string" }
218+
"outputs": {
219+
"hello": {
220+
"type": "object",
221+
"properties": {
222+
"greeting": { "type": "string" }
223+
},
224+
"required": ["greeting"]
206225
},
207-
"required": ["id", "name", "email", "createdAt"]
226+
"user.getById": {
227+
"type": "object",
228+
"properties": {
229+
"id": { "type": "string" },
230+
"name": { "type": "string" },
231+
"email": { "type": "string" }
232+
},
233+
"required": ["id", "name", "email"]
234+
},
235+
"user.create": {
236+
"type": "object",
237+
"properties": {
238+
"id": { "type": "string" },
239+
"name": { "type": "string" },
240+
"email": { "type": "string" },
241+
"createdAt": { "type": "string" }
242+
},
243+
"required": ["id", "name", "email", "createdAt"]
244+
}
208245
}
209246
}
210247
```
211248

212249
Then wire it up:
213250

214251
```typescript
215-
import outputSchemas from "./.trpc-studio.json";
252+
import schemas from "./.trpc-studio.json";
216253

217254
app.get("/studio", (_req, res) => {
218255
res.send(
219256
renderTrpcStudio(appRouter, {
220257
url: "http://localhost:3000/trpc",
221-
outputSchemas,
258+
inputSchemas: schemas.inputs,
259+
outputSchemas: schemas.outputs,
222260
meta: { title: "My API", version: "1.0.0" },
223261
}),
224262
);
@@ -247,6 +285,88 @@ app.get("/studio", (_req, res) => {
247285
| `--name <name>` | `appRouter` | Name of the exported router variable |
248286
| `--help` | | Show help message |
249287
288+
## Procedure Metadata (.meta())
289+
290+
trpc-studio automatically reads and displays tRPC's built-in `.meta()` on each
291+
procedure. No configuration needed — if your procedures have meta, it shows up.
292+
293+
```typescript
294+
const t = initTRPC
295+
.meta<{
296+
auth?: boolean;
297+
deprecated?: boolean;
298+
rateLimit?: number;
299+
tags?: string[];
300+
}>()
301+
.create();
302+
303+
const protectedProcedure = t.procedure.meta({ auth: true }).use(authMiddleware);
304+
305+
export const appRouter = t.router({
306+
getUser: protectedProcedure
307+
.meta({ tags: ["billing", "v2"] })
308+
.input(z.object({ id: z.string() }))
309+
.query(/* ... */),
310+
311+
legacyReport: t.procedure
312+
.meta({ deprecated: true, auth: true, tags: ["reporting"] })
313+
.query(/* ... */),
314+
});
315+
```
316+
317+
**In the UI:**
318+
319+
- **Sidebar** — 🔒 icon for `auth: true`, ⚠️ icon for `deprecated: true`, tag
320+
badges next to procedure names
321+
- **Detail panel** — all meta keys rendered as badge pills below the procedure
322+
header (e.g., `🔒 auth`, `⚠️ deprecated`, `rateLimit: 100`)
323+
- **Tag filtering** — if any procedures use `meta.tags`, a filter bar appears at
324+
the top of the sidebar. Click tags to filter procedures across all routers.
325+
Multi-select supported.
326+
327+
The rendering is generic — trpc-studio doesn't interpret the meta values, it
328+
just displays whatever keys and values are present.
329+
330+
## Authentication
331+
332+
Configure authentication so the "Try It Out" feature can execute protected
333+
procedures. An "Authorize" button appears in the top bar — click it to enter
334+
credentials. Values are stored in localStorage and persist across page
335+
refreshes.
336+
337+
```typescript
338+
renderTrpcStudio(appRouter, {
339+
url: "/api/trpc",
340+
auth: { type: "bearer", description: "JWT token from /api/auth/login" },
341+
});
342+
```
343+
344+
Multiple auth methods:
345+
346+
```typescript
347+
renderTrpcStudio(appRouter, {
348+
url: "/api/trpc",
349+
auth: [
350+
{ type: "bearer", description: "JWT token" },
351+
{
352+
type: "cookie",
353+
name: "next-auth.session-token",
354+
description: "NextAuth session",
355+
},
356+
{ type: "header", name: "x-api-key", description: "API key" },
357+
],
358+
});
359+
```
360+
361+
**Supported auth types:**
362+
363+
| Type | Header sent | Use case |
364+
| -------- | ------------------------------- | ----------------- |
365+
| `bearer` | `Authorization: Bearer <value>` | JWT, OAuth tokens |
366+
| `basic` | `Authorization: Basic <value>` | Basic auth |
367+
| `header` | `<name>: <value>` | API keys |
368+
| `cookie` | `Cookie: <name>=<value>` | Session cookies |
369+
250370
## API
251371

252372
### `renderTrpcStudio(router, options)`
@@ -256,17 +376,31 @@ Returns a self-contained HTML string.
256376
```typescript
257377
interface RenderOptions {
258378
url: string; // Base tRPC URL
259-
outputSchemas?: Record<string, JsonSchema>; // From CLI extractor
379+
outputSchemas?: Record<string, JsonSchema>; // From CLI: schemas.outputs
380+
inputSchemas?: Record<string, JsonSchema>; // From CLI: schemas.inputs (fallback for z.custom())
260381
transformer?: "superjson" | "none"; // Default: "none"
382+
auth?: AuthConfig | AuthConfig[]; // Authentication config for "Authorize" button
261383
meta?: {
262384
title?: string;
263385
description?: string;
264386
version?: string;
265387
};
266-
headers?: Record<string, string>; // Auth headers for "Try It Out"
388+
headers?: Record<string, string>; // Default headers for all requests
389+
}
390+
391+
interface AuthConfig {
392+
type: "bearer" | "cookie" | "header" | "basic";
393+
name?: string; // Required for "cookie" and "header" types
394+
description?: string; // Shown in the Authorize modal
267395
}
268396
```
269397

398+
**How input schema merging works:** Zod runtime schemas are the primary source.
399+
When a field produces an empty schema (e.g., `z.custom<T>()`), the CLI-extracted
400+
schema is used as a fallback. This gives you the best of both worlds — real Zod
401+
validation metadata where available, with TypeScript type info for `z.custom()`
402+
fields.
403+
270404
## Supported Zod Types
271405

272406
| Zod Type | Input Form Control | Notes |

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.11",
3+
"version": "0.2.0",
44
"license": "MIT",
55
"description": "Swagger-like UI for tRPC — auto-generated input forms, Try It Out execution, output type visualization, and a CLI for static type extraction",
66
"repository": {

0 commit comments

Comments
 (0)