@contractkit/plugin-typescript 0.34.1 → 0.36.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.
@@ -21,10 +21,47 @@ import {
21
21
  import { renderOutputTsType, quoteKey, headerNameToProperty, escapeJsDocLines, escapeSingleQuoted, sourceLink } from './ts-render.js';
22
22
  import { DECIMAL_IMPORT, DECIMAL_PRELUDE_LINES } from './decimal-runtime.js';
23
23
  import { basename, dirname, relative } from 'path';
24
+ import type { ServerFramework } from './server-framework.js';
25
+ import { KOA_SERVER_FRAMEWORK } from './server-framework-koa.js';
26
+
27
+ /** Which request-side object a validation block reads from. Names the variable the block declares. */
28
+ export type ParamKind = 'params' | 'query' | 'headers';
29
+
30
+ /**
31
+ * Identifiers the generated handler body binds for itself. A path parameter destructured under one
32
+ * of these names would redeclare it, so those bindings are renamed the same way a collision with a
33
+ * handler parameter is.
34
+ */
35
+ const GENERATOR_HANDLER_LOCALS = ['service', 'result', 'body', 'multipartBody', 'params', 'query', 'headers'] as const;
36
+
37
+ /**
38
+ * Local identifier for each inline path parameter, keyed by its declared name.
39
+ *
40
+ * The declared name is what the framework keys its params object by, so it stays the schema key and
41
+ * the route placeholder; only the local binding moves, and a `_` is appended until it collides with
42
+ * neither a reserved identifier nor another parameter's binding. Path parameters are spread
43
+ * positionally into the service call, so a rename is invisible to the service.
44
+ */
45
+ function bindPathParams(nodes: readonly { name: string }[], handlerLocals: readonly string[]): Map<string, string> {
46
+ const reserved = new Set<string>([...handlerLocals, ...GENERATOR_HANDLER_LOCALS]);
47
+ const taken = new Set<string>();
48
+ const bindings = new Map<string, string>();
49
+ for (const node of nodes) {
50
+ let local = toIdentifier(node.name);
51
+ while (reserved.has(local) || taken.has(local)) local += '_';
52
+ taken.add(local);
53
+ bindings.set(node.name, local);
54
+ }
55
+ return bindings;
56
+ }
24
57
 
25
58
  // ─── Content-type helpers ──────────────────────────────────────────────────
26
59
 
27
- /** Map a request MIME type to the koa-bodyparser parser token used in middleware. */
60
+ /**
61
+ * Map a request MIME type to the ServerKit parser token used in middleware. The tokens are the keys
62
+ * of the parser map in `@maroonedsoftware/servercore`, so they are the same whichever HTTP framework
63
+ * the router targets.
64
+ */
28
65
  function bodyParserToken(contentType: string): string {
29
66
  switch (classifyContentType(contentType)) {
30
67
  case 'urlencoded':
@@ -34,9 +71,8 @@ function bodyParserToken(contentType: string): string {
34
71
  case 'text':
35
72
  return 'text';
36
73
  case 'binary':
37
- // koa-bodyparser has no native binary token; fall back to text so the body is
38
- // still readable as a string. Services handling binary uploads should switch to
39
- // multipart/form-data.
74
+ // There is no native binary token; fall back to text so the body is still readable as a
75
+ // string. Services handling binary uploads should switch to multipart/form-data.
40
76
  return 'text';
41
77
  default:
42
78
  return 'json';
@@ -125,7 +161,7 @@ export function bodyTypesStructurallyEqual(a: ContractTypeNode, b: ContractTypeN
125
161
 
126
162
  // ─── Public entry point ────────────────────────────────────────────────────
127
163
 
128
- /** Options controlling how {@link generateOp} renders a Koa router module. */
164
+ /** Options controlling how {@link generateOp} renders a server router module. */
129
165
  export interface OpCodegenOptions {
130
166
  servicePathTemplate?: string;
131
167
  typeImportPathTemplate?: string;
@@ -143,8 +179,8 @@ export interface OpCodegenOptions {
143
179
  */
144
180
  includeInternal?: boolean;
145
181
  /**
146
- * Re-parse the service result through its declared response schema before writing `ctx.body`,
147
- * and write the parsed value. Requires the type file to hold Zod schemas (`server.zod`) —
182
+ * Re-parse the service result through its declared response schema before writing the response
183
+ * body, and write the parsed value. Requires the type file to hold Zod schemas (`server.zod`) —
148
184
  * plain interfaces are types, with no runtime schema value to validate against. Default false.
149
185
  */
150
186
  validateResponses?: boolean;
@@ -154,10 +190,18 @@ export interface OpCodegenOptions {
154
190
  * post-transform shape, which the schema itself cannot re-parse.
155
191
  */
156
192
  modelsWithTransform?: Set<string>;
193
+ /**
194
+ * Which HTTP framework the emitted router targets. Every framework-specific string in the output
195
+ * comes from here. Defaults to Koa, the only framework shipped today.
196
+ */
197
+ framework?: ServerFramework;
157
198
  }
158
199
 
200
+ /** {@link OpCodegenOptions} after {@link generateOp} has filled in the framework default. */
201
+ type ResolvedOpCodegenOptions = OpCodegenOptions & { framework: ServerFramework };
202
+
159
203
  /**
160
- * Generate a Koa router module for every operation in `root`, including the imports, type
204
+ * Generate a server router module for every operation in `root`, including the imports, type
161
205
  * aliases, and handler list.
162
206
  *
163
207
  * Imports are derived from the generated body — each candidate symbol is emitted only if it
@@ -166,6 +210,10 @@ export interface OpCodegenOptions {
166
210
  * trips `noUnusedLocals` and lint downstream.
167
211
  */
168
212
  export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): string {
213
+ // Resolved once here rather than defaulted at each use, so every helper below reads a framework
214
+ // that is definitely present and no branch can quietly fall back to a different one.
215
+ const resolved: ResolvedOpCodegenOptions = { ...options, framework: options.framework ?? KOA_SERVER_FRAMEWORK };
216
+ const framework = resolved.framework;
169
217
  // Collect all referenced types across all routes
170
218
  const types = collectTypes(root, options.modelsWithInput, options.modelsWithOutput);
171
219
  const services = collectServices(root);
@@ -177,14 +225,14 @@ export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): st
177
225
  lines.push('/**');
178
226
  lines.push(` * generated from ${sourceLink(basename(root.file), options.outPath, root.file)}`);
179
227
  lines.push('*/');
180
- lines.push(`export const ${routerName} = ServerKitRouter();`);
228
+ lines.push(framework.routerDeclaration(routerName));
181
229
  lines.push('');
182
230
 
183
231
  const includeInternal = options.includeInternal ?? true;
184
232
  for (const route of root.routes) {
185
233
  for (const op of route.operations) {
186
234
  if (!includeInternal && resolveModifiers(route, op).includes('internal')) continue;
187
- lines.push(...generateHandler(route, op, root, options));
235
+ lines.push(...generateHandler(route, op, root, resolved));
188
236
  lines.push('');
189
237
  }
190
238
  }
@@ -231,10 +279,7 @@ export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): st
231
279
 
232
280
  const body: string[] = [];
233
281
 
234
- const koaImports = ['ServerKitRouter', 'bodyParserMiddleware', 'requirePolicy', 'requireSignature'].filter(uses);
235
- if (koaImports.length > 0) {
236
- body.push(`import { ${koaImports.join(', ')} } from '@maroonedsoftware/koa';`);
237
- }
282
+ body.push(...framework.imports(uses));
238
283
 
239
284
  // Services and model names come from the AST, which over-approximates two ways: a model with an
240
285
  // Input/Output variant contributes its base name even when only the variant is ever annotated,
@@ -280,11 +325,12 @@ export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): st
280
325
 
281
326
  // ─── Handler generation ────────────────────────────────────────────────────
282
327
 
283
- function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNode, options: OpCodegenOptions): string[] {
328
+ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNode, options: ResolvedOpCodegenOptions): string[] {
284
329
  const lines: string[] = [];
285
330
  const file = root.file;
286
331
  const outPath = options.outPath;
287
332
  const modelsWithInput = options.modelsWithInput;
333
+ const framework = options.framework;
288
334
 
289
335
  lines.push('/**');
290
336
 
@@ -310,10 +356,10 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
310
356
  lines.push('*/');
311
357
 
312
358
  const method = op.method;
313
- // `:name` from `{name}`, mapped to a valid identifier. Unlike a query parameter or a header,
314
- // a path placeholder's name never reaches the wire — Koa matches by position — so renaming it
315
- // is free, and it is what lets `ctx.params` be destructured at all.
316
- const path = route.path.replace(PATH_PARAM_RE_G, (_m, name: string) => `:${toIdentifier(name)}`);
359
+ // The framework's placeholder syntax, from `{name}`, mapped to a valid identifier. Unlike a query
360
+ // parameter or a header, a path placeholder's name never reaches the wire — the framework matches
361
+ // by position — so renaming it is free, and it is what lets the params object be destructured.
362
+ const path = route.path.replace(PATH_PARAM_RE_G, (_m, name: string) => framework.pathParam(toIdentifier(name)));
317
363
  const bodies = op.request?.bodies ?? [];
318
364
  const hasBody = bodies.length > 0;
319
365
  const isSingleMultipart = bodies.length === 1 && bodies[0]!.contentType === 'multipart/form-data';
@@ -328,39 +374,40 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
328
374
  : policy === false
329
375
  ? '{ policy: false }'
330
376
  : `{ policy: '${policy}' }`;
331
- middlewares.push(`requirePolicy(${args})`);
377
+ middlewares.push(framework.middleware.policy(args));
332
378
  }
333
379
  if (hasBody) {
334
380
  const parserTokens = Array.from(new Set(bodies.map(b => bodyParserToken(b.contentType))));
335
381
  const tokensExpr = parserTokens.map(t => `'${t}'`).join(', ');
336
- middlewares.push(`bodyParserMiddleware([${tokensExpr}])`);
382
+ middlewares.push(framework.middleware.bodyParser(tokensExpr));
337
383
  }
338
384
  if (op.signature) {
339
385
  const sigArgs = op.signaturePolicy
340
386
  ? `'${escapeSingleQuoted(op.signature)}', { policy: '${escapeSingleQuoted(op.signaturePolicy)}' }`
341
387
  : `'${escapeSingleQuoted(op.signature)}'`;
342
- middlewares.push(`requireSignature(${sigArgs})`);
388
+ middlewares.push(framework.middleware.signature(sigArgs));
343
389
  }
344
- const middlewareStr = middlewares.length > 0 ? `, ${middlewares.join(', ')},` : ',';
345
-
346
- lines.push(`${deriveRouterName(file)}.${method}('${path}'${middlewareStr} async ctx => {`);
390
+ lines.push(framework.routeOpen(deriveRouterName(file), method, path, middlewares));
347
391
 
348
392
  // Params / query / headers validation (request-side — use Input variants)
349
- lines.push(...generateParamValidation(route.params, 'ctx.params', 'params', route.paramsMode ?? 'strict', '', modelsWithInput));
350
- lines.push(...generateParamValidation(op.query, 'ctx.query', 'query', op.queryMode ?? 'strict', '', modelsWithInput));
351
- lines.push(...generateParamValidation(op.headers, 'ctx.headers', 'headers', op.headersMode ?? 'strip', '', modelsWithInput));
393
+ const pathBindings =
394
+ route.params?.kind === 'params' ? bindPathParams(route.params.nodes, framework.handlerLocals) : undefined;
395
+
396
+ lines.push(...generateParamValidation(route.params, 'params', framework.request.params, route.paramsMode ?? 'strict', '', modelsWithInput, pathBindings));
397
+ lines.push(...generateParamValidation(op.query, 'query', framework.request.query, op.queryMode ?? 'strict', '', modelsWithInput));
398
+ lines.push(...generateParamValidation(op.headers, 'headers', framework.request.headers, op.headersMode ?? 'strip', '', modelsWithInput));
352
399
 
353
400
  // Body validation (request-side — use Input variants)
354
401
  if (hasBody && op.request) {
355
402
  if (isSingleMultipart) {
356
- lines.push(` const multipartBody = ctx.parsedBody as MultipartBody;`);
403
+ lines.push(` const multipartBody = ${framework.request.parsedBody} as MultipartBody;`);
357
404
  lines.push('');
358
405
  } else if (bodies.length === 1) {
359
- lines.push(` const body = await parseAndValidate(ctx.parsedBody, ${renderInputType(bodies[0]!.bodyType, modelsWithInput)});`);
406
+ lines.push(` const body = await parseAndValidate(${framework.request.parsedBody}, ${renderInputType(bodies[0]!.bodyType, modelsWithInput)});`);
360
407
  lines.push('');
361
408
  } else if (bodies.every(b => bodyTypesStructurallyEqual(b.bodyType, bodies[0]!.bodyType))) {
362
409
  // All declared MIMEs share the same body shape — single validation suffices
363
- lines.push(` const body = await parseAndValidate(ctx.parsedBody, ${renderInputType(bodies[0]!.bodyType, modelsWithInput)});`);
410
+ lines.push(` const body = await parseAndValidate(${framework.request.parsedBody}, ${renderInputType(bodies[0]!.bodyType, modelsWithInput)});`);
364
411
  lines.push('');
365
412
  } else {
366
413
  // Different body types per MIME — dispatch on Content-Type
@@ -370,13 +417,13 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
370
417
  )
371
418
  .join(' | ');
372
419
  lines.push(` let body!: ${annotation};`);
373
- lines.push(` switch (ctx.request.type) {`);
420
+ lines.push(` switch (${framework.request.contentType}) {`);
374
421
  for (const b of bodies) {
375
422
  lines.push(` case '${b.contentType}':`);
376
423
  if (b.contentType === 'multipart/form-data') {
377
- lines.push(` body = ctx.parsedBody as MultipartBody;`);
424
+ lines.push(` body = ${framework.request.parsedBody} as MultipartBody;`);
378
425
  } else {
379
- lines.push(` body = await parseAndValidate(ctx.parsedBody, ${renderInputType(b.bodyType, modelsWithInput)});`);
426
+ lines.push(` body = await parseAndValidate(${framework.request.parsedBody}, ${renderInputType(b.bodyType, modelsWithInput)});`);
380
427
  }
381
428
  lines.push(` break;`);
382
429
  }
@@ -389,7 +436,7 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
389
436
  // responsible for producing; the rest are documentation, or the thrown-error path.
390
437
  const emitted = emittedResponses(op);
391
438
  const serviceParts = inferService(op, route, file);
392
- const call = `await service.${serviceParts.methodName}(${buildArgs(route, op)})`;
439
+ const call = `await service.${serviceParts.methodName}(${buildArgs(route, op, pathBindings)})`;
393
440
 
394
441
  if (emitted.length > 1) {
395
442
  lines.push(...generateMultiStatusResult(emitted, serviceParts.className, call, options));
@@ -397,25 +444,26 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
397
444
  lines.push(...generateSingleStatusResult(emitted[0], op, serviceParts.className, call, options));
398
445
  }
399
446
 
400
- lines.push(`});`);
447
+ lines.push(...framework.routeClose());
401
448
 
402
449
  return lines;
403
450
  }
404
451
 
405
452
  /**
406
453
  * The service produces exactly one status (or none): the result is the body itself, or
407
- * `{ body, headers }` when the status declares headers, and `ctx.status` is a constant.
454
+ * `{ body, headers }` when the status declares headers, and the status code is a constant.
408
455
  *
409
456
  * A status declaring several mimes also gains a `contentType` the service picks, which is the
410
- * only thing here that can turn `ctx.type` from a literal into an expression.
457
+ * only thing here that can turn the response content type from a literal into an expression.
411
458
  */
412
459
  function generateSingleStatusResult(
413
460
  resp: OpResponseNode | undefined,
414
461
  op: OpOperationNode,
415
462
  className: string,
416
463
  call: string,
417
- options: OpCodegenOptions,
464
+ options: ResolvedOpCodegenOptions,
418
465
  ): string[] {
466
+ const framework = options.framework;
419
467
  const lines: string[] = [];
420
468
  const bodies = resp ? resp.bodies : [];
421
469
  const respHeaders = resp?.headers ?? [];
@@ -428,7 +476,7 @@ function generateSingleStatusResult(
428
476
  const { annotation, prelude } = formatTypeAnnotation(bodies[0]!.bodyType, options.modelsWithOutput);
429
477
  if (prelude) lines.push(` ${prelude}`);
430
478
  bodySchema = responseBodySchema(bodies[0]!.bodyType, options, prelude ? 'resultType' : undefined);
431
- lines.push(` const service = ctx.container.get(${className});`);
479
+ lines.push(` const service = ${framework.resolveService(className)};`);
432
480
  if (hasRespHeaders) {
433
481
  lines.push(` const result: { body: ${annotation}; headers: ${headersAnnotation} } = ${call};`);
434
482
  } else {
@@ -439,10 +487,10 @@ function generateSingleStatusResult(
439
487
  const { members, preludes } = rendered;
440
488
  bodySchema = rendered.bodySchema;
441
489
  for (const prelude of preludes) lines.push(` ${prelude}`);
442
- lines.push(` const service = ctx.container.get(${className});`);
490
+ lines.push(` const service = ${framework.resolveService(className)};`);
443
491
  lines.push(` const result: ${members.join(' | ')} = ${call};`);
444
492
  } else {
445
- lines.push(` const service = ctx.container.get(${className});`);
493
+ lines.push(` const service = ${framework.resolveService(className)};`);
446
494
  if (hasRespHeaders) {
447
495
  lines.push(` const result: { headers: ${headersAnnotation} } = ${call};`);
448
496
  } else {
@@ -456,15 +504,18 @@ function generateSingleStatusResult(
456
504
  // also what aligns the three generators: `observableResponses` excludes a bare `400:` too, so
457
505
  // the SDK already types such a method `Promise<void>` and `thrownResponses` puts the 400 in
458
506
  // `@throws`. A bodyless 204 success is exactly what `Promise<void>` means.
459
- lines.push(` ctx.status = ${resp?.statusCode ?? 204};`);
460
- lines.push(...headerSetLines(respHeaders, ' '));
507
+ lines.push(` ${framework.response.status(String(resp?.statusCode ?? 204))}`);
508
+ lines.push(...headerSetLines(respHeaders, ' ', framework));
461
509
 
462
510
  if (bodies.length === 1) {
463
- lines.push(` ctx.type = '${bodies[0]!.contentType}';`);
464
- lines.push(` ctx.body = ${responseBodyExpr(hasRespHeaders ? 'result.body' : 'result', bodySchema)};`);
511
+ lines.push(` ${framework.response.type(`'${bodies[0]!.contentType}'`)}`);
512
+ lines.push(...indent(framework.response.send(responseBodyExpr(hasRespHeaders ? 'result.body' : 'result', bodySchema)), ' '));
465
513
  } else if (bodies.length > 1) {
466
- lines.push(` ctx.type = result.contentType;`);
467
- lines.push(` ctx.body = ${responseBodyExpr('result.body', bodySchema)};`);
514
+ lines.push(` ${framework.response.type('result.contentType')}`);
515
+ lines.push(...indent(framework.response.send(responseBodyExpr('result.body', bodySchema)), ' '));
516
+ } else {
517
+ // Nothing to write, but a framework that ends a response by returning still needs a statement.
518
+ lines.push(...indent(framework.response.send(undefined), ' '));
468
519
  }
469
520
 
470
521
  return lines;
@@ -475,7 +526,8 @@ function generateSingleStatusResult(
475
526
  * `status`, and the handler switches on it so each status writes only its own headers, mime
476
527
  * and body.
477
528
  */
478
- function generateMultiStatusResult(emitted: OpResponseNode[], className: string, call: string, options: OpCodegenOptions): string[] {
529
+ function generateMultiStatusResult(emitted: OpResponseNode[], className: string, call: string, options: ResolvedOpCodegenOptions): string[] {
530
+ const framework = options.framework;
479
531
  const lines: string[] = [];
480
532
  const members: string[] = [];
481
533
  const preludes: string[] = [];
@@ -490,21 +542,23 @@ function generateMultiStatusResult(emitted: OpResponseNode[], className: string,
490
542
  }
491
543
 
492
544
  for (const prelude of preludes) lines.push(` ${prelude}`);
493
- lines.push(` const service = ctx.container.get(${className});`);
545
+ lines.push(` const service = ${framework.resolveService(className)};`);
494
546
  lines.push(` const result:`);
495
547
  for (const member of members) lines.push(` | ${member}`);
496
548
  lines.push(` = ${call};`);
497
549
  lines.push('');
498
- lines.push(` ctx.status = result.status;`);
550
+ lines.push(` ${framework.response.status('result.status')}`);
499
551
  lines.push(` switch (result.status) {`);
500
552
  for (const resp of emitted) {
501
553
  lines.push(` case ${resp.statusCode}:`);
502
- lines.push(...headerSetLines(resp.headers ?? [], ' '));
554
+ lines.push(...headerSetLines(resp.headers ?? [], ' ', framework));
503
555
  if (resp.bodies.length > 0) {
504
- lines.push(` ctx.type = result.contentType;`);
505
- lines.push(` ctx.body = ${responseBodyExpr('result.body', bodySchemas.get(resp.statusCode))};`);
556
+ lines.push(` ${framework.response.type('result.contentType')}`);
557
+ lines.push(...indent(framework.response.send(responseBodyExpr('result.body', bodySchemas.get(resp.statusCode))), ' '));
558
+ } else {
559
+ lines.push(...indent(framework.response.send(undefined), ' '));
506
560
  }
507
- lines.push(` break;`);
561
+ lines.push(...indent(framework.response.caseEnd(), ' '));
508
562
  }
509
563
  lines.push(` }`);
510
564
 
@@ -565,16 +619,20 @@ function renderHeadersAnnotation(headers: OpResponseHeaderNode[], modelsWithOutp
565
619
  return `{ ${fields.join('; ')} }`;
566
620
  }
567
621
 
568
- /** `ctx.set(...)` calls for a status's declared response headers, guarding the optional ones. */
569
- function headerSetLines(headers: OpResponseHeaderNode[], indent: string): string[] {
622
+ /** Response-header writes for a status's declared headers, guarding the optional ones. */
623
+ function headerSetLines(headers: OpResponseHeaderNode[], pad: string, framework: ServerFramework): string[] {
570
624
  return headers.map(h => {
571
625
  const accessor = `result.headers[${JSON.stringify(headerNameToProperty(h.name))}]`;
572
- return h.optional
573
- ? `${indent}if (${accessor} !== undefined) ctx.set('${h.name}', String(${accessor}));`
574
- : `${indent}ctx.set('${h.name}', String(${accessor}));`;
626
+ const write = framework.response.header(h.name, `String(${accessor})`);
627
+ return h.optional ? `${pad}if (${accessor} !== undefined) ${write}` : `${pad}${write}`;
575
628
  });
576
629
  }
577
630
 
631
+ /** Prefix each of a framework's statements with the handler indentation the caller is writing at. */
632
+ function indent(lines: string[], pad: string): string[] {
633
+ return lines.map(line => `${pad}${line}`);
634
+ }
635
+
578
636
  // ─── Inference helpers ─────────────────────────────────────────────────────
579
637
 
580
638
  /**
@@ -627,12 +685,14 @@ function inferMethodName(method: string, path: string): string {
627
685
  *
628
686
  * @returns The rendered argument list, or an empty string when the method takes no arguments.
629
687
  */
630
- export function buildArgs(route: OpRouteNode, op: OpOperationNode): string {
688
+ export function buildArgs(route: OpRouteNode, op: OpOperationNode, bindings?: Map<string, string>): string {
631
689
  const args: string[] = [];
632
690
  // Path params: spread individually (inline) or pass 'params' object (type-ref/ContractTypeNode)
633
691
  if (route.params) {
634
692
  if (route.params.kind === 'params') {
635
- args.push(...route.params.nodes.map(p => toIdentifier(p.name)));
693
+ // `bindings` carries any rename the handler needed; the MCP generator passes none, since
694
+ // its handlers bind different locals than a router's.
695
+ args.push(...route.params.nodes.map(p => bindings?.get(p.name) ?? toIdentifier(p.name)));
636
696
  } else {
637
697
  args.push('params');
638
698
  }
@@ -778,7 +838,7 @@ function responseBodySchema(bodyType: ContractTypeNode, options: OpCodegenOption
778
838
  }
779
839
 
780
840
  /**
781
- * The `ctx.body = ...` right-hand side for a response body: the raw result expression, or a
841
+ * The right-hand side of a response body write: the raw result expression, or a
782
842
  * `parseAndValidate` of it. The `500` is deliberate — a service returning a shape its own contract
783
843
  * rejects is a server fault, not a client one, and `@maroonedsoftware/zod` routes the field-level
784
844
  * detail to `internalDetails` (log-only) rather than the response body at 5xx.
@@ -789,37 +849,46 @@ function responseBodyExpr(value: string, schema: string | undefined): string {
789
849
 
790
850
  function generateParamValidation(
791
851
  source: ParamSource | undefined,
792
- ctxExpr: string,
793
- varName: string,
852
+ kind: ParamKind,
853
+ sourceExpr: string,
794
854
  mode: ObjectMode,
795
855
  suffix = '',
796
856
  modelsWithInput?: Set<string>,
857
+ bindings?: Map<string, string>,
797
858
  ): string[] {
798
859
  if (!source) return [];
799
860
  const lines: string[] = [];
800
- const isQuery = ctxExpr === 'ctx.query';
861
+ const isQuery = kind === 'query';
862
+ // Path params are destructured and spread into the service call; query and headers pass as
863
+ // whole objects. The variable the block declares is named after the kind either way.
864
+ const isPathParams = kind === 'params';
801
865
  if (source.kind === 'ref') {
802
866
  // Type reference — apply mode as a method call on the schema
803
867
  const typeName = modelsWithInput?.has(source.name) ? `${source.name}Input` : source.name;
804
- lines.push(` const ${varName} = await parseAndValidate(${ctxExpr}, ${typeName}.${mode}());`);
868
+ lines.push(` const ${kind} = await parseAndValidate(${sourceExpr}, ${typeName}.${mode}());`);
805
869
  lines.push('');
806
870
  } else if (source.kind === 'params') {
807
871
  // Inline param declarations — wrap with the appropriate z.*Object constructor
808
872
  if (source.nodes.length > 0) {
809
- // Destructure only for params (spread individually in service call);
810
- // query/headers are passed as whole objects.
811
- // Path params are destructured and spread into the service call; query and headers pass
812
- // as whole objects.
813
- const isPathParams = ctxExpr === 'ctx.params';
814
873
  const bind = (name: string) => (isPathParams ? toIdentifier(name) : name);
815
- const lhs = varName === 'params' ? `{ ${source.nodes.map(p => bind(p.name)).join(', ')} }` : varName;
874
+ // A path param whose binding was renamed is destructured under an alias, so the key the
875
+ // framework supplies stays the declared one while the local is collision-free.
876
+ const lhs = isPathParams
877
+ ? `{ ${source.nodes
878
+ .map(p => {
879
+ const wire = bind(p.name);
880
+ const local = bindings?.get(p.name) ?? wire;
881
+ return wire === local ? wire : `${wire}: ${local}`;
882
+ })
883
+ .join(', ')} }`
884
+ : kind;
816
885
  lines.push(` const ${lhs} = await parseAndValidate(`);
817
- lines.push(` ${ctxExpr},`);
886
+ lines.push(` ${sourceExpr},`);
818
887
  lines.push(` ${modeToWrapper(mode)}({`);
819
888
  for (const param of source.nodes) {
820
889
  // For path params the key must match the name in the route pattern above, which
821
- // is what `ctx.params` is keyed by. For query and headers it is the wire name,
822
- // quoted when that is not an identifier — those the client actually sends.
890
+ // is what the framework keys its params object by. For query and headers it is the
891
+ // wire name, quoted when that is not an identifier — those the client actually sends.
823
892
  const bound = bind(param.name);
824
893
  const key = isValidIdentifier(bound) ? bound : `'${bound}'`;
825
894
  // Delegating to renderQueryType rather than hand-rolling the array preprocess here:
@@ -837,7 +906,7 @@ function generateParamValidation(
837
906
  // ContractTypeNode — use query-aware rendering for query params (coerces single string → array),
838
907
  // otherwise use Input variant rendering; apply mode as a method call
839
908
  const schema = isQuery ? renderQueryType(source.node, modelsWithInput) : renderInputType(source.node, modelsWithInput);
840
- lines.push(` const ${varName} = await parseAndValidate(${ctxExpr}, (${schema}).${mode}());`);
909
+ lines.push(` const ${kind} = await parseAndValidate(${sourceExpr}, (${schema}).${mode}());`);
841
910
  lines.push('');
842
911
  }
843
912
  return lines;
package/src/index.ts CHANGED
@@ -45,6 +45,17 @@ import {
45
45
  } from './codegen-sdk.js';
46
46
  import { generatePlainTypes } from './codegen-plain-types.js';
47
47
  import { DEFAULT_REVIVABLE_SCALARS } from './codegen-revive.js';
48
+ import { resolveServerFramework, SERVER_FRAMEWORK_NAMES, type ServerFrameworkName } from './server-framework.js';
49
+ export {
50
+ SERVER_FRAMEWORK_NAMES,
51
+ DEFAULT_SERVER_FRAMEWORK_NAME,
52
+ resolveServerFramework,
53
+ SERVER_FRAMEWORKS,
54
+ type ServerFramework,
55
+ type ServerFrameworkName,
56
+ } from './server-framework.js';
57
+ export { KOA_SERVER_FRAMEWORK } from './server-framework-koa.js';
58
+ export { FASTIFY_SERVER_FRAMEWORK } from './server-framework-fastify.js';
48
59
 
49
60
  /** Taint set for the SDK's bigint response reviver. */
50
61
  const BIGINT_SCALARS: ReadonlySet<ScalarTypeNode['name']> = new Set(['bigint']);
@@ -65,14 +76,19 @@ import {
65
76
 
66
77
  // ─── Sub-config interfaces ─────────────────────────────────────────────────
67
78
 
68
- /** Koa server output: routers, and the type or Zod schema files they import. */
79
+ /** Server output: routers, and the type or Zod schema files they import. */
69
80
  export interface ServerConfig {
70
81
  /** Directory (relative to rootDir) where server files are written. Default: rootDir. */
71
82
  baseDir?: string;
83
+ /**
84
+ * HTTP framework the generated routers target. Also selects the flavour of the optional
85
+ * `mcp.router.ts` the `mcp` sub-config emits. Supported: `'koa'`. Default `'koa'`.
86
+ */
87
+ framework?: ServerFrameworkName;
72
88
  /** When true, `output.types` emits Zod schema files (via `generateContract`). When false/omitted, emits plain TypeScript. */
73
89
  zod?: boolean;
74
90
  output?: {
75
- /** Path template for Koa router files. Supports {filename}, {dir}, {area}. */
91
+ /** Path template for router files. Supports {filename}, {dir}, {area}. */
76
92
  routes?: string;
77
93
  /** Path template for type/schema files. Supports {filename}, {dir}, {area}. */
78
94
  types?: string;
@@ -151,7 +167,7 @@ export interface McpConfig {
151
167
  */
152
168
  types?: string;
153
169
  };
154
- /** Emit the `mcp.router.ts` route boilerplate. Default true. */
170
+ /** Emit the `mcp.router.ts` route boilerplate. Its framework follows `server.framework`. Default true. */
155
171
  emitRouter?: boolean;
156
172
  /** Mount path used in the emitted router. Default `/mcp`. */
157
173
  path?: string;
@@ -207,6 +223,14 @@ export function createTypescriptPlugin(config: TypescriptPluginConfig, rootDir:
207
223
 
208
224
  /** Reject config combinations that would generate code that cannot compile or cannot run. */
209
225
  function assertValidConfig(config: TypescriptPluginConfig): void {
226
+ // Runtime check, not just a type: config arrives as JSON, so `ServerFrameworkName` constrains
227
+ // programmatic callers only. Checked before the rest so a typo'd framework is the error reported.
228
+ const framework = config.server?.framework;
229
+ if (framework !== undefined && !(SERVER_FRAMEWORK_NAMES as readonly string[]).includes(framework)) {
230
+ throw new Error(
231
+ `plugin-typescript: server.framework '${String(framework)}' is not supported — expected one of: ${SERVER_FRAMEWORK_NAMES.join(', ')}.`,
232
+ );
233
+ }
210
234
  if (config.server?.validateResponses && !config.server.zod) {
211
235
  throw new Error(
212
236
  'plugin-typescript: server.validateResponses requires server.zod: true — without it output.types emits plain TypeScript interfaces, which are types with no runtime schema value for the router to validate against.',
@@ -367,6 +391,7 @@ function collectServerOutput(
367
391
  units: IncrementalUnit[],
368
392
  ): void {
369
393
  const serverBase = resolve(rootDir, config.baseDir ?? '.');
394
+ const framework = resolveServerFramework(config.framework);
370
395
  const modelsWithInput = inputs.modelsWithInput as Set<string>;
371
396
  const modelsWithOutput = inputs.modelsWithOutput as Set<string>;
372
397
  // Not `modelsWithOutput`: that set seeds only from `format(output=...)`, because only that case
@@ -418,7 +443,7 @@ function collectServerOutput(
418
443
  currentOutPath: typeOutPath,
419
444
  modelsWithInput,
420
445
  modelsWithOutput,
421
- // These types are consumed by Koa handlers, so `binary` is a Buffer, not a Blob.
446
+ // These types are consumed by server handlers, so `binary` is a Buffer, not a Blob.
422
447
  target: 'server' as const,
423
448
  };
424
449
  const content = config.zod ? generateContract(ast, renderCtx) : generatePlainTypes(ast, renderCtx);
@@ -446,6 +471,9 @@ function collectServerOutput(
446
471
  // this router's output with no change to `root` or the config.
447
472
  modelsWithTransform: sliceModelSet(refs, new Set(), modelsWithTransform),
448
473
  validateResponses: config.validateResponses ?? false,
474
+ // Covered by `sub` already, which is the whole sub-config; explicit for the same reason
475
+ // `validateResponses` is — the inputs that change a router's text read at a glance.
476
+ framework: framework.name,
449
477
  sub: subConfigKey,
450
478
  });
451
479
  units.push({
@@ -463,6 +491,7 @@ function collectServerOutput(
463
491
  modelsWithTransform,
464
492
  includeInternal: config.includeInternal,
465
493
  validateResponses: config.validateResponses,
494
+ framework,
466
495
  }),
467
496
  },
468
497
  ],
@@ -1107,7 +1136,10 @@ function collectMcpOutput(
1107
1136
  // ── Router (global, optional) ──
1108
1137
  if (config.emitRouter !== false) {
1109
1138
  const routerPath = join(mcpBase, config.output?.router ?? 'mcp.router.ts');
1110
- globalFiles.push({ relativePath: routerPath, content: generateMcpRouter({ path: config.path }) });
1139
+ // The mount is server-side wiring, so it follows the server sub-config's framework. Koa when
1140
+ // there is no `server` sub-config at all, which is the same default the router generator has.
1141
+ const framework = resolveServerFramework(fullConfig.server?.framework);
1142
+ globalFiles.push({ relativePath: routerPath, content: generateMcpRouter({ path: config.path, framework }) });
1111
1143
  }
1112
1144
  }
1113
1145