@contractkit/plugin-typescript 0.28.2 → 0.30.0

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.
@@ -1,5 +1,7 @@
1
1
  import type { ContractTypeNode } from '@contractkit/core';
2
+ /** Declaration emitted into generated files that reference the `json` scalar. */
2
3
  export declare const JSON_VALUE_TYPE_DECL = "export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };";
4
+ /** Quote a property name unless it is already a valid bare TypeScript identifier. */
3
5
  export declare function quoteKey(name: string): string;
4
6
  /** Escape text for safe inclusion inside a JSDoc block comment: neutralize the
5
7
  * block-comment terminator sequence and split embedded newlines into separate
@@ -9,18 +11,34 @@ export declare function escapeJsDocLines(text: string): string[];
9
11
  export declare function escapeSingleQuoted(s: string): string;
10
12
  /** Convert an HTTP header name (e.g. `preference-applied`, `X-Request-ID`, `ETag`) to camelCase for use as a JS property. */
11
13
  export declare function headerNameToProperty(name: string): string;
12
- export declare function renderTsType(type: ContractTypeNode): string;
14
+ /**
15
+ * Which runtime the emitted types describe. A few scalars have no single correct TypeScript
16
+ * type: `binary` is a `Blob` in a fetch-based client but a `Buffer` on a Node server. Everything
17
+ * else renders identically for both targets. Defaults to `'client'`.
18
+ */
19
+ export type TsRenderTarget = 'client' | 'server';
20
+ /**
21
+ * Render a contract type as a plain TypeScript type expression. Model refs render as their bare
22
+ * name; use `renderInputTsType` / `renderOutputTsType` to substitute Input/Output variants.
23
+ *
24
+ * @param target Runtime the type describes; only `binary` differs (`Buffer` vs `Blob`).
25
+ */
26
+ export declare function renderTsType(type: ContractTypeNode, target?: TsRenderTarget): string;
13
27
  /**
14
28
  * Like renderTsType, but substitutes model refs with their Input variant
15
29
  * when the model has visibility modifiers. Used for request-side types
16
30
  * (body, params, query, headers).
31
+ *
32
+ * @param target Runtime the type describes; only `binary` differs (`Buffer` vs `Blob`).
17
33
  */
18
- export declare function renderInputTsType(type: ContractTypeNode, modelsWithInput?: Set<string>): string;
34
+ export declare function renderInputTsType(type: ContractTypeNode, modelsWithInput?: Set<string>, target?: TsRenderTarget): string;
19
35
  /**
20
36
  * Like renderTsType, but substitutes model refs with their Output variant
21
37
  * (post-transform wire shape) when the model has format(output=...) or
22
38
  * transitively references one. Used for response-side types in routers
23
39
  * and SDK return types.
40
+ *
41
+ * @param target Runtime the type describes; only `binary` differs (`Buffer` vs `Blob`).
24
42
  */
25
- export declare function renderOutputTsType(type: ContractTypeNode, modelsWithOutput?: Set<string>): string;
43
+ export declare function renderOutputTsType(type: ContractTypeNode, modelsWithOutput?: Set<string>, target?: TsRenderTarget): string;
26
44
  //# sourceMappingURL=ts-render.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ts-render.d.ts","sourceRoot":"","sources":["../src/ts-render.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAA6B,MAAM,mBAAmB,CAAC;AAErF,eAAO,MAAM,oBAAoB,2GAA2G,CAAC;AAE7I,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE7C;AAED;;sFAEsF;AACtF,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAEvD;AAED,sFAAsF;AACtF,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,6HAA6H;AAC7H,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQzD;AAID,wBAAgB,YAAY,CAAC,IAAI,EAAE,gBAAgB,GAAG,MAAM,CAoC3D;AA+CD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,gBAAgB,EAAE,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,MAAM,CA2B/F;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB,EAAE,gBAAgB,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,MAAM,CA2BjG"}
