@contractkit/plugin-typescript 0.34.0 → 0.35.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,19 @@ 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';
24
29
 
25
30
  // ─── Content-type helpers ──────────────────────────────────────────────────
26
31
 
27
- /** Map a request MIME type to the koa-bodyparser parser token used in middleware. */
32
+ /**
33
+ * Map a request MIME type to the ServerKit parser token used in middleware. The tokens are the keys
34
+ * of the parser map in `@maroonedsoftware/servercore`, so they are the same whichever HTTP framework
35
+ * the router targets.
36
+ */
28
37
  function bodyParserToken(contentType: string): string {
29
38
  switch (classifyContentType(contentType)) {
30
39
  case 'urlencoded':
@@ -34,9 +43,8 @@ function bodyParserToken(contentType: string): string {
34
43
  case 'text':
35
44
  return 'text';
36
45
  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.
46
+ // There is no native binary token; fall back to text so the body is still readable as a
47
+ // string. Services handling binary uploads should switch to multipart/form-data.
40
48
  return 'text';
41
49
  default:
42
50
  return 'json';
@@ -125,7 +133,7 @@ export function bodyTypesStructurallyEqual(a: ContractTypeNode, b: ContractTypeN
125
133
 
126
134
  // ─── Public entry point ────────────────────────────────────────────────────
127
135
 
128
- /** Options controlling how {@link generateOp} renders a Koa router module. */
136
+ /** Options controlling how {@link generateOp} renders a server router module. */
129
137
  export interface OpCodegenOptions {
130
138
  servicePathTemplate?: string;
131
139
  typeImportPathTemplate?: string;
@@ -143,8 +151,8 @@ export interface OpCodegenOptions {
143
151
  */
144
152
  includeInternal?: boolean;
145
153
  /**
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`) —
154
+ * Re-parse the service result through its declared response schema before writing the response
155
+ * body, and write the parsed value. Requires the type file to hold Zod schemas (`server.zod`) —
148
156
  * plain interfaces are types, with no runtime schema value to validate against. Default false.
149
157
  */
150
158
  validateResponses?: boolean;
@@ -154,10 +162,18 @@ export interface OpCodegenOptions {
154
162
  * post-transform shape, which the schema itself cannot re-parse.
155
163
  */
156
164
  modelsWithTransform?: Set<string>;
165
+ /**
166
+ * Which HTTP framework the emitted router targets. Every framework-specific string in the output
167
+ * comes from here. Defaults to Koa, the only framework shipped today.
168
+ */
169
+ framework?: ServerFramework;
157
170
  }
158
171
 
172
+ /** {@link OpCodegenOptions} after {@link generateOp} has filled in the framework default. */
173
+ type ResolvedOpCodegenOptions = OpCodegenOptions & { framework: ServerFramework };
174
+
159
175
  /**
160
- * Generate a Koa router module for every operation in `root`, including the imports, type
176
+ * Generate a server router module for every operation in `root`, including the imports, type
161
177
  * aliases, and handler list.
162
178
  *
163
179
  * Imports are derived from the generated body — each candidate symbol is emitted only if it
@@ -166,6 +182,10 @@ export interface OpCodegenOptions {
166
182
  * trips `noUnusedLocals` and lint downstream.
167
183
  */
168
184
  export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): string {
185
+ // Resolved once here rather than defaulted at each use, so every helper below reads a framework
186
+ // that is definitely present and no branch can quietly fall back to a different one.
187
+ const resolved: ResolvedOpCodegenOptions = { ...options, framework: options.framework ?? KOA_SERVER_FRAMEWORK };
188
+ const framework = resolved.framework;
169
189
  // Collect all referenced types across all routes
170
190
  const types = collectTypes(root, options.modelsWithInput, options.modelsWithOutput);
171
191
  const services = collectServices(root);
@@ -177,14 +197,14 @@ export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): st
177
197
  lines.push('/**');
178
198
  lines.push(` * generated from ${sourceLink(basename(root.file), options.outPath, root.file)}`);
179
199
  lines.push('*/');
180
- lines.push(`export const ${routerName} = ServerKitRouter();`);
200
+ lines.push(framework.routerDeclaration(routerName));
181
201
  lines.push('');
182
202
 
183
203
  const includeInternal = options.includeInternal ?? true;
184
204
  for (const route of root.routes) {
185
205
  for (const op of route.operations) {
186
206
  if (!includeInternal && resolveModifiers(route, op).includes('internal')) continue;
187
- lines.push(...generateHandler(route, op, root, options));
207
+ lines.push(...generateHandler(route, op, root, resolved));
188
208
  lines.push('');
189
209
  }
190
210
  }
@@ -231,10 +251,7 @@ export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): st
231
251
 
232
252
  const body: string[] = [];
233
253
 
234
- const koaImports = ['ServerKitRouter', 'bodyParserMiddleware', 'requirePolicy', 'requireSignature'].filter(uses);
235
- if (koaImports.length > 0) {
236
- body.push(`import { ${koaImports.join(', ')} } from '@maroonedsoftware/koa';`);
237
- }
254
+ body.push(...framework.imports(uses));
238
255
 
239
256
  // Services and model names come from the AST, which over-approximates two ways: a model with an
240
257
  // Input/Output variant contributes its base name even when only the variant is ever annotated,
@@ -280,11 +297,12 @@ export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): st
280
297
 
281
298
  // ─── Handler generation ────────────────────────────────────────────────────
282
299
 
283
- function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNode, options: OpCodegenOptions): string[] {
300
+ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNode, options: ResolvedOpCodegenOptions): string[] {
284
301
  const lines: string[] = [];
285
302
  const file = root.file;
286
303
  const outPath = options.outPath;
287
304
  const modelsWithInput = options.modelsWithInput;
305
+ const framework = options.framework;
288
306
 
289
307
  lines.push('/**');
290
308
 
@@ -310,10 +328,10 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
310
328
  lines.push('*/');
311
329
 
312
330
  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)}`);
331
+ // The framework's placeholder syntax, from `{name}`, mapped to a valid identifier. Unlike a query
332
+ // parameter or a header, a path placeholder's name never reaches the wire — the framework matches
333
+ // by position — so renaming it is free, and it is what lets the params object be destructured.
334
+ const path = route.path.replace(PATH_PARAM_RE_G, (_m, name: string) => framework.pathParam(toIdentifier(name)));
317
335
  const bodies = op.request?.bodies ?? [];
318
336
  const hasBody = bodies.length > 0;
319
337
  const isSingleMultipart = bodies.length === 1 && bodies[0]!.contentType === 'multipart/form-data';
@@ -328,39 +346,37 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
328
346
  : policy === false
329
347
  ? '{ policy: false }'
330
348
  : `{ policy: '${policy}' }`;
331
- middlewares.push(`requirePolicy(${args})`);
349
+ middlewares.push(framework.middleware.policy(args));
332
350
  }
333
351
  if (hasBody) {
334
352
  const parserTokens = Array.from(new Set(bodies.map(b => bodyParserToken(b.contentType))));
335
353
  const tokensExpr = parserTokens.map(t => `'${t}'`).join(', ');
336
- middlewares.push(`bodyParserMiddleware([${tokensExpr}])`);
354
+ middlewares.push(framework.middleware.bodyParser(tokensExpr));
337
355
  }
338
356
  if (op.signature) {
339
357
  const sigArgs = op.signaturePolicy
340
358
  ? `'${escapeSingleQuoted(op.signature)}', { policy: '${escapeSingleQuoted(op.signaturePolicy)}' }`
341
359
  : `'${escapeSingleQuoted(op.signature)}'`;
342
- middlewares.push(`requireSignature(${sigArgs})`);
360
+ middlewares.push(framework.middleware.signature(sigArgs));
343
361
  }
344
- const middlewareStr = middlewares.length > 0 ? `, ${middlewares.join(', ')},` : ',';
345
-
346
- lines.push(`${deriveRouterName(file)}.${method}('${path}'${middlewareStr} async ctx => {`);
362
+ lines.push(framework.routeOpen(deriveRouterName(file), method, path, middlewares));
347
363
 
348
364
  // 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));
365
+ lines.push(...generateParamValidation(route.params, 'params', framework.request.params, route.paramsMode ?? 'strict', '', modelsWithInput));
366
+ lines.push(...generateParamValidation(op.query, 'query', framework.request.query, op.queryMode ?? 'strict', '', modelsWithInput));
367
+ lines.push(...generateParamValidation(op.headers, 'headers', framework.request.headers, op.headersMode ?? 'strip', '', modelsWithInput));
352
368
 
353
369
  // Body validation (request-side — use Input variants)
354
370
  if (hasBody && op.request) {
355
371
  if (isSingleMultipart) {
356
- lines.push(` const multipartBody = ctx.parsedBody as MultipartBody;`);
372
+ lines.push(` const multipartBody = ${framework.request.parsedBody} as MultipartBody;`);
357
373
  lines.push('');
358
374
  } else if (bodies.length === 1) {
359
- lines.push(` const body = await parseAndValidate(ctx.parsedBody, ${renderInputType(bodies[0]!.bodyType, modelsWithInput)});`);
375
+ lines.push(` const body = await parseAndValidate(${framework.request.parsedBody}, ${renderInputType(bodies[0]!.bodyType, modelsWithInput)});`);
360
376
  lines.push('');
361
377
  } else if (bodies.every(b => bodyTypesStructurallyEqual(b.bodyType, bodies[0]!.bodyType))) {
362
378
  // 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)});`);
379
+ lines.push(` const body = await parseAndValidate(${framework.request.parsedBody}, ${renderInputType(bodies[0]!.bodyType, modelsWithInput)});`);
364
380
  lines.push('');
365
381
  } else {
366
382
  // Different body types per MIME — dispatch on Content-Type
@@ -370,13 +386,13 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
370
386
  )
371
387
  .join(' | ');
372
388
  lines.push(` let body!: ${annotation};`);
373
- lines.push(` switch (ctx.request.type) {`);
389
+ lines.push(` switch (${framework.request.contentType}) {`);
374
390
  for (const b of bodies) {
375
391
  lines.push(` case '${b.contentType}':`);
376
392
  if (b.contentType === 'multipart/form-data') {
377
- lines.push(` body = ctx.parsedBody as MultipartBody;`);
393
+ lines.push(` body = ${framework.request.parsedBody} as MultipartBody;`);
378
394
  } else {
379
- lines.push(` body = await parseAndValidate(ctx.parsedBody, ${renderInputType(b.bodyType, modelsWithInput)});`);
395
+ lines.push(` body = await parseAndValidate(${framework.request.parsedBody}, ${renderInputType(b.bodyType, modelsWithInput)});`);
380
396
  }
381
397
  lines.push(` break;`);
382
398
  }
@@ -397,25 +413,26 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
397
413
  lines.push(...generateSingleStatusResult(emitted[0], op, serviceParts.className, call, options));
398
414
  }
