@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.
- package/CHANGELOG.md +42 -0
- package/dist/cli/main.cjs +92 -18
- package/dist/cli/main.cjs.map +1 -1
- package/dist/cli/main.js +92 -18
- package/dist/cli/main.js.map +1 -1
- package/dist/extension/index.cjs +2 -2
- package/dist/extension/index.cjs.map +1 -1
- package/dist/extension/index.d.cts +1 -1
- package/dist/extension/index.d.ts +1 -1
- package/dist/extension/index.js +2 -2
- package/dist/extension/index.js.map +1 -1
- package/dist/{index-D8RIMVpU.d.cts → index-DT8SgPxp.d.cts} +35 -2
- package/dist/{index-D8RIMVpU.d.ts → index-DT8SgPxp.d.ts} +35 -2
- package/dist/index.cjs +92 -16
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +64 -19
- package/dist/index.d.ts +64 -19
- package/dist/index.js +91 -16
- package/dist/index.js.map +1 -1
- package/dist/markers.cjs +34 -0
- package/dist/markers.cjs.map +1 -0
- package/dist/markers.d.cts +32 -0
- package/dist/markers.d.ts +32 -0
- package/dist/markers.js +9 -0
- package/dist/markers.js.map +1 -0
- package/dist/nest/index.cjs +4027 -3946
- package/dist/nest/index.cjs.map +1 -1
- package/dist/nest/index.d.cts +1 -1
- package/dist/nest/index.d.ts +1 -1
- package/dist/nest/index.js +4049 -3968
- package/dist/nest/index.js.map +1 -1
- package/package.json +11 -1
package/dist/extension/index.cjs
CHANGED
|
@@ -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,
|
|
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-
|
|
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-
|
|
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';
|
package/dist/extension/index.js
CHANGED
|
@@ -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,
|
|
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,
|
|
611
|
-
*
|
|
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,
|
|
611
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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,
|