@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 CHANGED
@@ -1,5 +1,47 @@
1
1
  # @dudousxd/nestjs-codegen
2
2
 
3
+ ## 0.14.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 093f3d5: A bare `@UploadedFile()` route (no `@Body()` DTO) now emits a working multipart leaf.
8
+ `requestShape().hasBody` ignored `multipart`, so while the ApiRouter TYPE promised
9
+ `body: { file: File | Blob }` (the multipart intersection), the generated call accepted no
10
+ body and sent no file. `multipart` now implies a body; routes with a `@Body()` DTO were
11
+ already correct.
12
+
13
+ ## 0.14.0
14
+
15
+ ### Minor Changes
16
+
17
+ - fc78a39: feat: binary (blob) response mode, `@AsQuery()` marker, CLI↔module config-drift guard, and a `handleQuery` TanStack helper.
18
+
19
+ - **Binary (blob) response mode.** A handler returning NestJS `StreamableFile` or Node `Buffer`
20
+ (including `Promise<StreamableFile>`) is now discovered as `binaryResponse: true` and emitted
21
+ with `response: RawResponse<Blob>` (never `Jsonify<...>`) — the leaf issues its request via
22
+ `fetcher.fetchBlob(...)` instead of the verb method, so callers get `{ data, status, headers }`
23
+ and can read `content-disposition` etc. Works on any HTTP method (`fetchBlob` already accepted
24
+ a `method` opt); a non-GET binary route passes it explicitly since `fetchBlob` defaults to GET.
25
+ `Observable`/`ReadableStream` handlers are unaffected — they stay on the existing SSE/stream
26
+ path. Each `ApiRouter` leaf now also carries a `binary` flag (`Route.Binary<K>` /
27
+ `Path.Binary<M, U>` type helpers), mirroring `stream`.
28
+ - **`@AsQuery()` marker** (new `@dudousxd/nestjs-codegen/markers` subpath — zero-import, runtime
29
+ no-op). Marks a non-GET route whose semantics are a read (e.g. a POST with a query-shaped
30
+ payload) so codegen emits `queryOptions` for it, exactly like a GET or a filter-search route.
31
+ - **CLI↔module config-drift guard.** The CLI (`nestjs-codegen.config.ts`) and the Nest module
32
+ (`NestjsCodegenModule.forRoot()`) can target the same `outDir` from independently-resolved
33
+ configs; if they genuinely differ (e.g. `serialization` `'json'` vs `'superjson'`), each run
34
+ used to silently overwrite the other's `api.ts` shape. `generate()` now throws a
35
+ `DriftGuardError` _before writing anything_ when the manifest's `entryPoint` differs from the
36
+ current run's AND the resolved configs' hashes differ — naming both entry points and
37
+ instructing how to fix it (share one config object, or set `driftGuard: false`). Same entry
38
+ point (a normal config edit) or same config across entry points both proceed as before.
39
+ - **TanStack: `handleQuery` helper**, emitted into `api.ts` whenever the TanStack layer is
40
+ active. Wraps any `{ queryKey, fetch }`-shaped handle (a POST-as-query handle, or a runtime
41
+ pick between two different handles) into a plain `{ queryKey, queryFn }` pair — solves the
42
+ useQuery-overload break from spreading a ternary of `queryOptions()` calls. Also: binary GET
43
+ routes get `queryOptions` but never `infiniteQueryOptions` (a download isn't paginated data).
44
+
3
45
  ## 0.13.2
4
46
 
5
47
  ### Patch Changes
package/dist/cli/main.cjs CHANGED
@@ -180,7 +180,8 @@ function applyDefaults(userConfig, cwd) {
180
180
  fileName: userConfig.mocks?.fileName ?? "mocks.ts",
181
181
  seed: userConfig.mocks?.seed ?? 1,
182
182
  baseUrl: userConfig.mocks?.baseUrl ?? ""
183
- }
183
+ },
184
+ driftGuard: userConfig.driftGuard ?? true
184
185
  };
185
186
  }
