@trpc/openapi 11.15.1-alpha → 11.15.2-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/generate.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import * as fs from 'node:fs';
1
2
  import * as path from 'node:path';
2
3
  import * as ts from 'typescript';
3
4
  import {
@@ -6,47 +7,26 @@ import {
6
7
  tryImportRouter,
7
8
  type RuntimeDescriptions,
8
9
  } from './schemaExtraction';
9
-
10
- const log = console;
11
-
12
- /**
13
- * A minimal JSON Schema subset used for OpenAPI 3.1 schemas.
14
- */
15
- export interface JsonSchema {
16
- $ref?: string;
17
- $defs?: Record<string, JsonSchema>;
18
- type?: string | string[];
19
- properties?: Record<string, JsonSchema>;
20
- required?: string[];
21
- items?: JsonSchema | false;
22
- prefixItems?: JsonSchema[];
23
- const?: string | number | boolean | null;
24
- enum?: (string | number | boolean | null)[];
25
- oneOf?: JsonSchema[];
26
- anyOf?: JsonSchema[];
27
- allOf?: JsonSchema[];
28
- not?: JsonSchema;
29
- additionalProperties?: JsonSchema | boolean;
30
- discriminator?: { propertyName: string; mapping?: Record<string, string> };
31
- format?: string;
32
- description?: string;
33
- minItems?: number;
34
- maxItems?: number;
35
- $schema?: string;
36
- }
10
+ import type {
11
+ Document,
12
+ OperationObject,
13
+ PathItemObject,
14
+ PathsObject,
15
+ SchemaObject,
16
+ } from './types';
37
17
 
38
18
  interface ProcedureInfo {
39
19
  path: string;
40
20
  type: 'query' | 'mutation' | 'subscription';
41
- inputSchema: JsonSchema | null;
42
- outputSchema: JsonSchema | null;
21
+ inputSchema: SchemaObject | null;
22
+ outputSchema: SchemaObject | null;
43
23
  description?: string;
44
24
  }
45
25
 
46
26
  /** State extracted from the router's root config. */
47
27
  interface RouterMeta {
48
- errorSchema: JsonSchema | null;
49
- schemas?: Record<string, JsonSchema>;
28
+ errorSchema: SchemaObject | null;
29
+ schemas?: Record<string, SchemaObject>;
50
30
  }
51
31
 
52
32
  export interface GenerateOptions {
@@ -61,14 +41,6 @@ export interface GenerateOptions {
61
41
  version?: string;
62
42
  }
63
43
 
64
- export interface OpenAPIDocument {
65
- openapi: string;
66
- jsonSchemaDialect?: string;
67
- info: { title: string; version: string };
68
- paths: Record<string, Record<string, unknown>>;
69
- components: Record<string, unknown>;
70
- }
71
-
72
44
  // ---------------------------------------------------------------------------
73
45
  // Flag helpers
74
46
  // ---------------------------------------------------------------------------
@@ -106,7 +78,7 @@ interface SchemaCtx {
106
78
  checker: ts.TypeChecker;
107
79
  visited: Set<ts.Type>;
108
80
  /** Collected named schemas for components/schemas. */
109
- schemas: Record<string, JsonSchema>;
81
+ schemas: Record<string, SchemaObject>;
110
82
  /** Map from TS type identity to its registered schema name. */
111
83
  typeToRef: Map<ts.Type, string>;
112
84
  }
@@ -137,6 +109,7 @@ function unwrapBrand(type: ts.Type): ts.Type {
137
109
  // ---------------------------------------------------------------------------
138
110
 
139
111
  const ANONYMOUS_NAMES = new Set(['__type', '__object', 'Object', '']);
112
+ const INTERNAL_COMPUTED_PROPERTY_SYMBOL = /^__@.*@\d+$/;
140
113
 
141
114
  /** Try to determine a meaningful name for a TS type (type alias or interface). */
142
115
  function getTypeName(type: ts.Type): string | null {
@@ -151,6 +124,34 @@ function getTypeName(type: ts.Type): string | null {
151
124
  return null;
152
125
  }
153
126
 
127
+ // Skips asyncGenerator and branded symbols etc when creating types
128
+ // Symbols can't be serialised
129
+ function shouldSkipPropertySymbol(prop: ts.Symbol): boolean {
130
+ return (
131
+ prop.declarations?.some((declaration) => {
132
+ const declarationName = ts.getNameOfDeclaration(declaration);
133
+ if (!declarationName || !ts.isComputedPropertyName(declarationName)) {
134
+ return false;
135
+ }
136
+
137
+ return INTERNAL_COMPUTED_PROPERTY_SYMBOL.test(prop.getName());
138
+ }) ?? false
139
+ );
140
+ }
141
+
142
+ function getReferencedSchema(
143
+ schema: SchemaObject | null,
144
+ schemas: Record<string, SchemaObject>,
145
+ ): SchemaObject | null {
146
+ const ref = schema?.$ref;
147
+ if (!ref?.startsWith('#/components/schemas/')) {
148
+ return schema;
149
+ }
150
+
151
+ const refName = ref.slice('#/components/schemas/'.length);
152
+ return refName ? (schemas[refName] ?? null) : schema;
153
+ }
154
+
154
155
  function ensureUniqueName(
155
156
  name: string,
156
157
  existing: Record<string, unknown>,
@@ -165,11 +166,15 @@ function ensureUniqueName(
165
166
  return `${name}${i}`;
166
167
  }
167
168
 
168
- function schemaRef(name: string): JsonSchema {
169
+ function schemaRef(name: string): SchemaObject {
169
170
  return { $ref: `#/components/schemas/${name}` };
170
171
  }
171
172
 
172
- function isNonEmptySchema(s: JsonSchema): boolean {
173
+ function isSelfSchemaRef(schema: SchemaObject, name: string): boolean {
174
+ return schema.$ref === schemaRef(name).$ref;
175
+ }
176
+
177
+ function isNonEmptySchema(s: SchemaObject): boolean {
173
178
  for (const _ in s) return true;
174
179
  return false;
175
180
  }
@@ -182,34 +187,56 @@ function isNonEmptySchema(s: JsonSchema): boolean {
182
187
  * Convert a TS type to a JSON Schema. If the type has been pre-registered
183
188
  * (or has a meaningful TS name), it is stored in `ctx.schemas` and a `$ref`
184
189
  * is returned instead of an inline schema.
190
+ *
191
+ * Named types (type aliases, interfaces) are auto-registered before conversion
192
+ * so that recursive references (including through unions and intersections)
193
+ * resolve to a `$ref` instead of causing infinite recursion.
185
194
  */
186
195
  function typeToJsonSchema(
187
196
  type: ts.Type,
188
197
  ctx: SchemaCtx,
189
198
  depth = 0,
190
- ): JsonSchema {
191
- if (depth > 20) {
192
- log.warn(
193
- `[openapi] Schema conversion reached maximum depth (20) for type "${ctx.checker.typeToString(type)}". The resulting schema will be incomplete.`,
194
- );
195
- return {};
196
- }
197
-
199
+ ): SchemaObject {
198
200
  // If this type is already registered as a named schema, return a $ref.
199
- const refName = ctx.typeToRef.get(type);
200
- if (refName) {
201
- if (refName in ctx.schemas) {
202
- return schemaRef(refName);
201
+ const existingRef = ctx.typeToRef.get(type);
202
+ if (existingRef) {
203
+ const storedSchema = ctx.schemas[existingRef];
204
+ if (
205
+ storedSchema &&
206
+ (isNonEmptySchema(storedSchema) || ctx.visited.has(type))
207
+ ) {
208
+ return schemaRef(existingRef);
203
209
  }
204
- // First encounter: set placeholder (circular ref guard), convert, store.
205
- ctx.schemas[refName] = {};
210
+
211
+ // First encounter for a pre-registered placeholder: convert once, but keep
212
+ // returning $ref for recursive edges while the type is actively visiting.
213
+ ctx.schemas[existingRef] = storedSchema ?? {};
206
214
  const schema = convertTypeToSchema(type, ctx, depth);
207
- ctx.schemas[refName] = schema;
208
- return schemaRef(refName);
215
+ if (!isSelfSchemaRef(schema, existingRef)) {
216
+ ctx.schemas[existingRef] = schema;
217
+ }
218
+ return schemaRef(existingRef);
209
219
  }
210
220
 
211
221
  const schema = convertTypeToSchema(type, ctx, depth);
212
222
 
223
+ // If a recursive reference was detected during conversion (via handleCyclicRef
224
+ // or convertPlainObject's auto-registration), the type is now registered in
225
+ // typeToRef. If the stored schema is still the empty placeholder, fill it in
226
+ // with the actual converted schema. Either way, return a $ref.
227
+ const postConvertRef = ctx.typeToRef.get(type);
228
+ if (postConvertRef) {
229
+ const stored = ctx.schemas[postConvertRef];
230
+ if (
231
+ stored &&
232
+ !isNonEmptySchema(stored) &&
233
+ !isSelfSchemaRef(schema, postConvertRef)
234
+ ) {
235
+ ctx.schemas[postConvertRef] = schema;
236
+ }
237
+ return schemaRef(postConvertRef);
238
+ }
239
+
213
240
  // Extract JSDoc from type alias symbol (e.g. `/** desc */ type Foo = string`)
214
241
  if (!schema.description && !schema.$ref && type.aliasSymbol) {
215
242
  const aliasJsDoc = getJsDocComment(type.aliasSymbol, ctx.checker);
@@ -229,7 +256,7 @@ function typeToJsonSchema(
229
256
  * When we encounter a type we're already visiting, it's recursive.
230
257
  * Register it as a named schema and return a $ref.
231
258
  */
232
- function handleCyclicRef(type: ts.Type, ctx: SchemaCtx): JsonSchema {
259
+ function handleCyclicRef(type: ts.Type, ctx: SchemaCtx): SchemaObject {
233
260
  let refName = ctx.typeToRef.get(type);
234
261
  if (!refName) {
235
262
  const name = getTypeName(type) ?? 'RecursiveType';
@@ -248,7 +275,7 @@ function convertPrimitiveOrLiteral(
248
275
  type: ts.Type,
249
276
  flags: ts.TypeFlags,
250
277
  checker: ts.TypeChecker,
251
- ): JsonSchema | null {
278
+ ): SchemaObject | null {
252
279
  if (flags & ts.TypeFlags.String) {
253
280
  return { type: 'string' };
254
281
  }
@@ -299,7 +326,7 @@ function convertUnionType(
299
326
  type: ts.UnionType,
300
327
  ctx: SchemaCtx,
301
328
  depth: number,
302
- ): JsonSchema {
329
+ ): SchemaObject {
303
330
  const members = type.types;
304
331
 
305
332
  // Strip undefined / void members (they make the field optional, not typed)
@@ -392,7 +419,7 @@ function convertUnionType(
392
419
  * If every schema in a oneOf is an object with a common required property
393
420
  * whose value is a `const`, return that property name. Otherwise return null.
394
421
  */
395
- function detectDiscriminatorProperty(schemas: JsonSchema[]): string | null {
422
+ function detectDiscriminatorProperty(schemas: SchemaObject[]): string | null {
396
423
  if (schemas.length < 2) {
397
424
  return null;
398
425
  }
@@ -426,7 +453,7 @@ function detectDiscriminatorProperty(schemas: JsonSchema[]): string | null {
426
453
  }
427
454
 
428
455
  /** A schema that is just `{ type: "somePrimitive" }` with no other keys. */
429
- function isSimpleTypeSchema(s: JsonSchema): boolean {
456
+ function isSimpleTypeSchema(s: SchemaObject): boolean {
430
457
  const keys = Object.keys(s);
431
458
  return keys.length === 1 && keys[0] === 'type' && typeof s.type === 'string';
432
459
  }
@@ -438,7 +465,7 @@ function isSimpleTypeSchema(s: JsonSchema): boolean {
438
465
  function tryCollapseLiteralUnion(
439
466
  nonNull: ts.Type[],
440
467
  hasNull: boolean,
441
- ): JsonSchema | null {
468
+ ): SchemaObject | null {
442
469
  if (nonNull.length <= 1) {
443
470
  return null;
444
471
  }
@@ -484,7 +511,7 @@ function convertIntersectionType(
484
511
  type: ts.IntersectionType,
485
512
  ctx: SchemaCtx,
486
513
  depth: number,
487
- ): JsonSchema {
514
+ ): SchemaObject {
488
515
  // Branded types (e.g. z.string().brand<'X'>()) appear as an intersection of
489
516
  // a primitive with a phantom object. Strip the object members — they are
490
517
  // always brand metadata.
@@ -515,7 +542,7 @@ function convertIntersectionType(
515
542
  }
516
543
 
517
544
  /** True when the schema is an inline `{ type: "object", ... }` (not a $ref). */
518
- function isInlineObjectSchema(s: JsonSchema): boolean {
545
+ function isInlineObjectSchema(s: SchemaObject): boolean {
519
546
  return s.type === 'object' && !s.$ref;
520
547
  }
521
548
 
@@ -523,7 +550,7 @@ function isInlineObjectSchema(s: JsonSchema): boolean {
523
550
  * Merge multiple `{ type: "object" }` schemas into one.
524
551
  * Falls back to `allOf` if any property names conflict across schemas.
525
552
  */
526
- function mergeObjectSchemas(schemas: JsonSchema[]): JsonSchema {
553
+ function mergeObjectSchemas(schemas: SchemaObject[]): SchemaObject {
527
554
  // Check for property name conflicts before merging.
528
555
  const seen = new Set<string>();
529
556
  for (const s of schemas) {
@@ -538,9 +565,9 @@ function mergeObjectSchemas(schemas: JsonSchema[]): JsonSchema {
538
565
  }
539
566
  }
540
567
 
541
- const properties: Record<string, JsonSchema> = {};
568
+ const properties: Record<string, SchemaObject> = {};
542
569
  const required: string[] = [];
543
- let additionalProperties: JsonSchema | boolean | undefined;
570
+ let additionalProperties: SchemaObject | boolean | undefined;
544
571
 
545
572
  for (const s of schemas) {
546
573
  if (s.properties) {
@@ -554,7 +581,7 @@ function mergeObjectSchemas(schemas: JsonSchema[]): JsonSchema {
554
581
  }
555
582
  }
556
583
 
557
- const result: JsonSchema = { type: 'object' };
584
+ const result: SchemaObject = { type: 'object' };
558
585
  if (Object.keys(properties).length > 0) {
559
586
  result.properties = properties;
560
587
  }
@@ -575,7 +602,7 @@ function convertWellKnownType(
575
602
  type: ts.Type,
576
603
  ctx: SchemaCtx,
577
604
  depth: number,
578
- ): JsonSchema | null {
605
+ ): SchemaObject | null {
579
606
  const symName = type.getSymbol()?.getName();
580
607
  if (symName === 'Date') {
581
608
  return { type: 'string', format: 'date-time' };
@@ -597,9 +624,9 @@ function convertArrayType(
597
624
  type: ts.Type,
598
625
  ctx: SchemaCtx,
599
626
  depth: number,
600
- ): JsonSchema {
627
+ ): SchemaObject {
601
628
  const [elem] = ctx.checker.getTypeArguments(type as ts.TypeReference);
602
- const schema: JsonSchema = { type: 'array' };
629
+ const schema: SchemaObject = { type: 'array' };
603
630
  if (elem) {
604
631
  schema.items = typeToJsonSchema(elem, ctx, depth + 1);
605
632
  }
@@ -610,7 +637,7 @@ function convertTupleType(
610
637
  type: ts.Type,
611
638
  ctx: SchemaCtx,
612
639
  depth: number,
613
- ): JsonSchema {
640
+ ): SchemaObject {
614
641
  const args = ctx.checker.getTypeArguments(type as ts.TypeReference);
615
642
  const schemas = args.map((a) => typeToJsonSchema(a, ctx, depth + 1));
616
643
  return {
@@ -626,7 +653,7 @@ function convertPlainObject(
626
653
  type: ts.Type,
627
654
  ctx: SchemaCtx,
628
655
  depth: number,
629
- ): JsonSchema {
656
+ ): SchemaObject {
630
657
  const { checker } = ctx;
631
658
  const stringIndexType = type.getStringIndexType();
632
659
  const typeProps = type.getProperties();
@@ -653,10 +680,14 @@ function convertPlainObject(
653
680
  }
654
681
 
655
682
  ctx.visited.add(type);
656
- const properties: Record<string, JsonSchema> = {};
683
+ const properties: Record<string, SchemaObject> = {};
657
684
  const required: string[] = [];
658
685
 
659
686
  for (const prop of typeProps) {
687
+ if (shouldSkipPropertySymbol(prop)) {
688
+ continue;
689
+ }
690
+
660
691
  const propType = checker.getTypeOfSymbol(prop);
661
692
  const propSchema = typeToJsonSchema(propType, ctx, depth + 1);
662
693
 
@@ -674,7 +705,7 @@ function convertPlainObject(
674
705
 
675
706
  ctx.visited.delete(type);
676
707
 
677
- const result: JsonSchema = { type: 'object' };
708
+ const result: SchemaObject = { type: 'object' };
678
709
  if (Object.keys(properties).length > 0) {
679
710
  result.properties = properties;
680
711
  }
@@ -707,7 +738,7 @@ function convertObjectType(
707
738
  type: ts.Type,
708
739
  ctx: SchemaCtx,
709
740
  depth: number,
710
- ): JsonSchema {
741
+ ): SchemaObject {
711
742
  const wellKnown = convertWellKnownType(type, ctx, depth);
712
743
  if (wellKnown) {
713
744
  return wellKnown;
@@ -732,7 +763,7 @@ function convertTypeToSchema(
732
763
  type: ts.Type,
733
764
  ctx: SchemaCtx,
734
765
  depth: number,
735
- ): JsonSchema {
766
+ ): SchemaObject {
736
767
  if (ctx.visited.has(type)) {
737
768
  return handleCyclicRef(type, ctx);
738
769
  }
@@ -745,10 +776,16 @@ function convertTypeToSchema(
745
776
  }
746
777
 
747
778
  if (type.isUnion()) {
748
- return convertUnionType(type, ctx, depth);
779
+ ctx.visited.add(type);
780
+ const result = convertUnionType(type, ctx, depth);
781
+ ctx.visited.delete(type);
782
+ return result;
749
783
  }
750
784
  if (type.isIntersection()) {
751
- return convertIntersectionType(type, ctx, depth);
785
+ ctx.visited.add(type);
786
+ const result = convertIntersectionType(type, ctx, depth);
787
+ ctx.visited.delete(type);
788
+ return result;
752
789
  }
753
790
  if (isObjectType(type)) {
754
791
  return convertObjectType(type, ctx, depth);
@@ -777,7 +814,7 @@ interface WalkCtx {
777
814
  function getProcedureTypeName(
778
815
  defType: ts.Type,
779
816
  checker: ts.TypeChecker,
780
- ): string | null {
817
+ ): ProcedureInfo['type'] | null {
781
818
  const typeSym = defType.getProperty('type');
782
819
  if (!typeSym) {
783
820
  return null;
@@ -816,6 +853,134 @@ interface ProcedureDef {
816
853
  typeName: string;
817
854
  path: string;
818
855
  description?: string;
856
+ symbol: ts.Symbol;
857
+ }
858
+
859
+ function shouldIncludeProcedureInOpenAPI(type: ProcedureInfo['type']): boolean {
860
+ return type !== 'subscription';
861
+ }
862
+
863
+ function getProcedureInputTypeName(type: ts.Type, path: string): string {
864
+ const directName = getTypeName(type);
865
+ if (directName) {
866
+ return directName;
867
+ }
868
+
869
+ for (const sym of [type.aliasSymbol, type.getSymbol()].filter(
870
+ (candidate): candidate is ts.Symbol => !!candidate,
871
+ )) {
872
+ for (const declaration of sym.declarations ?? []) {
873
+ const declarationName = ts.getNameOfDeclaration(declaration)?.getText();
874
+ if (
875
+ declarationName &&
876
+ !ANONYMOUS_NAMES.has(declarationName) &&
877
+ !declarationName.startsWith('__')
878
+ ) {
879
+ return declarationName;
880
+ }
881
+ }
882
+ }
883
+
884
+ const fallbackName = path
885
+ .split('.')
886
+ .filter(Boolean)
887
+ .map((segment) =>
888
+ segment
889
+ .split(/[^A-Za-z0-9]+/)
890
+ .filter(Boolean)
891
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
892
+ .join(''),
893
+ )
894
+ .join('');
895
+
896
+ return `${fallbackName || 'Procedure'}Input`;
897
+ }
898
+
899
+ function isUnknownLikeType(type: ts.Type): boolean {
900
+ return hasFlag(type, ts.TypeFlags.Unknown | ts.TypeFlags.Any);
901
+ }
902
+
903
+ function isCollapsedProcedureInputType(type: ts.Type): boolean {
904
+ return (
905
+ isUnknownLikeType(type) ||
906
+ (isObjectType(type) &&
907
+ type.getProperties().length === 0 &&
908
+ !type.getStringIndexType())
909
+ );
910
+ }
911
+
912
+ function recoverProcedureInputType(
913
+ def: ProcedureDef,
914
+ checker: ts.TypeChecker,
915
+ ): ts.Type | null {
916
+ let initializer: ts.Expression | null = null;
917
+ for (const declaration of def.symbol.declarations ?? []) {
918
+ if (ts.isPropertyAssignment(declaration)) {
919
+ initializer = declaration.initializer;
920
+ break;
921
+ }
922
+ if (ts.isVariableDeclaration(declaration) && declaration.initializer) {
923
+ initializer = declaration.initializer;
924
+ break;
925
+ }
926
+ }
927
+ if (!initializer) {
928
+ return null;
929
+ }
930
+
931
+ let recovered: ts.Type | null = null;
932
+ // Walk the builder chain and keep the last `.input(...)` parser output type.
933
+ const visit = (expr: ts.Expression): void => {
934
+ if (!ts.isCallExpression(expr)) {
935
+ return;
936
+ }
937
+
938
+ const callee = expr.expression;
939
+ if (!ts.isPropertyAccessExpression(callee)) {
940
+ return;
941
+ }
942
+
943
+ visit(callee.expression);
944
+ if (callee.name.text !== 'input') {
945
+ return;
946
+ }
947
+
948
+ const [parserExpr] = expr.arguments;
949
+ if (!parserExpr) {
950
+ return;
951
+ }
952
+
953
+ const parserType = checker.getTypeAtLocation(parserExpr);
954
+ const standardSym = parserType.getProperty('~standard');
955
+ if (!standardSym) {
956
+ return;
957
+ }
958
+
959
+ const standardType = checker.getTypeOfSymbolAtLocation(
960
+ standardSym,
961
+ parserExpr,
962
+ );
963
+ const typesSym = standardType.getProperty('types');
964
+ if (!typesSym) {
965
+ return;
966
+ }
967
+
968
+ const typesType = checker.getNonNullableType(
969
+ checker.getTypeOfSymbolAtLocation(typesSym, parserExpr),
970
+ );
971
+ const outputSym = typesType.getProperty('output');
972
+ if (!outputSym) {
973
+ return;
974
+ }
975
+
976
+ const outputType = checker.getTypeOfSymbolAtLocation(outputSym, parserExpr);
977
+ if (!isUnknownLikeType(outputType)) {
978
+ recovered = outputType;
979
+ }
980
+ };
981
+ visit(initializer);
982
+
983
+ return recovered;
819
984
  }
820
985
 
821
986
  function extractProcedure(def: ProcedureDef, ctx: WalkCtx): void {
@@ -833,24 +998,75 @@ function extractProcedure(def: ProcedureDef, ctx: WalkCtx): void {
833
998
 
834
999
  const inputType = inputSym ? checker.getTypeOfSymbol(inputSym) : null;
835
1000
  const outputType = outputSym ? checker.getTypeOfSymbol(outputSym) : null;
1001
+ const resolvedInputType =
1002
+ inputType && isCollapsedProcedureInputType(inputType)
1003
+ ? (recoverProcedureInputType(def, checker) ?? inputType)
1004
+ : inputType;
1005
+
1006
+ let inputSchema: SchemaObject | null = null;
1007
+ if (!resolvedInputType || isVoidLikeInput(resolvedInputType)) {
1008
+ // null is fine
1009
+ } else {
1010
+ // Pre-register recovered parser output types so recursive edges resolve to a
1011
+ // stable component ref instead of collapsing into `{}`.
1012
+ const ensureRecoveredInputRegistration = (type: ts.Type): void => {
1013
+ if (schemaCtx.typeToRef.has(type)) {
1014
+ return;
1015
+ }
1016
+
1017
+ const refName = ensureUniqueName(
1018
+ getProcedureInputTypeName(type, def.path),
1019
+ schemaCtx.schemas,
1020
+ );
1021
+ schemaCtx.typeToRef.set(type, refName);
1022
+ schemaCtx.schemas[refName] = {};
1023
+ };
836
1024
 
837
- const inputSchema =
838
- !inputType || isVoidLikeInput(inputType)
839
- ? null
840
- : typeToJsonSchema(inputType, schemaCtx);
1025
+ if (resolvedInputType !== inputType) {
1026
+ ensureRecoveredInputRegistration(resolvedInputType);
1027
+ }
1028
+
1029
+ const initialSchema = typeToJsonSchema(resolvedInputType, schemaCtx);
1030
+ if (
1031
+ !isNonEmptySchema(initialSchema) &&
1032
+ !schemaCtx.typeToRef.has(resolvedInputType)
1033
+ ) {
1034
+ ensureRecoveredInputRegistration(resolvedInputType);
1035
+ inputSchema = typeToJsonSchema(resolvedInputType, schemaCtx);
1036
+ } else {
1037
+ inputSchema = initialSchema;
1038
+ }
1039
+ }
841
1040
 
842
- const outputSchema: JsonSchema | null = outputType
1041
+ const outputSchema: SchemaObject | null = outputType
843
1042
  ? typeToJsonSchema(outputType, schemaCtx)
844
1043
  : null;
845
1044
 
846
1045
  // Overlay extracted schema descriptions onto the type-checker-generated schemas.
847
1046
  const runtimeDescs = ctx.runtimeDescriptions.get(def.path);
848
1047
  if (runtimeDescs) {
849
- if (inputSchema && runtimeDescs.input) {
850
- applyDescriptions(inputSchema, runtimeDescs.input);
1048
+ const resolvedInputSchema = getReferencedSchema(
1049
+ inputSchema,
1050
+ schemaCtx.schemas,
1051
+ );
1052
+ const resolvedOutputSchema = getReferencedSchema(
1053
+ outputSchema,
1054
+ schemaCtx.schemas,
1055
+ );
1056
+
1057
+ if (resolvedInputSchema && runtimeDescs.input) {
1058
+ applyDescriptions(
1059
+ resolvedInputSchema,
1060
+ runtimeDescs.input,
1061
+ schemaCtx.schemas,
1062
+ );
851
1063
  }
852
- if (outputSchema && runtimeDescs.output) {
853
- applyDescriptions(outputSchema, runtimeDescs.output);
1064
+ if (resolvedOutputSchema && runtimeDescs.output) {
1065
+ applyDescriptions(
1066
+ resolvedOutputSchema,
1067
+ runtimeDescs.output,
1068
+ schemaCtx.schemas,
1069
+ );
854
1070
  }
855
1071
  }
856
1072
 
@@ -868,6 +1084,48 @@ function getJsDocComment(
868
1084
  sym: ts.Symbol,
869
1085
  checker: ts.TypeChecker,
870
1086
  ): string | undefined {
1087
+ const isWithinPath = (candidate: string, parent: string): boolean => {
1088
+ const rel = path.relative(parent, candidate);
1089
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
1090
+ };
1091
+
1092
+ const normalize = (filePath: string): string => filePath.replace(/\\/g, '/');
1093
+ const workspaceRoot = normalize(process.cwd());
1094
+
1095
+ const declarations = sym.declarations ?? [];
1096
+ const isExternalNodeModulesDeclaration =
1097
+ declarations.length > 0 &&
1098
+ declarations.every((declaration) => {
1099
+ const sourceFile = declaration.getSourceFile();
1100
+ if (!sourceFile.isDeclarationFile) {
1101
+ return false;
1102
+ }
1103
+
1104
+ const declarationPath = normalize(sourceFile.fileName);
1105
+ if (!declarationPath.includes('/node_modules/')) {
1106
+ return false;
1107
+ }
1108
+
1109
+ try {
1110
+ const realPath = normalize(fs.realpathSync.native(sourceFile.fileName));
1111
+ // Keep JSDoc for workspace packages linked into node_modules
1112
+ // (e.g. monorepos using pnpm/yarn workspaces).
1113
+ if (
1114
+ isWithinPath(realPath, workspaceRoot) &&
1115
+ !realPath.includes('/node_modules/')
1116
+ ) {
1117
+ return false;
1118
+ }
1119
+ } catch {
1120
+ // Fall back to treating the declaration as external.
1121
+ }
1122
+
1123
+ return true;
1124
+ });
1125
+ if (isExternalNodeModulesDeclaration) {
1126
+ return undefined;
1127
+ }
1128
+
871
1129
  const parts = sym.getDocumentationComment(checker);
872
1130
  if (parts.length === 0) {
873
1131
  return undefined;
@@ -881,10 +1139,11 @@ interface WalkTypeOpts {
881
1139
  ctx: WalkCtx;
882
1140
  currentPath: string;
883
1141
  description?: string;
1142
+ symbol?: ts.Symbol;
884
1143
  }
885
1144
 
886
1145
  function walkType(opts: WalkTypeOpts): void {
887
- const { type, ctx, currentPath, description } = opts;
1146
+ const { type, ctx, currentPath, description, symbol } = opts;
888
1147
  if (ctx.seen.has(type)) {
889
1148
  return;
890
1149
  }
@@ -907,8 +1166,18 @@ function walkType(opts: WalkTypeOpts): void {
907
1166
 
908
1167
  const procedureTypeName = getProcedureTypeName(defType, checker);
909
1168
  if (procedureTypeName) {
1169
+ if (!shouldIncludeProcedureInOpenAPI(procedureTypeName)) {
1170
+ return;
1171
+ }
1172
+
910
1173
  extractProcedure(
911
- { defType, typeName: procedureTypeName, path: currentPath, description },
1174
+ {
1175
+ defType,
1176
+ typeName: procedureTypeName,
1177
+ path: currentPath,
1178
+ description,
1179
+ symbol: symbol ?? type.getSymbol() ?? defSym,
1180
+ },
912
1181
  ctx,
913
1182
  );
914
1183
  return;
@@ -942,7 +1211,13 @@ function walkRecord(recordType: ts.Type, ctx: WalkCtx, prefix: string): void {
942
1211
  const propType = ctx.schemaCtx.checker.getTypeOfSymbol(prop);
943
1212
  const fullPath = prefix ? `${prefix}.${prop.name}` : prop.name;
944
1213
  const description = getJsDocComment(prop, ctx.schemaCtx.checker);
945
- walkType({ type: propType, ctx, currentPath: fullPath, description });
1214
+ walkType({
1215
+ type: propType,
1216
+ ctx,
1217
+ currentPath: fullPath,
1218
+ description,
1219
+ symbol: prop,
1220
+ });
946
1221
  }
947
1222
  }
948
1223
 
@@ -1007,7 +1282,7 @@ function extractErrorSchema(
1007
1282
  routerType: ts.Type,
1008
1283
  checker: ts.TypeChecker,
1009
1284
  schemaCtx: SchemaCtx,
1010
- ): JsonSchema | null {
1285
+ ): SchemaObject | null {
1011
1286
  const walk = (type: ts.Type, keys: string[]): ts.Type | null => {
1012
1287
  const [head, ...rest] = keys;
1013
1288
  if (!head) {
@@ -1042,7 +1317,7 @@ function extractErrorSchema(
1042
1317
  // ---------------------------------------------------------------------------
1043
1318
 
1044
1319
  /** Fallback error schema when the router type doesn't expose an error shape. */
1045
- const DEFAULT_ERROR_SCHEMA: JsonSchema = {
1320
+ const DEFAULT_ERROR_SCHEMA: SchemaObject = {
1046
1321
  type: 'object',
1047
1322
  properties: {
1048
1323
  message: { type: 'string' },
@@ -1061,14 +1336,16 @@ const DEFAULT_ERROR_SCHEMA: JsonSchema = {
1061
1336
  * When the procedure has no output the envelope is still present but
1062
1337
  * the `data` property is omitted.
1063
1338
  */
1064
- function wrapInSuccessEnvelope(outputSchema: JsonSchema | null): JsonSchema {
1339
+ function wrapInSuccessEnvelope(
1340
+ outputSchema: SchemaObject | null,
1341
+ ): SchemaObject {
1065
1342
  const hasOutput = outputSchema !== null && isNonEmptySchema(outputSchema);
1066
- const resultSchema: JsonSchema = {
1343
+ const resultSchema: SchemaObject = {
1067
1344
  type: 'object',
1068
1345
  properties: {
1069
1346
  ...(hasOutput ? { data: outputSchema } : {}),
1070
1347
  },
1071
- ...(hasOutput ? { required: ['data' as const] } : {}),
1348
+ ...(hasOutput ? { required: ['data'] } : {}),
1072
1349
  };
1073
1350
  return {
1074
1351
  type: 'object',
@@ -1082,11 +1359,12 @@ function wrapInSuccessEnvelope(outputSchema: JsonSchema | null): JsonSchema {
1082
1359
  function buildProcedureOperation(
1083
1360
  proc: ProcedureInfo,
1084
1361
  method: 'get' | 'post',
1085
- ): Record<string, unknown> {
1086
- const operation: Record<string, unknown> = {
1362
+ ): OperationObject {
1363
+ const [tag = proc.path] = proc.path.split('.');
1364
+ const operation: OperationObject = {
1087
1365
  operationId: proc.path,
1088
1366
  ...(proc.description ? { description: proc.description } : {}),
1089
- tags: [proc.path.split('.')[0]],
1367
+ tags: [tag],
1090
1368
  responses: {
1091
1369
  '200': {
1092
1370
  description: 'Successful response',
@@ -1105,7 +1383,7 @@ function buildProcedureOperation(
1105
1383
  }
1106
1384
 
1107
1385
  if (method === 'get') {
1108
- operation['parameters'] = [
1386
+ operation.parameters = [
1109
1387
  {
1110
1388
  name: 'input',
1111
1389
  in: 'query',
@@ -1117,7 +1395,7 @@ function buildProcedureOperation(
1117
1395
  },
1118
1396
  ];
1119
1397
  } else {
1120
- operation['requestBody'] = {
1398
+ operation.requestBody = {
1121
1399
  required: true,
1122
1400
  content: { 'application/json': { schema: proc.inputSchema } },
1123
1401
  };
@@ -1130,20 +1408,23 @@ function buildOpenAPIDocument(
1130
1408
  procedures: ProcedureInfo[],
1131
1409
  options: GenerateOptions,
1132
1410
  meta: RouterMeta = { errorSchema: null },
1133
- ): OpenAPIDocument {
1134
- const paths: Record<string, Record<string, unknown>> = {};
1411
+ ): Document {
1412
+ const paths: PathsObject = {};
1135
1413
 
1136
1414
  for (const proc of procedures) {
1137
- if (proc.type === 'subscription') {
1415
+ if (!shouldIncludeProcedureInOpenAPI(proc.type)) {
1138
1416
  continue;
1139
1417
  }
1140
1418
 
1141
1419
  const opPath = `/${proc.path}`;
1142
1420
  const method = proc.type === 'query' ? 'get' : 'post';
1143
1421
 
1144
- const pathItem: Record<string, unknown> = paths[opPath] ?? {};
1422
+ const pathItem: PathItemObject = paths[opPath] ?? {};
1145
1423
  paths[opPath] = pathItem;
1146
- pathItem[method] = buildProcedureOperation(proc, method);
1424
+ pathItem[method] = buildProcedureOperation(
1425
+ proc,
1426
+ method,
1427
+ ) as PathItemObject[typeof method];
1147
1428
  }
1148
1429
 
1149
1430
  const hasNamedSchemas =
@@ -1158,7 +1439,7 @@ function buildOpenAPIDocument(
1158
1439
  },
1159
1440
  paths,
1160
1441
  components: {
1161
- ...(hasNamedSchemas ? { schemas: meta.schemas } : {}),
1442
+ ...(hasNamedSchemas && meta.schemas ? { schemas: meta.schemas } : {}),
1162
1443
  responses: {
1163
1444
  Error: {
1164
1445
  description: 'Error response',
@@ -1194,7 +1475,7 @@ function buildOpenAPIDocument(
1194
1475
  export async function generateOpenAPIDocument(
1195
1476
  routerFilePath: string,
1196
1477
  options: GenerateOptions = {},
1197
- ): Promise<OpenAPIDocument> {
1478
+ ): Promise<Document> {
1198
1479
  const resolvedPath = path.resolve(routerFilePath);
1199
1480
  const exportName = options.exportName ?? 'AppRouter';
1200
1481