1
+ {"version":3,"file":"ts-render.d.ts","sourceRoot":"","sources":["../src/ts-render.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAA6B,MAAM,mBAAmB,CAAC;AAErF,iFAAiF;AACjF,eAAO,MAAM,oBAAoB,2GAA2G,CAAC;AAE7I,qFAAqF;AACrF,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE7C;AAED;;sFAEsF;AACtF,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAEvD;AAED,sFAAsF;AACtF,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,6HAA6H;AAC7H,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQzD;AAID;;;;GAIG;AACH,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAEjD;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,gBAAgB,EAAE,MAAM,GAAE,cAAyB,GAAG,MAAM,CAoC9F;AAgDD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,gBAAgB,EAAE,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,MAAM,GAAE,cAAyB,GAAG,MAAM,CA2BlI;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB,EAAE,gBAAgB,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,MAAM,GAAE,cAAyB,GAAG,MAAM,CA2BpI"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contractkit/plugin-typescript",
3
- "version": "0.28.2",
3
+ "version": "0.30.0",
4
4
  "description": "ContractKit built-in plugin: TypeScript codegen (SDK clients, Koa routers, Zod schemas, plain types)",
5
5
  "author": {
6
6
  "name": "Marooned Software",
@@ -26,7 +26,7 @@
26
26
  ".": "./dist/index.js"
27
27
  },
28
28
  "dependencies": {
29
- "@contractkit/core": "0.23.0"
29
+ "@contractkit/core": "0.24.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@repo/config-eslint": "0.3.1",
@@ -22,6 +22,7 @@ import {
22
22
  collectExternalOutputRefs as ckCollectExternalOutputRefs,
23
23
  } from '@contractkit/core';
24
24
  import { escapeJsDocLines } from './ts-render.js';
25
+ import type { TsRenderTarget } from './ts-render.js';
25
26
 
26
27
  /**
27
28
  * Maps a ContractKit object mode to its Zod constructor name.
@@ -53,6 +54,12 @@ export interface ContractCodegenContext {
53
54
  modelsWithOutput?: Set<string>;
54
55
  /** If set, import JsonValue from this path instead of re-declaring it (avoids barrel re-export conflicts) */
55
56
  jsonValueImportPath?: string;
57
+ /**
58
+ * Runtime the emitted plain types describe. Only affects scalars whose TypeScript type differs
59
+ * per runtime (`binary` → `Buffer` on the server, `Blob` in the client). Ignored by
60
+ * `generateContract`, whose Zod schemas are server-shaped by construction. Default `'client'`.
61
+ */
62
+ target?: TsRenderTarget;
56
63
  }
57
64
 
58
65
  // ─── Public entry point ────────────────────────────────────────────────────
@@ -1,11 +1,10 @@
1
- import type { OpRootNode, OpRouteNode, OpOperationNode, ContractTypeNode, ParamSource, ObjectMode } from '@contractkit/core';
1
+ import type { OpRootNode, OpRouteNode, OpOperationNode, ContractTypeNode, ScalarTypeNode, ParamSource, ObjectMode } from '@contractkit/core';
2
2
  import { resolveModifiers, resolveSecurity, SECURITY_NONE, classifyContentType } from '@contractkit/core';
3
3
  import {
4
4
  renderType,
5
5
  renderInputType,
6
6
  renderQueryType,
7
7
  pascalToDotCase,
8
- typeNeedsDateTime,
9
8
  typeNeedsScalar,
10
9
  modeToWrapper,
11
10
  } from './codegen-contract.js';
@@ -126,44 +125,20 @@ export interface OpCodegenOptions {
126
125
  includeInternal?: boolean;
127
126
  }
128
127
 
129
- /** Generate a Koa router module for every operation in `root`, including the imports, type aliases, and handler list. */
128
+ /**
129
+ * Generate a Koa router module for every operation in `root`, including the imports, type
130
+ * aliases, and handler list.
131
+ *
132
+ * Imports are derived from the generated body — each candidate symbol is emitted only if it
133
+ * actually appears in the output. Deciding them from predicates over the AST instead means any
134
+ * drift between predicate and codegen leaves an unused import in every generated file, which
135
+ * trips `noUnusedLocals` and lint downstream.
136
+ */
130
137
  export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): string {
131
138
  // Collect all referenced types across all routes
132
139
  const types = collectTypes(root, options.modelsWithInput, options.modelsWithOutput);
133
140
  const services = collectServices(root);
134
141
  const routerName = deriveRouterName(root.file);
135
- const needsParseAndValidate = routeNeedsValidation(root);
136
-
137
- // Generate the body first so we can detect whether `z.` is actually referenced
138
- // before deciding whether to emit the zod import.
139
- const body: string[] = [];
140
- const needsSignature = fileNeedsSignature(root);
141
- const needsPolicy = fileNeedsPolicy(root);
142
- const koaImports = ['ServerKitRouter', 'bodyParserMiddleware'];
143
- if (needsPolicy) koaImports.push('requirePolicy');
144
- if (needsSignature) koaImports.push('requireSignature');
145
- body.push(`import { ${koaImports.join(', ')} } from '@maroonedsoftware/koa';`);
146
-
147
- for (const svc of services) {
148
- const modulePath = root.services?.[svc] ?? root.meta[svc] ?? deriveModulePath(svc, options.servicePathTemplate);
149
- body.push(`import { ${svc} } from '${modulePath}';`);
150
- }
151
-
152
- if (types.length > 0) {
153
- body.push(...generateTypeImports(types, root.file, options));
154
- }
155
-
156
- if (opNeedsDateTime(root)) {
157
- body.push(`import { DateTime } from 'luxon';`);
158
- }
159
-
160
- if (needsParseAndValidate) {
161
- body.push(`import { parseAndValidate } from '@maroonedsoftware/zod';`);
162
- }
163
-
164
- if (fileUsesMultipart(root)) {
165
- body.push(`import { MultipartBody } from '@maroonedsoftware/multipart';`);
166
- }
167
142
 
168
143
  const helpers: string[] = [];
169
144
  if (opNeedsScalar(root, 'binary')) {
@@ -174,6 +149,11 @@ export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): st
174
149
  `const _ZodDatetime = z.preprocess((val) => typeof val === 'string' ? DateTime.fromISO(val) : val, z.custom<DateTime>((val) => val instanceof DateTime && val.isValid, { message: 'Must be in ISO 8601 format' }));`,
175
150
  );
176
151
  }
