@dudousxd/nestjs-codegen 0.13.2 → 0.14.1

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.
@@ -29,8 +29,8 @@ module.exports = __toCommonJS(extension_exports);
29
29
  function requestShape(route) {
30
30
  const cs = route.contract?.contractSource;
31
31
  const isGet = route.method.toUpperCase() === "GET";
32
- const isQuery = isGet || !!cs?.filterFields?.length;
33
- const hasBody = !!cs?.bodyRef || cs?.body != null && cs.body !== "never";
32
+ const isQuery = isGet || !!cs?.filterFields?.length || !!cs?.asQuery;
33
+ const hasBody = !!cs?.bodyRef || cs?.body != null && cs.body !== "never" || !!cs?.multipart;
34
34
  const hasQuery = isGet || !!cs?.queryRef || cs?.query != null && cs.query !== "never";
35
35
  return { isGet, isQuery, hasBody, hasQuery };
36
36
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/extension/index.ts","../../src/extension/types.ts"],"sourcesContent":["export { defineExtension, requestShape } from './types.js';\nexport type {\n ApiClientLayer,\n ApiHeaderContribution,\n ApiModuleDeps,\n CodegenExtension,\n EmittedFile,\n ExtensionContext,\n LeafModel,\n RequestModel,\n RequestShape,\n} from './types.js';\n","import type { Project } from 'ts-morph';\nimport type { ResolvedConfig } from '../config/types.js';\nimport type { RouteDescriptor } from '../discovery/types.js';\n\n/**\n * The published, versioned extension contract for `@dudousxd/nestjs-codegen`.\n *\n * Extensions are **build-time** objects (usually returned by a factory so they can take\n * options) registered explicitly via `forRoot({ extensions: [...] })`. The host runs them\n * around the core discovery → IR → emit pipeline.\n *\n * Hooks split into **multi** (every extension runs; results accumulate or chain) and\n * **single-slot** (at most one extension may claim it — two claimers is a hard error).\n *\n * @remarks Semver 0.x — the shape may change until 1.0. Out-of-repo extensions should pin\n * a compatible `@dudousxd/nestjs-codegen` peer range.\n */\nexport interface CodegenExtension {\n /** Unique id. Used in conflict/collision errors and for deterministic ordering. */\n name: string;\n\n // ── multi hooks (every extension runs) ────────────────────────────────────\n\n /**\n * Mutate/augment the route IR before emit. Runs in registration order, chained\n * (each extension sees the previous one's output). Return the new array, or mutate\n * in place and return void. Example: the filter extension attaches `filterFields` to\n * matching routes here.\n */\n transformRoutes?(\n routes: RouteDescriptor[],\n ctx: ExtensionContext,\n ): RouteDescriptor[] | undefined | Promise<RouteDescriptor[] | undefined>;\n\n /**\n * Contribute extra output files (additive). Paths are relative to `outDir`; a path\n * claimed by two extensions is a hard error. Example: the Inertia extension does its\n * own page discovery via `ctx.project()` and emits `pages.d.ts` + `components.json`.\n */\n emitFiles?(ctx: ExtensionContext): EmittedFile[] | Promise<EmittedFile[]>;\n\n /**\n * Contribute top-level code to `api.ts` (imports + statements). Runs in registration\n * order; imports are deduped by the host. Example: the Inertia extension adds\n * `import { router } from '@inertiajs/react'` and the `navigate()` helper.\n */\n apiHeader?(ctx: ExtensionContext): ApiHeaderContribution | undefined;\n\n /**\n * Add named members to a **handle** leaf. Only runs when a client layer is active\n * (i.e. the leaf is a handle, not a bare callable). Member-name collisions across\n * extensions are a hard error. Example: the filter extension adds `filterQuery` to\n * leaves whose route carries `filterFields`.\n */\n apiMembers?(leaf: LeafModel, ctx: ExtensionContext): Record<string, string> | undefined;\n\n // ── single-slot hooks (at most one extension) ─────────────────────────────\n\n /**\n * Claims **what** a leaf returns and **how** it issues its request. At most one extension\n * may claim it. When unset, a leaf is a bare awaitable callable backed by the neutral\n * fetcher. Example: the TanStack extension wraps each leaf into a handle exposing\n * `{ fetch, queryKey, queryOptions | mutationOptions }`, composing with the fetcher\n * request the host passes in.\n */\n apiClientLayer?: ApiClientLayer;\n}\n\n/** Shared, read-only context handed to every extension hook. */\nexport interface ExtensionContext {\n cwd: string;\n outDir: string;\n routes: readonly RouteDescriptor[];\n config: ResolvedConfig;\n /** Lazily-created shared ts-morph Project for AST work (pages, custom decorators). */\n project(): Project;\n}\n\n/** A file contributed by an extension's `emitFiles` hook. */\nexport interface EmittedFile {\n /** Path relative to `outDir`. A collision across extensions throws. */\n path: string;\n contents: string;\n}\n\n/** Top-level `api.ts` contributions from an extension's `apiHeader` hook. */\nexport interface ApiHeaderContribution {\n /** Raw import lines (e.g. `import { router } from '@inertiajs/react';`), deduped by the host. */\n imports?: string[];\n /** Top-level statements appended after the api factory (e.g. the `navigate()` helper). */\n statements?: string[];\n}\n\n/**\n * The neutral, per-endpoint request model the host builds for each leaf before any\n * transport/layer runs. Extensions read this to render their output.\n */\nexport interface RequestModel {\n /** Dot-path route name, e.g. `users.show`. */\n routeName: string;\n method: 'get' | 'post' | 'put' | 'patch' | 'delete';\n isGet: boolean;\n /** True for reads: a GET, or a filter-search route (has `filterFields`) even when POST.\n * Client layers use this (not `isGet`) to decide query vs mutation helpers. */\n isQuery: boolean;\n hasParams: boolean;\n hasBody: boolean;\n /** Type of the leaf's `input` arg, e.g. `{ params: ...; query?: ... }` or `Record<string, never>`. */\n inputType: string;\n /** URL expression, e.g. `route('users.show', input?.params) || '/api/users/:id'`. */\n urlExpr: string;\n /** Request-options expression, e.g. `{ query: ... }` or `{ body: input?.body }`. */\n optsExpr: string;\n /** Response type access, e.g. `ApiRouter['users']['show']['response']`. */\n responseType: string;\n /** Stable query-key expression, e.g. `[\"users.show\", input] as const`. */\n queryKeyExpr: string;\n}\n\n/**\n * Per-leaf model passed through the api.ts pipeline: layer → member contributors → render.\n * `requestExpr` is the host's neutral fetcher request; `members`, when present, flips the\n * leaf from a bare callable to a handle.\n */\nexport interface LeafModel {\n route: RouteDescriptor;\n request: RequestModel;\n /** The expression that issues the request (the host's neutral fetcher call). */\n requestExpr: string;\n /** When present, the leaf renders as a handle exposing these members (ordered). */\n members?: Record<string, string>;\n}\n\n/**\n * Top-level `api.ts` imports a client layer depends on. A function of the context so it can\n * be route-aware (e.g. only import `mutationOptions` when a mutation exists). Imports are\n * deduped by the host across all extensions.\n */\nexport interface ApiModuleDeps {\n /** Raw import lines (e.g. `import { queryOptions as _q } from '@tanstack/react-query';`). */\n imports?(ctx: ExtensionContext): string[];\n}\n\n/** Single-slot: decides what a leaf returns (the handle members). */\nexport interface ApiClientLayer extends ApiModuleDeps {\n name: string;\n /**\n * Given the request expression (from the transport) and the leaf, return the handle's\n * members as an ordered `name → value` map (value is the expression after `name: `).\n * Returning members flips the leaf from a bare callable to a handle.\n */\n buildMembers(requestExpr: string, leaf: LeafModel, ctx: ExtensionContext): Record<string, string>;\n}\n\n/**\n * The four request-shape flags derived from a route's method + contract. Computed in ONE\n * place ({@link requestShape}) and read by both the host emitter and client-layer\n * extensions, so the \"filter-search POST counts as a read\" rule is encoded exactly once.\n */\nexport interface RequestShape {\n /** The route is a `GET`. */\n isGet: boolean;\n /** True for reads: a GET, or a filter-search route (carries `filterFields`) even when POST.\n * Client layers use this (not `isGet`) to decide query vs mutation helpers. */\n isQuery: boolean;\n /** The route carries a body contract (a mutation payload). */\n hasBody: boolean;\n /** The route can take a query string — always for GET; a mutation may too (query + body). */\n hasQuery: boolean;\n}\n\n/**\n * Compute the {@link RequestShape} flags for a route from its method and contract. This is\n * the SINGLE source of truth for these flags — `buildRequestModel`, the TanStack layer's\n * `imports()`, and any other reader must call this rather than re-deriving. The\n * \"filter-search POST counts as a read\" rule lives here and nowhere else.\n */\nexport function requestShape(route: RouteDescriptor): RequestShape {\n const cs = route.contract?.contractSource;\n const isGet = route.method.toUpperCase() === 'GET';\n const isQuery = isGet || !!cs?.filterFields?.length;\n const hasBody = !!cs?.bodyRef || (cs?.body != null && cs.body !== 'never');\n const hasQuery = isGet || !!cs?.queryRef || (cs?.query != null && cs.query !== 'never');\n return { isGet, isQuery, hasBody, hasQuery };\n}\n\n/** Identity helper for authoring extensions with full type inference. */\nexport function defineExtension(ext: CodegenExtension): CodegenExtension {\n return ext;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACiLO,SAAS,aAAa,OAAsC;AACjE,QAAM,KAAK,MAAM,UAAU;AAC3B,QAAM,QAAQ,MAAM,OAAO,YAAY,MAAM;AAC7C,QAAM,UAAU,SAAS,CAAC,CAAC,IAAI,cAAc;AAC7C,QAAM,UAAU,CAAC,CAAC,IAAI,WAAY,IAAI,QAAQ,QAAQ,GAAG,SAAS;AAClE,QAAM,WAAW,SAAS,CAAC,CAAC,IAAI,YAAa,IAAI,SAAS,QAAQ,GAAG,UAAU;AAC/E,SAAO,EAAE,OAAO,SAAS,SAAS,SAAS;AAC7C;AAGO,SAAS,gBAAgB,KAAyC;AACvE,SAAO;AACT;","names":[]}
1
+ {"version":3,"sources":["../../src/extension/index.ts","../../src/extension/types.ts"],"sourcesContent":["export { defineExtension, requestShape } from './types.js';\nexport type {\n ApiClientLayer,\n ApiHeaderContribution,\n ApiModuleDeps,\n CodegenExtension,\n EmittedFile,\n ExtensionContext,\n LeafModel,\n RequestModel,\n RequestShape,\n} from './types.js';\n","import type { Project } from 'ts-morph';\nimport type { ResolvedConfig } from '../config/types.js';\nimport type { RouteDescriptor } from '../discovery/types.js';\n\n/**\n * The published, versioned extension contract for `@dudousxd/nestjs-codegen`.\n *\n * Extensions are **build-time** objects (usually returned by a factory so they can take\n * options) registered explicitly via `forRoot({ extensions: [...] })`. The host runs them\n * around the core discovery → IR → emit pipeline.\n *\n * Hooks split into **multi** (every extension runs; results accumulate or chain) and\n * **single-slot** (at most one extension may claim it — two claimers is a hard error).\n *\n * @remarks Semver 0.x — the shape may change until 1.0. Out-of-repo extensions should pin\n * a compatible `@dudousxd/nestjs-codegen` peer range.\n */\nexport interface CodegenExtension {\n /** Unique id. Used in conflict/collision errors and for deterministic ordering. */\n name: string;\n\n // ── multi hooks (every extension runs) ────────────────────────────────────\n\n /**\n * Mutate/augment the route IR before emit. Runs in registration order, chained\n * (each extension sees the previous one's output). Return the new array, or mutate\n * in place and return void. Example: the filter extension attaches `filterFields` to\n * matching routes here.\n */\n transformRoutes?(\n routes: RouteDescriptor[],\n ctx: ExtensionContext,\n ): RouteDescriptor[] | undefined | Promise<RouteDescriptor[] | undefined>;\n\n /**\n * Contribute extra output files (additive). Paths are relative to `outDir`; a path\n * claimed by two extensions is a hard error. Example: the Inertia extension does its\n * own page discovery via `ctx.project()` and emits `pages.d.ts` + `components.json`.\n */\n emitFiles?(ctx: ExtensionContext): EmittedFile[] | Promise<EmittedFile[]>;\n\n /**\n * Contribute top-level code to `api.ts` (imports + statements). Runs in registration\n * order; imports are deduped by the host. Example: the Inertia extension adds\n * `import { router } from '@inertiajs/react'` and the `navigate()` helper.\n */\n apiHeader?(ctx: ExtensionContext): ApiHeaderContribution | undefined;\n\n /**\n * Add named members to a **handle** leaf. Only runs when a client layer is active\n * (i.e. the leaf is a handle, not a bare callable). Member-name collisions across\n * extensions are a hard error. Example: the filter extension adds `filterQuery` to\n * leaves whose route carries `filterFields`.\n */\n apiMembers?(leaf: LeafModel, ctx: ExtensionContext): Record<string, string> | undefined;\n\n // ── single-slot hooks (at most one extension) ─────────────────────────────\n\n /**\n * Claims **what** a leaf returns and **how** it issues its request. At most one extension\n * may claim it. When unset, a leaf is a bare awaitable callable backed by the neutral\n * fetcher. Example: the TanStack extension wraps each leaf into a handle exposing\n * `{ fetch, queryKey, queryOptions | mutationOptions }`, composing with the fetcher\n * request the host passes in.\n */\n apiClientLayer?: ApiClientLayer;\n}\n\n/** Shared, read-only context handed to every extension hook. */\nexport interface ExtensionContext {\n cwd: string;\n outDir: string;\n routes: readonly RouteDescriptor[];\n config: ResolvedConfig;\n /** Lazily-created shared ts-morph Project for AST work (pages, custom decorators). */\n project(): Project;\n}\n\n/** A file contributed by an extension's `emitFiles` hook. */\nexport interface EmittedFile {\n /** Path relative to `outDir`. A collision across extensions throws. */\n path: string;\n contents: string;\n}\n\n/** Top-level `api.ts` contributions from an extension's `apiHeader` hook. */\nexport interface ApiHeaderContribution {\n /** Raw import lines (e.g. `import { router } from '@inertiajs/react';`), deduped by the host. */\n imports?: string[];\n /** Top-level statements appended after the api factory (e.g. the `navigate()` helper). */\n statements?: string[];\n}\n\n/**\n * The neutral, per-endpoint request model the host builds for each leaf before any\n * transport/layer runs. Extensions read this to render their output.\n */\nexport interface RequestModel {\n /** Dot-path route name, e.g. `users.show`. */\n routeName: string;\n method: 'get' | 'post' | 'put' | 'patch' | 'delete';\n isGet: boolean;\n /** True for reads: a GET, a filter-search route (has `filterFields`), or an\n * `@AsQuery()`-marked route — even when the method is POST. Client layers\n * use this (not `isGet`) to decide query vs mutation helpers. */\n isQuery: boolean;\n hasParams: boolean;\n hasBody: boolean;\n /** Type of the leaf's `input` arg, e.g. `{ params: ...; query?: ... }` or `Record<string, never>`. */\n inputType: string;\n /** URL expression, e.g. `route('users.show', input?.params) || '/api/users/:id'`. */\n urlExpr: string;\n /** Request-options expression, e.g. `{ query: ... }` or `{ body: input?.body }`. */\n optsExpr: string;\n /** Response type access, e.g. `ApiRouter['users']['show']['response']`. */\n responseType: string;\n /** Stable query-key expression, e.g. `[\"users.show\", input] as const`. */\n queryKeyExpr: string;\n}\n\n/**\n * Per-leaf model passed through the api.ts pipeline: layer → member contributors → render.\n * `requestExpr` is the host's neutral fetcher request; `members`, when present, flips the\n * leaf from a bare callable to a handle.\n */\nexport interface LeafModel {\n route: RouteDescriptor;\n request: RequestModel;\n /** The expression that issues the request (the host's neutral fetcher call). */\n requestExpr: string;\n /** When present, the leaf renders as a handle exposing these members (ordered). */\n members?: Record<string, string>;\n}\n\n/**\n * Top-level `api.ts` imports a client layer depends on. A function of the context so it can\n * be route-aware (e.g. only import `mutationOptions` when a mutation exists). Imports are\n * deduped by the host across all extensions.\n */\nexport interface ApiModuleDeps {\n /** Raw import lines (e.g. `import { queryOptions as _q } from '@tanstack/react-query';`). */\n imports?(ctx: ExtensionContext): string[];\n}\n\n/** Single-slot: decides what a leaf returns (the handle members). */\nexport interface ApiClientLayer extends ApiModuleDeps {\n name: string;\n /**\n * Given the request expression (from the transport) and the leaf, return the handle's\n * members as an ordered `name → value` map (value is the expression after `name: `).\n * Returning members flips the leaf from a bare callable to a handle.\n */\n buildMembers(requestExpr: string, leaf: LeafModel, ctx: ExtensionContext): Record<string, string>;\n}\n\n/**\n * The four request-shape flags derived from a route's method + contract. Computed in ONE\n * place ({@link requestShape}) and read by both the host emitter and client-layer\n * extensions, so the \"filter-search POST counts as a read\" rule is encoded exactly once.\n */\nexport interface RequestShape {\n /** The route is a `GET`. */\n isGet: boolean;\n /** True for reads: a GET, or a filter-search route (carries `filterFields`) even when POST.\n * Client layers use this (not `isGet`) to decide query vs mutation helpers. */\n isQuery: boolean;\n /** The route carries a body contract (a mutation payload). */\n hasBody: boolean;\n /** The route can take a query string — always for GET; a mutation may too (query + body). */\n hasQuery: boolean;\n}\n\n/**\n * Compute the {@link RequestShape} flags for a route from its method and contract. This is\n * the SINGLE source of truth for these flags — `buildRequestModel`, the TanStack layer's\n * `imports()`, and any other reader must call this rather than re-deriving. The\n * \"filter-search POST counts as a read\" rule lives here and nowhere else.\n */\nexport function requestShape(route: RouteDescriptor): RequestShape {\n const cs = route.contract?.contractSource;\n const isGet = route.method.toUpperCase() === 'GET';\n // A route counts as a read when it's a GET, a filter-search POST (carries\n // `filterFields`), or explicitly opted in via `@AsQuery()` (`cs.asQuery`) —\n // e.g. a POST whose semantics are a read (a query-shaped payload).\n const isQuery = isGet || !!cs?.filterFields?.length || !!cs?.asQuery;\n // `multipart` implies a body even when the route has no `@Body()` at all (a bare\n // `@UploadedFile()` route): the type block intersects `multipartBody` into the body\n // type, so the runtime leaf must accept and forward it too — otherwise the ApiRouter\n // type promises `body: { file: File | Blob }` while the generated call silently\n // drops the file.\n const hasBody = !!cs?.bodyRef || (cs?.body != null && cs.body !== 'never') || !!cs?.multipart;\n const hasQuery = isGet || !!cs?.queryRef || (cs?.query != null && cs.query !== 'never');\n return { isGet, isQuery, hasBody, hasQuery };\n}\n\n/** Identity helper for authoring extensions with full type inference. */\nexport function defineExtension(ext: CodegenExtension): CodegenExtension {\n return ext;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACkLO,SAAS,aAAa,OAAsC;AACjE,QAAM,KAAK,MAAM,UAAU;AAC3B,QAAM,QAAQ,MAAM,OAAO,YAAY,MAAM;AAI7C,QAAM,UAAU,SAAS,CAAC,CAAC,IAAI,cAAc,UAAU,CAAC,CAAC,IAAI;AAM7D,QAAM,UAAU,CAAC,CAAC,IAAI,WAAY,IAAI,QAAQ,QAAQ,GAAG,SAAS,WAAY,CAAC,CAAC,IAAI;AACpF,QAAM,WAAW,SAAS,CAAC,CAAC,IAAI,YAAa,IAAI,SAAS,QAAQ,GAAG,UAAU;AAC/E,SAAO,EAAE,OAAO,SAAS,SAAS,SAAS;AAC7C;AAGO,SAAS,gBAAgB,KAAyC;AACvE,SAAO;AACT;","names":[]}
@@ -1,2 +1,2 @@
1
- export { m as ApiClientLayer, n as ApiHeaderContribution, o as ApiModuleDeps, C as CodegenExtension, p as EmittedFile, E as ExtensionContext, L as LeafModel, q as RequestModel, s as RequestShape, t as defineExtension, u as requestShape } from '../index-D8RIMVpU.cjs';
1
+ export { m as ApiClientLayer, n as ApiHeaderContribution, o as ApiModuleDeps, C as CodegenExtension, p as EmittedFile, E as ExtensionContext, L as LeafModel, q as RequestModel, s as RequestShape, t as defineExtension, u as requestShape } from '../index-DT8SgPxp.cjs';
2
2
  import 'ts-morph';
@@ -1,2 +1,2 @@
1
- export { m as ApiClientLayer, n as ApiHeaderContribution, o as ApiModuleDeps, C as CodegenExtension, p as EmittedFile, E as ExtensionContext, L as LeafModel, q as RequestModel, s as RequestShape, t as defineExtension, u as requestShape } from '../index-D8RIMVpU.js';
1
+ export { m as ApiClientLayer, n as ApiHeaderContribution, o as ApiModuleDeps, C as CodegenExtension, p as EmittedFile, E as ExtensionContext, L as LeafModel, q as RequestModel, s as RequestShape, t as defineExtension, u as requestShape } from '../index-DT8SgPxp.js';
2
2
  import 'ts-morph';
@@ -2,8 +2,8 @@
2
2
  function requestShape(route) {
3
3
  const cs = route.contract?.contractSource;
4
4
  const isGet = route.method.toUpperCase() === "GET";
5
- const isQuery = isGet || !!cs?.filterFields?.length;
6
- const hasBody = !!cs?.bodyRef || cs?.body != null && cs.body !== "never";
5
+ const isQuery = isGet || !!cs?.filterFields?.length || !!cs?.asQuery;
6
+ const hasBody = !!cs?.bodyRef || cs?.body != null && cs.body !== "never" || !!cs?.multipart;
7
7
  const hasQuery = isGet || !!cs?.queryRef || cs?.query != null && cs.query !== "never";
8
8
  return { isGet, isQuery, hasBody, hasQuery };
9
9
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/extension/types.ts"],"sourcesContent":["import type { Project } from 'ts-morph';\nimport type { ResolvedConfig } from '../config/types.js';\nimport type { RouteDescriptor } from '../discovery/types.js';\n\n/**\n * The published, versioned extension contract for `@dudousxd/nestjs-codegen`.\n *\n * Extensions are **build-time** objects (usually returned by a factory so they can take\n * options) registered explicitly via `forRoot({ extensions: [...] })`. The host runs them\n * around the core discovery → IR → emit pipeline.\n *\n * Hooks split into **multi** (every extension runs; results accumulate or chain) and\n * **single-slot** (at most one extension may claim it — two claimers is a hard error).\n *\n * @remarks Semver 0.x — the shape may change until 1.0. Out-of-repo extensions should pin\n * a compatible `@dudousxd/nestjs-codegen` peer range.\n */\nexport interface CodegenExtension {\n /** Unique id. Used in conflict/collision errors and for deterministic ordering. */\n name: string;\n\n // ── multi hooks (every extension runs) ────────────────────────────────────\n\n /**\n * Mutate/augment the route IR before emit. Runs in registration order, chained\n * (each extension sees the previous one's output). Return the new array, or mutate\n * in place and return void. Example: the filter extension attaches `filterFields` to\n * matching routes here.\n */\n transformRoutes?(\n routes: RouteDescriptor[],\n ctx: ExtensionContext,\n ): RouteDescriptor[] | undefined | Promise<RouteDescriptor[] | undefined>;\n\n /**\n * Contribute extra output files (additive). Paths are relative to `outDir`; a path\n * claimed by two extensions is a hard error. Example: the Inertia extension does its\n * own page discovery via `ctx.project()` and emits `pages.d.ts` + `components.json`.\n */\n emitFiles?(ctx: ExtensionContext): EmittedFile[] | Promise<EmittedFile[]>;\n\n /**\n * Contribute top-level code to `api.ts` (imports + statements). Runs in registration\n * order; imports are deduped by the host. Example: the Inertia extension adds\n * `import { router } from '@inertiajs/react'` and the `navigate()` helper.\n */\n apiHeader?(ctx: ExtensionContext): ApiHeaderContribution | undefined;\n\n /**\n * Add named members to a **handle** leaf. Only runs when a client layer is active\n * (i.e. the leaf is a handle, not a bare callable). Member-name collisions across\n * extensions are a hard error. Example: the filter extension adds `filterQuery` to\n * leaves whose route carries `filterFields`.\n */\n apiMembers?(leaf: LeafModel, ctx: ExtensionContext): Record<string, string> | undefined;\n\n // ── single-slot hooks (at most one extension) ─────────────────────────────\n\n /**\n * Claims **what** a leaf returns and **how** it issues its request. At most one extension\n * may claim it. When unset, a leaf is a bare awaitable callable backed by the neutral\n * fetcher. Example: the TanStack extension wraps each leaf into a handle exposing\n * `{ fetch, queryKey, queryOptions | mutationOptions }`, composing with the fetcher\n * request the host passes in.\n */\n apiClientLayer?: ApiClientLayer;\n}\n\n/** Shared, read-only context handed to every extension hook. */\nexport interface ExtensionContext {\n cwd: string;\n outDir: string;\n routes: readonly RouteDescriptor[];\n config: ResolvedConfig;\n /** Lazily-created shared ts-morph Project for AST work (pages, custom decorators). */\n project(): Project;\n}\n\n/** A file contributed by an extension's `emitFiles` hook. */\nexport interface EmittedFile {\n /** Path relative to `outDir`. A collision across extensions throws. */\n path: string;\n contents: string;\n}\n\n/** Top-level `api.ts` contributions from an extension's `apiHeader` hook. */\nexport interface ApiHeaderContribution {\n /** Raw import lines (e.g. `import { router } from '@inertiajs/react';`), deduped by the host. */\n imports?: string[];\n /** Top-level statements appended after the api factory (e.g. the `navigate()` helper). */\n statements?: string[];\n}\n\n/**\n * The neutral, per-endpoint request model the host builds for each leaf before any\n * transport/layer runs. Extensions read this to render their output.\n */\nexport interface RequestModel {\n /** Dot-path route name, e.g. `users.show`. */\n routeName: string;\n method: 'get' | 'post' | 'put' | 'patch' | 'delete';\n isGet: boolean;\n /** True for reads: a GET, or a filter-search route (has `filterFields`) even when POST.\n * Client layers use this (not `isGet`) to decide query vs mutation helpers. */\n isQuery: boolean;\n hasParams: boolean;\n hasBody: boolean;\n /** Type of the leaf's `input` arg, e.g. `{ params: ...; query?: ... }` or `Record<string, never>`. */\n inputType: string;\n /** URL expression, e.g. `route('users.show', input?.params) || '/api/users/:id'`. */\n urlExpr: string;\n /** Request-options expression, e.g. `{ query: ... }` or `{ body: input?.body }`. */\n optsExpr: string;\n /** Response type access, e.g. `ApiRouter['users']['show']['response']`. */\n responseType: string;\n /** Stable query-key expression, e.g. `[\"users.show\", input] as const`. */\n queryKeyExpr: string;\n}\n\n/**\n * Per-leaf model passed through the api.ts pipeline: layer → member contributors → render.\n * `requestExpr` is the host's neutral fetcher request; `members`, when present, flips the\n * leaf from a bare callable to a handle.\n */\nexport interface LeafModel {\n route: RouteDescriptor;\n request: RequestModel;\n /** The expression that issues the request (the host's neutral fetcher call). */\n requestExpr: string;\n /** When present, the leaf renders as a handle exposing these members (ordered). */\n members?: Record<string, string>;\n}\n\n/**\n * Top-level `api.ts` imports a client layer depends on. A function of the context so it can\n * be route-aware (e.g. only import `mutationOptions` when a mutation exists). Imports are\n * deduped by the host across all extensions.\n */\nexport interface ApiModuleDeps {\n /** Raw import lines (e.g. `import { queryOptions as _q } from '@tanstack/react-query';`). */\n imports?(ctx: ExtensionContext): string[];\n}\n\n/** Single-slot: decides what a leaf returns (the handle members). */\nexport interface ApiClientLayer extends ApiModuleDeps {\n name: string;\n /**\n * Given the request expression (from the transport) and the leaf, return the handle's\n * members as an ordered `name → value` map (value is the expression after `name: `).\n * Returning members flips the leaf from a bare callable to a handle.\n */\n buildMembers(requestExpr: string, leaf: LeafModel, ctx: ExtensionContext): Record<string, string>;\n}\n\n/**\n * The four request-shape flags derived from a route's method + contract. Computed in ONE\n * place ({@link requestShape}) and read by both the host emitter and client-layer\n * extensions, so the \"filter-search POST counts as a read\" rule is encoded exactly once.\n */\nexport interface RequestShape {\n /** The route is a `GET`. */\n isGet: boolean;\n /** True for reads: a GET, or a filter-search route (carries `filterFields`) even when POST.\n * Client layers use this (not `isGet`) to decide query vs mutation helpers. */\n isQuery: boolean;\n /** The route carries a body contract (a mutation payload). */\n hasBody: boolean;\n /** The route can take a query string — always for GET; a mutation may too (query + body). */\n hasQuery: boolean;\n}\n\n/**\n * Compute the {@link RequestShape} flags for a route from its method and contract. This is\n * the SINGLE source of truth for these flags — `buildRequestModel`, the TanStack layer's\n * `imports()`, and any other reader must call this rather than re-deriving. The\n * \"filter-search POST counts as a read\" rule lives here and nowhere else.\n */\nexport function requestShape(route: RouteDescriptor): RequestShape {\n const cs = route.contract?.contractSource;\n const isGet = route.method.toUpperCase() === 'GET';\n const isQuery = isGet || !!cs?.filterFields?.length;\n const hasBody = !!cs?.bodyRef || (cs?.body != null && cs.body !== 'never');\n const hasQuery = isGet || !!cs?.queryRef || (cs?.query != null && cs.query !== 'never');\n return { isGet, isQuery, hasBody, hasQuery };\n}\n\n/** Identity helper for authoring extensions with full type inference. */\nexport function defineExtension(ext: CodegenExtension): CodegenExtension {\n return ext;\n}\n"],"mappings":";AAiLO,SAAS,aAAa,OAAsC;AACjE,QAAM,KAAK,MAAM,UAAU;AAC3B,QAAM,QAAQ,MAAM,OAAO,YAAY,MAAM;AAC7C,QAAM,UAAU,SAAS,CAAC,CAAC,IAAI,cAAc;AAC7C,QAAM,UAAU,CAAC,CAAC,IAAI,WAAY,IAAI,QAAQ,QAAQ,GAAG,SAAS;AAClE,QAAM,WAAW,SAAS,CAAC,CAAC,IAAI,YAAa,IAAI,SAAS,QAAQ,GAAG,UAAU;AAC/E,SAAO,EAAE,OAAO,SAAS,SAAS,SAAS;AAC7C;AAGO,SAAS,gBAAgB,KAAyC;AACvE,SAAO;AACT;","names":[]}
1
+ {"version":3,"sources":["../../src/extension/types.ts"],"sourcesContent":["import type { Project } from 'ts-morph';\nimport type { ResolvedConfig } from '../config/types.js';\nimport type { RouteDescriptor } from '../discovery/types.js';\n\n/**\n * The published, versioned extension contract for `@dudousxd/nestjs-codegen`.\n *\n * Extensions are **build-time** objects (usually returned by a factory so they can take\n * options) registered explicitly via `forRoot({ extensions: [...] })`. The host runs them\n * around the core discovery → IR → emit pipeline.\n *\n * Hooks split into **multi** (every extension runs; results accumulate or chain) and\n * **single-slot** (at most one extension may claim it — two claimers is a hard error).\n *\n * @remarks Semver 0.x — the shape may change until 1.0. Out-of-repo extensions should pin\n * a compatible `@dudousxd/nestjs-codegen` peer range.\n */\nexport interface CodegenExtension {\n /** Unique id. Used in conflict/collision errors and for deterministic ordering. */\n name: string;\n\n // ── multi hooks (every extension runs) ────────────────────────────────────\n\n /**\n * Mutate/augment the route IR before emit. Runs in registration order, chained\n * (each extension sees the previous one's output). Return the new array, or mutate\n * in place and return void. Example: the filter extension attaches `filterFields` to\n * matching routes here.\n */\n transformRoutes?(\n routes: RouteDescriptor[],\n ctx: ExtensionContext,\n ): RouteDescriptor[] | undefined | Promise<RouteDescriptor[] | undefined>;\n\n /**\n * Contribute extra output files (additive). Paths are relative to `outDir`; a path\n * claimed by two extensions is a hard error. Example: the Inertia extension does its\n * own page discovery via `ctx.project()` and emits `pages.d.ts` + `components.json`.\n */\n emitFiles?(ctx: ExtensionContext): EmittedFile[] | Promise<EmittedFile[]>;\n\n /**\n * Contribute top-level code to `api.ts` (imports + statements). Runs in registration\n * order; imports are deduped by the host. Example: the Inertia extension adds\n * `import { router } from '@inertiajs/react'` and the `navigate()` helper.\n */\n apiHeader?(ctx: ExtensionContext): ApiHeaderContribution | undefined;\n\n /**\n * Add named members to a **handle** leaf. Only runs when a client layer is active\n * (i.e. the leaf is a handle, not a bare callable). Member-name collisions across\n * extensions are a hard error. Example: the filter extension adds `filterQuery` to\n * leaves whose route carries `filterFields`.\n */\n apiMembers?(leaf: LeafModel, ctx: ExtensionContext): Record<string, string> | undefined;\n\n // ── single-slot hooks (at most one extension) ─────────────────────────────\n\n /**\n * Claims **what** a leaf returns and **how** it issues its request. At most one extension\n * may claim it. When unset, a leaf is a bare awaitable callable backed by the neutral\n * fetcher. Example: the TanStack extension wraps each leaf into a handle exposing\n * `{ fetch, queryKey, queryOptions | mutationOptions }`, composing with the fetcher\n * request the host passes in.\n */\n apiClientLayer?: ApiClientLayer;\n}\n\n/** Shared, read-only context handed to every extension hook. */\nexport interface ExtensionContext {\n cwd: string;\n outDir: string;\n routes: readonly RouteDescriptor[];\n config: ResolvedConfig;\n /** Lazily-created shared ts-morph Project for AST work (pages, custom decorators). */\n project(): Project;\n}\n\n/** A file contributed by an extension's `emitFiles` hook. */\nexport interface EmittedFile {\n /** Path relative to `outDir`. A collision across extensions throws. */\n path: string;\n contents: string;\n}\n\n/** Top-level `api.ts` contributions from an extension's `apiHeader` hook. */\nexport interface ApiHeaderContribution {\n /** Raw import lines (e.g. `import { router } from '@inertiajs/react';`), deduped by the host. */\n imports?: string[];\n /** Top-level statements appended after the api factory (e.g. the `navigate()` helper). */\n statements?: string[];\n}\n\n/**\n * The neutral, per-endpoint request model the host builds for each leaf before any\n * transport/layer runs. Extensions read this to render their output.\n */\nexport interface RequestModel {\n /** Dot-path route name, e.g. `users.show`. */\n routeName: string;\n method: 'get' | 'post' | 'put' | 'patch' | 'delete';\n isGet: boolean;\n /** True for reads: a GET, a filter-search route (has `filterFields`), or an\n * `@AsQuery()`-marked route — even when the method is POST. Client layers\n * use this (not `isGet`) to decide query vs mutation helpers. */\n isQuery: boolean;\n hasParams: boolean;\n hasBody: boolean;\n /** Type of the leaf's `input` arg, e.g. `{ params: ...; query?: ... }` or `Record<string, never>`. */\n inputType: string;\n /** URL expression, e.g. `route('users.show', input?.params) || '/api/users/:id'`. */\n urlExpr: string;\n /** Request-options expression, e.g. `{ query: ... }` or `{ body: input?.body }`. */\n optsExpr: string;\n /** Response type access, e.g. `ApiRouter['users']['show']['response']`. */\n responseType: string;\n /** Stable query-key expression, e.g. `[\"users.show\", input] as const`. */\n queryKeyExpr: string;\n}\n\n/**\n * Per-leaf model passed through the api.ts pipeline: layer → member contributors → render.\n * `requestExpr` is the host's neutral fetcher request; `members`, when present, flips the\n * leaf from a bare callable to a handle.\n */\nexport interface LeafModel {\n route: RouteDescriptor;\n request: RequestModel;\n /** The expression that issues the request (the host's neutral fetcher call). */\n requestExpr: string;\n /** When present, the leaf renders as a handle exposing these members (ordered). */\n members?: Record<string, string>;\n}\n\n/**\n * Top-level `api.ts` imports a client layer depends on. A function of the context so it can\n * be route-aware (e.g. only import `mutationOptions` when a mutation exists). Imports are\n * deduped by the host across all extensions.\n */\nexport interface ApiModuleDeps {\n /** Raw import lines (e.g. `import { queryOptions as _q } from '@tanstack/react-query';`). */\n imports?(ctx: ExtensionContext): string[];\n}\n\n/** Single-slot: decides what a leaf returns (the handle members). */\nexport interface ApiClientLayer extends ApiModuleDeps {\n name: string;\n /**\n * Given the request expression (from the transport) and the leaf, return the handle's\n * members as an ordered `name → value` map (value is the expression after `name: `).\n * Returning members flips the leaf from a bare callable to a handle.\n */\n buildMembers(requestExpr: string, leaf: LeafModel, ctx: ExtensionContext): Record<string, string>;\n}\n\n/**\n * The four request-shape flags derived from a route's method + contract. Computed in ONE\n * place ({@link requestShape}) and read by both the host emitter and client-layer\n * extensions, so the \"filter-search POST counts as a read\" rule is encoded exactly once.\n */\nexport interface RequestShape {\n /** The route is a `GET`. */\n isGet: boolean;\n /** True for reads: a GET, or a filter-search route (carries `filterFields`) even when POST.\n * Client layers use this (not `isGet`) to decide query vs mutation helpers. */\n isQuery: boolean;\n /** The route carries a body contract (a mutation payload). */\n hasBody: boolean;\n /** The route can take a query string — always for GET; a mutation may too (query + body). */\n hasQuery: boolean;\n}\n\n/**\n * Compute the {@link RequestShape} flags for a route from its method and contract. This is\n * the SINGLE source of truth for these flags — `buildRequestModel`, the TanStack layer's\n * `imports()`, and any other reader must call this rather than re-deriving. The\n * \"filter-search POST counts as a read\" rule lives here and nowhere else.\n */\nexport function requestShape(route: RouteDescriptor): RequestShape {\n const cs = route.contract?.contractSource;\n const isGet = route.method.toUpperCase() === 'GET';\n // A route counts as a read when it's a GET, a filter-search POST (carries\n // `filterFields`), or explicitly opted in via `@AsQuery()` (`cs.asQuery`) —\n // e.g. a POST whose semantics are a read (a query-shaped payload).\n const isQuery = isGet || !!cs?.filterFields?.length || !!cs?.asQuery;\n // `multipart` implies a body even when the route has no `@Body()` at all (a bare\n // `@UploadedFile()` route): the type block intersects `multipartBody` into the body\n // type, so the runtime leaf must accept and forward it too — otherwise the ApiRouter\n // type promises `body: { file: File | Blob }` while the generated call silently\n // drops the file.\n const hasBody = !!cs?.bodyRef || (cs?.body != null && cs.body !== 'never') || !!cs?.multipart;\n const hasQuery = isGet || !!cs?.queryRef || (cs?.query != null && cs.query !== 'never');\n return { isGet, isQuery, hasBody, hasQuery };\n}\n\n/** Identity helper for authoring extensions with full type inference. */\nexport function defineExtension(ext: CodegenExtension): CodegenExtension {\n return ext;\n}\n"],"mappings":";AAkLO,SAAS,aAAa,OAAsC;AACjE,QAAM,KAAK,MAAM,UAAU;AAC3B,QAAM,QAAQ,MAAM,OAAO,YAAY,MAAM;AAI7C,QAAM,UAAU,SAAS,CAAC,CAAC,IAAI,cAAc,UAAU,CAAC,CAAC,IAAI;AAM7D,QAAM,UAAU,CAAC,CAAC,IAAI,WAAY,IAAI,QAAQ,QAAQ,GAAG,SAAS,WAAY,CAAC,CAAC,IAAI;AACpF,QAAM,WAAW,SAAS,CAAC,CAAC,IAAI,YAAa,IAAI,SAAS,QAAQ,GAAG,UAAU;AAC/E,SAAO,EAAE,OAAO,SAAS,SAAS,SAAS;AAC7C;AAGO,SAAS,gBAAgB,KAAyC;AACvE,SAAO;AACT;","names":[]}
@@ -330,6 +330,19 @@ interface UserConfig {
330
330
  /** Base URL prepended to handler paths. Default: `''` (relative paths). */
331
331
  baseUrl?: string;
332
332
  };
333
+ /**
334
+ * Guard against the CLI (`nestjs-codegen.config.ts`) and the Nest module
335
+ * (`NestjsCodegenModule.forRoot()`) writing the SAME `outDir` from two
336
+ * different, drifted configs (classic case: `serialization` `'json'` on one
337
+ * entry point vs `'superjson'` on the other) — which otherwise ping-pongs
338
+ * `api.ts` between two shapes as each entry point overwrites the other's
339
+ * output. When both entry points' resolved configs differ, `generate()`
340
+ * throws before writing anything, naming both entry points and instructing
341
+ * you to make them read the same config object. Set `false` to opt out.
342
+ *
343
+ * @default true
344
+ */
345
+ driftGuard?: boolean;
333
346
  }
334
347
  interface ScopeConfig {
335
348
  glob: string;
@@ -390,6 +403,7 @@ interface ResolvedConfig {
390
403
  forms: ResolvedFormsConfig;
391
404
  openapi: ResolvedOpenApiConfig;
392
405
  mocks: ResolvedMocksConfig;
406
+ driftGuard: boolean;
393
407
  }
394
408
 
395
409
  interface TypeRef {
@@ -504,6 +518,24 @@ interface ContractSource {
504
518
  * or the inline `body` text — preserving the import when there is one.
505
519
  */
506
520
  multipartBody?: string | null;
521
+ /**
522
+ * True when the route's (Promise-unwrapped) handler return type is a binary
523
+ * download — NestJS `StreamableFile` or Node `Buffer`. The emitted `response`
524
+ * becomes `RawResponse<Blob>` (never `Jsonify<...>`) and the generated leaf
525
+ * issues its request via `fetcher.fetchBlob(...)` instead of the verb method,
526
+ * so callers get `{ data, status, headers }` — the headers carry
527
+ * `content-disposition` (download filename) etc. Mutually exclusive with
528
+ * {@link stream}: `Observable`/`ReadableStream` handlers stay on the SSE path.
529
+ */
530
+ binaryResponse?: boolean;
531
+ /**
532
+ * True when the handler carries the `@AsQuery()` marker decorator
533
+ * (`@dudousxd/nestjs-codegen/markers`) — an explicit per-route opt-in marking
534
+ * a non-GET route (e.g. a POST with a query-shaped payload) as a READ, so
535
+ * {@link import('../extension/types.js').requestShape} emits `queryOptions`
536
+ * for it the same way a GET or a filter-search route does.
537
+ */
538
+ asQuery?: boolean;
507
539
  }
508
540
  interface ContractDescriptor {
509
541
  contractSource: ContractSource;
@@ -607,8 +639,9 @@ interface RequestModel {
607
639
  routeName: string;
608
640
  method: 'get' | 'post' | 'put' | 'patch' | 'delete';
609
641
  isGet: boolean;
610
- /** True for reads: a GET, or a filter-search route (has `filterFields`) even when POST.
611
- * Client layers use this (not `isGet`) to decide query vs mutation helpers. */
642
+ /** True for reads: a GET, a filter-search route (has `filterFields`), or an
643
+ * `@AsQuery()`-marked route even when the method is POST. Client layers
644
+ * use this (not `isGet`) to decide query vs mutation helpers. */
612
645
  isQuery: boolean;
613
646
  hasParams: boolean;
614
647
  hasBody: boolean;
@@ -330,6 +330,19 @@ interface UserConfig {
330
330
  /** Base URL prepended to handler paths. Default: `''` (relative paths). */
331
331
  baseUrl?: string;
332
332
  };
333
+ /**
334
+ * Guard against the CLI (`nestjs-codegen.config.ts`) and the Nest module
335
+ * (`NestjsCodegenModule.forRoot()`) writing the SAME `outDir` from two
336
+ * different, drifted configs (classic case: `serialization` `'json'` on one
337
+ * entry point vs `'superjson'` on the other) — which otherwise ping-pongs
338
+ * `api.ts` between two shapes as each entry point overwrites the other's
339
+ * output. When both entry points' resolved configs differ, `generate()`
340
+ * throws before writing anything, naming both entry points and instructing
341
+ * you to make them read the same config object. Set `false` to opt out.
342
+ *
343
+ * @default true
344
+ */
345
+ driftGuard?: boolean;
333
346
  }
334
347
  interface ScopeConfig {
335
348
  glob: string;
@@ -390,6 +403,7 @@ interface ResolvedConfig {
390
403
  forms: ResolvedFormsConfig;
391
404
  openapi: ResolvedOpenApiConfig;
392
405
  mocks: ResolvedMocksConfig;
406
+ driftGuard: boolean;
393
407
  }
394
408
 
395
409
  interface TypeRef {
@@ -504,6 +518,24 @@ interface ContractSource {
504
518
  * or the inline `body` text — preserving the import when there is one.
505
519
  */
506
520
  multipartBody?: string | null;
521
+ /**
522
+ * True when the route's (Promise-unwrapped) handler return type is a binary
523
+ * download — NestJS `StreamableFile` or Node `Buffer`. The emitted `response`
524
+ * becomes `RawResponse<Blob>` (never `Jsonify<...>`) and the generated leaf
525
+ * issues its request via `fetcher.fetchBlob(...)` instead of the verb method,
526
+ * so callers get `{ data, status, headers }` — the headers carry
527
+ * `content-disposition` (download filename) etc. Mutually exclusive with
528
+ * {@link stream}: `Observable`/`ReadableStream` handlers stay on the SSE path.
529
+ */
530
+ binaryResponse?: boolean;
531
+ /**
532
+ * True when the handler carries the `@AsQuery()` marker decorator
533
+ * (`@dudousxd/nestjs-codegen/markers`) — an explicit per-route opt-in marking
534
+ * a non-GET route (e.g. a POST with a query-shaped payload) as a READ, so
535
+ * {@link import('../extension/types.js').requestShape} emits `queryOptions`
536
+ * for it the same way a GET or a filter-search route does.
537
+ */
538
+ asQuery?: boolean;
507
539
  }
508
540
  interface ContractDescriptor {
509
541
  contractSource: ContractSource;
@@ -607,8 +639,9 @@ interface RequestModel {
607
639
  routeName: string;
608
640
  method: 'get' | 'post' | 'put' | 'patch' | 'delete';
609
641
  isGet: boolean;
610
- /** True for reads: a GET, or a filter-search route (has `filterFields`) even when POST.
611
- * Client layers use this (not `isGet`) to decide query vs mutation helpers. */
642
+ /** True for reads: a GET, a filter-search route (has `filterFields`), or an
643
+ * `@AsQuery()`-marked route even when the method is POST. Client layers
644
+ * use this (not `isGet`) to decide query vs mutation helpers. */
612
645
  isQuery: boolean;
613
646
  hasParams: boolean;
614
647
  hasBody: boolean;
package/dist/index.cjs CHANGED
@@ -32,6 +32,7 @@ var src_exports = {};
32
32
  __export(src_exports, {
33
33
  CodegenError: () => CodegenError,
34
34
  ConfigError: () => ConfigError,
35
+ DriftGuardError: () => DriftGuardError,
35
36
  VERSION: () => VERSION,
36
37
  acquireLock: () => acquireLock,
37
38
  buildMocksFile: () => buildMocksFile,
@@ -211,7 +212,8 @@ function applyDefaults(userConfig, cwd) {
211
212
  fileName: userConfig.mocks?.fileName ?? "mocks.ts",
212
213
  seed: userConfig.mocks?.seed ?? 1,
213
214
  baseUrl: userConfig.mocks?.baseUrl ?? ""
214
- }
215
+ },
216
+ driftGuard: userConfig.driftGuard ?? true
215
217
  };
216
218
  }
217
219
  async function loadConfig(cwd) {
@@ -671,8 +673,8 @@ async function collectEmittedFiles(extensions, ctx) {
671
673
  function requestShape(route) {
672
674
  const cs = route.contract?.contractSource;
673
675
  const isGet = route.method.toUpperCase() === "GET";
674
- const isQuery = isGet || !!cs?.filterFields?.length;
675
- const hasBody = !!cs?.bodyRef || cs?.body != null && cs.body !== "never";
676
+ const isQuery = isGet || !!cs?.filterFields?.length || !!cs?.asQuery;
677
+ const hasBody = !!cs?.bodyRef || cs?.body != null && cs.body !== "never" || !!cs?.multipart;
676
678
  const hasQuery = isGet || !!cs?.queryRef || cs?.query != null && cs.query !== "never";
677
679
  return { isGet, isQuery, hasBody, hasQuery };
678
680
  }
@@ -778,6 +780,7 @@ function emitFilterQueryType(c) {
778
780
  return `import('@dudousxd/nestjs-filter-client').TypedFilterQuery<${emitFilterQueryTypeArgs(c)}>`;
779
781
  }
780
782
  function buildResponseType(c, outDir, serialization) {
783
+ if (c.contractSource.binaryResponse) return "RawResponse<Blob>";
781
784
  const raw = rawResponseType(c, outDir);
782
785
  return serialization === "json" ? `Jsonify<${raw}>` : raw;
783
786
  }
@@ -832,8 +835,9 @@ function emitRouterTypeBlock(tree, indent, outDir, serialization) {
832
835
  const safeUrl = JSON.stringify(c.path);
833
836
  const filterFields = c.contractSource.filterFields?.length ? c.contractSource.filterFields.map((f) => JSON.stringify(f)).join(" | ") : "never";
834
837
  const stream = c.contractSource.stream ? "true" : "false";
838
+ const binary = c.contractSource.binaryResponse ? "true" : "false";
835
839
  lines.push(
836
- `${pad}${objKey}: { method: ${safeMethod}; url: ${safeUrl}; params: ${params}; query: ${query}; body: ${body}; response: ${response}; error: ${error}; filterFields: ${filterFields}; stream: ${stream} };`
840
+ `${pad}${objKey}: { method: ${safeMethod}; url: ${safeUrl}; params: ${params}; query: ${query}; body: ${body}; response: ${response}; error: ${error}; filterFields: ${filterFields}; stream: ${stream}; binary: ${binary} };`
837
841
  );
838
842
  } else {
839
843
  lines.push(`${pad}${objKey}: {`);
@@ -879,6 +883,9 @@ function buildRequestModel(c) {
879
883
  if (hasQuery) optsParts.push("query: input?.query as Record<string, unknown> | undefined");
880
884
  if (hasBody) optsParts.push("body: input?.body");
881
885
  if (hasBody && c.contractSource.multipart) optsParts.push("multipart: true");
886
+ if (c.contractSource.binaryResponse && m !== "get") {
887
+ optsParts.unshift(`method: ${JSON.stringify(m.toUpperCase())}`);
888
+ }
882
889
  const optsExpr = optsParts.length ? `{ ${optsParts.join(", ")} }` : "{}";
883
890
  return {
884
891
  routeName: c.name,
@@ -898,7 +905,8 @@ function buildRequestModel(c) {
898
905
  queryKeyExpr: `(input === undefined ? [${flat}] as const : [${flat}, input] as const)`
899
906
  };
900
907
  }
901
- function renderFetcherRequest(req) {
908
+ function renderFetcherRequest(req, binaryResponse) {
909
+ if (binaryResponse) return `fetcher.fetchBlob(${req.urlExpr}, ${req.optsExpr})`;
902
910
  return `fetcher.${req.method}<${req.responseType}>(${req.urlExpr}, ${req.optsExpr})`;
903
911
  }
904
912
  function emitReqHelper() {
@@ -959,7 +967,7 @@ function emitApiObjectBlock(tree, indent, p) {
959
967
  const leaf = {
960
968
  route: node.route,
961
969
  request: req,
962
- requestExpr: renderFetcherRequest(req)
970
+ requestExpr: renderFetcherRequest(req, node.contractSource.binaryResponse === true)
963
971
  };
964
972
  const owned = /* @__PURE__ */ new Map();
965
973
  if (p.layer) {
@@ -1019,6 +1027,8 @@ var ROUTE_NAMESPACE = [
1019
1027
  ' export type FilterFields<K extends string> = ResolveByName<K, "filterFields">;',
1020
1028
  " /** The streamed element type of an `@Sse()`/streaming route \u2014 the type yielded by its `stream()` AsyncIterable. */",
1021
1029
  ' export type Stream<K extends string> = ResolveByName<K, "response">;',
1030
+ " /** True for a binary/blob route (`StreamableFile`/`Buffer` handler return type). */",
1031
+ ' export type Binary<K extends string> = ResolveByName<K, "binary">;',
1022
1032
  " export type Request<K extends string> = {",
1023
1033
  " body: Body<K>;",
1024
1034
  " query: Query<K>;",
@@ -1036,6 +1046,7 @@ var PATH_NAMESPACE = [
1036
1046
  ' export type Error<M extends string, U extends string> = ResolveByPath<M, U, "error">;',
1037
1047
  ' export type FilterFields<M extends string, U extends string> = ResolveByPath<M, U, "filterFields">;',
1038
1048
  ' export type Stream<M extends string, U extends string> = ResolveByPath<M, U, "response">;',
1049
+ ' export type Binary<M extends string, U extends string> = ResolveByPath<M, U, "binary">;',
1039
1050
  "}",
1040
1051
  ""
1041
1052
  ];
@@ -1048,6 +1059,7 @@ var EMPTY_ROUTE_NAMESPACE = [
1048
1059
  " export type Error<K extends string> = never;",
1049
1060
  " export type FilterFields<K extends string> = never;",
1050
1061
  " export type Stream<K extends string> = never;",
1062
+ " export type Binary<K extends string> = never;",
1051
1063
  " export type Request<K extends string> = { body: never; query: never; params: never };",
1052
1064
  "}",
1053
1065
  ""
@@ -1061,6 +1073,7 @@ var EMPTY_PATH_NAMESPACE = [
1061
1073
  " export type Error<M extends string, U extends string> = never;",
1062
1074
  " export type FilterFields<M extends string, U extends string> = never;",
1063
1075
  " export type Stream<M extends string, U extends string> = never;",
1076
+ " export type Binary<M extends string, U extends string> = never;",
1064
1077
  "}",
1065
1078
  ""
1066
1079
  ];
@@ -1126,6 +1139,9 @@ function buildApiFile(routes, outDir, opts = {}) {
1126
1139
  if (serialization === "json" && contracted.length > 0) {
1127
1140
  lines.push(`import type { Jsonify } from '${runtimeImport}';`);
1128
1141
  }
1142
+ if (contracted.some((r) => r.contract?.contractSource.binaryResponse)) {
1143
+ lines.push(`import type { RawResponse } from '${runtimeImport}';`);
1144
+ }
1129
1145
  if (importsByFile.size > 0 && outDir) {
1130
1146
  lines.push("");
1131
1147
  const emittedNames = /* @__PURE__ */ new Set();
@@ -1162,6 +1178,12 @@ function buildApiFile(routes, outDir, opts = {}) {
1162
1178
  lines.push("");
1163
1179
  lines.push(...EMPTY_ROUTE_NAMESPACE);
1164
1180
  lines.push(...EMPTY_PATH_NAMESPACE);
1181
+ for (const ext of headerExts) {
1182
+ const statements = ext.apiHeader?.(ctx)?.statements;
1183
+ if (statements?.length) {
1184
+ lines.push(...statements, "");
1185
+ }
1186
+ }
1165
1187
  return lines.join("\n");
1166
1188
  }
1167
1189
  const tree = /* @__PURE__ */ new Map();
@@ -2182,11 +2204,22 @@ var import_node_path12 = require("path");
2182
2204
  var import_fast_glob2 = __toESM(require("fast-glob"), 1);
2183
2205
  var MANIFEST_FILE = ".codegen-manifest.json";
2184
2206
  var LOCK_FILE = ".watcher.lock";
2207
+ var DriftGuardError = class extends Error {
2208
+ constructor(message) {
2209
+ super(message);
2210
+ this.name = "DriftGuardError";
2211
+ }
2212
+ };
2213
+ function isEntryPoint(value) {
2214
+ return value === "cli" || value === "module";
2215
+ }
2185
2216
  function isManifestShape(value) {
2186
2217
  if (typeof value !== "object" || value === null) return false;
2187
2218
  const candidate = value;
2188
2219
  if (typeof candidate.version !== "string") return false;
2189
2220
  if (typeof candidate.hash !== "string") return false;
2221
+ if (candidate.entryPoint !== void 0 && !isEntryPoint(candidate.entryPoint)) return false;
2222
+ if (candidate.configHash !== void 0 && typeof candidate.configHash !== "string") return false;
2190
2223
  if (!Array.isArray(candidate.files)) return false;
2191
2224
  return candidate.files.every((entry) => typeof entry === "string");
2192
2225
  }
@@ -2229,11 +2262,20 @@ async function readManifest(outDir) {
2229
2262
  const raw = await (0, import_promises11.readFile)((0, import_node_path12.join)(outDir, MANIFEST_FILE), "utf8");
2230
2263
  const parsed = JSON.parse(raw);
2231
2264
  if (!isManifestShape(parsed)) return null;
2232
- return { version: parsed.version, hash: parsed.hash, files: parsed.files };
2265
+ return {
2266
+ version: parsed.version,
2267
+ hash: parsed.hash,
2268
+ ...parsed.entryPoint ? { entryPoint: parsed.entryPoint } : {},
2269
+ ...parsed.configHash ? { configHash: parsed.configHash } : {},
2270
+ files: parsed.files
2271
+ };
2233
2272
  } catch {
2234
2273
  return null;
2235
2274
  }
2236
2275
  }
2276
+ function computeConfigHash(config) {
2277
+ return (0, import_node_crypto.createHash)("sha256").update(serializeConfig(config)).digest("hex");
2278
+ }
2237
2279
  async function writeManifest(outDir, manifest) {
2238
2280
  await (0, import_promises11.writeFile)((0, import_node_path12.join)(outDir, MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}
2239
2281
  `, "utf8");
@@ -2278,7 +2320,10 @@ function debugWarn(message) {
2278
2320
  }
2279
2321
 
2280
2322
  // src/generate.ts
2281
- async function generate(config, inputRoutes = []) {
2323
+ function driftGuardMessage(outDir, previousEntryPoint, currentEntryPoint) {
2324
+ return `[nestjs-codegen] Config drift detected in "${outDir}": the last generate ran from the "${previousEntryPoint}" entry point, this run is from the "${currentEntryPoint}" entry point, and their resolved configs differ (e.g. \`serialization: "json"\` vs \`"superjson"\`). Both entry points must read the SAME config \u2014 export a shared config object (e.g. codegen.config.ts) and import it from BOTH nestjs-codegen.config.ts (CLI) and NestjsCodegenModule.forRoot() (Nest module), or set \`driftGuard: false\` on either config to opt out of this check.`;
2325
+ }
2326
+ async function generate(config, inputRoutes = [], entryPoint = "cli") {
2282
2327
  setCodegenDebug(config.debug);
2283
2328
  const inputsHash = await computeInputsHash(config);
2284
2329
  const manifest = await readManifest(config.codegen.outDir);
@@ -2286,6 +2331,12 @@ async function generate(config, inputRoutes = []) {
2286
2331
  console.log(`[nestjs-codegen] ${config.codegen.outDir} up to date, skipped`);
2287
2332
  return;
2288
2333
  }
2334
+ const configHash = computeConfigHash(config);
2335
+ if (config.driftGuard && manifest?.entryPoint && manifest.entryPoint !== entryPoint && manifest.configHash && manifest.configHash !== configHash) {
2336
+ throw new DriftGuardError(
2337
+ driftGuardMessage(config.codegen.outDir, manifest.entryPoint, entryPoint)
2338
+ );
2339
+ }
2289
2340
  const extensions = config.extensions ?? [];
2290
2341
  let routes = inputRoutes;
2291
2342
  const ctx = createExtensionContext(config, () => routes);
@@ -2351,6 +2402,8 @@ async function generate(config, inputRoutes = []) {
2351
2402
  await writeManifest(config.codegen.outDir, {
2352
2403
  version: VERSION,
2353
2404
  hash: inputsHash,
2405
+ entryPoint,
2406
+ configHash,
2354
2407
  files: outputFiles
2355
2408
  });
2356
2409
  }
@@ -3925,6 +3978,14 @@ function resolveBodyQueryResponseRef(typeNode, sourceFile, project) {
3925
3978
  var STREAM_CONTAINERS = /* @__PURE__ */ new Set(["Observable", "AsyncIterable", "AsyncIterableIterator"]);
3926
3979
  var STREAM_CONTAINERS_GENERATOR = /* @__PURE__ */ new Set(["AsyncGenerator"]);
3927
3980
  var STREAM_ENVELOPES = /* @__PURE__ */ new Set(["MessageEvent", "MessageEventLike"]);
3981
+ var BINARY_RESPONSE_TYPES = /* @__PURE__ */ new Set(["StreamableFile", "Buffer"]);
3982
+ function detectBinaryResponse(method) {
3983
+ const node = unwrapNamedContainer(method.getReturnTypeNode(), /* @__PURE__ */ new Set(["Promise"]));
3984
+ if (!node || !import_ts_morph7.Node.isTypeReference(node)) return false;
3985
+ const typeName = node.getTypeName();
3986
+ const name = import_ts_morph7.Node.isIdentifier(typeName) ? typeName.getText() : "";
3987
+ return BINARY_RESPONSE_TYPES.has(name);
3988
+ }
3928
3989
  function detectStreamElement(method) {
3929
3990
  const hasSse = method.getDecorators().some((d) => d.getName() === "Sse");
3930
3991
  let node = method.getReturnTypeNode();
@@ -3945,6 +4006,9 @@ function streamContainerElement(node) {
3945
4006
  }
3946
4007
  return null;
3947
4008
  }
4009
+ function hasAsQueryDecorator(method) {
4010
+ return method.getDecorators().some((d) => d.getName() === "AsQuery");
4011
+ }
3948
4012
  function unwrapNamedContainer(node, names) {
3949
4013
  if (!node || !import_ts_morph7.Node.isTypeReference(node)) return node;
3950
4014
  const typeName = node.getTypeName();
@@ -3962,6 +4026,8 @@ function extractDtoContract(method, sourceFile, project) {
3962
4026
  const multipartBody = uploads.fields ? `{ ${uploads.fields} }` : null;
3963
4027
  const streamElement = detectStreamElement(method);
3964
4028
  const isStream = streamElement !== null;
4029
+ const binaryResponse = detectBinaryResponse(method);
4030
+ const asQuery = hasAsQueryDecorator(method);
3965
4031
  if (filterInfo && filterInfo.source === "body") {
3966
4032
  const bodyType = "import('@dudousxd/nestjs-filter-client').FilterQueryResult";
3967
4033
  body = body ?? bodyType;
@@ -3969,7 +4035,7 @@ function extractDtoContract(method, sourceFile, project) {
3969
4035
  const paramsType = extractParamsType(method, sourceFile, project);
3970
4036
  const response = isStream ? resolveTypeNodeToString(streamElement, sourceFile, project, 3) : extractResponseType(method, sourceFile, project);
3971
4037
  const errorInfo = extractErrorType(method, sourceFile, project);
3972
- if (body === null && query === null && paramsType === null && response === "unknown" && errorInfo === null && filterInfo === null && !isStream && !uploads.multipart) {
4038
+ if (body === null && query === null && paramsType === null && response === "unknown" && errorInfo === null && filterInfo === null && !isStream && !uploads.multipart && !binaryResponse && !asQuery) {
3973
4039
  return null;
3974
4040
  }
3975
4041
  let bodyRef = null;
@@ -4044,7 +4110,9 @@ function extractDtoContract(method, sourceFile, project) {
4044
4110
  querySchema,
4045
4111
  stream: isStream,
4046
4112
  multipart: uploads.multipart,
4047
- multipartBody
4113
+ multipartBody,
4114
+ binaryResponse,
4115
+ asQuery
4048
4116
  };
4049
4117
  }
4050
4118
  function resolveParamClass(method, decoratorName, sourceFile, project) {
@@ -4531,7 +4599,9 @@ function extractDtoRoute(args) {
4531
4599
  querySchema: dtoContract?.querySchema ?? null,
4532
4600
  stream: dtoContract?.stream ?? false,
4533
4601
  multipart: dtoContract?.multipart ?? false,
4534
- multipartBody: dtoContract?.multipartBody ?? null
4602
+ multipartBody: dtoContract?.multipartBody ?? null,
4603
+ binaryResponse: dtoContract?.binaryResponse ?? false,
4604
+ asQuery: dtoContract?.asQuery ?? false
4535
4605
  }
4536
4606
  });
4537
4607
  }
@@ -4624,6 +4694,7 @@ var PAGES_DEBOUNCE_MS = 150;
4624
4694
  var NO_OP_WATCHER = { close: async () => {
4625
4695
  } };
4626
4696
  async function watch(config, onChange, options = {}) {
4697
+ const entryPoint = options.entryPoint ?? "cli";
4627
4698
  const lock = await acquireLock(config.codegen.outDir);
4628
4699
  if (lock === null) {
4629
4700
  let holderPid = "unknown";
@@ -4655,13 +4726,17 @@ async function watch(config, onChange, options = {}) {
4655
4726
  try {
4656
4727
  const initialRoutes = (await getDiscovery()).discover();
4657
4728
  lastRoutes = initialRoutes;
4658
- await generate(config, initialRoutes);
4729
+ await generate(config, initialRoutes, entryPoint);
4659
4730
  } catch (err) {
4731
+ if (err instanceof DriftGuardError) {
4732
+ console.error(err.message);
4733
+ return;
4734
+ }
4660
4735
  console.warn(
4661
4736
  `[nestjs-codegen] Initial route discovery failed, falling back to pages-only: ${err instanceof Error ? err.message : String(err)}`
4662
4737
  );
4663
4738
  try {
4664
- await generate(config, lastRoutes);
4739
+ await generate(config, lastRoutes, entryPoint);
4665
4740
  } catch {
4666
4741
  }
4667
4742
  }
@@ -4689,7 +4764,7 @@ async function watch(config, onChange, options = {}) {
4689
4764
  pagesDebounceTimer = setTimeout(async () => {
4690
4765
  pagesDebounceTimer = void 0;
4691
4766
  try {
4692
- await generate(config, lastRoutes);
4767
+ await generate(config, lastRoutes, entryPoint);
4693
4768
  } catch (err) {
4694
4769
  console.error(
4695
4770
  "[nestjs-codegen] Pages generation failed:",
@@ -4721,7 +4796,7 @@ async function watch(config, onChange, options = {}) {
4721
4796
  try {
4722
4797
  const routes = await (await getDiscovery()).rediscover(changed);
4723
4798
  lastRoutes = routes;
4724
- await generate(config, routes);
4799
+ await generate(config, routes, entryPoint);
4725
4800
  } catch (err) {
4726
4801
  console.error(
4727
4802
  "[nestjs-codegen] Contracts generation failed:",
@@ -4844,11 +4919,12 @@ function createChainModuleRenderer(opts) {
4844
4919
  }
4845
4920
 
4846
4921
  // src/index.ts
4847
- var VERSION = "0.13.2";
4922
+ var VERSION = "0.14.1";
4848
4923
  // Annotate the CommonJS export names for ESM import in node:
4849
4924
  0 && (module.exports = {
4850
4925
  CodegenError,
4851
4926
  ConfigError,
4927
+ DriftGuardError,
4852
4928
  VERSION,
4853
4929
  acquireLock,
4854
4930
  buildMocksFile,