@contractkit/plugin-typescript 0.29.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,15 @@
1
- import type { OpRootNode, OpRouteNode, OpOperationNode, OpRequestBodyNode, ContractTypeNode, ParamSource } from '@contractkit/core';
2
- import { resolveModifiers, isJsonMime, classifyContentType } from '@contractkit/core';
1
+ import type {
2
+ OpRootNode,
3
+ OpRouteNode,
4
+ OpOperationNode,
5
+ OpRequestBodyNode,
6
+ OpResponseNode,
7
+ OpResponseBodyNode,
8
+ OpResponseHeaderNode,
9
+ ContractTypeNode,
10
+ ParamSource,
11
+ } from '@contractkit/core';
12
+ import { resolveModifiers, isJsonMime, classifyContentType, observableResponses, thrownResponses } from '@contractkit/core';
3
13
  import { renderInputTsType, renderOutputTsType, quoteKey, headerNameToProperty, escapeJsDocLines, JSON_VALUE_TYPE_DECL } from './ts-render.js';
4
14
  import { pascalToDotCase, typeNeedsScalar } from './codegen-contract.js';
5
15
  import { bodyTypesStructurallyEqual } from './codegen-operation.js';
@@ -128,16 +138,17 @@ export function generateSdk(root: OpRootNode, options: SdkCodegenOptions = {}):
128
138
  if (sdkNeedsBigIntReplacer(root, includeInternal)) valueImports.push('bigIntReplacer');
129
139
  if (sdkNeedsBigIntReviver(root, includeInternal)) valueImports.push('parseJson');
130
140
  if (sdkNeedsQueryString(root, includeInternal)) valueImports.push('buildQueryString');
141
+ if (sdkNeedsReadContentType(root, includeInternal)) valueImports.push('readContentType');
131
142
  if (valueImports.length > 0) {
132
143
  lines.push(`import { ${valueImports.join(', ')} } from '${rel}';`);
133
144
  }