152
+ if (opNeedsScalar(root, 'interval')) {
153
+ helpers.push(
154
+ `const _ZodInterval = z.preprocess((val) => typeof val === 'string' ? Interval.fromISO(val) : val, z.custom<Interval>((val) => val instanceof Interval && val.isValid, { message: 'Must be an ISO 8601 interval' })).transform(val => val.toISO()!);`,
155
+ );
156
+ }
177
157
  if (opNeedsScalar(root, 'json')) {
178
158
  helpers.push(`type _JsonValue = string | number | boolean | null | _JsonValue[] | { [key: string]: _JsonValue };`);
179
159
  helpers.push(
@@ -200,6 +180,43 @@ export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): st
200
180
  }
201
181
  }
202
182
 
183
+ // Imports are decided from the code we just generated, not from predicates over the AST that
184
+ // have to be kept in step with it by hand. A predicate that drifts leaves an unused import in
185
+ // every generated file, which trips `noUnusedLocals` and lint in consuming projects.
186
+ const generated = [...(helpers.length ? ['', ...helpers] : []), ...lines].join('\n');
187
+ const uses = (symbol: string) => new RegExp(`\\b${symbol}\\b`).test(generated);
188
+
189
+ const body: string[] = [];
190
+
191
+ const koaImports = ['ServerKitRouter', 'bodyParserMiddleware', 'requirePolicy', 'requireSignature'].filter(uses);
192
+ if (koaImports.length > 0) {
193
+ body.push(`import { ${koaImports.join(', ')} } from '@maroonedsoftware/koa';`);
194
+ }
195
+
196
+ for (const svc of services) {
197
+ const modulePath = root.services?.[svc] ?? root.meta[svc] ?? deriveModulePath(svc, options.servicePathTemplate);
198
+ body.push(`import { ${svc} } from '${modulePath}';`);
199
+ }
200
+
201
+ if (types.length > 0) {
202
+ body.push(...generateTypeImports(types, root.file, options));
203
+ }
204
+
205
+ // luxon is needed for date/time/datetime (DateTime), duration (Duration) and interval (Interval);
206
+ // the rendered Zod schemas and the service-result annotations both reference these classes.
207
+ const luxonImports = ['DateTime', 'Duration', 'Interval'].filter(uses);
208
+ if (luxonImports.length > 0) {
209
+ body.push(`import { ${luxonImports.join(', ')} } from 'luxon';`);
210
+ }
211
+
212
+ if (uses('parseAndValidate')) {
213
+ body.push(`import { parseAndValidate } from '@maroonedsoftware/zod';`);
214
+ }
215
+
216
+ if (uses('MultipartBody')) {
217
+ body.push(`import { MultipartBody } from '@maroonedsoftware/multipart';`);
218
+ }
219
+
203
220
  const allContent = [...body, ...(helpers.length ? ['', ...helpers] : []), ...lines].join('\n');
204
221
  const needsZod = /\bz\./.test(allContent);
205
222
  return (needsZod ? `import { z } from 'zod';\n` : '') + allContent;
@@ -317,7 +334,10 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
317
334
  const hasRespHeaders = respHeaders.length > 0;
