@contractkit/plugin-typescript 0.28.1 → 0.29.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.
- package/.turbo/turbo-build$colon$ci.log +5 -5
- package/.turbo/turbo-test$colon$ci.log +21 -19
- package/CHANGELOG.md +16 -0
- package/README.md +4 -0
- package/dist/codegen-contract.d.ts +7 -0
- package/dist/codegen-contract.d.ts.map +1 -1
- package/dist/codegen-mcp.d.ts +38 -0
- package/dist/codegen-mcp.d.ts.map +1 -0
- package/dist/codegen-operation.d.ts +42 -1
- package/dist/codegen-operation.d.ts.map +1 -1
- package/dist/codegen-plain-types.d.ts +3 -0
- package/dist/codegen-plain-types.d.ts.map +1 -1
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +686 -96
- package/dist/index.js.map +1 -1
- package/dist/ts-render.d.ts +21 -3
- package/dist/ts-render.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/codegen-contract.ts +7 -0
- package/src/codegen-mcp.ts +501 -0
- package/src/codegen-operation.ts +104 -10
- package/src/codegen-plain-types.ts +47 -22
- package/src/index.ts +175 -1
- package/src/ts-render.ts +52 -32
- package/tests/codegen-mcp.test.ts +246 -0
- package/tests/codegen-operation.test.ts +89 -0
- package/tests/codegen-plain-types.test.ts +48 -0
- package/tests/codegen-server.test.ts +37 -0
- package/tests/pipeline.test.ts +59 -0
package/src/codegen-operation.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
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,
|
|
@@ -107,6 +107,7 @@ export function bodyTypesStructurallyEqual(a: ContractTypeNode, b: ContractTypeN
|
|
|
107
107
|
|
|
108
108
|
// ─── Public entry point ────────────────────────────────────────────────────
|
|
109
109
|
|
|
110
|
+
/** Options controlling how {@link generateOp} renders a Koa router module. */
|
|
110
111
|
export interface OpCodegenOptions {
|
|
111
112
|
servicePathTemplate?: string;
|
|
112
113
|
typeImportPathTemplate?: string;
|
|
@@ -152,8 +153,14 @@ export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): st
|
|
|
152
153
|
body.push(...generateTypeImports(types, root.file, options));
|
|
153
154
|
}
|
|
154
155
|
|
|
155
|
-
|
|
156
|
-
|
|
156
|
+
// luxon is needed for date/time/datetime (DateTime), duration (Duration) and interval (Interval);
|
|
157
|
+
// the rendered Zod schemas and the service-result annotations both reference these classes.
|
|
158
|
+
const luxonImports: string[] = [];
|
|
159
|
+
if (opNeedsDateTime(root)) luxonImports.push('DateTime');
|
|
160
|
+
if (opNeedsScalar(root, 'duration')) luxonImports.push('Duration');
|
|
161
|
+
if (opNeedsScalar(root, 'interval')) luxonImports.push('Interval');
|
|
162
|
+
if (luxonImports.length > 0) {
|
|
163
|
+
body.push(`import { ${luxonImports.join(', ')} } from 'luxon';`);
|
|
157
164
|
}
|
|
158
165
|
|
|
159
166
|
if (needsParseAndValidate) {
|
|
@@ -173,6 +180,11 @@ export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): st
|
|
|
173
180
|
`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' }));`,
|
|
174
181
|
);
|
|
175
182
|
}
|
|
183
|
+
if (opNeedsScalar(root, 'interval')) {
|
|
184
|
+
helpers.push(
|
|
185
|
+
`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()!);`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
176
188
|
if (opNeedsScalar(root, 'json')) {
|
|
177
189
|
helpers.push(`type _JsonValue = string | number | boolean | null | _JsonValue[] | { [key: string]: _JsonValue };`);
|
|
178
190
|
helpers.push(
|
|
@@ -267,7 +279,7 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
|
|
|
267
279
|
}
|
|
268
280
|
const middlewareStr = middlewares.length > 0 ? `, ${middlewares.join(', ')},` : ',';
|
|
269
281
|
|
|
270
|
-
lines.push(`${deriveRouterName(file)}.${method}('${path}'${middlewareStr} async
|
|
282
|
+
lines.push(`${deriveRouterName(file)}.${method}('${path}'${middlewareStr} async ctx => {`);
|
|
271
283
|
|
|
272
284
|
// Params / query / headers validation (request-side — use Input variants)
|
|
273
285
|
lines.push(...generateParamValidation(route.params, 'ctx.params', 'params', route.paramsMode ?? 'strict', '', modelsWithInput));
|
|
@@ -316,7 +328,10 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
|
|
|
316
328
|
const hasRespHeaders = respHeaders.length > 0;
|
|
317
329
|
const headersAnnotation = hasRespHeaders
|
|
318
330
|
? `{ ${respHeaders
|
|
319
|
-
.map(
|
|
331
|
+
.map(
|
|
332
|
+
h =>
|
|
333
|
+
`${quoteKey(headerNameToProperty(h.name))}${h.optional ? '?' : ''}: ${renderOutputTsType(h.type, options.modelsWithOutput, 'server')}`,
|
|
334
|
+
)
|
|
320
335
|
.join('; ')} }`
|
|
321
336
|
: '';
|
|
322
337
|
|
|
@@ -368,7 +383,16 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
|
|
|
368
383
|
|
|
369
384
|
// ─── Inference helpers ─────────────────────────────────────────────────────
|
|
370
385
|
|
|
371
|
-
|
|
386
|
+
/**
|
|
387
|
+
* Resolve the service class and method a handler should delegate to.
|
|
388
|
+
*
|
|
389
|
+
* Uses the operation's explicit `service: Class.method` declaration when present; otherwise derives
|
|
390
|
+
* the class from the contract file name (`ledger.categories.ck` → `LedgerCategoriesService`) and the
|
|
391
|
+
* method from the HTTP verb and whether the path carries a parameter (`get` → `list` / `getById`).
|
|
392
|
+
*
|
|
393
|
+
* @param file Path of the `.ck` file the operation came from.
|
|
394
|
+
*/
|
|
395
|
+
export function inferService(op: OpOperationNode, route: OpRouteNode, file: string): { className: string; methodName: string } {
|
|
372
396
|
// If explicitly declared: service: ServiceClass.methodName
|
|
373
397
|
if (op.service) {
|
|
374
398
|
const [cls = '', method] = op.service.split('.');
|
|
@@ -400,7 +424,16 @@ function inferMethodName(method: string, path: string): string {
|
|
|
400
424
|
}
|
|
401
425
|
}
|
|
402
426
|
|
|
403
|
-
|
|
427
|
+
/**
|
|
428
|
+
* Build the comma-separated argument list passed to the service method in a generated handler.
|
|
429
|
+
*
|
|
430
|
+
* Order is params, body, query, headers. Inline path params are spread as individual identifiers;
|
|
431
|
+
* a referenced/compound params type is passed as a single `params` object. A lone
|
|
432
|
+
* `multipart/form-data` request body is passed as `multipartBody` rather than `body`.
|
|
433
|
+
*
|
|
434
|
+
* @returns The rendered argument list, or an empty string when the method takes no arguments.
|
|
435
|
+
*/
|
|
436
|
+
export function buildArgs(route: OpRouteNode, op: OpOperationNode): string {
|
|
404
437
|
const args: string[] = [];
|
|
405
438
|
// Path params: spread individually (inline) or pass 'params' object (type-ref/ContractTypeNode)
|
|
406
439
|
if (route.params) {
|
|
@@ -423,6 +456,52 @@ function buildArgs(route: OpRouteNode, op: OpOperationNode): string {
|
|
|
423
456
|
return args.join(', ');
|
|
424
457
|
}
|
|
425
458
|
|
|
459
|
+
/**
|
|
460
|
+
* Map a `.ck` scalar to the TypeScript type a server handler sees, i.e. `z.infer` of the schema
|
|
461
|
+
* `renderType` emits for that scalar. This is deliberately NOT `renderTsScalar` from ts-render:
|
|
462
|
+
* that one describes the wire/SDK view (`binary` → `Blob`, dates → `string`), while the router
|
|
463
|
+
* runs on Node against the parsed Zod output (`binary` → `Buffer`, dates → luxon `DateTime`).
|
|
464
|
+
*/
|
|
465
|
+
function serverTsScalar(name: ScalarTypeNode['name']): string {
|
|
466
|
+
switch (name) {
|
|
467
|
+
case 'string':
|
|
468
|
+
case 'email':
|
|
469
|
+
case 'url':
|
|
470
|
+
case 'uuid':
|
|
471
|
+
return 'string';
|
|
472
|
+
case 'number':
|
|
473
|
+
case 'int':
|
|
474
|
+
return 'number';
|
|
475
|
+
case 'bigint':
|
|
476
|
+
return 'bigint';
|
|
477
|
+
case 'boolean':
|
|
478
|
+
return 'boolean';
|
|
479
|
+
case 'date':
|
|
480
|
+
case 'time':
|
|
481
|
+
case 'datetime':
|
|
482
|
+
return 'DateTime';
|
|
483
|
+
case 'duration':
|
|
484
|
+
return 'Duration';
|
|
485
|
+
case 'interval':
|
|
486
|
+
// _ZodInterval transforms to an ISO string, so the inferred output type is string.
|
|
487
|
+
return 'string';
|
|
488
|
+
case 'binary':
|
|
489
|
+
return 'Buffer';
|
|
490
|
+
case 'json':
|
|
491
|
+
return '_JsonValue';
|
|
492
|
+
case 'object':
|
|
493
|
+
return 'Record<string, unknown>';
|
|
494
|
+
case 'null':
|
|
495
|
+
return 'null';
|
|
496
|
+
case 'unknown':
|
|
497
|
+
return 'unknown';
|
|
498
|
+
default: {
|
|
499
|
+
const _exhaustive: never = name;
|
|
500
|
+
throw new Error(`plugin-typescript: unmapped scalar '${String(_exhaustive)}' — add a case`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
426
505
|
function formatTypeAnnotation(bodyType: ContractTypeNode, modelsWithOutput?: Set<string>): { annotation: string; prelude?: string } {
|
|
427
506
|
if (bodyType.kind === 'array') {
|
|
428
507
|
const inner = formatTypeAnnotation(bodyType.item, modelsWithOutput);
|
|
@@ -432,7 +511,7 @@ function formatTypeAnnotation(bodyType: ContractTypeNode, modelsWithOutput?: Set
|
|
|
432
511
|
const name = modelsWithOutput?.has(bodyType.name) ? `${bodyType.name}Output` : bodyType.name;
|
|
433
512
|
return { annotation: name };
|
|
434
513
|
}
|
|
435
|
-
if (bodyType.kind === 'scalar') return { annotation: bodyType.name };
|
|
514
|
+
if (bodyType.kind === 'scalar') return { annotation: serverTsScalar(bodyType.name) };
|
|
436
515
|
// For complex types, extract schema into a variable so the result line stays readable
|
|
437
516
|
const schema = renderType(bodyType);
|
|
438
517
|
return {
|
|
@@ -792,7 +871,13 @@ function isValidIdentifier(name: string): boolean {
|
|
|
792
871
|
|
|
793
872
|
// ─── Naming conventions ────────────────────────────────────────────────────
|
|
794
873
|
|
|
795
|
-
|
|
874
|
+
/**
|
|
875
|
+
* Derive the PascalCase base name used for router, service, and type names from a contract file path.
|
|
876
|
+
*
|
|
877
|
+
* Strips directories and the `.op`/`.ck` extension, then PascalCases each dot-separated segment
|
|
878
|
+
* (`contracts/ledger.categories.ck` → `LedgerCategories`). Falls back to `Resource` for an empty path.
|
|
879
|
+
*/
|
|
880
|
+
export function deriveBaseName(file: string): string {
|
|
796
881
|
const base =
|
|
797
882
|
file
|
|
798
883
|
.split('/')
|
|
@@ -809,7 +894,16 @@ function deriveRouterName(file: string): string {
|
|
|
809
894
|
return `${deriveBaseName(file)}Router`;
|
|
810
895
|
}
|
|
811
896
|
|
|
812
|
-
|
|
897
|
+
/**
|
|
898
|
+
* Resolve the import specifier for a service class.
|
|
899
|
+
*
|
|
900
|
+
* Drops the trailing `Service` suffix and kebab-cases the remainder, then applies `template` if given
|
|
901
|
+
* (`{name}` → `Ledger`, `{kebab}` → `ledger`). Without a template, defaults to
|
|
902
|
+
* `#modules/<kebab>/<kebab>.service.js`.
|
|
903
|
+
*
|
|
904
|
+
* @param template Optional `servicePathTemplate` from the plugin config.
|
|
905
|
+
*/
|
|
906
|
+
export function deriveModulePath(serviceName: string, template?: string): string {
|
|
813
907
|
// LedgerService -> #modules/ledger/ledger.service.js
|
|
814
908
|
const base = serviceName.replace(/Service$/, '');
|
|
815
909
|
const kebab = base.replace(/([A-Z])/g, m => `-${m.toLowerCase()}`).replace(/^-/, '');
|
|
@@ -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
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
type SdkScaffoldDeps,
|
|
42
42
|
} from './codegen-sdk.js';
|
|
43
43
|
import { generatePlainTypes } from './codegen-plain-types.js';
|
|
44
|
+
import { generateMcpFile, generateMcpAggregator, generateMcpRouter, hasMcpOperations, deriveMcpRegisterFnName } from './codegen-mcp.js';
|
|
44
45
|
import {
|
|
45
46
|
TEMPLATE_VAR_RE,
|
|
46
47
|
resolveTemplate,
|
|
@@ -56,6 +57,7 @@ import {
|
|
|
56
57
|
|
|
57
58
|
// ─── Sub-config interfaces ─────────────────────────────────────────────────
|
|
58
59
|
|
|
60
|
+
/** Koa server output: routers, and the type or Zod schema files they import. */
|
|
59
61
|
export interface ServerConfig {
|
|
60
62
|
/** Directory (relative to rootDir) where server files are written. Default: rootDir. */
|
|
61
63
|
baseDir?: string;
|
|
@@ -73,6 +75,7 @@ export interface ServerConfig {
|
|
|
73
75
|
includeInternal?: boolean;
|
|
74
76
|
}
|
|
75
77
|
|
|
78
|
+
/** TypeScript SDK client output: the client class, per-area operation clients, and their types. */
|
|
76
79
|
export interface SdkConfig {
|
|
77
80
|
baseDir?: string;
|
|
78
81
|
name?: string;
|
|
@@ -94,21 +97,59 @@ export interface SdkConfig {
|
|
|
94
97
|
scaffold?: boolean;
|
|
95
98
|
}
|
|
96
99
|
|
|
100
|
+
/** Standalone Zod schema output, independent of the server and SDK sub-generators. */
|
|
97
101
|
export interface ZodConfig {
|
|
98
102
|
baseDir?: string;
|
|
99
103
|
output?: string;
|
|
100
104
|
}
|
|
101
105
|
|
|
106
|
+
/** Standalone plain TypeScript type output, independent of the server and SDK sub-generators. */
|
|
102
107
|
export interface TypesConfig {
|
|
103
108
|
baseDir?: string;
|
|
104
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';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** MCP tool output: per-op-file handlers, the aggregator, and the optional POST route. */
|
|
119
|
+
export interface McpConfig {
|
|
120
|
+
/** Directory (relative to rootDir) where MCP files are written. Default: rootDir. */
|
|
121
|
+
baseDir?: string;
|
|
122
|
+
output?: {
|
|
123
|
+
/** Path template for per-op-file tool handlers. Supports {filename}, {dir}, {area}. Default `{filename}.mcp.ts`. */
|
|
124
|
+
tools?: string;
|
|
125
|
+
/** Path (or template) for the aggregator that assembles the McpToolHandlerMap. Default `mcp.tools.ts`. */
|
|
126
|
+
index?: string;
|
|
127
|
+
/** Path (or template) for the optional POST /mcp route file. Default `mcp.router.ts`. */
|
|
128
|
+
router?: string;
|
|
129
|
+
/**
|
|
130
|
+
* Path template for the model **Zod schema** files the tools import (for arg validation and
|
|
131
|
+
* `z.toJSONSchema`). When omitted, falls back to the `server` sub-config's `output.types`
|
|
132
|
+
* (if `server.zod`) or the `zod` sub-config's output. Tools require Zod schemas, not plain types.
|
|
133
|
+
*/
|
|
134
|
+
types?: string;
|
|
135
|
+
};
|
|
136
|
+
/** Emit the `mcp.router.ts` route boilerplate. Default true. */
|
|
137
|
+
emitRouter?: boolean;
|
|
138
|
+
/** Mount path used in the emitted router. Default `/mcp`. */
|
|
139
|
+
path?: string;
|
|
140
|
+
/** Import path template for service implementations (same semantics as ServerConfig). */
|
|
141
|
+
servicePathTemplate?: string;
|
|
142
|
+
/** Whether to expose operations marked `internal` as MCP tools. Default false. */
|
|
143
|
+
includeInternal?: boolean;
|
|
105
144
|
}
|
|
106
145
|
|
|
146
|
+
/** Top-level plugin config. Each sub-config that is present enables its sub-generator. */
|
|
107
147
|
export interface TypescriptPluginConfig {
|
|
108
148
|
server?: ServerConfig;
|
|
109
149
|
sdk?: SdkConfig;
|
|
110
150
|
zod?: ZodConfig;
|
|
111
151
|
types?: TypesConfig;
|
|
152
|
+
mcp?: McpConfig;
|
|
112
153
|
}
|
|
113
154
|
|
|
114
155
|
// ─── Caching constants ─────────────────────────────────────────────────────
|
|
@@ -165,6 +206,7 @@ async function runTypescriptCodegen(
|
|
|
165
206
|
if (config.sdk) collectSdkOutput(config.sdk, rootDir, inputs, units, globalFiles);
|
|
166
207
|
if (config.zod) collectZodOutput(config.zod, rootDir, inputs, units);
|
|
167
208
|
if (config.types) collectTypesOutput(config.types, rootDir, inputs, units);
|
|
209
|
+
if (config.mcp) collectMcpOutput(config.mcp, config, rootDir, inputs, units, globalFiles);
|
|
168
210
|
|
|
169
211
|
const result = runIncrementalCodegen({
|
|
170
212
|
codegenVersion: TYPESCRIPT_CODEGEN_VERSION,
|
|
@@ -325,6 +367,8 @@ function collectServerOutput(
|
|
|
325
367
|
currentOutPath: typeOutPath,
|
|
326
368
|
modelsWithInput,
|
|
327
369
|
modelsWithOutput,
|
|
370
|
+
// These types are consumed by Koa handlers, so `binary` is a Buffer, not a Blob.
|
|
371
|
+
target: 'server' as const,
|
|
328
372
|
};
|
|
329
373
|
const content = config.zod ? generateContract(ast, renderCtx) : generatePlainTypes(ast, renderCtx);
|
|
330
374
|
return [{ relativePath: typeOutPath, content }];
|
|
@@ -831,13 +875,143 @@ function collectTypesOutput(
|
|
|
831
875
|
render: () => [
|
|
832
876
|
{
|
|
833
877
|
relativePath: outPath,
|
|
834
|
-
content: generatePlainTypes(ast, {
|
|
878
|
+
content: generatePlainTypes(ast, {
|
|
879
|
+
modelOutPaths,
|
|
880
|
+
currentOutPath: outPath,
|
|
881
|
+
modelsWithInput,
|
|
882
|
+
modelsWithOutput,
|
|
883
|
+
target: config.target,
|
|
884
|
+
}),
|
|
835
885
|
},
|
|
836
886
|
],
|
|
837
887
|
});
|
|
838
888
|
}
|
|
839
889
|
}
|
|
840
890
|
|
|
891
|
+
// ─── MCP sub-generator ─────────────────────────────────────────────────────
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Resolve where the model **Zod schema** files live so the MCP tools can import them (for arg
|
|
895
|
+
* validation + `z.toJSONSchema`). Precedence: explicit `mcp.output.types` → the `server` sub-config's
|
|
896
|
+
* `output.types` (only when `server.zod`) → the `zod` sub-config's output. Returns an empty map when
|
|
897
|
+
* none resolve (imports then fall back to a colocated `./<name>.js` guess).
|
|
898
|
+
*/
|
|
899
|
+
function resolveMcpModelOutPaths(
|
|
900
|
+
config: TypescriptPluginConfig,
|
|
901
|
+
rootDir: string,
|
|
902
|
+
contractRoots: readonly ContractRootNode[],
|
|
903
|
+
commonRoot: string,
|
|
904
|
+
modelsWithInput: Set<string>,
|
|
905
|
+
modelsWithOutput: Set<string>,
|
|
906
|
+
): Map<string, string> {
|
|
907
|
+
const map = new Map<string, string>();
|
|
908
|
+
let base: string;
|
|
909
|
+
let template: string | undefined;
|
|
910
|
+
let suffix: string;
|
|
911
|
+
if (config.mcp?.output?.types) {
|
|
912
|
+
base = resolve(rootDir, config.mcp.baseDir ?? '.');
|
|
913
|
+
template = config.mcp.output.types;
|
|
914
|
+
suffix = '.ts';
|
|
915
|
+
} else if (config.server?.zod && config.server.output?.types) {
|
|
916
|
+
base = resolve(rootDir, config.server.baseDir ?? '.');
|
|
917
|
+
template = config.server.output.types;
|
|
918
|
+
suffix = '.ts';
|
|
919
|
+
} else if (config.zod) {
|
|
920
|
+
base = resolve(rootDir, config.zod.baseDir ?? '.');
|
|
921
|
+
template = config.zod.output;
|
|
922
|
+
suffix = '.schema.ts';
|
|
923
|
+
} else {
|
|
924
|
+
return map;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
for (const ast of contractRoots) {
|
|
928
|
+
const outPath = computeContractOutPath(ast.file, base, template, suffix, commonRoot, ast.meta);
|
|
929
|
+
for (const model of ast.models) {
|
|
930
|
+
map.set(model.name, outPath);
|
|
931
|
+
if (modelsWithInput.has(model.name)) map.set(`${model.name}Input`, outPath);
|
|
932
|
+
if (modelsWithOutput.has(model.name)) map.set(`${model.name}Output`, outPath);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
return map;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function collectMcpOutput(
|
|
939
|
+
config: McpConfig,
|
|
940
|
+
fullConfig: TypescriptPluginConfig,
|
|
941
|
+
rootDir: string,
|
|
942
|
+
inputs: Parameters<NonNullable<ContractKitPlugin['generateTargets']>>[0],
|
|
943
|
+
units: IncrementalUnit[],
|
|
944
|
+
globalFiles: IncrementalOutputFile[],
|
|
945
|
+
): void {
|
|
946
|
+
const mcpBase = resolve(rootDir, config.baseDir ?? '.');
|
|
947
|
+
const modelsWithInput = inputs.modelsWithInput as Set<string>;
|
|
948
|
+
const modelsWithOutput = inputs.modelsWithOutput as Set<string>;
|
|
949
|
+
const modelMap = buildModelMap(inputs.contractRoots);
|
|
950
|
+
const allFiles = [...inputs.contractRoots.map(r => r.file), ...inputs.opRoots.map(r => r.file)];
|
|
951
|
+
const commonRoot = commonDir(allFiles, rootDir);
|
|
952
|
+
const subConfigKey = stableSubConfig(config);
|
|
953
|
+
const includeInternal = config.includeInternal ?? false;
|
|
954
|
+
|
|
955
|
+
const modelOutPaths = resolveMcpModelOutPaths(fullConfig, rootDir, inputs.contractRoots, commonRoot, modelsWithInput, modelsWithOutput);
|
|
956
|
+
|
|
957
|
+
// ── Per-op-root tool-handler units (only files with MCP-exposed ops) ──
|
|
958
|
+
const entries: { outPath: string; registerFn: string }[] = [];
|
|
959
|
+
for (const ast of inputs.opRoots) {
|
|
960
|
+
if (!hasMcpOperations(ast, includeInternal)) continue;
|
|
961
|
+
const outPath = computeOpOutPath(ast.file, mcpBase, config.output?.tools, '.mcp.ts', commonRoot, ast.meta);
|
|
962
|
+
const refs = collectOpRootRefs(ast, modelMap);
|
|
963
|
+
const fingerprint = hashFingerprint({
|
|
964
|
+
kind: 'mcp-tools',
|
|
965
|
+
v: TYPESCRIPT_CODEGEN_VERSION,
|
|
966
|
+
outPath,
|
|
967
|
+
root: ast,
|
|
968
|
+
outPathSlice: sliceOutPathMap(refs, modelOutPaths, modelsWithInput, modelsWithOutput),
|
|
969
|
+
modelsWithInput: sliceModelSet(refs, new Set(), modelsWithInput),
|
|
970
|
+
modelsWithOutput: sliceModelSet(refs, new Set(), modelsWithOutput),
|
|
971
|
+
servicePathTemplate: config.servicePathTemplate ?? null,
|
|
972
|
+
includeInternal,
|
|
973
|
+
sub: subConfigKey,
|
|
974
|
+
});
|
|
975
|
+
units.push({
|
|
976
|
+
key: `mcp-tools::${outPath}`,
|
|
977
|
+
fingerprint,
|
|
978
|
+
render: () => [
|
|
979
|
+
{
|
|
980
|
+
relativePath: outPath,
|
|
981
|
+
content: generateMcpFile(ast, {
|
|
982
|
+
outPath,
|
|
983
|
+
modelOutPaths,
|
|
984
|
+
modelsWithInput,
|
|
985
|
+
modelsWithOutput,
|
|
986
|
+
servicePathTemplate: config.servicePathTemplate,
|
|
987
|
+
includeInternal,
|
|
988
|
+
}),
|
|
989
|
+
},
|
|
990
|
+
],
|
|
991
|
+
});
|
|
992
|
+
entries.push({ outPath, registerFn: deriveMcpRegisterFnName(ast.file) });
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
if (entries.length === 0) return;
|
|
996
|
+
|
|
997
|
+
// ── Aggregator (global) ──
|
|
998
|
+
const indexPath = join(mcpBase, config.output?.index ?? 'mcp.tools.ts');
|
|
999
|
+
const aggregatorEntries = entries
|
|
1000
|
+
.map(e => {
|
|
1001
|
+
let rel = relative(dirname(indexPath), e.outPath).replace(/\.ts$/, '.js');
|
|
1002
|
+
if (!rel.startsWith('.')) rel = './' + rel;
|
|
1003
|
+
return { registerFn: e.registerFn, importPath: rel };
|
|
1004
|
+
})
|
|
1005
|
+
.sort((a, b) => a.registerFn.localeCompare(b.registerFn));
|
|
1006
|
+
globalFiles.push({ relativePath: indexPath, content: generateMcpAggregator(aggregatorEntries) });
|
|
1007
|
+
|
|
1008
|
+
// ── Router (global, optional) ──
|
|
1009
|
+
if (config.emitRouter !== false) {
|
|
1010
|
+
const routerPath = join(mcpBase, config.output?.router ?? 'mcp.router.ts');
|
|
1011
|
+
globalFiles.push({ relativePath: routerPath, content: generateMcpRouter({ path: config.path }) });
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
841
1015
|
// ─── Manifest IO + cleanup ─────────────────────────────────────────────────
|
|
842
1016
|
|
|
843
1017
|
function readManifest(manifestPath: string): IncrementalManifest {
|