399
415
 
400
- lines.push(`});`);
416
+ lines.push(...framework.routeClose());
401
417
 
402
418
  return lines;
403
419
  }
404
420
 
405
421
  /**
406
422
  * 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.
423
+ * `{ body, headers }` when the status declares headers, and the status code is a constant.
408
424
  *
409
425
  * 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.
426
+ * only thing here that can turn the response content type from a literal into an expression.
411
427
  */
412
428
  function generateSingleStatusResult(
413
429
  resp: OpResponseNode | undefined,
414
430
  op: OpOperationNode,
415
431
  className: string,
416
432
  call: string,
417
- options: OpCodegenOptions,
433
+ options: ResolvedOpCodegenOptions,
418
434
  ): string[] {
435
+ const framework = options.framework;
419
436
  const lines: string[] = [];
420
437
  const bodies = resp ? resp.bodies : [];
421
438
  const respHeaders = resp?.headers ?? [];
@@ -428,7 +445,7 @@ function generateSingleStatusResult(
428
445
  const { annotation, prelude } = formatTypeAnnotation(bodies[0]!.bodyType, options.modelsWithOutput);
429
446
  if (prelude) lines.push(` ${prelude}`);
430
447
  bodySchema = responseBodySchema(bodies[0]!.bodyType, options, prelude ? 'resultType' : undefined);
431
- lines.push(` const service = ctx.container.get(${className});`);
448
+ lines.push(` const service = ${framework.resolveService(className)};`);
432
449
  if (hasRespHeaders) {
433
450
  lines.push(` const result: { body: ${annotation}; headers: ${headersAnnotation} } = ${call};`);
434
451
  } else {
@@ -439,10 +456,10 @@ function generateSingleStatusResult(
439
456
  const { members, preludes } = rendered;
440
457
  bodySchema = rendered.bodySchema;
441
458
  for (const prelude of preludes) lines.push(` ${prelude}`);
442
- lines.push(` const service = ctx.container.get(${className});`);
459
+ lines.push(` const service = ${framework.resolveService(className)};`);
443
460
  lines.push(` const result: ${members.join(' | ')} = ${call};`);
444
461
  } else {
445
- lines.push(` const service = ctx.container.get(${className});`);
462
+ lines.push(` const service = ${framework.resolveService(className)};`);
446
463
  if (hasRespHeaders) {
447
464
  lines.push(` const result: { headers: ${headersAnnotation} } = ${call};`);
448
465
  } else {
@@ -456,15 +473,18 @@ function generateSingleStatusResult(
456
473
  // also what aligns the three generators: `observableResponses` excludes a bare `400:` too, so
457
474
  // the SDK already types such a method `Promise<void>` and `thrownResponses` puts the 400 in
458
475
  // `@throws`. A bodyless 204 success is exactly what `Promise<void>` means.
459
- lines.push(` ctx.status = ${resp?.statusCode ?? 204};`);
460
- lines.push(...headerSetLines(respHeaders, ' '));
476
+ lines.push(` ${framework.response.status(String(resp?.statusCode ?? 204))}`);
477
+ lines.push(...headerSetLines(respHeaders, ' ', framework));
461
478
 
462
479
  if (bodies.length === 1) {
463
- lines.push(` ctx.type = '${bodies[0]!.contentType}';`);
464
- lines.push(` ctx.body = ${responseBodyExpr(hasRespHeaders ? 'result.body' : 'result', bodySchema)};`);
480
+ lines.push(` ${framework.response.type(`'${bodies[0]!.contentType}'`)}`);
481
+ lines.push(...indent(framework.response.send(responseBodyExpr(hasRespHeaders ? 'result.body' : 'result', bodySchema)), ' '));
465
482
  } else if (bodies.length > 1) {
466
- lines.push(` ctx.type = result.contentType;`);
467
- lines.push(` ctx.body = ${responseBodyExpr('result.body', bodySchema)};`);
483
+ lines.push(` ${framework.response.type('result.contentType')}`);
484
+ lines.push(...indent(framework.response.send(responseBodyExpr('result.body', bodySchema)), ' '));
485
+ } else {
486
+ // Nothing to write, but a framework that ends a response by returning still needs a statement.
487
+ lines.push(...indent(framework.response.send(undefined), ' '));
468
488
  }
469
489
 
470
490
  return lines;
@@ -475,7 +495,8 @@ function generateSingleStatusResult(
475
495
  * `status`, and the handler switches on it so each status writes only its own headers, mime
476
496
  * and body.
477
497
  */
478
- function generateMultiStatusResult(emitted: OpResponseNode[], className: string, call: string, options: OpCodegenOptions): string[] {
498
+ function generateMultiStatusResult(emitted: OpResponseNode[], className: string, call: string, options: ResolvedOpCodegenOptions): string[] {
499
+ const framework = options.framework;
479
500
  const lines: string[] = [];
480
501
  const members: string[] = [];
481
502
  const preludes: string[] = [];
@@ -490,21 +511,23 @@ function generateMultiStatusResult(emitted: OpResponseNode[], className: string,
490
511
  }
491
512
 
492
513
  for (const prelude of preludes) lines.push(` ${prelude}`);
493
- lines.push(` const service = ctx.container.get(${className});`);
514
+ lines.push(` const service = ${framework.resolveService(className)};`);
494
515
  lines.push(` const result:`);
495
516
  for (const member of members) lines.push(` | ${member}`);
496
517
  lines.push(` = ${call};`);
497
518
  lines.push('');
498
- lines.push(` ctx.status = result.status;`);
519
+ lines.push(` ${framework.response.status('result.status')}`);
499
520
  lines.push(` switch (result.status) {`);
500
521
  for (const resp of emitted) {
501
522
  lines.push(` case ${resp.statusCode}:`);
502
- lines.push(...headerSetLines(resp.headers ?? [], ' '));
523
+ lines.push(...headerSetLines(resp.headers ?? [], ' ', framework));
503
524
  if (resp.bodies.length > 0) {
504
- lines.push(` ctx.type = result.contentType;`);
505
- lines.push(` ctx.body = ${responseBodyExpr('result.body', bodySchemas.get(resp.statusCode))};`);
525
+ lines.push(` ${framework.response.type('result.contentType')}`);
526
+ lines.push(...indent(framework.response.send(responseBodyExpr('result.body', bodySchemas.get(resp.statusCode))), ' '));
527
+ } else {
528
+ lines.push(...indent(framework.response.send(undefined), ' '));
506
529
  }
507
- lines.push(` break;`);
530
+ lines.push(...indent(framework.response.caseEnd(), ' '));
508
531
  }
509
532
  lines.push(` }`);
510
533
 
@@ -565,16 +588,20 @@ function renderHeadersAnnotation(headers: OpResponseHeaderNode[], modelsWithOutp
565
588
  return `{ ${fields.join('; ')} }`;
566
589
  }
567
590
 
568
- /** `ctx.set(...)` calls for a status's declared response headers, guarding the optional ones. */
569
- function headerSetLines(headers: OpResponseHeaderNode[], indent: string): string[] {
591
+ /** Response-header writes for a status's declared headers, guarding the optional ones. */
592
+ function headerSetLines(headers: OpResponseHeaderNode[], pad: string, framework: ServerFramework): string[] {
570
593
  return headers.map(h => {
571
594
  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}));`;
595
+ const write = framework.response.header(h.name, `String(${accessor})`);
596
+ return h.optional ? `${pad}if (${accessor} !== undefined) ${write}` : `${pad}${write}`;
575
597
  });
576
598
  }
577
599
 
600
+ /** Prefix each of a framework's statements with the handler indentation the caller is writing at. */
601
+ function indent(lines: string[], pad: string): string[] {
602
+ return lines.map(line => `${pad}${line}`);
603
+ }
604
+
578
605
  // ─── Inference helpers ─────────────────────────────────────────────────────
579
606
 
580
607
  /**
@@ -778,7 +805,7 @@ function responseBodySchema(bodyType: ContractTypeNode, options: OpCodegenOption
778
805
  }
779
806
 
780
807
  /**
781
- * The `ctx.body = ...` right-hand side for a response body: the raw result expression, or a
808
+ * The right-hand side of a response body write: the raw result expression, or a
782
809
  * `parseAndValidate` of it. The `500` is deliberate — a service returning a shape its own contract
783
810
  * rejects is a server fault, not a client one, and `@maroonedsoftware/zod` routes the field-level
784
811
  * detail to `internalDetails` (log-only) rather than the response body at 5xx.
@@ -789,37 +816,35 @@ function responseBodyExpr(value: string, schema: string | undefined): string {
789
816
 
790
817
  function generateParamValidation(
791
818
  source: ParamSource | undefined,
792
- ctxExpr: string,
793
- varName: string,
819
+ kind: ParamKind,
820
+ sourceExpr: string,
794
821
  mode: ObjectMode,
795
822
  suffix = '',
796
823
  modelsWithInput?: Set<string>,
797
824
  ): string[] {
798
825
  if (!source) return [];
799
826
  const lines: string[] = [];
800
- const isQuery = ctxExpr === 'ctx.query';
827
+ const isQuery = kind === 'query';
828
+ // Path params are destructured and spread into the service call; query and headers pass as
829
+ // whole objects. The variable the block declares is named after the kind either way.
830
+ const isPathParams = kind === 'params';
801
831
  if (source.kind === 'ref') {
802
832
  // Type reference — apply mode as a method call on the schema
803
833
  const typeName = modelsWithInput?.has(source.name) ? `${source.name}Input` : source.name;
804
- lines.push(` const ${varName} = await parseAndValidate(${ctxExpr}, ${typeName}.${mode}());`);
834
+ lines.push(` const ${kind} = await parseAndValidate(${sourceExpr}, ${typeName}.${mode}());`);
805
835
  lines.push('');
806
836
  } else if (source.kind === 'params') {
807
837
  // Inline param declarations — wrap with the appropriate z.*Object constructor
808
838
  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
839
  const bind = (name: string) => (isPathParams ? toIdentifier(name) : name);
815
- const lhs = varName === 'params' ? `{ ${source.nodes.map(p => bind(p.name)).join(', ')} }` : varName;
840
+ const lhs = isPathParams ? `{ ${source.nodes.map(p => bind(p.name)).join(', ')} }` : kind;
816
841
  lines.push(` const ${lhs} = await parseAndValidate(`);
817
- lines.push(` ${ctxExpr},`);
842
+ lines.push(` ${sourceExpr},`);
818
843
  lines.push(` ${modeToWrapper(mode)}({`);
819
844
  for (const param of source.nodes) {
820
845
  // 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.
846
+ // is what the framework keys its params object by. For query and headers it is the
847
+ // wire name, quoted when that is not an identifier — those the client actually sends.
823
848
  const bound = bind(param.name);
824
849
  const key = isValidIdentifier(bound) ? bound : `'${bound}'`;
825
850
  // Delegating to renderQueryType rather than hand-rolling the array preprocess here:
@@ -837,7 +862,7 @@ function generateParamValidation(
837
862
  // ContractTypeNode — use query-aware rendering for query params (coerces single string → array),
838
863
  // otherwise use Input variant rendering; apply mode as a method call
839
864
  const schema = isQuery ? renderQueryType(source.node, modelsWithInput) : renderInputType(source.node, modelsWithInput);
840
- lines.push(` const ${varName} = await parseAndValidate(${ctxExpr}, (${schema}).${mode}());`);
865
+ lines.push(` const ${kind} = await parseAndValidate(${sourceExpr}, (${schema}).${mode}());`);
841
866
  lines.push('');
842
867
  }
843
868
  return lines;
package/src/index.ts CHANGED
@@ -45,6 +45,16 @@ 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';
48
58
 
49
59
  /** Taint set for the SDK's bigint response reviver. */
50
60
  const BIGINT_SCALARS: ReadonlySet<ScalarTypeNode['name']> = new Set(['bigint']);
@@ -65,14 +75,19 @@ import {
65
75
 
66
76
  // ─── Sub-config interfaces ─────────────────────────────────────────────────
67
77
 
68
- /** Koa server output: routers, and the type or Zod schema files they import. */
78
+ /** Server output: routers, and the type or Zod schema files they import. */
69
79
  export interface ServerConfig {
70
80
  /** Directory (relative to rootDir) where server files are written. Default: rootDir. */
71
81
  baseDir?: string;
82
+ /**
83
+ * HTTP framework the generated routers target. Also selects the flavour of the optional
84
+ * `mcp.router.ts` the `mcp` sub-config emits. Supported: `'koa'`. Default `'koa'`.
85
+ */
86
+ framework?: ServerFrameworkName;
72
87
  /** When true, `output.types` emits Zod schema files (via `generateContract`). When false/omitted, emits plain TypeScript. */
73
88
  zod?: boolean;
74
89
  output?: {
75
- /** Path template for Koa router files. Supports {filename}, {dir}, {area}. */
90
+ /** Path template for router files. Supports {filename}, {dir}, {area}. */
76
91
  routes?: string;
77
92
  /** Path template for type/schema files. Supports {filename}, {dir}, {area}. */
78
93
  types?: string;
@@ -151,7 +166,7 @@ export interface McpConfig {
151
166
  */
152
167
  types?: string;
153
168
  };
154
- /** Emit the `mcp.router.ts` route boilerplate. Default true. */
169
+ /** Emit the `mcp.router.ts` route boilerplate. Its framework follows `server.framework`. Default true. */
155
170
  emitRouter?: boolean;
156
171
  /** Mount path used in the emitted router. Default `/mcp`. */
157
172
  path?: string;
@@ -207,6 +222,14 @@ export function createTypescriptPlugin(config: TypescriptPluginConfig, rootDir:
207
222
 
208
223
  /** Reject config combinations that would generate code that cannot compile or cannot run. */
209
224
  function assertValidConfig(config: TypescriptPluginConfig): void {
225
+ // Runtime check, not just a type: config arrives as JSON, so `ServerFrameworkName` constrains
226
+ // programmatic callers only. Checked before the rest so a typo'd framework is the error reported.
227
+ const framework = config.server?.framework;
228
+ if (framework !== undefined && !(SERVER_FRAMEWORK_NAMES as readonly string[]).includes(framework)) {
229
+ throw new Error(
230
+ `plugin-typescript: server.framework '${String(framework)}' is not supported — expected one of: ${SERVER_FRAMEWORK_NAMES.join(', ')}.`,
231
+ );
232
+ }
210
233
  if (config.server?.validateResponses && !config.server.zod) {
211
234
  throw new Error(
212
235
  '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 +390,7 @@ function collectServerOutput(
367
390
  units: IncrementalUnit[],
368
391
  ): void {
369
392
  const serverBase = resolve(rootDir, config.baseDir ?? '.');
393
+ const framework = resolveServerFramework(config.framework);
370
394
  const modelsWithInput = inputs.modelsWithInput as Set<string>;
371
395
  const modelsWithOutput = inputs.modelsWithOutput as Set<string>;
372
396
  // Not `modelsWithOutput`: that set seeds only from `format(output=...)`, because only that case
@@ -418,7 +442,7 @@ function collectServerOutput(
418
442
  currentOutPath: typeOutPath,
419
443
  modelsWithInput,
420
444
  modelsWithOutput,
421
- // These types are consumed by Koa handlers, so `binary` is a Buffer, not a Blob.
445
+ // These types are consumed by server handlers, so `binary` is a Buffer, not a Blob.
422
446
  target: 'server' as const,
423
447
  };
424
448
  const content = config.zod ? generateContract(ast, renderCtx) : generatePlainTypes(ast, renderCtx);
@@ -446,6 +470,9 @@ function collectServerOutput(
446
470
  // this router's output with no change to `root` or the config.
447
471
  modelsWithTransform: sliceModelSet(refs, new Set(), modelsWithTransform),
448
472
  validateResponses: config.validateResponses ?? false,
473
+ // Covered by `sub` already, which is the whole sub-config; explicit for the same reason
474
+ // `validateResponses` is — the inputs that change a router's text read at a glance.
475
+ framework: framework.name,
449
476
  sub: subConfigKey,
450
477
  });
451
478
  units.push({
@@ -463,6 +490,7 @@ function collectServerOutput(
463
490
  modelsWithTransform,
464
491
  includeInternal: config.includeInternal,
465
492
  validateResponses: config.validateResponses,
493
+ framework,
466
494
  }),
467
495
  },
468
496
  ],
@@ -1107,7 +1135,10 @@ function collectMcpOutput(
1107
1135
  // ── Router (global, optional) ──
1108
1136
  if (config.emitRouter !== false) {
1109
1137
  const routerPath = join(mcpBase, config.output?.router ?? 'mcp.router.ts');
1110
- globalFiles.push({ relativePath: routerPath, content: generateMcpRouter({ path: config.path }) });
1138
+ // The mount is server-side wiring, so it follows the server sub-config's framework. Koa when
1139
+ // there is no `server` sub-config at all, which is the same default the router generator has.
1140
+ const framework = resolveServerFramework(fullConfig.server?.framework);
1141
+ globalFiles.push({ relativePath: routerPath, content: generateMcpRouter({ path: config.path, framework }) });
1111
1142
  }
1112
1143
  }
1113
1144
 
@@ -0,0 +1,112 @@
1
+ import type { ServerFramework } from './server-framework.js';
2
+
3
+ /** Module the Koa flavour of ServerKit publishes its router and route middleware from. */
4
+ const KOA_RUNTIME_MODULE = '@maroonedsoftware/koa';
5
+
6
+ /**
7
+ * Symbols importable from {@link KOA_RUNTIME_MODULE}. Every one is a name the adapter itself emits,
8
+ * so none can collide with a service class or router name derived from a contract.
9
+ */
10
+ const KOA_RUNTIME_SYMBOLS = ['ServerKitRouter', 'bodyParserMiddleware', 'requirePolicy', 'requireSignature'] as const;
11
+
12
+ /**
13
+ * ServerKit on Koa: the router is a `@koa/router` instance, handlers take a single `ctx`, and a
14
+ * response is written by assigning to `ctx.status` / `ctx.type` / `ctx.body` rather than returned.
15
+ */
16
+ export const KOA_SERVER_FRAMEWORK: ServerFramework = {
17
+ name: 'koa',
18
+
19
+ imports(uses) {
20
+ const symbols = KOA_RUNTIME_SYMBOLS.filter(uses);
21
+ return symbols.length > 0 ? [`import { ${symbols.join(', ')} } from '${KOA_RUNTIME_MODULE}';`] : [];
22
+ },
23
+
24
+ routerDeclaration(routerName) {
25
+ return `export const ${routerName} = ServerKitRouter();`;
26
+ },
27
+
28
+ pathParam(identifier) {
29
+ return `:${identifier}`;
30
+ },
31
+
32
+ routeOpen(routerName, method, path, middlewares) {
33
+ const middlewareStr = middlewares.length > 0 ? `, ${middlewares.join(', ')},` : ',';
34
+ return `${routerName}.${method}('${path}'${middlewareStr} async ctx => {`;
35
+ },
36
+
37
+ routeClose() {
38
+ return ['});'];
39
+ },
40
+
41
+ middleware: {
42
+ policy(args) {
43
+ return `requirePolicy(${args})`;
44
+ },
45
+ bodyParser(tokensExpr) {
46
+ return `bodyParserMiddleware([${tokensExpr}])`;
47
+ },
48
+ signature(args) {
49
+ return `requireSignature(${args})`;
50
+ },
51
+ },
52
+
53
+ request: {
54
+ params: 'ctx.params',
55
+ query: 'ctx.query',
56
+ headers: 'ctx.headers',
57
+ // Not `ctx.request.body`: the ServerKit body parser drains the stream and writes its result
58
+ // here, and in Koa `ctx.body` is the *response* body.
59
+ parsedBody: 'ctx.parsedBody',
60
+ // Koa strips the parameters off `Content-Type` for this accessor already.
61
+ contentType: 'ctx.request.type',
62
+ },
63
+
64
+ resolveService(className) {
65
+ return `ctx.container.get(${className})`;
66
+ },
67
+
68
+ response: {
69
+ status(expr) {
70
+ return `ctx.status = ${expr};`;
71
+ },
72
+ header(name, valueExpr) {
73
+ return `ctx.set('${name}', ${valueExpr});`;
74
+ },
75
+ type(expr) {
76
+ return `ctx.type = ${expr};`;
77
+ },
78
+ send(bodyExpr) {
79
+ // A bodyless response needs no statement at all: Koa sends whatever `ctx.status` and the
80
+ // headers say once the handler resolves.
81
+ return bodyExpr === undefined ? [] : [`ctx.body = ${bodyExpr};`];
82
+ },
83
+ caseEnd() {
84
+ return ['break;'];
85
+ },
86
+ },
87
+
88
+ mcpRouter({ path }) {
89
+ return `import { ServerKitRouter, bodyParserMiddleware, requireSignature } from '${KOA_RUNTIME_MODULE}';
90
+ import { McpDispatcher, createMcpRequestContext, MCP_AUTH_POLICY } from '@maroonedsoftware/mcp';
91
+
92
+ /** Mount the MCP endpoint onto a ServerKit router. Bind \`registerMcpTools\` to the \`McpToolHandlerMap\` token. */
93
+ export function mountMcp(router: ReturnType<typeof ServerKitRouter>): void {
94
+ router.post('${path}', bodyParserMiddleware(['json']), requireSignature('mcp', { policy: MCP_AUTH_POLICY }), async (ctx) => {
95
+ const dispatcher = ctx.container.get(McpDispatcher);
96
+ const context = createMcpRequestContext({ requestId: ctx.requestId, logger: ctx.logger });
97
+ if (dispatcher.sessionMode === 'stateful') {
98
+ ctx.respond = false;
99
+ await dispatcher.dispatchStateful(
100
+ { req: ctx.req, res: ctx.res, body: ctx.parsedBody, sessionId: ctx.get('mcp-session-id') },
101
+ context,
102
+ );
103
+ } else {
104
+ const response = await dispatcher.dispatch(JSON.parse(String(ctx.rawBody)), context);
105
+ if (response) ctx.body = response;
106
+ else ctx.status = 202; // a notification — nothing to return
107
+ }
108
+ });
109
+ }
110
+ `;
111
+ },
112
+ };