@contractkit/plugin-typescript 0.35.0 → 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.
@@ -0,0 +1,8 @@
1
+ import type { ServerFramework } from './server-framework.js';
2
+ /**
3
+ * ServerKit on Fastify: `ServerKitRouter()` collects routes the way a Koa app reads and mounts them
4
+ * as a Fastify plugin, handlers take `(request, reply)` where the request *is* the ServerKit
5
+ * context, and a response is sent by returning `reply.send(...)` rather than by assignment.
6
+ */
7
+ export declare const FASTIFY_SERVER_FRAMEWORK: ServerFramework;
8
+ //# sourceMappingURL=server-framework-fastify.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server-framework-fastify.d.ts","sourceRoot":"","sources":["../src/server-framework-fastify.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAc7D;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,EAAE,eA4GtC,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"server-framework-koa.d.ts","sourceRoot":"","sources":["../src/server-framework-koa.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAW7D;;;GAGG;AACH,eAAO,MAAM,oBAAoB,EAAE,eAgGlC,CAAC"}
1
+ {"version":3,"file":"server-framework-koa.d.ts","sourceRoot":"","sources":["../src/server-framework-koa.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAW7D;;;GAGG;AACH,eAAO,MAAM,oBAAoB,EAAE,eAkGlC,CAAC"}
@@ -2,7 +2,7 @@
2
2
  * HTTP frameworks the server sub-generator can target. Adding a name here without adding an adapter
3
3
  * to {@link SERVER_FRAMEWORKS} fails to compile, which is the point of keeping the two in step.
4
4
  */
5
- export declare const SERVER_FRAMEWORK_NAMES: readonly ["koa"];
5
+ export declare const SERVER_FRAMEWORK_NAMES: readonly ["koa", "fastify"];
6
6
  /** One of {@link SERVER_FRAMEWORK_NAMES}. */
7
7
  export type ServerFrameworkName = (typeof SERVER_FRAMEWORK_NAMES)[number];
8
8
  /** The framework assumed when a config names none. */
@@ -29,6 +29,13 @@ export interface ServerFramework {
29
29
  routerDeclaration(routerName: string): string;
30
30
  /** Placeholder syntax for one path parameter, given a name already mapped to a valid identifier. */
31
31
  pathParam(identifier: string): string;
32
+ /**
33
+ * Identifiers the handler signature itself binds — `ctx`, or `request` and `reply`. A path
34
+ * parameter is destructured into the handler body, so one declared with the same name would
35
+ * shadow the handler's own parameter: a redeclaration under `tsc`, and a temporal-dead-zone
36
+ * `ReferenceError` at runtime. Codegen renames the local binding to avoid these.
37
+ */
38
+ readonly handlerLocals: readonly string[];
32
39
  /** Opening line of a handler, including its middleware and the handler function's parameters. */
33
40
  routeOpen(routerName: string, method: string, path: string, middlewares: readonly string[]): string;
34
41
  /** Lines that close a handler opened by {@link routeOpen}. */
@@ -1 +1 @@
1
- {"version":3,"file":"server-framework.d.ts","sourceRoot":"","sources":["../src/server-framework.ts"],"names":[],"mappings":"AAEA;;;GAGG;AACH,eAAO,MAAM,sBAAsB,kBAAmB,CAAC;AAEvD,6CAA6C;AAC7C,MAAM,MAAM,mBAAmB,GAAG,CAAC,OAAO,sBAAsB,CAAC,CAAC,MAAM,CAAC,CAAC;AAE1E,sDAAsD;AACtD,eAAO,MAAM,6BAA6B,EAAE,mBAA2B,CAAC;AAExE;;;;;;;GAOG;AACH,MAAM,WAAW,eAAe;IAC5B,QAAQ,CAAC,IAAI,EAAE,mBAAmB,CAAC;IAEnC;;;;;;OAMG;IACH,OAAO,CAAC,IAAI,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,GAAG,MAAM,EAAE,CAAC;IAErD,+DAA+D;IAC/D,iBAAiB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAAC;IAE9C,oGAAoG;IACpG,SAAS,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAAC;IAEtC,iGAAiG;IACjG,SAAS,CAAC,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAAC;IAEpG,8DAA8D;IAC9D,UAAU,IAAI,MAAM,EAAE,CAAC;IAEvB,qFAAqF;IACrF,QAAQ,CAAC,UAAU,EAAE;QACjB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;QAC7B,UAAU,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAAC;QACvC,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;KACnC,CAAC;IAEF,uDAAuD;IACvD,QAAQ,CAAC,OAAO,EAAE;QACd,MAAM,EAAE,MAAM,CAAC;QACf,KAAK,EAAE,MAAM,CAAC;QACd,OAAO,EAAE,MAAM,CAAC;QAChB,6DAA6D;QAC7D,UAAU,EAAE,MAAM,CAAC;QACnB;;;;WAIG;QACH,WAAW,EAAE,MAAM,CAAC;KACvB,CAAC;IAEF,mFAAmF;IACnF,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC;IAE1C,qDAAqD;IACrD,QAAQ,CAAC,QAAQ,EAAE;QACf,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;QAC7B;;;WAGG;QACH,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC;QAChD,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;QAC3B;;;;WAIG;QACH,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,EAAE,CAAC;QAC7C,6FAA6F;QAC7F,OAAO,IAAI,MAAM,EAAE,CAAC;KACvB,CAAC;IAEF,+FAA+F;IAC/F,SAAS,CAAC,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG,MAAM,CAAC;CAChD;AAED;;;GAGG;AACH,eAAO,MAAM,iBAAiB,EAAE,QAAQ,CAAC,MAAM,CAAC,mBAAmB,EAAE,eAAe,CAAC,CAEpF,CAAC;AAEF;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,eAAe,CAShF"}
1
+ {"version":3,"file":"server-framework.d.ts","sourceRoot":"","sources":["../src/server-framework.ts"],"names":[],"mappings":"AAGA;;;GAGG;AACH,eAAO,MAAM,sBAAsB,6BAA8B,CAAC;AAElE,6CAA6C;AAC7C,MAAM,MAAM,mBAAmB,GAAG,CAAC,OAAO,sBAAsB,CAAC,CAAC,MAAM,CAAC,CAAC;AAE1E,sDAAsD;AACtD,eAAO,MAAM,6BAA6B,EAAE,mBAA2B,CAAC;AAExE;;;;;;;GAOG;AACH,MAAM,WAAW,eAAe;IAC5B,QAAQ,CAAC,IAAI,EAAE,mBAAmB,CAAC;IAEnC;;;;;;OAMG;IACH,OAAO,CAAC,IAAI,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,GAAG,MAAM,EAAE,CAAC;IAErD,+DAA+D;IAC/D,iBAAiB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAAC;IAE9C,oGAAoG;IACpG,SAAS,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAAC;IAEtC;;;;;OAKG;IACH,QAAQ,CAAC,aAAa,EAAE,SAAS,MAAM,EAAE,CAAC;IAE1C,iGAAiG;IACjG,SAAS,CAAC,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAAC;IAEpG,8DAA8D;IAC9D,UAAU,IAAI,MAAM,EAAE,CAAC;IAEvB,qFAAqF;IACrF,QAAQ,CAAC,UAAU,EAAE;QACjB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;QAC7B,UAAU,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAAC;QACvC,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;KACnC,CAAC;IAEF,uDAAuD;IACvD,QAAQ,CAAC,OAAO,EAAE;QACd,MAAM,EAAE,MAAM,CAAC;QACf,KAAK,EAAE,MAAM,CAAC;QACd,OAAO,EAAE,MAAM,CAAC;QAChB,6DAA6D;QAC7D,UAAU,EAAE,MAAM,CAAC;QACnB;;;;WAIG;QACH,WAAW,EAAE,MAAM,CAAC;KACvB,CAAC;IAEF,mFAAmF;IACnF,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC;IAE1C,qDAAqD;IACrD,QAAQ,CAAC,QAAQ,EAAE;QACf,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;QAC7B;;;WAGG;QACH,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC;QAChD,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;QAC3B;;;;WAIG;QACH,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,EAAE,CAAC;QAC7C,6FAA6F;QAC7F,OAAO,IAAI,MAAM,EAAE,CAAC;KACvB,CAAC;IAEF,+FAA+F;IAC/F,SAAS,CAAC,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG,MAAM,CAAC;CAChD;AAED;;;GAGG;AACH,eAAO,MAAM,iBAAiB,EAAE,QAAQ,CAAC,MAAM,CAAC,mBAAmB,EAAE,eAAe,CAAC,CAGpF,CAAC;AAEF;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,eAAe,CAShF"}
package/llms.txt CHANGED
@@ -56,7 +56,7 @@ Path templates accept `{filename}`, `{dir}`, `{area}`, and — in `sdk.output.sd
56
56
 
57
57
  | Key | Emits |
58
58
  | --- | --- |
59
- | `server` | Server routers from `operation` declarations, plus the type or Zod files they import. `framework` selects the HTTP framework; only `koa` today |
59
+ | `server` | Server routers from `operation` declarations, plus the type or Zod files they import. `framework` selects the HTTP framework: `koa` or `fastify` |
60
60
  | `sdk` | The SDK class, per-area operation clients, and their types |
61
61
  | `zod` | Standalone Zod schemas, independent of `server` and `sdk` |
62
62
  | `types` | Standalone plain TypeScript types, independent of `server` and `sdk` |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@contractkit/plugin-typescript",
3
- "version": "0.35.0",
4
- "description": "ContractKit built-in plugin: TypeScript codegen (SDK clients, Koa routers, Zod schemas, plain types)",
3
+ "version": "0.36.0",
4
+ "description": "ContractKit built-in plugin: TypeScript codegen (SDK clients, Koa or Fastify routers, Zod schemas, plain types)",
5
5
  "license": "MIT",
6
6
  "author": {
7
7
  "name": "Marooned Software",
@@ -27,6 +27,34 @@ import { KOA_SERVER_FRAMEWORK } from './server-framework-koa.js';
27
27
  /** Which request-side object a validation block reads from. Names the variable the block declares. */
28
28
  export type ParamKind = 'params' | 'query' | 'headers';
29
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
+ }
57
+
30
58
  // ─── Content-type helpers ──────────────────────────────────────────────────
31
59
 
32
60
  /**
@@ -362,7 +390,10 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
362
390
  lines.push(framework.routeOpen(deriveRouterName(file), method, path, middlewares));
363
391
 
364
392
  // Params / query / headers validation (request-side — use Input variants)
365
- lines.push(...generateParamValidation(route.params, 'params', framework.request.params, route.paramsMode ?? 'strict', '', 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));
366
397
  lines.push(...generateParamValidation(op.query, 'query', framework.request.query, op.queryMode ?? 'strict', '', modelsWithInput));
367
398
  lines.push(...generateParamValidation(op.headers, 'headers', framework.request.headers, op.headersMode ?? 'strip', '', modelsWithInput));
368
399
 
@@ -405,7 +436,7 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
405
436
  // responsible for producing; the rest are documentation, or the thrown-error path.
406
437
  const emitted = emittedResponses(op);
407
438
  const serviceParts = inferService(op, route, file);
408
- const call = `await service.${serviceParts.methodName}(${buildArgs(route, op)})`;
439
+ const call = `await service.${serviceParts.methodName}(${buildArgs(route, op, pathBindings)})`;
409
440
 
410
441
  if (emitted.length > 1) {
411
442
  lines.push(...generateMultiStatusResult(emitted, serviceParts.className, call, options));
@@ -654,12 +685,14 @@ function inferMethodName(method: string, path: string): string {
654
685
  *
655
686
  * @returns The rendered argument list, or an empty string when the method takes no arguments.
656
687
  */
657
- export function buildArgs(route: OpRouteNode, op: OpOperationNode): string {
688
+ export function buildArgs(route: OpRouteNode, op: OpOperationNode, bindings?: Map<string, string>): string {
658
689
  const args: string[] = [];
659
690
  // Path params: spread individually (inline) or pass 'params' object (type-ref/ContractTypeNode)
660
691
  if (route.params) {
661
692
  if (route.params.kind === 'params') {
662
- 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)));
663
696
  } else {
664
697
  args.push('params');
665
698
  }
@@ -821,6 +854,7 @@ function generateParamValidation(
821
854
  mode: ObjectMode,
822
855
  suffix = '',
823
856
  modelsWithInput?: Set<string>,
857
+ bindings?: Map<string, string>,
824
858
  ): string[] {
825
859
  if (!source) return [];
826
860
  const lines: string[] = [];
@@ -837,7 +871,17 @@ function generateParamValidation(
837
871
  // Inline param declarations — wrap with the appropriate z.*Object constructor
838
872
  if (source.nodes.length > 0) {
839
873
  const bind = (name: string) => (isPathParams ? toIdentifier(name) : name);
840
- const lhs = isPathParams ? `{ ${source.nodes.map(p => bind(p.name)).join(', ')} }` : kind;
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;
841
885
  lines.push(` const ${lhs} = await parseAndValidate(`);
842
886
  lines.push(` ${sourceExpr},`);
843
887
  lines.push(` ${modeToWrapper(mode)}({`);
package/src/index.ts CHANGED
@@ -55,6 +55,7 @@ export {
55
55
  type ServerFrameworkName,
56
56
  } from './server-framework.js';
57
57
  export { KOA_SERVER_FRAMEWORK } from './server-framework-koa.js';
58
+ export { FASTIFY_SERVER_FRAMEWORK } from './server-framework-fastify.js';
58
59
 
59
60
  /** Taint set for the SDK's bigint response reviver. */
60
61
  const BIGINT_SCALARS: ReadonlySet<ScalarTypeNode['name']> = new Set(['bigint']);
@@ -0,0 +1,128 @@
1
+ import type { ServerFramework } from './server-framework.js';
2
+
3
+ /** Module the Fastify flavour of ServerKit publishes its router and route middleware from. */
4
+ const FASTIFY_RUNTIME_MODULE = '@maroonedsoftware/fastify';
5
+
6
+ /**
7
+ * Symbols importable from {@link FASTIFY_RUNTIME_MODULE}. Every one is a name the adapter itself
8
+ * emits, so none can collide with a service class or router name derived from a contract.
9
+ *
10
+ * `requestMediaType` is here because Fastify has no accessor that strips the parameters off
11
+ * `Content-Type`, and it is only referenced when an operation declares several request MIME types.
12
+ */
13
+ const FASTIFY_RUNTIME_SYMBOLS = ['ServerKitRouter', 'bodyParserMiddleware', 'requirePolicy', 'requireSignature', 'requestMediaType'] as const;
14
+
15
+ /**
16
+ * ServerKit on Fastify: `ServerKitRouter()` collects routes the way a Koa app reads and mounts them
17
+ * as a Fastify plugin, handlers take `(request, reply)` where the request *is* the ServerKit
18
+ * context, and a response is sent by returning `reply.send(...)` rather than by assignment.
19
+ */
20
+ export const FASTIFY_SERVER_FRAMEWORK: ServerFramework = {
21
+ name: 'fastify',
22
+
23
+ imports(uses) {
24
+ const symbols = FASTIFY_RUNTIME_SYMBOLS.filter(uses);
25
+ return symbols.length > 0 ? [`import { ${symbols.join(', ')} } from '${FASTIFY_RUNTIME_MODULE}';`] : [];
26
+ },
27
+
28
+ routerDeclaration(routerName) {
29
+ return `export const ${routerName} = ServerKitRouter();`;
30
+ },
31
+
32
+ pathParam(identifier) {
33
+ return `:${identifier}`;
34
+ },
35
+
36
+ handlerLocals: ['request', 'reply'],
37
+
38
+ routeOpen(routerName, method, path, middlewares) {
39
+ const middlewareStr = middlewares.length > 0 ? `, ${middlewares.join(', ')},` : ',';
40
+ return `${routerName}.${method}('${path}'${middlewareStr} async (request, reply) => {`;
41
+ },
42
+
43
+ routeClose() {
44
+ return ['});'];
45
+ },
46
+
47
+ middleware: {
48
+ policy(args) {
49
+ return `requirePolicy(${args})`;
50
+ },
51
+ bodyParser(tokensExpr) {
52
+ return `bodyParserMiddleware([${tokensExpr}])`;
53
+ },
54
+ signature(args) {
55
+ return `requireSignature(${args})`;
56
+ },
57
+ },
58
+
59
+ request: {
60
+ params: 'request.params',
61
+ query: 'request.query',
62
+ headers: 'request.headers',
63
+ // ServerKit parses lazily per route, so Fastify's own `request.body` is never populated.
64
+ parsedBody: 'request.parsedBody',
65
+ // A call, not a property: the raw header carries `; charset=utf-8`, which would match none of
66
+ // the declared MIME literals the generated switch compares against.
67
+ contentType: 'requestMediaType(request)',
68
+ },
69
+
70
+ resolveService(className) {
71
+ return `request.container.get(${className})`;
72
+ },
73
+
74
+ response: {
75
+ status(expr) {
76
+ return `reply.status(${expr});`;
77
+ },
78
+ header(name, valueExpr) {
79
+ return `reply.header('${name}', ${valueExpr});`;
80
+ },
81
+ type(expr) {
82
+ return `reply.type(${expr});`;
83
+ },
84
+ send(bodyExpr) {
85
+ // Unlike Koa, a bodyless response still needs a statement: a handler that neither returns
86
+ // a body nor calls `send` leaves the request hanging.
87
+ return bodyExpr === undefined ? ['return reply.send();'] : [`return reply.send(${bodyExpr});`];
88
+ },
89
+ caseEnd() {
90
+ // Every status case has already returned, so a `break` here would be unreachable code.
91
+ return [];
92
+ },
93
+ },
94
+
95
+ mcpRouter({ path }) {
96
+ return `import { type ServerKitRouterType, bodyParserMiddleware, requireSignature, requestHeader } from '${FASTIFY_RUNTIME_MODULE}';
97
+ import { McpDispatcher, createMcpRequestContext, MCP_AUTH_POLICY } from '@maroonedsoftware/mcp';
98
+
99
+ /** Mount the MCP endpoint onto a ServerKit router. Bind \`registerMcpTools\` to the \`McpToolHandlerMap\` token. */
100
+ export function mountMcp(router: ServerKitRouterType): void {
101
+ router.post('${path}', bodyParserMiddleware(['json']), requireSignature('mcp', { policy: MCP_AUTH_POLICY }), async (request, reply) => {
102
+ const dispatcher = request.container.get(McpDispatcher);
103
+ const context = createMcpRequestContext({ requestId: request.requestId, logger: request.logger });
104
+ if (dispatcher.sessionMode === 'stateful') {
105
+ // Fastify's equivalent of Koa's \`ctx.respond = false\`: the dispatcher writes the raw
106
+ // response itself, and the request scope is disposed on the raw socket close instead.
107
+ reply.hijack();
108
+ await dispatcher.dispatchStateful(
109
+ {
110
+ req: request.raw,
111
+ res: reply.raw,
112
+ body: request.parsedBody,
113
+ // \`requestHeader\` returns '' for an absent header; the session id is optional.
114
+ sessionId: requestHeader(request, 'mcp-session-id') || undefined,
115
+ },
116
+ context,
117
+ );
118
+ return;
119
+ }
120
+ const response = await dispatcher.dispatch(JSON.parse(String(request.rawBody)), context);
121
+ if (response) return reply.send(response);
122
+ reply.status(202); // a notification — nothing to return
123
+ return reply.send();
124
+ });
125
+ }
126
+ `;
127
+ },
128
+ };
@@ -29,6 +29,8 @@ export const KOA_SERVER_FRAMEWORK: ServerFramework = {
29
29
  return `:${identifier}`;
30
30
  },
31
31
 
32
+ handlerLocals: ['ctx'],
33
+
32
34
  routeOpen(routerName, method, path, middlewares) {
33
35
  const middlewareStr = middlewares.length > 0 ? `, ${middlewares.join(', ')},` : ',';
34
36
  return `${routerName}.${method}('${path}'${middlewareStr} async ctx => {`;
@@ -1,10 +1,11 @@
1
1
  import { KOA_SERVER_FRAMEWORK } from './server-framework-koa.js';
2
+ import { FASTIFY_SERVER_FRAMEWORK } from './server-framework-fastify.js';
2
3
 
3
4
  /**
4
5
  * HTTP frameworks the server sub-generator can target. Adding a name here without adding an adapter
5
6
  * to {@link SERVER_FRAMEWORKS} fails to compile, which is the point of keeping the two in step.
6
7
  */
7
- export const SERVER_FRAMEWORK_NAMES = ['koa'] as const;
8
+ export const SERVER_FRAMEWORK_NAMES = ['koa', 'fastify'] as const;
8
9
 
9
10
  /** One of {@link SERVER_FRAMEWORK_NAMES}. */
10
11
  export type ServerFrameworkName = (typeof SERVER_FRAMEWORK_NAMES)[number];
@@ -38,6 +39,14 @@ export interface ServerFramework {
38
39
  /** Placeholder syntax for one path parameter, given a name already mapped to a valid identifier. */
39
40
  pathParam(identifier: string): string;
40
41
 
42
+ /**
43
+ * Identifiers the handler signature itself binds — `ctx`, or `request` and `reply`. A path
44
+ * parameter is destructured into the handler body, so one declared with the same name would
45
+ * shadow the handler's own parameter: a redeclaration under `tsc`, and a temporal-dead-zone
46
+ * `ReferenceError` at runtime. Codegen renames the local binding to avoid these.
47
+ */
48
+ readonly handlerLocals: readonly string[];
49
+
41
50
  /** Opening line of a handler, including its middleware and the handler function's parameters. */
42
51
  routeOpen(routerName: string, method: string, path: string, middlewares: readonly string[]): string;
43
52
 
@@ -98,6 +107,7 @@ export interface ServerFramework {
98
107
  */
99
108
  export const SERVER_FRAMEWORKS: Readonly<Record<ServerFrameworkName, ServerFramework>> = {
100
109
  koa: KOA_SERVER_FRAMEWORK,
110
+ fastify: FASTIFY_SERVER_FRAMEWORK,
101
111
  };
102
112
 
103
113
  /**
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { generateOp } from '../src/codegen-operation.js';
3
3
  import type { ServerFramework } from '../src/server-framework.js';
4
+ import { FASTIFY_SERVER_FRAMEWORK } from '../src/server-framework-fastify.js';
4
5
  import { scalarType, refType, opParam, opRequest, opMultiRequest, opResponse, opResponseMulti, opOperation, opRoute, opRoot } from './helpers.js';
5
6
 
6
7
  /**
@@ -15,6 +16,7 @@ const STUB: ServerFramework = {
15
16
  imports: uses => (uses('StubRouter') ? ["import { StubRouter } from '@stub/http';"] : []),
16
17
  routerDeclaration: routerName => `export const ${routerName} = StubRouter();`,
17
18
  pathParam: identifier => `<${identifier}>`,
19
+ handlerLocals: ['rq', 'rs'],
18
20
  routeOpen: (routerName, method, path, middlewares) => `${routerName}.route('${method}', '${path}', [${middlewares.join(', ')}], async (rq, rs) => {`,
19
21
  routeClose: () => ['}, END);'],
20
22
  middleware: {
@@ -138,3 +140,45 @@ describe('generateOp — framework seam', () => {
138
140
  expect(statusSwitch).not.toContain('break;');
139
141
  });
140
142
  });
143
+
144
+ describe('generateOp — Fastify', () => {
145
+ const output = generateOp(everyBranchRoot(), { framework: FASTIFY_SERVER_FRAMEWORK });
146
+
147
+ it('opens each handler with the Fastify signature', () => {
148
+ expect(output).toContain("UsersRouter.post('/payments/:paymentId', requirePolicy(), bodyParserMiddleware(['json']), requireSignature('stripe'), async (request, reply) => {");
149
+ expect(output).not.toMatch(/\bctx\b/);
150
+ });
151
+
152
+ it('imports the runtime helper only because the multi-MIME switch uses it', () => {
153
+ expect(output).toContain(
154
+ "import { ServerKitRouter, bodyParserMiddleware, requirePolicy, requireSignature, requestMediaType } from '@maroonedsoftware/fastify';",
155
+ );
156
+ expect(output).toContain('switch (requestMediaType(request)) {');
157
+ });
158
+
159
+ it('reads the request through the request object', () => {
160
+ expect(output).toContain('request.params');
161
+ expect(output).toContain('request.query');
162
+ expect(output).toContain('request.headers');
163
+ expect(output).toContain('await parseAndValidate(request.parsedBody,');
164
+ expect(output).toContain('request.container.get(UsersService)');
165
+ });
166
+
167
+ it('writes the response through reply, returning the send', () => {
168
+ expect(output).toContain('reply.status(201);');
169
+ expect(output).toContain("reply.type('application/json');");
170
+ expect(output).toContain('return reply.send(result.body);');
171
+ expect(output).toContain('reply.header(\'etag\', String(result.headers["etag"]));');
172
+ });
173
+
174
+ it('sends explicitly for a bodyless response, where Koa writes nothing', () => {
175
+ expect(output).toContain('reply.status(204);');
176
+ expect(output).toContain('return reply.send();');
177
+ });
178
+
179
+ it('leaves no break in the status switch, since every case returns', () => {
180
+ const statusSwitch = output.slice(output.indexOf('switch (result.status) {'));
181
+ expect(statusSwitch).toContain('case 202:');
182
+ expect(statusSwitch).not.toContain('break;');
183
+ });
184
+ });
@@ -394,6 +394,51 @@ describe('generateOperation', () => {
394
394
 
395
395
  // ─── Query validation ────────────────────────────────────────
396
396
 
397
+ describe('path params that would shadow a handler local', () => {
398
+ it('renames the binding but keeps the placeholder and the schema key', () => {
399
+ // `ctx` is the Koa handler's own parameter. Destructuring one under that name redeclares
400
+ // it: a tsc error, and a temporal-dead-zone ReferenceError at runtime.
401
+ const root = opRoot([opRoute('/threads/{ctx}', [opOperation('get')], [opParam('ctx', scalarType('uuid'))])]);
402
+ const output = generateOp(root);
403
+ expect(output).toContain("get('/threads/:ctx'");
404
+ expect(output).toContain('ctx: z.uuid()');
405
+ expect(output).toContain('const { ctx: ctx_ } = await parseAndValidate(');
406
+ expect(output).not.toContain('const { ctx } =');
407
+ });
408
+
409
+ it('passes the renamed identifier to the service', () => {
410
+ const root = opRoot([opRoute('/threads/{ctx}', [opOperation('get')], [opParam('ctx', scalarType('uuid'))])]);
411
+ expect(generateOp(root)).toContain('await service.getById(ctx_)');
412
+ });
413
+
414
+ it('renames a param that would shadow one of the generator\'s own locals', () => {
415
+ // `body` is the variable the request-body block declares.
416
+ const root = opRoot([
417
+ opRoute('/posts/{body}', [opOperation('post', { request: opRequest('CreatePost') })], [opParam('body', scalarType('uuid'))]),
418
+ ]);
419
+ const output = generateOp(root);
420
+ expect(output).toContain('const { body: body_ } = await parseAndValidate(');
421
+ expect(output).toContain('const body = await parseAndValidate(');
422
+ expect(output).toContain('await service.create(body_, body)');
423
+ });
424
+
425
+ it('leaves a param alone when nothing collides', () => {
426
+ const root = opRoot([opRoute('/users/{userId}', [opOperation('get')], [opParam('userId', scalarType('uuid'))])]);
427
+ const output = generateOp(root);
428
+ expect(output).toContain('const { userId } = await parseAndValidate(');
429
+ expect(output).toContain('await service.getById(userId)');
430
+ });
431
+
432
+ it('keeps two params distinct when a rename would collapse them onto one name', () => {
433
+ const root = opRoot([
434
+ opRoute('/x/{ctx}/{ctx_}', [opOperation('get')], [opParam('ctx', scalarType('uuid')), opParam('ctx_', scalarType('uuid'))]),
435
+ ]);
436
+ const output = generateOp(root);
437
+ expect(output).toContain('const { ctx: ctx_, ctx_: ctx__ } = await parseAndValidate(');
438
+ expect(output).toContain('await service.getById(ctx_, ctx__)');
439
+ });
440
+ });
441
+
397
442
  describe('query validation', () => {
398
443
  it('generates query validation block', () => {
399
444
  const root = opRoot([
@@ -0,0 +1,148 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { FASTIFY_SERVER_FRAMEWORK as fastify } from '../src/server-framework-fastify.js';
3
+
4
+ /**
5
+ * Pinned method by method, the way the Koa adapter is. These strings are the contract with
6
+ * `@maroonedsoftware/fastify`, so a change to any of them should fail here, naming the piece,
7
+ * rather than only as a snapshot diff in another package.
8
+ */
9
+ describe('FASTIFY_SERVER_FRAMEWORK', () => {
10
+ it('is named fastify', () => {
11
+ expect(fastify.name).toBe('fastify');
12
+ });
13
+
14
+ describe('imports', () => {
15
+ it('emits one line naming every symbol the body uses', () => {
16
+ expect(fastify.imports(() => true)).toEqual([
17
+ "import { ServerKitRouter, bodyParserMiddleware, requirePolicy, requireSignature, requestMediaType } from '@maroonedsoftware/fastify';",
18
+ ]);
19
+ });
20
+
21
+ it('narrows to the symbols the body actually references', () => {
22
+ expect(fastify.imports(s => s === 'requestMediaType')).toEqual(["import { requestMediaType } from '@maroonedsoftware/fastify';"]);
23
+ });
24
+
25
+ it('emits nothing when the body references none of them', () => {
26
+ expect(fastify.imports(() => false)).toEqual([]);
27
+ });
28
+ });
29
+
30
+ it('declares the router', () => {
31
+ expect(fastify.routerDeclaration('UsersRouter')).toBe('export const UsersRouter = ServerKitRouter();');
32
+ });
33
+
34
+ it('renders a path parameter with a colon', () => {
35
+ expect(fastify.pathParam('userId')).toBe(':userId');
36
+ });
37
+
38
+ it('reserves the two identifiers its handler signature binds', () => {
39
+ expect(fastify.handlerLocals).toEqual(['request', 'reply']);
40
+ });
41
+
42
+ describe('routeOpen', () => {
43
+ it('omits the middleware list when there is none', () => {
44
+ expect(fastify.routeOpen('UsersRouter', 'get', '/users', [])).toBe("UsersRouter.get('/users', async (request, reply) => {");
45
+ });
46
+
47
+ it('places the middleware between the path and the handler', () => {
48
+ expect(fastify.routeOpen('UsersRouter', 'post', '/users', ['requirePolicy()', "bodyParserMiddleware(['json'])"])).toBe(
49
+ "UsersRouter.post('/users', requirePolicy(), bodyParserMiddleware(['json']), async (request, reply) => {",
50
+ );
51
+ });
52
+ });
53
+
54
+ it('closes a route', () => {
55
+ expect(fastify.routeClose()).toEqual(['});']);
56
+ });
57
+
58
+ it('renders the route middleware factories, which take the same arguments as Koa\'s', () => {
59
+ expect(fastify.middleware.policy('')).toBe('requirePolicy()');
60
+ expect(fastify.middleware.policy("{ policy: 'admin' }")).toBe("requirePolicy({ policy: 'admin' })");
61
+ expect(fastify.middleware.bodyParser("'json', 'multipart'")).toBe("bodyParserMiddleware(['json', 'multipart'])");
62
+ expect(fastify.middleware.signature("'slack'")).toBe("requireSignature('slack')");
63
+ });
64
+
65
+ it('reads the request off the request object, which is the context', () => {
66
+ expect(fastify.request).toEqual({
67
+ params: 'request.params',
68
+ query: 'request.query',
69
+ headers: 'request.headers',
70
+ parsedBody: 'request.parsedBody',
71
+ // A call rather than a property: the raw header carries `; charset=…`, which matches no
72
+ // declared MIME literal.
73
+ contentType: 'requestMediaType(request)',
74
+ });
75
+ });
76
+
77
+ it('resolves a service from the request container', () => {
78
+ expect(fastify.resolveService('PaymentService')).toBe('request.container.get(PaymentService)');
79
+ });
80
+
81
+ describe('response', () => {
82
+ it('sets the status through reply', () => {
83
+ expect(fastify.response.status('200')).toBe('reply.status(200);');
84
+ expect(fastify.response.status('result.status')).toBe('reply.status(result.status);');
85
+ });
86
+
87
+ it('sets a header as a single statement, so the optional guard can wrap it', () => {
88
+ expect(fastify.response.header('x-request-id', 'String(result.headers["xRequestId"])')).toBe(
89
+ 'reply.header(\'x-request-id\', String(result.headers["xRequestId"]));',
90
+ );
91
+ });
92
+
93
+ it('sets the content type', () => {
94
+ expect(fastify.response.type("'application/json'")).toBe("reply.type('application/json');");
95
+ expect(fastify.response.type('result.contentType')).toBe('reply.type(result.contentType);');
96
+ });
97
+
98
+ it('returns the send, including for a response with no body', () => {
99
+ expect(fastify.response.send('result')).toEqual(['return reply.send(result);']);
100
+ // Unlike Koa: a handler that neither returns a body nor calls send leaves the request hanging.
101
+ expect(fastify.response.send(undefined)).toEqual(['return reply.send();']);
102
+ });
103
+
104
+ it('closes a status case with nothing, since the case already returned', () => {
105
+ expect(fastify.response.caseEnd()).toEqual([]);
106
+ });
107
+ });
108
+
109
+ describe('mcpRouter', () => {
110
+ const out = fastify.mcpRouter({ path: '/mcp' });
111
+
112
+ it('mounts the dispatcher at the given path', () => {
113
+ expect(fastify.mcpRouter({ path: '/tools' })).toContain("router.post('/tools',");
114
+ expect(out).toContain("from '@maroonedsoftware/fastify'");
115
+ expect(out).toContain('const dispatcher = request.container.get(McpDispatcher);');
116
+ });
117
+
118
+ it('names the exported router type rather than inferring it', () => {
119
+ expect(out).toContain('export function mountMcp(router: ServerKitRouterType): void {');
120
+ // And therefore never imports the factory value, which `noUnusedLocals` would reject.
121
+ expect(out).toContain("import { type ServerKitRouterType, bodyParserMiddleware, requireSignature, requestHeader } from '@maroonedsoftware/fastify';");
122
+ });
123
+
124
+ it('hijacks the reply for a stateful session, which is Fastify\'s ctx.respond = false', () => {
125
+ expect(out).toContain('reply.hijack();');
126
+ expect(out).toContain('req: request.raw,');
127
+ expect(out).toContain('res: reply.raw,');
128
+ });
129
+
130
+ it('reads the parsed body, not Fastify\'s own request.body', () => {
131
+ expect(out).toContain('body: request.parsedBody,');
132
+ expect(out).not.toContain('request.body,');
133
+ });
134
+
135
+ it('passes undefined rather than the empty string for an absent session id', () => {
136
+ expect(out).toContain("sessionId: requestHeader(request, 'mcp-session-id') || undefined,");
137
+ });
138
+
139
+ it('answers a notification with a bodyless 202', () => {
140
+ expect(out).toContain('reply.status(202);');
141
+ expect(out).toContain('return reply.send();');
142
+ });
143
+
144
+ it('escapes the backticks in its own doc comment rather than closing the template', () => {
145
+ expect(out).toContain('Bind `registerMcpTools` to the `McpToolHandlerMap` token.');
146
+ });
147
+ });
148
+ });
@@ -9,6 +9,7 @@ describe('resolveServerFramework', () => {
9
9
 
10
10
  it('resolves a supported name', () => {
11
11
  expect(resolveServerFramework('koa').name).toBe('koa');
12
+ expect(resolveServerFramework('fastify').name).toBe('fastify');
12
13
  });
13
14
 
14
15
  it('rejects an unsupported name, naming what is supported', () => {