186
187
  async function loadConfig(cwd) {
@@ -640,8 +641,8 @@ async function collectEmittedFiles(extensions, ctx) {
640
641
  function requestShape(route) {
641
642
  const cs = route.contract?.contractSource;
642
643
  const isGet = route.method.toUpperCase() === "GET";
643
- const isQuery = isGet || !!cs?.filterFields?.length;
644
- const hasBody = !!cs?.bodyRef || cs?.body != null && cs.body !== "never";
644
+ const isQuery = isGet || !!cs?.filterFields?.length || !!cs?.asQuery;
645
+ const hasBody = !!cs?.bodyRef || cs?.body != null && cs.body !== "never" || !!cs?.multipart;
645
646
  const hasQuery = isGet || !!cs?.queryRef || cs?.query != null && cs.query !== "never";
646
647
  return { isGet, isQuery, hasBody, hasQuery };
647
648
  }
@@ -747,6 +748,7 @@ function emitFilterQueryType(c) {
747
748
  return `import('@dudousxd/nestjs-filter-client').TypedFilterQuery<${emitFilterQueryTypeArgs(c)}>`;
748
749
  }
749
750
  function buildResponseType(c, outDir, serialization) {
751
+ if (c.contractSource.binaryResponse) return "RawResponse<Blob>";
750
752
  const raw = rawResponseType(c, outDir);
751
753
  return serialization === "json" ? `Jsonify<${raw}>` : raw;
752
754
  }
@@ -801,8 +803,9 @@ function emitRouterTypeBlock(tree, indent, outDir, serialization) {
801
803
  const safeUrl = JSON.stringify(c.path);
802
804
  const filterFields = c.contractSource.filterFields?.length ? c.contractSource.filterFields.map((f) => JSON.stringify(f)).join(" | ") : "never";
803
805
  const stream = c.contractSource.stream ? "true" : "false";
806
+ const binary = c.contractSource.binaryResponse ? "true" : "false";
804
807
  lines.push(
805
- `${pad}${objKey}: { method: ${safeMethod}; url: ${safeUrl}; params: ${params}; query: ${query}; body: ${body}; response: ${response}; error: ${error}; filterFields: ${filterFields}; stream: ${stream} };`
808
+ `${pad}${objKey}: { method: ${safeMethod}; url: ${safeUrl}; params: ${params}; query: ${query}; body: ${body}; response: ${response}; error: ${error}; filterFields: ${filterFields}; stream: ${stream}; binary: ${binary} };`
806
809
  );
807
810
  } else {
808
811
  lines.push(`${pad}${objKey}: {`);
@@ -848,6 +851,9 @@ function buildRequestModel(c) {
848
851
  if (hasQuery) optsParts.push("query: input?.query as Record<string, unknown> | undefined");
849
852
  if (hasBody) optsParts.push("body: input?.body");
850
853
  if (hasBody && c.contractSource.multipart) optsParts.push("multipart: true");
854
+ if (c.contractSource.binaryResponse && m !== "get") {
855
+ optsParts.unshift(`method: ${JSON.stringify(m.toUpperCase())}`);
856
+ }
851
857
  const optsExpr = optsParts.length ? `{ ${optsParts.join(", ")} }` : "{}";
852
858
  return {
853
859
  routeName: c.name,
@@ -867,7 +873,8 @@ function buildRequestModel(c) {
867
873
  queryKeyExpr: `(input === undefined ? [${flat}] as const : [${flat}, input] as const)`
868
874
  };
869
875
  }
870
- function renderFetcherRequest(req) {
876
+ function renderFetcherRequest(req, binaryResponse) {
877
+ if (binaryResponse) return `fetcher.fetchBlob(${req.urlExpr}, ${req.optsExpr})`;
871
878
  return `fetcher.${req.method}<${req.responseType}>(${req.urlExpr}, ${req.optsExpr})`;
872
879
  }
873
880
  function emitReqHelper() {
@@ -928,7 +935,7 @@ function emitApiObjectBlock(tree, indent, p) {
928
935
  const leaf = {
929
936
  route: node.route,
930
937
  request: req,
931
- requestExpr: renderFetcherRequest(req)
938
+ requestExpr: renderFetcherRequest(req, node.contractSource.binaryResponse === true)
932
939
  };
933
940
  const owned = /* @__PURE__ */ new Map();
934
941
  if (p.layer) {
@@ -988,6 +995,8 @@ var ROUTE_NAMESPACE = [
988
995
  ' export type FilterFields<K extends string> = ResolveByName<K, "filterFields">;',
989
996
  " /** The streamed element type of an `@Sse()`/streaming route \u2014 the type yielded by its `stream()` AsyncIterable. */",
990
997
  ' export type Stream<K extends string> = ResolveByName<K, "response">;',
998
+ " /** True for a binary/blob route (`StreamableFile`/`Buffer` handler return type). */",
999
+ ' export type Binary<K extends string> = ResolveByName<K, "binary">;',
991
1000
  " export type Request<K extends string> = {",
992
1001
  " body: Body<K>;",
993
1002
  " query: Query<K>;",
@@ -1005,6 +1014,7 @@ var PATH_NAMESPACE = [
1005
1014
  ' export type Error<M extends string, U extends string> = ResolveByPath<M, U, "error">;',
1006
1015
  ' export type FilterFields<M extends string, U extends string> = ResolveByPath<M, U, "filterFields">;',
1007
1016
  ' export type Stream<M extends string, U extends string> = ResolveByPath<M, U, "response">;',
1017
+ ' export type Binary<M extends string, U extends string> = ResolveByPath<M, U, "binary">;',
1008
1018
  "}",
1009
1019
  ""
1010
1020
  ];
@@ -1017,6 +1027,7 @@ var EMPTY_ROUTE_NAMESPACE = [
1017
1027
  " export type Error<K extends string> = never;",
1018
1028
  " export type FilterFields<K extends string> = never;",
1019
1029
  " export type Stream<K extends string> = never;",
1030
+ " export type Binary<K extends string> = never;",
1020
1031
  " export type Request<K extends string> = { body: never; query: never; params: never };",
1021
1032
  "}",
1022
1033
  ""
@@ -1030,6 +1041,7 @@ var EMPTY_PATH_NAMESPACE = [
1030
1041
  " export type Error<M extends string, U extends string> = never;",
1031
1042
  " export type FilterFields<M extends string, U extends string> = never;",
1032
1043
  " export type Stream<M extends string, U extends string> = never;",
1044
+ " export type Binary<M extends string, U extends string> = never;",
1033
1045
  "}",
1034
1046
  ""
1035
1047
  ];
@@ -1095,6 +1107,9 @@ function buildApiFile(routes, outDir, opts = {}) {
1095
1107
  if (serialization === "json" && contracted.length > 0) {
1096
1108
  lines.push(`import type { Jsonify } from '${runtimeImport}';`);
1097
1109
  }
1110
+ if (contracted.some((r) => r.contract?.contractSource.binaryResponse)) {
1111
+ lines.push(`import type { RawResponse } from '${runtimeImport}';`);
1112
+ }
1098
1113
  if (importsByFile.size > 0 && outDir) {
1099
1114
  lines.push("");
1100
1115
  const emittedNames = /* @__PURE__ */ new Set();
@@ -1131,6 +1146,12 @@ function buildApiFile(routes, outDir, opts = {}) {
1131
1146
  lines.push("");
1132
1147
  lines.push(...EMPTY_ROUTE_NAMESPACE);
1133
1148
  lines.push(...EMPTY_PATH_NAMESPACE);
1149
+ for (const ext of headerExts) {
1150
+ const statements = ext.apiHeader?.(ctx)?.statements;
1151
+ if (statements?.length) {
1152
+ lines.push(...statements, "");
1153
+ }
1154
+ }
1134
1155
  return lines.join("\n");
1135
1156
  }
1136
1157
  const tree = /* @__PURE__ */ new Map();
@@ -2148,11 +2169,22 @@ var import_node_path12 = require("path");
2148
2169
  var import_fast_glob2 = __toESM(require("fast-glob"), 1);
2149
2170
  var MANIFEST_FILE = ".codegen-manifest.json";
2150
2171
  var LOCK_FILE = ".watcher.lock";
2172
+ var DriftGuardError = class extends Error {
2173
+ constructor(message) {
2174
+ super(message);
2175
+ this.name = "DriftGuardError";
2176
+ }
2177
+ };
2178
+ function isEntryPoint(value) {
2179
+ return value === "cli" || value === "module";
2180
+ }
2151
2181
  function isManifestShape(value) {
2152
2182
  if (typeof value !== "object" || value === null) return false;
2153
2183
  const candidate = value;
2154
2184
  if (typeof candidate.version !== "string") return false;
2155
2185
  if (typeof candidate.hash !== "string") return false;
2186
+ if (candidate.entryPoint !== void 0 && !isEntryPoint(candidate.entryPoint)) return false;
2187
+ if (candidate.configHash !== void 0 && typeof candidate.configHash !== "string") return false;
2156
2188
  if (!Array.isArray(candidate.files)) return false;
2157
2189
  return candidate.files.every((entry) => typeof entry === "string");
2158
2190
  }
@@ -2195,11 +2227,20 @@ async function readManifest(outDir) {
2195
2227
  const raw = await (0, import_promises11.readFile)((0, import_node_path12.join)(outDir, MANIFEST_FILE), "utf8");
2196
2228
  const parsed = JSON.parse(raw);
2197
2229
  if (!isManifestShape(parsed)) return null;
2198
- return { version: parsed.version, hash: parsed.hash, files: parsed.files };
2230
+ return {
2231
+ version: parsed.version,
2232
+ hash: parsed.hash,
2233
+ ...parsed.entryPoint ? { entryPoint: parsed.entryPoint } : {},
2234
+ ...parsed.configHash ? { configHash: parsed.configHash } : {},
2235
+ files: parsed.files
2236
+ };
2199
2237
  } catch {
2200
2238
  return null;
2201
2239
  }
2202
2240
  }
2241
+ function computeConfigHash(config) {
2242
+ return (0, import_node_crypto.createHash)("sha256").update(serializeConfig(config)).digest("hex");
2243
+ }
2203
2244
  async function writeManifest(outDir, manifest) {
2204
2245
  await (0, import_promises11.writeFile)((0, import_node_path12.join)(outDir, MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}
2205
2246
  `, "utf8");
@@ -2244,7 +2285,10 @@ function debugWarn(message) {
2244
2285
  }
2245
2286
 
2246
2287
  // src/generate.ts
2247
- async function generate(config, inputRoutes = []) {
2288
+ function driftGuardMessage(outDir, previousEntryPoint, currentEntryPoint) {
2289
+ 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.`;
2290
+ }
2291
+ async function generate(config, inputRoutes = [], entryPoint = "cli") {
2248
2292
  setCodegenDebug(config.debug);
2249
2293
  const inputsHash = await computeInputsHash(config);
2250
2294
  const manifest = await readManifest(config.codegen.outDir);
@@ -2252,6 +2296,12 @@ async function generate(config, inputRoutes = []) {
2252
2296
  console.log(`[nestjs-codegen] ${config.codegen.outDir} up to date, skipped`);
2253
2297
  return;
2254
2298
  }
2299
+ const configHash = computeConfigHash(config);
2300
+ if (config.driftGuard && manifest?.entryPoint && manifest.entryPoint !== entryPoint && manifest.configHash && manifest.configHash !== configHash) {
2301
+ throw new DriftGuardError(
2302
+ driftGuardMessage(config.codegen.outDir, manifest.entryPoint, entryPoint)
2303
+ );
2304
+ }
2255
2305
  const extensions = config.extensions ?? [];
2256
2306
  let routes = inputRoutes;
2257
2307
  const ctx = createExtensionContext(config, () => routes);
@@ -2317,6 +2367,8 @@ async function generate(config, inputRoutes = []) {
2317
2367
  await writeManifest(config.codegen.outDir, {
2318
2368
  version: VERSION,
2319
2369
  hash: inputsHash,
2370
+ entryPoint,
2371
+ configHash,
2320
2372
  files: outputFiles
2321
2373
  });
2322
2374
  }
@@ -3891,6 +3943,14 @@ function resolveBodyQueryResponseRef(typeNode, sourceFile, project) {
3891
3943
  var STREAM_CONTAINERS = /* @__PURE__ */ new Set(["Observable", "AsyncIterable", "AsyncIterableIterator"]);
3892
3944
  var STREAM_CONTAINERS_GENERATOR = /* @__PURE__ */ new Set(["AsyncGenerator"]);
3893
3945
  var STREAM_ENVELOPES = /* @__PURE__ */ new Set(["MessageEvent", "MessageEventLike"]);
3946
+ var BINARY_RESPONSE_TYPES = /* @__PURE__ */ new Set(["StreamableFile", "Buffer"]);
3947
+ function detectBinaryResponse(method) {
3948
+ const node = unwrapNamedContainer(method.getReturnTypeNode(), /* @__PURE__ */ new Set(["Promise"]));
3949
+ if (!node || !import_ts_morph7.Node.isTypeReference(node)) return false;
3950
+ const typeName = node.getTypeName();
3951
+ const name = import_ts_morph7.Node.isIdentifier(typeName) ? typeName.getText() : "";
3952
+ return BINARY_RESPONSE_TYPES.has(name);
3953
+ }
3894
3954
  function detectStreamElement(method) {
3895
3955
  const hasSse = method.getDecorators().some((d) => d.getName() === "Sse");
3896
3956
  let node = method.getReturnTypeNode();
@@ -3911,6 +3971,9 @@ function streamContainerElement(node) {
3911
3971
  }
3912
3972
  return null;
3913
3973
  }
3974
+ function hasAsQueryDecorator(method) {
3975
+ return method.getDecorators().some((d) => d.getName() === "AsQuery");
3976
+ }
3914
3977
  function unwrapNamedContainer(node, names) {
3915
3978
  if (!node || !import_ts_morph7.Node.isTypeReference(node)) return node;
3916
3979
  const typeName = node.getTypeName();
@@ -3928,6 +3991,8 @@ function extractDtoContract(method, sourceFile, project) {
3928
3991
  const multipartBody = uploads.fields ? `{ ${uploads.fields} }` : null;
3929
3992
  const streamElement = detectStreamElement(method);
3930
3993
  const isStream = streamElement !== null;
3994
+ const binaryResponse = detectBinaryResponse(method);
3995
+ const asQuery = hasAsQueryDecorator(method);
3931
3996
  if (filterInfo && filterInfo.source === "body") {
3932
3997
  const bodyType = "import('@dudousxd/nestjs-filter-client').FilterQueryResult";
3933
3998
  body = body ?? bodyType;
@@ -3935,7 +4000,7 @@ function extractDtoContract(method, sourceFile, project) {
3935
4000
  const paramsType = extractParamsType(method, sourceFile, project);
3936
4001
  const response = isStream ? resolveTypeNodeToString(streamElement, sourceFile, project, 3) : extractResponseType(method, sourceFile, project);
3937
4002
  const errorInfo = extractErrorType(method, sourceFile, project);
3938
- if (body === null && query === null && paramsType === null && response === "unknown" && errorInfo === null && filterInfo === null && !isStream && !uploads.multipart) {
4003
+ if (body === null && query === null && paramsType === null && response === "unknown" && errorInfo === null && filterInfo === null && !isStream && !uploads.multipart && !binaryResponse && !asQuery) {
3939
4004
  return null;
3940
4005
  }
3941
4006
  let bodyRef = null;
@@ -4010,7 +4075,9 @@ function extractDtoContract(method, sourceFile, project) {
4010
4075
  querySchema,
4011
4076
  stream: isStream,
4012
4077
  multipart: uploads.multipart,
4013
- multipartBody
4078
+ multipartBody,
4079
+ binaryResponse,
4080
+ asQuery
4014
4081
  };
4015
4082
  }
4016
4083
  function resolveParamClass(method, decoratorName, sourceFile, project) {
@@ -4497,7 +4564,9 @@ function extractDtoRoute(args) {
4497
4564
  querySchema: dtoContract?.querySchema ?? null,
4498
4565
  stream: dtoContract?.stream ?? false,
4499
4566
  multipart: dtoContract?.multipart ?? false,
4500
- multipartBody: dtoContract?.multipartBody ?? null
4567
+ multipartBody: dtoContract?.multipartBody ?? null,
4568
+ binaryResponse: dtoContract?.binaryResponse ?? false,
4569
+ asQuery: dtoContract?.asQuery ?? false
4501
4570
  }
4502
4571
  });
4503
4572
  }
@@ -4590,6 +4659,7 @@ var PAGES_DEBOUNCE_MS = 150;
4590
4659
  var NO_OP_WATCHER = { close: async () => {
4591
4660
  } };
4592
4661
  async function watch(config, onChange, options = {}) {
4662
+ const entryPoint = options.entryPoint ?? "cli";
4593
4663
  const lock = await acquireLock(config.codegen.outDir);
4594
4664
  if (lock === null) {
4595
4665
  let holderPid = "unknown";
@@ -4621,13 +4691,17 @@ async function watch(config, onChange, options = {}) {
4621
4691
  try {
4622
4692
  const initialRoutes = (await getDiscovery()).discover();
4623
4693
  lastRoutes = initialRoutes;
4624
- await generate(config, initialRoutes);
4694
+ await generate(config, initialRoutes, entryPoint);
4625
4695
  } catch (err) {
4696
+ if (err instanceof DriftGuardError) {
4697
+ console.error(err.message);
4698
+ return;
4699
+ }
4626
4700
  console.warn(
4627
4701
  `[nestjs-codegen] Initial route discovery failed, falling back to pages-only: ${err instanceof Error ? err.message : String(err)}`
4628
4702
  );
4629
4703
  try {
4630
- await generate(config, lastRoutes);
4704
+ await generate(config, lastRoutes, entryPoint);
4631
4705
  } catch {
4632
4706
  }
4633
4707
  }
@@ -4655,7 +4729,7 @@ async function watch(config, onChange, options = {}) {
4655
4729
  pagesDebounceTimer = setTimeout(async () => {
4656
4730
  pagesDebounceTimer = void 0;
4657
4731
  try {
4658
- await generate(config, lastRoutes);
4732
+ await generate(config, lastRoutes, entryPoint);
4659
4733
  } catch (err) {
4660
4734
  console.error(
4661
4735
  "[nestjs-codegen] Pages generation failed:",
@@ -4687,7 +4761,7 @@ async function watch(config, onChange, options = {}) {
4687
4761
  try {
4688
4762
  const routes = await (await getDiscovery()).rediscover(changed);
4689
4763
  lastRoutes = routes;
4690
- await generate(config, routes);
4764
+ await generate(config, routes, entryPoint);
4691
4765
  } catch (err) {
4692
4766
  console.error(
4693
4767
  "[nestjs-codegen] Contracts generation failed:",
@@ -4727,14 +4801,14 @@ async function watch(config, onChange, options = {}) {
4727
4801
  }
4728
4802
 
4729
4803
  // src/index.ts
4730
- var VERSION = "0.13.2";
4804
+ var VERSION = "0.14.1";
4731
4805
 
4732
4806
  // src/cli/codegen.ts
4733
4807
  async function runCodegen(opts = {}) {
4734
4808
  const cwd = opts.cwd ?? process.cwd();
4735
4809
  const config = await loadConfig(cwd);
4736
4810
  if (opts.watch) {
4737
- const watcher = await watch(config);
4811
+ const watcher = await watch(config, void 0, { entryPoint: "cli" });
4738
4812
  await new Promise((resolve4) => {
4739
4813
  function onSignal() {
4740
4814
  watcher.close().then(resolve4).catch(resolve4);
@@ -4749,7 +4823,7 @@ async function runCodegen(opts = {}) {
4749
4823
  glob: config.contracts.glob,
4750
4824
  ...config.app?.tsconfig ? { tsconfig: config.app.tsconfig } : {}
4751
4825
  });
4752
- await generate(config, routes);
4826
+ await generate(config, routes, "cli");
4753
4827
  console.log("\u2713 Codegen generated artifacts in", config.codegen.outDir);
4754
4828
  }
4755
4829