134
145
  } else {
135
146
  lines.push('');
136
- lines.push('export class SdkError extends Error {');
147
+ lines.push('export class SdkError<TBody = unknown> extends Error {');
137
148
  lines.push(' constructor(');
138
149
  lines.push(' public readonly status: number,');
139
150
  lines.push(' public readonly statusText: string,');
140
- lines.push(' public readonly body: unknown,');
151
+ lines.push(' public readonly body: TBody,');
141
152
  lines.push(' public readonly headers: Headers,');
142
153
  lines.push(' ) {');
143
154
  lines.push(' super(`${status} ${statusText}`);');
@@ -145,7 +156,16 @@ export function generateSdk(root: OpRootNode, options: SdkCodegenOptions = {}):
145
156
  lines.push(' }');
146
157
  lines.push('}');
147
158
  lines.push('');
148
- lines.push('export type SdkFetch = (url: string, init: RequestInit) => Promise<Response>;');
159
+ lines.push('export interface SdkRequestInit extends RequestInit {');
160
+ lines.push(' /**');
161
+ lines.push(' * Statuses this operation declares as values rather than errors — a 304 from');
162
+ lines.push(' * conditional-GET middleware, or an error status the service returns deliberately.');
163
+ lines.push(' * Anything else at or above 400 still throws SdkError.');
164
+ lines.push(' */');
165
+ lines.push(' expectStatuses?: number[];');
166
+ lines.push('}');
167
+ lines.push('');
168
+ lines.push('export type SdkFetch = (url: string, init: SdkRequestInit) => Promise<Response>;');
149
169
  lines.push('');
150
170
  lines.push('export interface SdkOptions {');
151
171
  lines.push(' baseUrl: string;');
@@ -155,9 +175,13 @@ export function generateSdk(root: OpRootNode, options: SdkCodegenOptions = {}):
155
175
  lines.push(' requestIdFactory?: () => string;');
156
176
  lines.push('}');
157
177
  lines.push('');
178
+ lines.push('export function readContentType(res: Response): string {');
179
+ lines.push(" return res.headers.get('content-type')?.split(';')[0]?.trim() ?? '';");
180
+ lines.push('}');
181
+ lines.push('');
158
182
  lines.push('export function createSdkFetch(options: SdkOptions): SdkFetch {');
159
183
  lines.push(' const getRequestId = options.requestIdFactory ?? (() => crypto.randomUUID());');
160
- lines.push(' return async (url: string, init: RequestInit): Promise<Response> => {');
184
+ lines.push(' return async (url: string, init: SdkRequestInit): Promise<Response> => {');
161
185
  lines.push(" const baseHeaders = typeof options.headers === 'function'");
162
186
  lines.push(' ? await options.headers()');
163
187
  lines.push(' : options.headers ?? {};');
@@ -165,7 +189,7 @@ export function generateSdk(root: OpRootNode, options: SdkCodegenOptions = {}):
165
189
  lines.push(' ...init,');
166
190
  lines.push(" headers: { ...baseHeaders, 'X-Request-ID': getRequestId(), ...init.headers as Record<string, string> },");
167
191
  lines.push(' });');
168
- lines.push(' if (!res.ok) {');
192
+ lines.push(' if (!res.ok && !(init.expectStatuses ?? []).includes(res.status)) {');
169
193
  lines.push(' const text = await res.text();');
170
194
  lines.push(' let body: unknown;');
171
195
  lines.push(' try { body = JSON.parse(text); } catch { body = text; }');
@@ -199,6 +223,12 @@ export function generateSdk(root: OpRootNode, options: SdkCodegenOptions = {}):
199
223
 
200
224
  lines.push('');
201
225
 
226
+ const errorAliases = generateErrorBodyAliases(root, options);
227
+ if (errorAliases.length > 0) {
228
+ lines.push(...errorAliases);
229
+ lines.push('');
230
+ }
231
+
202
232
  // Client class
203
233
  lines.push('/**');
204
234
  const relFile = options.outPath ? relative(dirname(options.outPath), root.file) : root.file;
@@ -266,29 +296,49 @@ function generateMethod(route: OpRouteNode, op: OpOperationNode, file: string, o
266
296
 
267
297
  // Determine return type — response side uses Output variants (post-transform wire shape).
268
298
  // For non-JSON responses the schema is ignored: text/* is read as string, binary as Blob.
269
- const primaryResponse = op.responses.find(r => r.bodyType) ?? op.responses[0];
270
- const isVoid = !primaryResponse?.bodyType;
271
- const respCategory = primaryResponse?.contentType ? classifyContentType(primaryResponse.contentType) : 'json';
272
- const dataType = isVoid
273
- ? 'void'
274
- : respCategory === 'text'
275
- ? 'string'
276
- : respCategory === 'binary'
277
- ? 'Blob'
278
- : renderOutputTsType(primaryResponse!.bodyType!, modelsWithOutput);
299
+ //
300
+ // `observableResponses` is the client-side mirror of the router's `emittedResponses`: it also
301
+ // covers statuses the service never writes but a client can still receive, such as a 304 from
302
+ // conditional-GET middleware. Anything left over reaches the caller as a thrown SdkError.
303
+ const observable = observableResponses(op);
304
+ const thrown = thrownResponses(op);
305
+ const isMultiStatus = observable.length > 1;
306
+ const primaryResponse = observable[0];
307
+ const primaryBodies = primaryResponse ? primaryResponse.bodies : [];
308
+ const isVoid = primaryBodies.length === 0;
279
309
  const respHeaders = primaryResponse?.headers ?? [];
280
310
  const hasRespHeaders = respHeaders.length > 0;
281
- const headersShape = hasRespHeaders
282
- ? `{ ${respHeaders.map(h => `${quoteKey(headerNameToProperty(h.name))}${h.optional ? '?' : ''}: ${renderOutputTsType(h.type, modelsWithOutput)}`).join('; ')} }`
283
- : '';
284
- const returnType = hasRespHeaders ? (isVoid ? `{ headers: ${headersShape} }` : `{ data: ${dataType}; headers: ${headersShape} }`) : dataType;
311
+ const headersShape = hasRespHeaders ? renderSdkHeadersShape(respHeaders, modelsWithOutput) : '';
312
+
313
+ // A union of more than one member is broken across lines — a four-status operation runs to
314
+ // several hundred characters on one line otherwise.
315
+ let returnMembers: string[] | undefined;
316
+ let returnType = '';
317
+ if (isMultiStatus) {
318
+ returnMembers = observable.flatMap(r => sdkResponseMembers(r, modelsWithOutput, true));
319
+ } else if (primaryBodies.length > 1) {
320
+ returnMembers = sdkResponseMembers(primaryResponse!, modelsWithOutput, false);
321
+ } else {
322
+ const dataType = isVoid ? 'void' : sdkDataType(primaryBodies[0]!, modelsWithOutput);
323
+ returnType = hasRespHeaders ? (isVoid ? `{ headers: ${headersShape} }` : `{ data: ${dataType}; headers: ${headersShape} }`) : dataType;
324
+ }
325
+ if (returnMembers?.length === 1) {
326
+ returnType = returnMembers[0]!;
327
+ returnMembers = undefined;
328
+ }
329
+
330
+ // Statuses the shared fetch would otherwise reject. All-2xx operations pass nothing, so the
331
+ // overwhelmingly common case keeps its existing call shape.
332
+ const expectStatuses = observable.filter(r => r.statusCode < 200 || r.statusCode >= 300).map(r => r.statusCode);
285
333
 
286
334
  // JSDoc
287
335
  const desc = op.description ?? route.description;
288
- if (op.name || desc) {
336
+ const errorBodyName = thrown.some(r => r.bodies.length > 0) ? errorBodyTypeName(route, op) : undefined;
337
+ if (op.name || desc || errorBodyName) {
289
338
  const tags: string[] = [];
290
339
  if (op.name) tags.push(`@name ${op.name}`);
291
340
  if (desc) tags.push(`@description ${desc}`);
341
+ if (errorBodyName) tags.push(`@throws {SdkError<${errorBodyName}>} on ${thrown.map(r => r.statusCode).join(', ')}`);
292
342
  const contentLines = tags.flatMap(t => escapeJsDocLines(t));
293
343
  if (contentLines.length === 1) {
294
344
  lines.push(` /** ${contentLines[0]} */`);
@@ -299,7 +349,13 @@ function generateMethod(route: OpRouteNode, op: OpOperationNode, file: string, o
299
349
  }
300
350
  }
301
351
 
302
- lines.push(` async ${methodName}(${paramStr}): Promise<${returnType}> {`);
352
+ if (returnMembers) {
353
+ lines.push(` async ${methodName}(${paramStr}): Promise<`);
354
+ for (const member of returnMembers) lines.push(` | ${member}`);
355
+ lines.push(` > {`);
356
+ } else {
357
+ lines.push(` async ${methodName}(${paramStr}): Promise<${returnType}> {`);
358
+ }
303
359
 
304
360
  // Build URL with path params
305
361
  const urlExpr = buildUrlExpression(route.path, route.params);
@@ -378,7 +434,10 @@ function generateMethod(route: OpRouteNode, op: OpOperationNode, file: string, o
378
434
  }
379
435
  }
380
436
 
381
- const resultPrefix = isVoid && !hasRespHeaders ? '' : 'const result = ';
437
+ if (expectStatuses.length > 0) fetchArgs.push(`expectStatuses: [${expectStatuses.join(', ')}]`);
438
+
439
+ const needsResult = isMultiStatus || !isVoid || hasRespHeaders;
440
+ const resultPrefix = needsResult ? 'const result = ' : '';
382
441
  if (fetchArgs.length === 2 && !hasBody && !hasOpHeaders && !hasQuery) {
383
442
  // Simple case — inline
384
443
  lines.push(` ${resultPrefix}await this.fetch(\`${fetchUrl}\`, { method: '${httpMethod}' });`);
@@ -390,21 +449,30 @@ function generateMethod(route: OpRouteNode, op: OpOperationNode, file: string, o
390
449
  lines.push(` });`);
391
450
  }
392
451
 
393
- const readBodyExpr =
394
- respCategory === 'text' ? `await result.text()` : respCategory === 'binary' ? `await result.blob()` : `await parseJson<${dataType}>(result)`;
395
-
396
- if (hasRespHeaders) {
397
- const headerEntries = respHeaders
398
- .map(h => `${quoteKey(headerNameToProperty(h.name))}: result.headers.get('${h.name}') ?? undefined`)
399
- .join(', ');
452
+ if (isMultiStatus) {
453
+ // The status is only known at runtime, so the caller gets a union to narrow. The lowest
454
+ // status is the default branch, which keeps the function exhaustively returning.
455
+ const [fallback, ...rest] = observable;
456
+ lines.push(` switch (result.status) {`);
457
+ for (const resp of rest) {
458
+ lines.push(` case ${resp.statusCode}:`);
459
+ lines.push(...sdkReturnLines(resp, modelsWithOutput, ' ', true));
460
+ }
461
+ lines.push(` default:`);
462
+ lines.push(...sdkReturnLines(fallback!, modelsWithOutput, ' ', true));
463
+ lines.push(` }`);
464
+ } else if (primaryBodies.length > 1) {
465
+ lines.push(...sdkReturnLines(primaryResponse!, modelsWithOutput, ' ', false));
466
+ } else if (hasRespHeaders) {
467
+ const headerEntries = sdkHeaderEntries(respHeaders);
400
468
  if (isVoid) {
401
469
  lines.push(` return { headers: { ${headerEntries} } };`);
402
470
  } else {
403
- lines.push(` const data = ${readBodyExpr};`);
471
+ lines.push(` const data = ${sdkReadExpr(primaryBodies[0]!, modelsWithOutput)};`);
404
472
  lines.push(` return { data, headers: { ${headerEntries} } };`);
405
473
  }
406
474
  } else if (!isVoid) {
407
- lines.push(` return ${readBodyExpr};`);
475
+ lines.push(` return ${sdkReadExpr(primaryBodies[0]!, modelsWithOutput)};`);
408
476
  }
409
477
 
410
478
  lines.push(' }');
@@ -412,6 +480,130 @@ function generateMethod(route: OpRouteNode, op: OpOperationNode, file: string, o
412
480
  return lines;
413
481
  }
414
482
 
483
+ // ─── Response shapes ──────────────────────────────────────────────────────
484
+
485
+ /** The TypeScript type a client sees for one response body. */
486
+ function sdkDataType(body: OpResponseBodyNode, modelsWithOutput?: Set<string>): string {
487
+ const category = classifyContentType(body.contentType);
488
+ if (category === 'text') return 'string';
489
+ if (category === 'binary') return 'Blob';
490
+ return renderOutputTsType(body.bodyType, modelsWithOutput);
491
+ }
492
+
493
+ /** How a client reads one response body off the `Response`. */
494
+ function sdkReadExpr(body: OpResponseBodyNode, modelsWithOutput?: Set<string>): string {
495
+ const category = classifyContentType(body.contentType);
496
+ if (category === 'text') return 'await result.text()';
497
+ if (category === 'binary') return 'await result.blob()';
498
+ return `await parseJson<${renderOutputTsType(body.bodyType, modelsWithOutput)}>(result)`;
499
+ }
500
+
501
+ function renderSdkHeadersShape(headers: OpResponseHeaderNode[], modelsWithOutput?: Set<string>): string {
502
+ const fields = headers.map(
503
+ h => `${quoteKey(headerNameToProperty(h.name))}${h.optional ? '?' : ''}: ${renderOutputTsType(h.type, modelsWithOutput)}`,
504
+ );
505
+ return `{ ${fields.join('; ')} }`;
506
+ }
507
+
508
+ function sdkHeaderEntries(headers: OpResponseHeaderNode[]): string {
509
+ return headers.map(h => `${quoteKey(headerNameToProperty(h.name))}: result.headers.get('${h.name}') ?? undefined`).join(', ');
510
+ }
511
+
512
+ /**
513
+ * Render one response as the members of the client's return union — the mirror of the router's
514
+ * service-result members, with `data` in place of `body`.
515
+ *
516
+ * Collapses to a single member with a union of mime literals when every declared mime yields the
517
+ * same data type; otherwise one member per mime, so `contentType` and `data` stay correlated.
518
+ */
519
+ function sdkResponseMembers(resp: OpResponseNode, modelsWithOutput: Set<string> | undefined, includeStatus: boolean): string[] {
520
+ const bodies = resp.bodies;
521
+ const headers = resp.headers ?? [];
522
+ const leading = includeStatus ? [`status: ${resp.statusCode}`] : [];
523
+ const trailing = headers.length > 0 ? [`headers: ${renderSdkHeadersShape(headers, modelsWithOutput)}`] : [];
524
+
525
+ if (bodies.length === 0) {
526
+ return [`{ ${[...leading, ...trailing].join('; ')} }`];
527
+ }
528
+
529
+ const dataTypes = bodies.map(b => sdkDataType(b, modelsWithOutput));
530
+ if (dataTypes.every(t => t === dataTypes[0])) {
531
+ const contentType = bodies.map(b => `'${b.contentType}'`).join(' | ');
532
+ return [`{ ${[...leading, `contentType: ${contentType}`, `data: ${dataTypes[0]}`, ...trailing].join('; ')} }`];
533
+ }
534
+ return bodies.map((b, i) => `{ ${[...leading, `contentType: '${b.contentType}'`, `data: ${dataTypes[i]}`, ...trailing].join('; ')} }`);
535
+ }
536
+
537
+ /** The `return` statement(s) that build one response's member of the return union. */
538
+ function sdkReturnLines(resp: OpResponseNode, modelsWithOutput: Set<string> | undefined, indent: string, includeStatus: boolean): string[] {
539
+ const bodies = resp.bodies;
540
+ const headers = resp.headers ?? [];
541
+ const leading = includeStatus ? [`status: ${resp.statusCode}`] : [];
542
+ const trailing = headers.length > 0 ? [`headers: { ${sdkHeaderEntries(headers)} }`] : [];
543
+
544
+ if (bodies.length === 0) {
545
+ return [`${indent}return { ${[...leading, ...trailing].join(', ')} };`];
546
+ }
547
+ if (bodies.length === 1) {
548
+ const fields = [...leading, `contentType: '${bodies[0]!.contentType}'`, `data: ${sdkReadExpr(bodies[0]!, modelsWithOutput)}`, ...trailing];
549
+ return [`${indent}return { ${fields.join(', ')} };`];
550
+ }
551
+
552
+ const dataTypes = bodies.map(b => sdkDataType(b, modelsWithOutput));
553
+ if (dataTypes.every(t => t === dataTypes[0])) {
554
+ // Every mime reads the same way, so only the label has to come off the wire.
555
+ const cast = bodies.map(b => `'${b.contentType}'`).join(' | ');
556
+ const fields = [...leading, `contentType: readContentType(result) as ${cast}`, `data: ${sdkReadExpr(bodies[0]!, modelsWithOutput)}`, ...trailing];
557
+ return [`${indent}return { ${fields.join(', ')} };`];
558
+ }
559
+
560
+ // The mimes read differently, so the client has to dispatch on what actually came back.
561
+ const lines = [`${indent}switch (readContentType(result)) {`];
562
+ for (const body of bodies.slice(1)) {
563
+ const fields = [...leading, `contentType: '${body.contentType}'`, `data: ${sdkReadExpr(body, modelsWithOutput)}`, ...trailing];
564
+ lines.push(`${indent} case '${body.contentType}':`);
565
+ lines.push(`${indent} return { ${fields.join(', ')} };`);
566
+ }
567
+ const first = bodies[0]!;
568
+ const fallbackFields = [...leading, `contentType: '${first.contentType}'`, `data: ${sdkReadExpr(first, modelsWithOutput)}`, ...trailing];
569
+ lines.push(`${indent} default:`);
570
+ lines.push(`${indent} return { ${fallbackFields.join(', ')} };`);
571
+ lines.push(`${indent}}`);
572
+ return lines;
573
+ }
574
+
575
+ // ─── Error body typing ────────────────────────────────────────────────────
576
+
577
+ function errorBodyTypeName(route: OpRouteNode, op: OpOperationNode): string {
578
+ const method = deriveMethodName(op, route);
579
+ return `${method.charAt(0).toUpperCase()}${method.slice(1)}ErrorBody`;
580
+ }
581
+
582
+ /**
583
+ * Module-level `…ErrorBody` aliases for every operation whose thrown statuses declare a body.
584
+ *
585
+ * TypeScript cannot type a `throw`, so the alias plus the method's `@throws` tag is as far as the
586
+ * error contract can be carried: it gives callers something to narrow `SdkError.body` to instead
587
+ * of leaving them with `unknown`.
588
+ */
589
+ export function generateErrorBodyAliases(root: OpRootNode, options: SdkCodegenOptions): string[] {
590
+ const includeInternal = options.includeInternal ?? false;
591
+ const lines: string[] = [];
592
+ for (const route of root.routes) {
593
+ for (const op of route.operations) {
594
+ const mods = resolveModifiers(route, op);
595
+ if (!includeInternal && mods.includes('internal')) continue;
596
+ const types = new Set<string>();
597
+ for (const resp of thrownResponses(op)) {
598
+ for (const body of resp.bodies) types.add(sdkDataType(body, options.modelsWithOutput));
599
+ }
600
+ if (types.size === 0) continue;
601
+ lines.push(`export type ${errorBodyTypeName(route, op)} = ${[...types].join(' | ')};`);
602
+ }
603
+ }
604
+ return lines;
605
+ }
606
+
415
607
  // ─── URL building ─────────────────────────────────────────────────────────
416
608
 
417
609
  function buildUrlExpression(path: string, _?: ParamSource): string {
@@ -632,9 +824,9 @@ function collectTypes(root: OpRootNode, modelsWithInput?: Set<string>, modelsWit
632
824
  }
633
825
  }
634
826
  for (const resp of op.responses) {
635
- if (resp.bodyType) {
636
- collectTypeNodeRefs(resp.bodyType, types);
637
- collectOutputTypeNodeRefs(resp.bodyType, types, modelsWithOutput);
827
+ for (const body of resp.bodies) {
828
+ collectTypeNodeRefs(body.bodyType, types);
829
+ collectOutputTypeNodeRefs(body.bodyType, types, modelsWithOutput);
638
830
  }
639
831
  if (resp.headers) {
640
832
  for (const h of resp.headers) {
@@ -738,6 +930,20 @@ function sdkNeedsQueryString(root: OpRootNode, includeInternal = false): boolean
738
930
  return false;
739
931
  }
740
932
 
933
+ /**
934
+ * True if any emitted operation has a status declaring several mimes, so the client has to read
935
+ * the actual content type off the response to know which it got.
936
+ */
937
+ function sdkNeedsReadContentType(root: OpRootNode, includeInternal = false): boolean {
938
+ for (const route of root.routes) {
939
+ for (const op of route.operations) {
940
+ if (!includeInternal && resolveModifiers(route, op).includes('internal')) continue;
941
+ if (observableResponses(op).some(r => r.bodies.length > 1)) return true;
942
+ }
943
+ }
944
+ return false;
945
+ }
946
+
741
947
  /** True if any emitted operation serializes a JSON request body (uses bigIntReplacer). */
742
948
  function sdkNeedsBigIntReplacer(root: OpRootNode, includeInternal = false): boolean {
743
949
  for (const route of root.routes) {
@@ -754,13 +960,8 @@ function sdkNeedsBigIntReviver(root: OpRootNode, includeInternal = false): boole
754
960
  for (const route of root.routes) {
755
961
  for (const op of route.operations) {
756
962
  if (!includeInternal && resolveModifiers(route, op).includes('internal')) continue;
757
- if (
758
- op.responses.some(r => {
759
- if (!r.bodyType) return false;
760
- // Only JSON-shaped responses use parseJson — text/binary read raw.
761
- return !r.contentType || classifyContentType(r.contentType) === 'json';
762
- })
763
- ) {
963
+ // Only JSON-shaped responses use parseJson — text/binary read raw.
964
+ if (op.responses.some(r => r.bodies.some(b => classifyContentType(b.contentType) === 'json'))) {
764
965
  return true;
765
966
  }
766
967
  }
@@ -779,7 +980,7 @@ function sdkNeedsJson(root: OpRootNode, includeInternal = false): boolean {
779
980
  };
780
981
  if (
781
982
  !!op.request?.bodies.some(b => typeNeedsScalar(b.bodyType, 'json')) ||
782
- op.responses.some(r => r.bodyType && typeNeedsScalar(r.bodyType, 'json')) ||
983
+ op.responses.some(r => r.bodies.some(b => typeNeedsScalar(b.bodyType, 'json'))) ||
783
984
  check(op.query) ||
784
985
  check(op.headers) ||
785
986
  check(route.params)
@@ -882,11 +1083,11 @@ function deriveTypeImportPath(file: string, template?: string): string {
882
1083
  /** Generate the shared SdkOptions interface file. */
883
1084
  export function generateSdkOptions(): string {
884
1085
  return [
885
- 'export class SdkError extends Error {',
1086
+ 'export class SdkError<TBody = unknown> extends Error {',
886
1087
  ' constructor(',
887
1088
  ' public readonly status: number,',
888
1089
  ' public readonly statusText: string,',
889
- ' public readonly body: unknown,',
1090
+ ' public readonly body: TBody,',
890
1091
  ' public readonly headers: Headers,',
891
1092
  ' ) {',
892
1093
  ' super(`${status} ${statusText}`);',
@@ -894,7 +1095,16 @@ export function generateSdkOptions(): string {
894
1095
  ' }',
895
1096
  '}',
896
1097
  '',
897
- 'export type SdkFetch = (url: string, init: RequestInit) => Promise<Response>;',
1098
+ 'export interface SdkRequestInit extends RequestInit {',
1099
+ ' /**',
1100
+ ' * Statuses this operation declares as values rather than errors — a 304 from',
1101
+ ' * conditional-GET middleware, or an error status the service returns deliberately.',
1102
+ ' * Anything else at or above 400 still throws SdkError.',
1103
+ ' */',
1104
+ ' expectStatuses?: number[];',
1105
+ '}',
1106
+ '',
1107
+ 'export type SdkFetch = (url: string, init: SdkRequestInit) => Promise<Response>;',
898
1108
  '',
899
1109
  'export interface SdkOptions {',
900
1110
  ' baseUrl: string;',
@@ -920,9 +1130,13 @@ export function generateSdkOptions(): string {
920
1130
  '',
921
1131
  JSON_VALUE_TYPE_DECL,
922
1132
  '',
1133
+ 'export function readContentType(res: Response): string {',
1134
+ " return res.headers.get('content-type')?.split(';')[0]?.trim() ?? '';",
1135
+ '}',
1136
+ '',
923
1137
  'export function createSdkFetch(options: SdkOptions): SdkFetch {',
924
1138
  ' const getRequestId = options.requestIdFactory ?? (() => crypto.randomUUID());',
925
- ' return async (url: string, init: RequestInit): Promise<Response> => {',
1139
+ ' return async (url: string, init: SdkRequestInit): Promise<Response> => {',
926
1140
  " const baseHeaders = typeof options.headers === 'function'",
927
1141
  ' ? await options.headers()',
928
1142
  ' : options.headers ?? {};',
@@ -930,7 +1144,7 @@ export function generateSdkOptions(): string {
930
1144
  ' ...init,',
931
1145
  " headers: { ...baseHeaders, 'X-Request-ID': getRequestId(), ...init.headers as Record<string, string> },",
932
1146
  ' });',
933
- ' if (!res.ok) {',
1147
+ ' if (!res.ok && !(init.expectStatuses ?? []).includes(res.status)) {',
934
1148
  ' const text = await res.text();',
935
1149
  ' let body: unknown;',
936
1150
  ' try { body = JSON.parse(text); } catch { body = text; }',
@@ -1116,6 +1330,8 @@ export function generateAreaClient(input: AreaClientInput): string {
1116
1330
 
1117
1331
  // ── Merge inputs across all inline files ────────────────────────────────
1118
1332
  const collectedMethodLines: string[] = [];
1333
+ // Aliases are keyed off method names, which already collide-check below, so a Set is enough.
1334
+ const collectedErrorAliases = new Set<string>();
1119
1335
  const seenMethods = new Set<string>();
1120
1336
  const typesByImportPath = new Map<string, Set<string>>();
1121
1337
  const unresolvedTypes = new Set<string>();
@@ -1123,6 +1339,7 @@ export function generateAreaClient(input: AreaClientInput): string {
1123
1339
  let needsBigIntReplacer = false;
1124
1340
  let needsBigIntReviver = false;
1125
1341
  let needsQueryString = false;
1342
+ let needsReadContentType = false;
1126
1343
 
1127
1344
  for (const inline of inlineFiles) {
1128
1345
  const includeInternal = inline.codegenOptions.includeInternal ?? false;
@@ -1136,10 +1353,12 @@ export function generateAreaClient(input: AreaClientInput): string {
1136
1353
  seenMethods.add(name);
1137
1354
  }
1138
1355
  collectedMethodLines.push(...methodLines);
1356
+ for (const alias of generateErrorBodyAliases(inline.root, inline.codegenOptions)) collectedErrorAliases.add(alias);
1139
1357
  if (sdkNeedsJson(inline.root, includeInternal)) needsJson = true;
1140
1358
  if (sdkNeedsBigIntReplacer(inline.root, includeInternal)) needsBigIntReplacer = true;
1141
1359
  if (sdkNeedsBigIntReviver(inline.root, includeInternal)) needsBigIntReviver = true;
1142
1360
  if (sdkNeedsQueryString(inline.root, includeInternal)) needsQueryString = true;
1361
+ if (sdkNeedsReadContentType(inline.root, includeInternal)) needsReadContentType = true;
1143
1362
 
1144
1363
  // Resolve each file's type refs against THIS file's modelOutPaths, but
1145
1364
  // produce import paths relative to the area client's outPath (not the
@@ -1179,6 +1398,7 @@ export function generateAreaClient(input: AreaClientInput): string {
1179
1398
  if (needsBigIntReplacer) valueImports.push('bigIntReplacer');
1180
1399
  if (needsBigIntReviver) valueImports.push('parseJson');
1181
1400
  if (needsQueryString) valueImports.push('buildQueryString');
1401
+ if (needsReadContentType) valueImports.push('readContentType');
1182
1402
  if (valueImports.length > 0) {
1183
1403
  lines.push(`import { ${valueImports.join(', ')} } from '${sdkOptionsRel}';`);
1184
1404
  }
@@ -1201,6 +1421,11 @@ export function generateAreaClient(input: AreaClientInput): string {
1201
1421
  }
1202
1422
  lines.push('');
1203
1423
 
1424
+ if (collectedErrorAliases.size > 0) {
1425
+ lines.push(...collectedErrorAliases);
1426
+ lines.push('');
1427
+ }
1428
+
1204
1429
  // ── <Area>Client class ──────────────────────────────────────────────────
1205
1430
  lines.push(`export class ${className} {`);
1206
1431
  for (const sc of subareaClients) {
package/src/index.ts CHANGED
@@ -262,7 +262,7 @@ function collectOpRootRefs(root: OpRootNode, modelMap: Map<string, ModelNode>):
262
262
  for (const body of op.request.bodies) seeds.push(body.bodyType);
263
263
  }
264
264
  for (const resp of op.responses) {
265
- if (resp.bodyType) seeds.push(resp.bodyType);
265
+ for (const body of resp.bodies) seeds.push(body.bodyType);
266
266
  if (resp.headers) {
267
267
  for (const h of resp.headers) seeds.push(h.type);
268
268
  }