318
335
  const headersAnnotation = hasRespHeaders
319
336
  ? `{ ${respHeaders
320
- .map(h => `${quoteKey(headerNameToProperty(h.name))}${h.optional ? '?' : ''}: ${renderOutputTsType(h.type, options.modelsWithOutput)}`)
337
+ .map(
338
+ h =>
339
+ `${quoteKey(headerNameToProperty(h.name))}${h.optional ? '?' : ''}: ${renderOutputTsType(h.type, options.modelsWithOutput, 'server')}`,
340
+ )
321
341
  .join('; ')} }`
322
342
  : '';
323
343
 
@@ -442,6 +462,52 @@ export function buildArgs(route: OpRouteNode, op: OpOperationNode): string {
442
462
  return args.join(', ');
443
463
  }
444
464
 
465
+ /**
466
+ * Map a `.ck` scalar to the TypeScript type a server handler sees, i.e. `z.infer` of the schema
467
+ * `renderType` emits for that scalar. This is deliberately NOT `renderTsScalar` from ts-render:
468
+ * that one describes the wire/SDK view (`binary` → `Blob`, dates → `string`), while the router
469
+ * runs on Node against the parsed Zod output (`binary` → `Buffer`, dates → luxon `DateTime`).
470
+ */
471
+ function serverTsScalar(name: ScalarTypeNode['name']): string {
472
+ switch (name) {
473
+ case 'string':
474
+ case 'email':
475
+ case 'url':
476
+ case 'uuid':
477
+ return 'string';
478
+ case 'number':
479
+ case 'int':
480
+ return 'number';
481
+ case 'bigint':
482
+ return 'bigint';
483
+ case 'boolean':
484
+ return 'boolean';
485
+ case 'date':
486
+ case 'time':
487
+ case 'datetime':
488
+ return 'DateTime';
489
+ case 'duration':
490
+ return 'Duration';
491
+ case 'interval':
492
+ // _ZodInterval transforms to an ISO string, so the inferred output type is string.
493
+ return 'string';
494
+ case 'binary':
495
+ return 'Buffer';
496
+ case 'json':
497
+ return '_JsonValue';
498
+ case 'object':
499
+ return 'Record<string, unknown>';
500
+ case 'null':
501
+ return 'null';
502
+ case 'unknown':
503
+ return 'unknown';
504
+ default: {
505
+ const _exhaustive: never = name;
506
+ throw new Error(`plugin-typescript: unmapped scalar '${String(_exhaustive)}' — add a case`);
507
+ }
508
+ }
509
+ }
510
+
445
511
  function formatTypeAnnotation(bodyType: ContractTypeNode, modelsWithOutput?: Set<string>): { annotation: string; prelude?: string } {
446
512
  if (bodyType.kind === 'array') {
447
513
  const inner = formatTypeAnnotation(bodyType.item, modelsWithOutput);
@@ -451,7 +517,7 @@ function formatTypeAnnotation(bodyType: ContractTypeNode, modelsWithOutput?: Set
451
517
  const name = modelsWithOutput?.has(bodyType.name) ? `${bodyType.name}Output` : bodyType.name;
452
518
  return { annotation: name };
453
519
  }
454
- if (bodyType.kind === 'scalar') return { annotation: bodyType.name };
520
+ if (bodyType.kind === 'scalar') return { annotation: serverTsScalar(bodyType.name) };
455
521
  // For complex types, extract schema into a variable so the result line stays readable
456
522
  const schema = renderType(bodyType);
457
523
  return {
@@ -720,26 +786,7 @@ function collectTypeNodeRefs(type: ContractTypeNode, out: Set<string>): void {
720
786
  }
721
787
  }
722
788
 
723
- function paramSourceNeedsDateTime(source: ParamSource | undefined): boolean {
724
- if (!source) return false;
725
- if (source.kind === 'ref') return false;
726
- if (source.kind === 'params') return source.nodes.some(p => typeNeedsDateTime(p.type));
727
- return typeNeedsDateTime(source.node);
728
- }
729
789
 
730
- function opNeedsDateTime(root: OpRootNode): boolean {
731
- return root.routes.some(
732
- route =>
733
- paramSourceNeedsDateTime(route.params) ||
734
- route.operations.some(
735
- op =>
736
- !!op.request?.bodies.some(b => typeNeedsDateTime(b.bodyType)) ||
737
- op.responses.some(r => r.bodyType && typeNeedsDateTime(r.bodyType)) ||
738
- paramSourceNeedsDateTime(op.query) ||
739
- paramSourceNeedsDateTime(op.headers),
740
- ),
741
- );
742
- }
743
790
 
744
791
  function paramSourceNeedsScalar(source: ParamSource | undefined, name: string): boolean {
745
792
  if (!source) return false;
@@ -778,32 +825,10 @@ function collectServices(root: OpRootNode): string[] {
778
825
  return [...services].sort();
779
826
  }
780
827
 
781
- function hasParamSource(source?: ParamSource): boolean {
782
- if (!source) return false;
783
- if (source.kind === 'ref') return true;
784
- if (source.kind === 'params') return source.nodes.length > 0;
785
- return true; // type
786
- }
787
828
 
788
- function routeNeedsValidation(root: OpRootNode): boolean {
789
- return root.routes.some(
790
- r => hasParamSource(r.params) || r.operations.some(op => !!op.request || hasParamSource(op.query) || hasParamSource(op.headers)),
791
- );
792
- }
793
829
 
794
- function fileNeedsPolicy(root: OpRootNode): boolean {
795
- return root.routes.some(route => route.operations.some(op => resolveSecurity(route, op, root) !== SECURITY_NONE));
796
- }
797
830
 
798
- function fileNeedsSignature(root: OpRootNode): boolean {
799
- return root.routes.some(route => route.operations.some(op => !!op.signature));
800
- }
801
831
 
802
- function fileUsesMultipart(root: OpRootNode): boolean {
803
- return root.routes.some(route =>
804
- route.operations.some(op => (op.request?.bodies ?? []).some(b => b.contentType === 'multipart/form-data')),
805
- );
806
- }
807
832
 
808
833
  function isValidIdentifier(name: string): boolean {
809
834
  return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name);
@@ -11,6 +11,7 @@ import {
11
11
  rootNeedsScalar,
12
12
  } from './codegen-contract.js';
13
13
  import { renderTsType, renderInputTsType, renderOutputTsType, quoteKey, escapeJsDocLines, JSON_VALUE_TYPE_DECL } from './ts-render.js';
14
+ import type { TsRenderTarget } from './ts-render.js';
14
15
 
15
16
  // ─── Public entry point ────────────────────────────────────────────────────
16
17
 
@@ -19,8 +20,12 @@ import { renderTsType, renderInputTsType, renderOutputTsType, quoteKey, escapeJs
19
20
  * Unlike `generateContract()` which produces Zod schemas, this emits
20
21
  * vanilla TypeScript `interface` and `type` declarations suitable
21
22
  * for SDK consumers that don't need runtime validation.
23
+ *
24
+ * @param context Import resolution and Input/Output variant sets. `context.target` selects the
25
+ * runtime the types describe (`'server'` renders `binary` as `Buffer`, `'client'` as `Blob`).
22
26
  */
23
27
  export function generatePlainTypes(root: ContractRootNode, context?: ContractCodegenContext): string {
28
+ const target: TsRenderTarget = context?.target ?? 'client';
24
29
  const externalRefs = collectExternalRefs(root);
25
30
  const lines: string[] = [];
26
31
 
@@ -58,7 +63,7 @@ export function generatePlainTypes(root: ContractRootNode, context?: ContractCod
58
63
  const modelMap = new Map(root.models.map(m => [m.name, m]));
59
64
 
60
65
  for (const model of topoSortModels(root.models)) {
61
- lines.push(...generateModel(model, context?.currentOutPath, allModelsWithInput, allModelsWithOutput, modelMap));
66
+ lines.push(...generateModel(model, target, context?.currentOutPath, allModelsWithInput, allModelsWithOutput, modelMap));
62
67
  lines.push('');
63
68
  }
64
69
 
@@ -69,6 +74,7 @@ export function generatePlainTypes(root: ContractRootNode, context?: ContractCod
69
74
 
70
75
  function generateModel(
71
76
  model: ModelNode,
77
+ target: TsRenderTarget,
72
78
  outPath?: string,
73
79
  modelsWithInput?: Set<string>,
74
80
  modelsWithOutput?: Set<string>,
@@ -76,18 +82,20 @@ function generateModel(
76
82
  ): string[] {
77
83
  // Type alias: Name : typeExpression
78
84
  if (model.type) {
79
- return generateTypeAlias(model, outPath, modelsWithInput, modelsWithOutput);
85
+ return generateTypeAlias(model, target, outPath, modelsWithInput, modelsWithOutput);
80
86
  }
81
87
 
82
88
  // A model needs Input/read split if it has visibility-modified fields OR if it
83
89
  // transitively references models that have Input variants (captured in modelsWithInput).
84
90
  const needsInputSplit = model.fields.some(f => f.visibility !== 'normal') || (modelsWithInput?.has(model.name) ?? false);
85
91
 
86
- const lines = needsInputSplit ? generateVisibilityModel(model, outPath, modelsWithInput, modelMap) : generateSimpleModel(model, outPath, modelMap);
92
+ const lines = needsInputSplit
93
+ ? generateVisibilityModel(model, target, outPath, modelsWithInput, modelMap)
94
+ : generateSimpleModel(model, target, outPath, modelMap);
87
95
 
88
96
  if (modelsWithOutput?.has(model.name)) {
89
97
  lines.push('');
90
- lines.push(...generateOutputModel(model, modelsWithOutput));
98
+ lines.push(...generateOutputModel(model, target, modelsWithOutput));
91
99
  }
92
100
  return lines;
93
101
  }
@@ -132,15 +140,21 @@ function generateComments(model: ModelNode, outPath?: string): string[] {
132
140
  return lines;
133
141
  }
134
142
 
135
- function generateTypeAlias(model: ModelNode, outPath?: string, modelsWithInput?: Set<string>, modelsWithOutput?: Set<string>): string[] {
143
+ function generateTypeAlias(
144
+ model: ModelNode,
145
+ target: TsRenderTarget,
146
+ outPath?: string,
147
+ modelsWithInput?: Set<string>,
148
+ modelsWithOutput?: Set<string>,
149
+ ): string[] {
136
150
  const lines: string[] = [];
137
151
  lines.push(...generateComments(model, outPath));
138
- lines.push(`export type ${model.name} = ${renderTsType(model.type!)};`);
152
+ lines.push(`export type ${model.name} = ${renderTsType(model.type!, target)};`);
139
153
  if (modelsWithInput?.has(model.name)) {
140
- lines.push(`export type ${model.name}Input = ${renderInputTsType(model.type!, modelsWithInput)};`);
154
+ lines.push(`export type ${model.name}Input = ${renderInputTsType(model.type!, modelsWithInput, target)};`);
141
155
  }
142
156
  if (modelsWithOutput?.has(model.name)) {
143
- lines.push(`export type ${model.name}Output = ${renderOutputTsType(model.type!, modelsWithOutput)};`);
157
+ lines.push(`export type ${model.name}Output = ${renderOutputTsType(model.type!, modelsWithOutput, target)};`);
144
158
  }
145
159
  return lines;
146
160
  }
@@ -158,7 +172,7 @@ function buildExtendsClause(bases: string[], overrideNames: string[], baseNameRe
158
172
  return ` extends ${wrapped.join(', ')}`;
159
173
  }
160
174
 
161
- function generateSimpleModel(model: ModelNode, outPath?: string, modelMap?: Map<string, ModelNode>): string[] {
175
+ function generateSimpleModel(model: ModelNode, target: TsRenderTarget, outPath?: string, modelMap?: Map<string, ModelNode>): string[] {
162
176
  const lines: string[] = [];
163
177
  lines.push(...generateComments(model, outPath));
164
178
 
@@ -167,14 +181,20 @@ function generateSimpleModel(model: ModelNode, outPath?: string, modelMap?: Map<
167
181
  lines.push(`export interface ${model.name}${buildExtendsClause(bases, overrideNames, b => b)} {`);
168
182
 
169
183
  for (const field of model.fields) {
170
- lines.push(` ${renderField(field)}`);
184
+ lines.push(` ${renderField(field, target)}`);
171
185
  }
172
186
 
173
187
  lines.push('}');
174
188
  return lines;
175
189
  }
176
190
 
177
- function generateVisibilityModel(model: ModelNode, outPath?: string, modelsWithInput?: Set<string>, modelMap?: Map<string, ModelNode>): string[] {
191
+ function generateVisibilityModel(
192
+ model: ModelNode,
193
+ target: TsRenderTarget,
194
+ outPath?: string,
195
+ modelsWithInput?: Set<string>,
196
+ modelMap?: Map<string, ModelNode>,
197
+ ): string[] {
178
198
  const lines: string[] = [];
179
199
  lines.push(...generateComments(model, outPath));
180
200
 
@@ -185,7 +205,7 @@ function generateVisibilityModel(model: ModelNode, outPath?: string, modelsWithI
185
205
  const readFields = model.fields.filter(f => f.visibility !== 'writeonly');
186
206
  lines.push(`export interface ${model.name}${buildExtendsClause(bases, overrideNames, b => b)} {`);
187
207
  for (const field of readFields) {
188
- lines.push(` ${renderField(field)}`);
208
+ lines.push(` ${renderField(field, target)}`);
189
209
  }
190
210
  lines.push('}');
191
211
  lines.push('');
@@ -196,7 +216,7 @@ function generateVisibilityModel(model: ModelNode, outPath?: string, modelsWithI
196
216
  const inputResolver = (b: string) => (modelsWithInput?.has(b) ? `${b}Input` : b);
197
217
  lines.push(`export interface ${model.name}Input${buildExtendsClause(bases, overrideNames, inputResolver)} {`);
198
218
  for (const field of writeFields) {
199
- lines.push(` ${modelsWithInput ? renderInputField(field, modelsWithInput) : renderField(field)}`);
219
+ lines.push(` ${modelsWithInput ? renderInputField(field, modelsWithInput, target) : renderField(field, target)}`);
200
220
  }
201
221
  lines.push('}');
202
222
 
@@ -217,9 +237,9 @@ function withFieldJsDoc(jsdocParts: string[], line: string): string {
217
237
  return `/**\n${body}\n */\n ${line}`;
218
238
  }
219
239
 
220
- function renderField(field: FieldNode): string {
240
+ function renderField(field: FieldNode, target: TsRenderTarget): string {
221
241
  const opt = field.optional || field.default !== undefined ? '?' : '';
222
- let typeStr = renderTsType(field.type);
242
+ let typeStr = renderTsType(field.type, target);
223
243
  if (field.nullable) typeStr += ' | null';
224
244
  const line = `${quoteKey(field.name)}${opt}: ${typeStr};`;
225
245
  const jsdocParts: string[] = [];
@@ -228,9 +248,9 @@ function renderField(field: FieldNode): string {
228
248
  return withFieldJsDoc(jsdocParts, line);
229
249
  }
230
250
 
231
- function renderInputField(field: FieldNode, modelsWithInput: Set<string>): string {
251
+ function renderInputField(field: FieldNode, modelsWithInput: Set<string>, target: TsRenderTarget): string {
232
252
  const opt = field.optional || field.default !== undefined ? '?' : '';
233
- let typeStr = renderInputTsType(field.type, modelsWithInput);
253
+ let typeStr = renderInputTsType(field.type, modelsWithInput, target);
234
254
  if (field.nullable) typeStr += ' | null';
235
255
  const line = `${quoteKey(field.name)}${opt}: ${typeStr};`;
236
256
  const jsdocParts: string[] = [];
@@ -264,7 +284,7 @@ function applyOutputCase(name: string, c: 'camel' | 'snake' | 'pascal' | undefin
264
284
  * ancestor has format(...) (see `flattenFormatChain` in codegen-contract); we mirror that here
265
285
  * so the plain interface matches the wire shape produced by the Zod transform.
266
286
  */
267
- function generateOutputModel(model: ModelNode, modelsWithOutput: Set<string>): string[] {
287
+ function generateOutputModel(model: ModelNode, target: TsRenderTarget, modelsWithOutput: Set<string>): string[] {
268
288
  const lines: string[] = [];
269
289
  const outputCase = model.outputCase && model.outputCase !== 'camel' ? model.outputCase : undefined;
270
290
  const readFields = model.fields.filter(f => f.visibility !== 'writeonly');
@@ -279,7 +299,7 @@ function generateOutputModel(model: ModelNode, modelsWithOutput: Set<string>): s
279
299
  : '';
280
300
  lines.push(`export interface ${model.name}Output${baseExt} {`);
281
301
  for (const field of readFields) {
282
- lines.push(` ${renderOutputField(field, model.outputCase, modelsWithOutput)}`);
302
+ lines.push(` ${renderOutputField(field, model.outputCase, modelsWithOutput, target)}`);
283
303
  }
284
304
  lines.push('}');
285
305
  return lines;
@@ -288,16 +308,21 @@ function generateOutputModel(model: ModelNode, modelsWithOutput: Set<string>): s
288
308
  // Direct hit: emit a flat interface with renamed keys.
289
309
  lines.push(`export interface ${model.name}Output {`);
290
310
  for (const field of readFields) {
291
- lines.push(` ${renderOutputField(field, outputCase, modelsWithOutput)}`);
311
+ lines.push(` ${renderOutputField(field, outputCase, modelsWithOutput, target)}`);
292
312
  }
293
313
  lines.push('}');
294
314
  return lines;
295
315
  }
296
316
 
297
- function renderOutputField(field: FieldNode, outputCase: 'camel' | 'snake' | 'pascal' | undefined, modelsWithOutput: Set<string>): string {
317
+ function renderOutputField(
318
+ field: FieldNode,
319
+ outputCase: 'camel' | 'snake' | 'pascal' | undefined,
320
+ modelsWithOutput: Set<string>,
321
+ target: TsRenderTarget,
322
+ ): string {
298
323
  const opt = field.optional || field.default !== undefined ? '?' : '';
299
324
  const key = applyOutputCase(field.name, outputCase);
300
- let typeStr = renderOutputTsType(field.type, modelsWithOutput);
325
+ let typeStr = renderOutputTsType(field.type, modelsWithOutput, target);
301
326
  if (field.nullable) typeStr += ' | null';
302
327
  const line = `${quoteKey(key)}${opt}: ${typeStr};`;
303
328
  const jsdocParts: string[] = [];
package/src/index.ts CHANGED
@@ -57,6 +57,7 @@ import {
57
57
 
58
58
  // ─── Sub-config interfaces ─────────────────────────────────────────────────
59
59
 
60
+ /** Koa server output: routers, and the type or Zod schema files they import. */
60
61
  export interface ServerConfig {
61
62
  /** Directory (relative to rootDir) where server files are written. Default: rootDir. */
62
63
  baseDir?: string;
@@ -74,6 +75,7 @@ export interface ServerConfig {
74
75
  includeInternal?: boolean;
75
76
  }
76
77
 
78
+ /** TypeScript SDK client output: the client class, per-area operation clients, and their types. */
77
79
  export interface SdkConfig {
78
80
  baseDir?: string;
79
81
  name?: string;
@@ -95,16 +97,25 @@ export interface SdkConfig {
95
97
  scaffold?: boolean;
96
98
  }
97
99
 
100
+ /** Standalone Zod schema output, independent of the server and SDK sub-generators. */
98
101
  export interface ZodConfig {
99
102
  baseDir?: string;
100
103
  output?: string;
101
104
  }
102
105
 
106
+ /** Standalone plain TypeScript type output, independent of the server and SDK sub-generators. */
103
107
  export interface TypesConfig {
104
108
  baseDir?: string;
105
109
  output?: string;
110
+ /**
111
+ * Runtime the emitted types describe. Affects scalars whose TypeScript type is runtime-specific:
112
+ * `binary` renders as `Buffer` for `'server'` and `Blob` for `'client'`. Default `'client'`.
113
+ * The `server` and `sdk` sub-generators set this themselves.
114
+ */
115
+ target?: 'client' | 'server';
106
116
  }
107
117
 
118
+ /** MCP tool output: per-op-file handlers, the aggregator, and the optional POST route. */
108
119
  export interface McpConfig {
109
120
  /** Directory (relative to rootDir) where MCP files are written. Default: rootDir. */
110
121
  baseDir?: string;
@@ -132,6 +143,7 @@ export interface McpConfig {
132
143
  includeInternal?: boolean;
133
144
  }
134
145
 
146
+ /** Top-level plugin config. Each sub-config that is present enables its sub-generator. */
135
147
  export interface TypescriptPluginConfig {
136
148
  server?: ServerConfig;
137
149
  sdk?: SdkConfig;
@@ -355,6 +367,8 @@ function collectServerOutput(
355
367
  currentOutPath: typeOutPath,
356
368
  modelsWithInput,
357
369
  modelsWithOutput,
370
+ // These types are consumed by Koa handlers, so `binary` is a Buffer, not a Blob.
371
+ target: 'server' as const,
358
372
  };
359
373
  const content = config.zod ? generateContract(ast, renderCtx) : generatePlainTypes(ast, renderCtx);
360
374
  return [{ relativePath: typeOutPath, content }];
@@ -861,7 +875,13 @@ function collectTypesOutput(
861
875
  render: () => [
862
876
  {
863
877
  relativePath: outPath,
864
- content: generatePlainTypes(ast, { modelOutPaths, currentOutPath: outPath, modelsWithInput, modelsWithOutput }),
878
+ content: generatePlainTypes(ast, {
879
+ modelOutPaths,
880
+ currentOutPath: outPath,
881
+ modelsWithInput,
882
+ modelsWithOutput,
883
+ target: config.target,
884
+ }),
865
885
  },
866
886
  ],
867
887
  });