typespec-http-zod 0.26.0 → 0.28.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.
package/dist/src/api.js CHANGED
@@ -1,13 +1,16 @@
1
- import { emitFile, getDoc, getDocData, getEncode, getLocationContext, getSummary, isErrorModel, isNeverType, isVoidType, navigateProgram, NoTarget, resolveEncodedName, resolvePath, } from "@typespec/compiler";
1
+ import { emitFile, getDoc, getDocData, getEncode, getLocationContext, getSummary, isErrorModel, isNeverType, isVoidType, navigateProgram, NoTarget, resolveEncodedName, resolvePath, walkPropertiesInherited, } from "@typespec/compiler";
2
2
  import { getAuthenticationForOperation, resolveRequestVisibility, Visibility, } from "@typespec/http";
3
3
  import { getOperationId, resolveOperationId } from "@typespec/openapi";
4
4
  import { reportDiagnostic } from "./lib.js";
5
5
  import { SchemaRegistry, TypeRegistry } from "./registry.js";
6
- import { typeToTs } from "./types.js";
6
+ import { isJsonMediaType, isMediaRange, isTextualMediaType, mediaTypeEssenceOf, mediaTypeTypeOf, resultMembersOf, statusCodesOf, } from "./results.js";
7
+ import { propertyTypeToTs } from "./types.js";
7
8
  import { resolveStreamModule, streamedTypeOf, withStreamModule } from "./streams.js";
8
9
  import { resolveVersioningModule, serviceSnapshots } from "./versioning.js";
9
10
  import { EXPLODED_QUERY_SOURCE, explodedQueryCall, needsExpansionDecoding, routeTemplateOf, URI_EXPANSION_SOURCE, uriExpansionCall, } from "./uri-template.js";
10
- import { collectExternalImports, noteDeclaredVocabularies, noteKeyVocabularies, objectKey, wireKindOf, withContractsPackage, withSealedObjects, withVisibility, } from "./zod.js";
11
+ export { CONTENTLESS_STATUS_CODES, STATUS_CODES } from "./statuses.js";
12
+ export { isJsonMediaType, isMediaRange, isTextualMediaType, mediaTypeEssenceOf, mediaTypeTypeOf, resultMembersOf, statusCodesOf, } from "./results.js";
13
+ import { collectExternalImports, isPayloadProperty, noteDeclaredVocabularies, noteKeyVocabularies, objectKey, wireKindOf, withContractsPackage, withSealedObjects, withVisibility, } from "./zod.js";
11
14
  /**
12
15
  * `typespec-http-zod` - Zod validators and framework-free contract types from a TypeSpec HTTP
13
16
  * service, agreeing with the OpenAPI document `@typespec/openapi3` publishes from the same source.
@@ -154,10 +157,10 @@ let operationIds;
154
157
  /**
155
158
  * Establish a fresh id resolver for one service's emission.
156
159
  *
157
- * **Scoped per SERVICE rather than per walk, because the walks see different subsets.**
158
- * `collectRoutes` visits every operation with a success status; `collectRequestTypes` skips any with
159
- * no body and no parameters. Deduplicating independently would let the two assign a suffix to
160
- * different operations, and the artefacts would name one operation two ways.
160
+ * **Scoped per SERVICE rather than per walk, because every walk has to name an operation the same
161
+ * way.** `collectRoutes` and `collectRequestTypes` both visit every operation, but each is a separate
162
+ * walk; deduplicating independently would let a suffix depend on the order a walk happened to meet
163
+ * two operations in, and the artefacts would name one operation two ways.
161
164
  *
162
165
  * A reset rather than a wrapper, because the emission loop is `for (const snapshot of snapshots)`
163
166
  * and awaits each service to completion before the next begins. Nothing interleaves, so there is no
@@ -275,7 +278,7 @@ function statusKeysOf(program, response) {
275
278
  return keys;
276
279
  }
277
280
  /**
278
- * Every response the operation declares, one per status key, in OpenAPI's precedence order.
281
+ * Every response the operation declares, one per status key, in the order they were RESOLVED.
279
282
  *
280
283
  * **Each response's own body, headers and media types, and nothing borrowed from another.** The
281
284
  * failure arms used to be collected here separately from the success, and before that every failure
@@ -288,10 +291,23 @@ function statusKeysOf(program, response) {
288
291
  * Where two responses resolve to the same status the first body wins, which is what the document
289
292
  * does, and media types and headers are the union across them.
290
293
  *
291
- * Call inside `withVisibility(program, Visibility.Read, ...)`: a response is read at `Read`, and
292
- * resolving it under a request visibility declares components under the wrong suffix.
294
+ * Resolution order, not precedence order, because a renderer that declares components has to
295
+ * declare them in the order they were always declared; {@link inPrecedenceOrder} sorts.
296
+ *
297
+ * **Resolved once per operation, and every walk reads that record.** Resolving reports what the
298
+ * document cannot publish, such as a status range OpenAPI cannot key; the route walk and the contract
299
+ * walk each resolved the same operation, so with contracts emitted every such error was raised twice.
293
300
  */
294
- function responsesOf(program, operation, registry) {
301
+ function resolvedResponsesOf(program, operation) {
302
+ const resolved = resolvedResponses.get(operation);
303
+ if (resolved !== undefined)
304
+ return resolved;
305
+ const fresh = resolveResponses(program, operation);
306
+ resolvedResponses.set(operation, fresh);
307
+ return fresh;
308
+ }
309
+ const resolvedResponses = new WeakMap();
310
+ function resolveResponses(program, operation) {
295
311
  const byStatus = new Map();
296
312
  for (const response of operation.responses) {
297
313
  for (const status of statusKeysOf(program, response)) {
@@ -302,22 +318,28 @@ function responsesOf(program, operation, registry) {
302
318
  contentTypes: new Set(),
303
319
  headers: [],
304
320
  streamed: false,
321
+ file: false,
305
322
  };
306
323
  for (const content of response.responses) {
307
324
  if (entry.body === undefined && content.body?.bodyKind === "single") {
308
325
  entry.body = bodyTypeOf(content.body.type);
309
326
  }
327
+ /**
328
+ * **An `Http.File` response's body is its contents, served as bytes.** The same reading
329
+ * as a file request body: a media type and nothing to serialise. Read only for
330
+ * `"single"`, every download in `type/file` declared `image/png` and was served empty.
331
+ */
332
+ if (entry.body === undefined && content.body?.bodyKind === "file") {
333
+ entry.body = content.body.contents.type;
334
+ entry.file = true;
335
+ }
310
336
  for (const contentType of content.body?.contentTypes ?? []) {
311
337
  entry.contentTypes.add(contentType);
312
338
  }
313
339
  for (const [name, property] of Object.entries(content.headers ?? {})) {
314
340
  if (entry.headers.some((existing) => existing.name === name))
315
341
  continue;
316
- entry.headers.push({
317
- name,
318
- type: typeToTs(program, property.type),
319
- optional: property.optional,
320
- });
342
+ entry.headers.push({ name, property });
321
343
  }
322
344
  }
323
345
  if (streamedTypeOf(program, response.type) !== undefined)
@@ -325,27 +347,73 @@ function responsesOf(program, operation, registry) {
325
347
  byStatus.set(key, entry);
326
348
  }
327
349
  }
328
- return [...byStatus.values()]
329
- .map((entry) => {
350
+ return [...byStatus.values()].map((entry) => {
330
351
  const contentTypes = [...entry.contentTypes];
331
- const binary = entry.body !== undefined && isBinaryPart(entry.body) && isRawBinaryMediaType(contentTypes);
332
352
  return {
333
353
  status: entry.status,
334
- schema: entry.body === undefined
335
- ? undefined
336
- : binary
337
- ? "z.unknown()"
338
- : registry.expressionFor(entry.body),
354
+ body: entry.body,
339
355
  contentTypes,
340
356
  headers: entry.headers,
341
- binary,
357
+ binary: entry.file ||
358
+ (entry.body !== undefined &&
359
+ isBinaryPart(entry.body) &&
360
+ isRawBinaryMediaType(contentTypes)),
342
361
  streamed: entry.streamed,
343
- textual: entry.body !== undefined && isStringType(entry.body),
362
+ textual: !entry.file && entry.body !== undefined && isStringType(entry.body),
344
363
  };
345
- })
346
- .toSorted((a, b) => statusPrecedenceOf(a.status) - statusPrecedenceOf(b.status) ||
364
+ });
365
+ }
366
+ /** Responses in OpenAPI's precedence order: exact codes, then ranges, then `default`. */
367
+ function inPrecedenceOrder(responses) {
368
+ return responses.toSorted((a, b) => statusPrecedenceOf(a.status) - statusPrecedenceOf(b.status) ||
347
369
  statusOrderOf(a.status) - statusOrderOf(b.status));
348
370
  }
371
+ /**
372
+ * Every response the operation declares, rendered as Zod, in OpenAPI's precedence order.
373
+ *
374
+ * Every header is rendered before any body, and bodies in precedence order, which is the order this
375
+ * has always declared components in: the emitted files do not move because the resolution moved.
376
+ *
377
+ * Call inside `withVisibility(program, Visibility.Read, ...)`: a response is read at `Read`, and
378
+ * resolving it under a request visibility declares components under the wrong suffix.
379
+ */
380
+ function responsesOf(program, operation, registry) {
381
+ const withHeaders = resolvedResponsesOf(program, operation).map((response) => ({
382
+ response,
383
+ headers: response.headers.map(({ name, property }) => ({
384
+ name,
385
+ /**
386
+ * **Encode-aware, as the schema beside it is.** `@encode(seconds, int32)` makes a `duration`
387
+ * an integer on the wire, and the header schema checks an integer; reading the declared type
388
+ * alone published `string`, so a handler satisfying its own result type was refused by its
389
+ * own header check and the arm could not be returned without a cast.
390
+ */
391
+ type: propertyTypeToTs(program, property),
392
+ /**
393
+ * **The Zod expression, which used to be discarded here.** Every REQUEST parameter goes
394
+ * through `expressionForProperty` a few hundred lines below; a response header went
395
+ * through `typeToTs` and nothing else, so the emitted arm carried a name and an
396
+ * optionality and no way to check a value. A response body that breaks its schema throws
397
+ * `ResponseContractError`; a header that breaks its own was served.
398
+ */
399
+ schema: registry.expressionForProperty(property),
400
+ optional: property.optional,
401
+ })),
402
+ }));
403
+ return inPrecedenceOrder(withHeaders.map(({ response, headers }) => ({ status: response.status, response, headers }))).map(({ response, headers }) => ({
404
+ status: response.status,
405
+ schema: response.body === undefined
406
+ ? undefined
407
+ : response.binary
408
+ ? "z.unknown()"
409
+ : registry.expressionFor(response.body),
410
+ contentTypes: response.contentTypes,
411
+ headers,
412
+ binary: response.binary,
413
+ streamed: response.streamed,
414
+ textual: response.textual,
415
+ }));
416
+ }
349
417
  /**
350
418
  * Where an arm sits in OpenAPI's own precedence order.
351
419
  *
@@ -531,33 +599,6 @@ export function errorBodyOf(program, service) {
531
599
  function bodyTypeOf(type) {
532
600
  return isVoidType(type) || isNeverType(type) ? undefined : type;
533
601
  }
534
- /**
535
- * The declared body type of the operation's success response, if it has one.
536
- *
537
- * **`void` and `never` ARE "no body", and reading them as types was a defect.** A response with
538
- * no content reaches this in two shapes: as no body at all, which `undefined` already covered, and
539
- * as a body whose type is `void` - which is what `tsp-openapi3` writes when it converts a `204`,
540
- * and what `op remove(): void` means. The second used to be returned as a type, walked, and refused
541
- * with `Cannot emit Zod for Intrinsic: 'void' has no runtime representation'`. That took out the
542
- * Swagger Petstore, the most widely implemented reference API there is, on the round-trip suite's
543
- * first run.
544
- *
545
- * One fact arriving by two roads. Answering it here rather than at each of the four call sites is
546
- * what keeps them from drifting - `collectRoutes`, the contract types, the dual-status
547
- * discriminator and the alternate-arm schema all want the same answer to the same question.
548
- */
549
- function successBodyOf(operation, status) {
550
- for (const response of operation.responses) {
551
- if (response.statusCodes !== status)
552
- continue;
553
- for (const content of response.responses) {
554
- if (content.body?.bodyKind !== "single")
555
- continue;
556
- return bodyTypeOf(content.body.type);
557
- }
558
- }
559
- return undefined;
560
- }
561
602
  /**
562
603
  * The media types this operation's success response can be served as.
563
604
  *
@@ -793,7 +834,18 @@ function isBinaryPart(type) {
793
834
  export function isRawBinaryMediaType(contentTypes) {
794
835
  if (contentTypes.length === 0)
795
836
  return false;
796
- return contentTypes.every((type) => !/^application\/(json|.*\+json)$/.test(type));
837
+ return contentTypes.every((type) => !isJsonMediaType(type));
838
+ }
839
+ /**
840
+ * A media type a server can parse a request body from into a value its schema can check: JSON and
841
+ * the `+json` suffix RFC 6839 defines, a URL-encoded form, and multipart. Anything else - XML, a
842
+ * vendor type - has no reading this emitter can derive from the document.
843
+ */
844
+ function isParseableMediaType(type) {
845
+ const essence = mediaTypeEssenceOf(type);
846
+ return (isJsonMediaType(essence) ||
847
+ essence === "application/x-www-form-urlencoded" ||
848
+ essence.startsWith("multipart/"));
797
849
  }
798
850
  /** The declared request body type, if the operation takes one. */
799
851
  function requestBodyOf(operation) {
@@ -968,9 +1020,52 @@ function mediaTypeDecoded(declared) {
968
1020
  .map((entry) => entry.trim().replace(/^"|"$/g, ""));
969
1021
  if (values === undefined || values.length === 0)
970
1022
  return declared;
971
- // Lowercased only when every declared value already is, so an oddly-cased one keeps matching.
972
- const normalise = values.every((value) => value === value.toLowerCase()) ? ".toLowerCase()" : "";
973
- return `z.preprocess((raw) => (typeof raw === "string" ? (raw.split(";")[0] ?? "").trim()${normalise} : raw), ${declared})`;
1023
+ /**
1024
+ * **A declared media RANGE admits the types inside it, and a literal admits only itself.**
1025
+ *
1026
+ * \`@header contentType: "text/*"\` published \`z.literal("text/*")\`, so the only request that
1027
+ * satisfied it was one sending the range itself as its \`Content-Type\`, which no client does.
1028
+ * Measured before this existed: \`text/plain\` and \`text/html\` both answered 400 on a route whose
1029
+ * document declares the whole \`text\` group.
1030
+ *
1031
+ * A template literal is the Zod counterpart of the TypeScript type the contract already uses for
1032
+ * a range, so the validator and the handler's own type say the same thing.
1033
+ */
1034
+ const admitted = values.map((value) => {
1035
+ /**
1036
+ * **The full wildcard admits every media type, so there is nothing left to match on.**
1037
+ *
1038
+ * Spelled as a template literal it becomes the prefix `*` + `/`, which matches only a
1039
+ * Content-Type whose text begins with the range's own first two characters. Measured on the
1040
+ * emitted schema: `application/json`, `text/plain` and `image/png` were all REFUSED and the
1041
+ * literal `*` + `/*` was the one value accepted, which is the defect the range branch exists
1042
+ * to remove rather than to respell. A consumer declares this on a file upload that takes
1043
+ * whatever the caller sends.
1044
+ */
1045
+ if (value === "*/*")
1046
+ return "z.string()";
1047
+ return isMediaRange(value)
1048
+ ? `z.templateLiteral([${JSON.stringify(mediaTypeEssenceOf(value).slice(0, -1))}, z.string()])`
1049
+ : `z.literal(${JSON.stringify(value)})`;
1050
+ });
1051
+ const schema = admitted.length === 1 ? (admitted[0] ?? declared) : `z.union([${admitted.join(", ")}])`;
1052
+ const ranged = values.some(isMediaRange) ? schema : declared;
1053
+ /**
1054
+ * **The request's media type is reduced to its essence, and so is every declared one.** A value
1055
+ * declared as its own essence - `application/json` - is the literal the essence is compared with.
1056
+ * One declared with a parameter or in capitals - `application/json; charset=utf-8` - is restored
1057
+ * from the essence, so the request is admitted and the handler is given the type as the document
1058
+ * declares it, which is what its input type says. Compared as written, such a value refused every
1059
+ * request: no request's essence carries a parameter.
1060
+ */
1061
+ const restored = values.filter((value) => !isMediaRange(value) && mediaTypeEssenceOf(value) !== value);
1062
+ const essence = `(raw.split(";")[0] ?? "").trim().toLowerCase()`;
1063
+ const decoded = restored.length === 0
1064
+ ? essence
1065
+ : `((essence) => ${restored
1066
+ .map((value) => `essence === ${JSON.stringify(mediaTypeEssenceOf(value))} ? ${JSON.stringify(value)} : `)
1067
+ .join("")}essence)(${essence})`;
1068
+ return `z.preprocess((raw) => (typeof raw === "string" ? ${decoded} : raw), ${ranged})`;
974
1069
  }
975
1070
  function parameterSchemasOf(program, operation, registry, segments,
976
1071
  /** The names of a query string written into the route itself, which no parameter may gather. */
@@ -983,7 +1078,22 @@ literalQueryNames) {
983
1078
  .filter((parameter) => parameter.type === "query")
984
1079
  .map((parameter) => parameter.name);
985
1080
  for (const parameter of operation.parameters.parameters) {
986
- if (parameter.type !== "path" && parameter.type !== "query" && parameter.type !== "header") {
1081
+ /**
1082
+ * **`cookie` is here because `@typespec/http` puts it here**, and excluding it was silent.
1083
+ * `HttpOperationParameter` discriminates on `"header" | "cookie" | "query" | "path"`, and this
1084
+ * filter named three of the four, so a `@cookie` reached no validator, no handler input and no
1085
+ * diagnostic while `@typespec/openapi3` published it as `in: cookie, required: true`. A
1086
+ * requirement the document states and the server cannot see is the defect class this package
1087
+ * exists to remove, and a cookie is where a session and a CSRF token normally live.
1088
+ *
1089
+ * The exclusion was by omission rather than by a `never` check, which is why TypeScript never
1090
+ * objected; every other place that reads `parameter.type` handles an encoding question a cookie
1091
+ * does not have, since `CookieOptions` carries only a name.
1092
+ */
1093
+ if (parameter.type !== "path" &&
1094
+ parameter.type !== "query" &&
1095
+ parameter.type !== "header" &&
1096
+ parameter.type !== "cookie") {
987
1097
  continue;
988
1098
  }
989
1099
  /**
@@ -1096,12 +1206,20 @@ literalQueryNames) {
1096
1206
  (byTarget["header-without-accept"] ??= []).push(entry);
1097
1207
  }
1098
1208
  }
1209
+ const fileContentType = fileContentTypeOf(operation);
1210
+ if (fileContentType !== undefined) {
1211
+ // The same decoding a declared `Content-Type` header gets: parameters dropped, lowercased.
1212
+ const entry = `\t${objectKey("Content-Type")}: ${mediaTypeDecoded(registry.expressionFor(fileContentType.type))},`;
1213
+ (byTarget["header"] ??= []).push(entry);
1214
+ (byTarget["header-without-accept"] ??= []).push(entry);
1215
+ }
1099
1216
  const shape = (target) => {
1100
1217
  const entries = byTarget[target];
1101
1218
  return entries === undefined ? undefined : `z.object({\n${entries.join("\n")}\n})`;
1102
1219
  };
1103
1220
  const query = shape("query");
1104
1221
  return {
1222
+ cookie: shape("cookie"),
1105
1223
  path: shape("path"),
1106
1224
  query: query === undefined
1107
1225
  ? undefined
@@ -1132,6 +1250,178 @@ function modelPropertiesOf(model) {
1132
1250
  }
1133
1251
  return chain.flatMap((current) => [...current.properties.values()]);
1134
1252
  }
1253
+ /**
1254
+ * **The media type an `Http.File` body declares, as the input it is.** A file's `contentType` is the
1255
+ * request's `Content-Type`: `Http.File<"image/png" | "image/jpeg">` accepts either and the handler has
1256
+ * to know which arrived, exactly as it would from a declared `@header contentType`. `@typespec/http`
1257
+ * keeps it on the file model rather than among the operation's parameters, so without this a
1258
+ * handler for a two-type upload had the bytes and no way to tell what they were.
1259
+ *
1260
+ * `undefined` where the body is not a file, or where the operation already declares the header.
1261
+ */
1262
+ function fileContentTypeOf(operation) {
1263
+ const body = operation.parameters.body;
1264
+ if (body?.bodyKind !== "file")
1265
+ return undefined;
1266
+ const declared = operation.parameters.parameters.some((parameter) => parameter.type === "header" && parameter.name.toLowerCase() === "content-type");
1267
+ return declared ? undefined : body.contentTypeProperty;
1268
+ }
1269
+ /**
1270
+ * Where a request body reaches a handler's input: spread into it, named under a property, or handed
1271
+ * over unread.
1272
+ *
1273
+ * **One decision, read by every artefact that states an operation's input.** The server emitters
1274
+ * render a handler's input from {@link EmittedRoute.bodyProperty}; the contract types in
1275
+ * `requests.gen.ts` re-derived the input for themselves and always merged, so for every body this
1276
+ * names - an indexer, an optional body, a scalar, enum or union - `WireInputs` described an input no
1277
+ * handler receives.
1278
+ */
1279
+ /**
1280
+ * The keys a merged body would contribute to the handler's input: each property of the body SENT,
1281
+ * at the operation's request visibility, under its wire name, or each named part of a multipart
1282
+ * body. A read-only property is not sent, so it contributes nothing.
1283
+ */
1284
+ function mergedBodyKeysOf(program, operation) {
1285
+ const body = operation.parameters.body;
1286
+ if (body?.bodyKind === "multipart") {
1287
+ return body.parts.flatMap((part) => (part.name === undefined ? [] : [part.name]));
1288
+ }
1289
+ const type = requestBodyOf(operation);
1290
+ if (type?.kind !== "Model")
1291
+ return [];
1292
+ return withVisibility(program, resolveRequestVisibility(program, operation.operation, operation.verb), () => [...walkPropertiesInherited(type)]
1293
+ .filter((property) => isPayloadProperty(program, property))
1294
+ .map((property) => resolveEncodedName(program, property, "application/json")));
1295
+ }
1296
+ function requestBodyPlacementOf(program, operation) {
1297
+ const requestType = requestBodyOf(operation);
1298
+ const bodyParameter = operation.parameters.body;
1299
+ const contentTypes = [...(bodyParameter?.contentTypes ?? [])];
1300
+ /**
1301
+ * A `bytes` body means the BYTES are the contract.
1302
+ *
1303
+ * A webhook's MAC covers exactly what arrived, so a parsed-and-re-serialised object would
1304
+ * verify a different string and every delivery would fail authentication.
1305
+ */
1306
+ const bytes = requestType?.kind === "Scalar" && requestType.name === "bytes";
1307
+ /**
1308
+ * **A body declared only under media types nothing can parse into it is handed over raw.** A
1309
+ * model as `application/xml` has no serialisation this emitter can derive, and the server has no
1310
+ * reader for it; it used to get a header check and then call the handler with the body never
1311
+ * read, while the warning beside it said such requests were refused. Handed over as it arrived,
1312
+ * the handler parses it - exactly what the response side already asks of a handler serving a model
1313
+ * as XML. It is the text under a textual media type and the bytes under any other
1314
+ * (`rawBodyBinary`). A string body is not this: it IS text, and its schema still checks it
1315
+ * (`requestTextual`).
1316
+ */
1317
+ const unparseable = bodyParameter?.bodyKind === "single" &&
1318
+ requestType !== undefined &&
1319
+ !bytes &&
1320
+ !isStringType(requestType) &&
1321
+ contentTypes.length > 0 &&
1322
+ contentTypes.every((type) => !isParseableMediaType(type));
1323
+ /**
1324
+ * **An `Http.File` body is raw too: the file's contents ARE the body.** `@typespec/openapi3`
1325
+ * publishes it as `content: { "image/png": { schema: { contentMediaType: "image/png" } } }` - a
1326
+ * media type and no type, nothing to parse and nothing to validate - which is exactly a `bytes`
1327
+ * body under that media type. `requestBodyOf` reads only `bodyKind === "single"`, so a file body
1328
+ * reached no placement at all: the handler was passed `{}` and the upload was dropped, from a
1329
+ * compile that reported success, on all four uploads in `type/file`.
1330
+ */
1331
+ const rawBodyProperty = bytes || bodyParameter?.bodyKind === "file" || unparseable
1332
+ ? (bodyParameter?.property?.name ?? "body")
1333
+ : undefined;
1334
+ /**
1335
+ * A body with an indexer is named rather than spread - see `bodyProperty`. Read from the
1336
+ * resolved model's own indexer rather than from the emitted schema text, which would be a
1337
+ * decision made by pattern-matching generated output.
1338
+ */
1339
+ /**
1340
+ * **An OPTIONAL body is named for the same reason an indexed one is**: the merge cannot
1341
+ * express "absent". See {@link EmittedRoute.optionalBody}.
1342
+ */
1343
+ const optionalBody = bodyParameter?.property?.optional === true;
1344
+ /**
1345
+ * **An optional MULTIPART body reaches the same rule by a second route, because
1346
+ * `requestType` cannot see it.** `requestBodyOf` resolves a type only for
1347
+ * `bodyKind === "single"`, so `requestType` is `undefined` for every multipart operation
1348
+ * and the `optionalBody` disjunct above was unreachable however the document declared it.
1349
+ *
1350
+ * The emitted server for such an operation did not compile. Measured, and the mechanism is
1351
+ * worth stating because it is not a union: spreading a value typed `Parts | undefined`
1352
+ * makes every part OPTIONAL, so `{ file?: ...; pages?: ... }` was passed where
1353
+ * `{ file: ...; pages: ... }` was required. `TS2345`, inside `app.gen.ts` itself.
1354
+ *
1355
+ * That is the failure {@link EmittedRoute.optionalBody} predicts in prose - a merge has no
1356
+ * way to say "these properties are here only sometimes" without making every one of them
1357
+ * optional, which is a weaker and different claim about the body that IS sent. So this is
1358
+ * the stated rule reaching the one body kind it had never been applied to, not a new rule.
1359
+ *
1360
+ * **The REQUIRED arm is deliberately excluded.** The indexed reason does not apply to a
1361
+ * parts model, so a required multipart body merges as safely as a required single one, and
1362
+ * `typespec-hono/test/filepart/` compiles a handler that reads `input.file` directly.
1363
+ * Widening this to "multipart is always named" would rewrite that directory for no defect.
1364
+ *
1365
+ * This is the third hole the `"single"` gate has produced, after `multipartSchemaOf` and
1366
+ * `multipartTsOf`, both recorded above.
1367
+ */
1368
+ const optionalMultipartBody = bodyParameter?.bodyKind === "multipart" && optionalBody;
1369
+ /**
1370
+ * **A body that is not a MODEL has no properties to merge, and merging it anyway lost it.**
1371
+ *
1372
+ * `@body body: string` emitted a handler input of `Record<string, never>` and a call site of
1373
+ * `{}`: the server validated the body and then never handed it to the handler, silently, from
1374
+ * a compile that reported success. `@body body: SomeEnum` was the same rule failing loudly,
1375
+ * `TS2698: Spread types may only be created from object types`, which is what
1376
+ * `payload/media-type`, `type/enum/*`, `type/scalar` and `versioning/returnTypeChangedFrom`
1377
+ * had been pinned on in the conformance corpus.
1378
+ *
1379
+ * A scalar, an enum, a union or a literal body is therefore NAMED, which is the treatment an
1380
+ * indexed or optional body already gets and for the same reason: the merge cannot express it.
1381
+ *
1382
+ * **Except a `bytes` body**, which {@link EmittedRoute.rawBodyProperty} already names, and
1383
+ * naming it twice would put the same value under two keys.
1384
+ */
1385
+ const unmergeableBody = requestType !== undefined && requestType.kind !== "Model" && rawBodyProperty === undefined;
1386
+ /**
1387
+ * **A body sharing a key with a parameter is named, because merged one value replaces the
1388
+ * other.** `PUT /renames/{name}` with a body `{ name }` handed the handler one `name`, the body's,
1389
+ * and the path's was lost with nothing reported - in `parameters/spread`'s composite request, and
1390
+ * in any spec whose body repeats a path or query parameter. The document states the two
1391
+ * separately; named, the handler is given both. A read-only property is not in the body sent, so
1392
+ * `@path id` beside a model whose `id` is read-only collides with nothing and still merges.
1393
+ */
1394
+ const parameterNames = new Set(operation.parameters.parameters.map((parameter) => parameter.name));
1395
+ const collides = rawBodyProperty === undefined &&
1396
+ mergedBodyKeysOf(program, operation).some((key) => parameterNames.has(key));
1397
+ const bodyProperty = (requestType?.kind === "Model" && (requestType.indexer !== undefined || optionalBody)) ||
1398
+ optionalMultipartBody ||
1399
+ unmergeableBody ||
1400
+ collides
1401
+ ? (bodyParameter?.property?.name ?? "body")
1402
+ : undefined;
1403
+ /**
1404
+ * A file is handed over as a stream of bytes whatever its media type. `@typespec/http` lets an
1405
+ * emitter represent a textual file "as strings or streams of textual characters", and one
1406
+ * representation for every file is what lets a file declared `application/json` be told from a
1407
+ * JSON body - which the media type alone cannot do.
1408
+ *
1409
+ * **A body nothing parses is bytes unless every type it is declared under is text.** Decoded as
1410
+ * text, `application/octet-stream` lost every byte that is not valid UTF-8; see
1411
+ * {@link isTextualMediaType}.
1412
+ */
1413
+ const rawBodyBinary = rawBodyProperty !== undefined &&
1414
+ (unparseable
1415
+ ? !contentTypes.every(isTextualMediaType)
1416
+ : bodyParameter?.bodyKind === "file" || isRawBinaryMediaType(contentTypes));
1417
+ return {
1418
+ rawBodyProperty,
1419
+ rawBodyBinary,
1420
+ rawBodyUnvalidated: unparseable,
1421
+ bodyProperty,
1422
+ optionalBody,
1423
+ };
1424
+ }
1135
1425
  export function collectRoutes(program, registry, service) {
1136
1426
  const routes = [];
1137
1427
  {
@@ -1161,73 +1451,7 @@ export function collectRoutes(program, registry, service) {
1161
1451
  // `@requestVisibility` when the operation states one, otherwise the verb's default - the
1162
1452
  // same resolution `@typespec/openapi3` performs before emitting a request schema.
1163
1453
  const requestVisibility = resolveRequestVisibility(program, operation.operation, operation.verb);
1164
- /**
1165
- * A `bytes` body means the BYTES are the contract.
1166
- *
1167
- * A webhook's MAC covers exactly what arrived, so a parsed-and-re-serialised object would
1168
- * verify a different string and every delivery would fail authentication.
1169
- */
1170
- const bodyParameter = operation.parameters.body;
1171
- const rawBodyProperty = requestType?.kind === "Scalar" && requestType.name === "bytes"
1172
- ? (bodyParameter?.property?.name ?? "body")
1173
- : undefined;
1174
- /**
1175
- * A body with an indexer is named rather than spread - see `bodyProperty`. Read from the
1176
- * resolved model's own indexer rather than from the emitted schema text, which would be a
1177
- * decision made by pattern-matching generated output.
1178
- */
1179
- /**
1180
- * **An OPTIONAL body is named for the same reason an indexed one is**: the merge cannot
1181
- * express "absent". See {@link EmittedRoute.optionalBody}.
1182
- */
1183
- const optionalBody = bodyParameter?.property?.optional === true;
1184
- /**
1185
- * **An optional MULTIPART body reaches the same rule by a second route, because
1186
- * `requestType` cannot see it.** `requestBodyOf` resolves a type only for
1187
- * `bodyKind === "single"`, so `requestType` is `undefined` for every multipart operation
1188
- * and the `optionalBody` disjunct above was unreachable however the document declared it.
1189
- *
1190
- * The emitted server for such an operation did not compile. Measured, and the mechanism is
1191
- * worth stating because it is not a union: spreading a value typed `Parts | undefined`
1192
- * makes every part OPTIONAL, so `{ file?: ...; pages?: ... }` was passed where
1193
- * `{ file: ...; pages: ... }` was required. `TS2345`, inside `app.gen.ts` itself.
1194
- *
1195
- * That is the failure {@link EmittedRoute.optionalBody} predicts in prose - a merge has no
1196
- * way to say "these properties are here only sometimes" without making every one of them
1197
- * optional, which is a weaker and different claim about the body that IS sent. So this is
1198
- * the stated rule reaching the one body kind it had never been applied to, not a new rule.
1199
- *
1200
- * **The REQUIRED arm is deliberately excluded.** The indexed reason does not apply to a
1201
- * parts model, so a required multipart body merges as safely as a required single one, and
1202
- * `typespec-hono/test/filepart/` compiles a handler that reads `input.file` directly.
1203
- * Widening this to "multipart is always named" would rewrite that directory for no defect.
1204
- *
1205
- * This is the third hole the `"single"` gate has produced, after `multipartSchemaOf` and
1206
- * `multipartTsOf`, both recorded above.
1207
- */
1208
- const optionalMultipartBody = bodyParameter?.bodyKind === "multipart" && optionalBody;
1209
- /**
1210
- * **A body that is not a MODEL has no properties to merge, and merging it anyway lost it.**
1211
- *
1212
- * `@body body: string` emitted a handler input of `Record<string, never>` and a call site of
1213
- * `{}`: the server validated the body and then never handed it to the handler, silently, from
1214
- * a compile that reported success. `@body body: SomeEnum` was the same rule failing loudly,
1215
- * `TS2698: Spread types may only be created from object types`, which is what
1216
- * `payload/media-type`, `type/enum/*`, `type/scalar` and `versioning/returnTypeChangedFrom`
1217
- * had been pinned on in the conformance corpus.
1218
- *
1219
- * A scalar, an enum, a union or a literal body is therefore NAMED, which is the treatment an
1220
- * indexed or optional body already gets and for the same reason: the merge cannot express it.
1221
- *
1222
- * **Except a `bytes` body**, which {@link EmittedRoute.rawBodyProperty} already names, and
1223
- * naming it twice would put the same value under two keys.
1224
- */
1225
- const unmergeableBody = requestType !== undefined && requestType.kind !== "Model" && rawBodyProperty === undefined;
1226
- const bodyProperty = (requestType?.kind === "Model" && (requestType.indexer !== undefined || optionalBody)) ||
1227
- optionalMultipartBody ||
1228
- unmergeableBody
1229
- ? (bodyParameter?.property?.name ?? "body")
1230
- : undefined;
1454
+ const { rawBodyProperty, rawBodyBinary, rawBodyUnvalidated, bodyProperty, optionalBody } = requestBodyPlacementOf(program, operation);
1231
1455
  const authentication = authenticationFor(program, operation);
1232
1456
  const template = routeTemplateOf(operation.uriTemplate, operation.parameters.parameters
1233
1457
  .filter((parameter) => parameter.type === "path")
@@ -1299,6 +1523,7 @@ export function collectRoutes(program, registry, service) {
1299
1523
  ...(() => {
1300
1524
  const split = withVisibility(program, requestVisibility, () => parameterSchemasOf(program, operation, registry, template.segments, template.literalQuery.map(([name]) => name)));
1301
1525
  return {
1526
+ cookieSchema: split.cookie,
1302
1527
  pathSchema: split.path,
1303
1528
  querySchema: split.query,
1304
1529
  queryFieldsSchema: split.queryFields,
@@ -1315,6 +1540,8 @@ export function collectRoutes(program, registry, service) {
1315
1540
  */
1316
1541
  responses: withVisibility(program, Visibility.Read, () => responsesOf(program, operation, registry)),
1317
1542
  rawBodyProperty,
1543
+ rawBodyBinary,
1544
+ rawBodyUnvalidated,
1318
1545
  bodyProperty,
1319
1546
  optionalBody,
1320
1547
  scopes: authentication.scopes,
@@ -1452,13 +1679,30 @@ function renderSchemas(registry, externals, routeDeclarations, compileSchemas) {
1452
1679
  * parse - the walk captures the reference while it is still in the temporal dead zone, so the
1453
1680
  * failure surfaces at request time rather than at import.
1454
1681
  *
1455
- * **Keyed on `annotated`, the fact the registry already holds** - it is set exactly when the
1456
- * declaration is deferred, which is exactly when `z.lazy()` is written - rather than on the
1457
- * shape of the emitted text. A model on a cycle whose back edge sits on a GETTER is a different
1458
- * case and is compiled like any other: measured, it parses, because a getter is not resolved
1459
- * until something reads it.
1682
+ * **CYCLE MEMBERSHIP decides this, not deferral, and that took a zod upgrade to learn.**
1683
+ *
1684
+ * It was keyed on `annotated` - set exactly when the declaration is deferred, which is exactly
1685
+ * when `z.lazy()` is written - on the measurement that a model whose back edge sits on a GETTER
1686
+ * compiled and parsed fine. That was true on zod 4.5.2 and is false on 4.6.5, which made the
1687
+ * recursive parse state weak. The failure moved from the deferred declaration to the one that
1688
+ * REFERENCES it, which was still being compiled:
1689
+ *
1690
+ * ```
1691
+ * const branchSchema = z.lazy(() => z.union([nodeSchema, z.string()]));
1692
+ * const nodeSchema = z.compile(z.strictObject({ ..., branch: branchSchema.exactOptional() }));
1693
+ * nodeSchema.safeParse({ label: "a", attributes: {} });
1694
+ * zod 4.5.2: true
1695
+ * zod 4.6.5: TypeError: Cannot read properties of undefined (reading '_zod')
1696
+ * ```
1697
+ *
1698
+ * Bisected: naming a compiled schema from inside a `z.lazy()` body is the whole trigger, and
1699
+ * the self-referencing getter is not needed for it. So the same rule the registry already
1700
+ * applies to structural types applies here - every member of the cycle, not only the deferred
1701
+ * one - and `structural` is the fact it already holds for exactly that set.
1702
+ *
1703
+ * The peer range admits 4.6, so this reached a running server on the first parse.
1460
1704
  */
1461
- return `\n${declared}\nexport const ${d.identifier}${annotation} = ${compiled(d.source, compileSchemas && d.annotated !== true)};\n`;
1705
+ return `\n${declared}\nexport const ${d.identifier}${annotation} = ${compiled(d.source, compileSchemas && d.structural === undefined)};\n`;
1462
1706
  });
1463
1707
  /**
1464
1708
  * **Written only when something in the file names it**, like the `ResponseArm` import below and for
@@ -1490,10 +1734,9 @@ function renderSchemas(registry, externals, routeDeclarations, compileSchemas) {
1490
1734
  * `p?: T` directly - at every depth, which a shallow mapped type never reached - so the narrowing
1491
1735
  * happens once, in the schema, and this helper has nothing left to do.
1492
1736
  *
1493
- * The contract types in `requests.gen.ts` still keep `?: T | undefined` on purpose: they are the
1494
- * floor a PRODUCER supplies, and `{ p: undefined }` serialises identically to omitting `p`, so
1495
- * refusing it there would cost every producer a conditional spread and buy the wire nothing.
1496
- * Narrowing THAT surface was tried in `0.19.0` and reverted - it broke `(ctx, input) => ok(input)`.
1737
+ * The contract types in `requests.gen.ts` say the same since `0.28.0`: `?: T`, and
1738
+ * `?: T | undefined` only where `.default()` accepts an explicit `undefined`. They had kept it
1739
+ * everywhere on reasons that stopped holding once this schema moved - see `propertyToTs`.
1497
1740
  */
1498
1741
  const usesZod = new Set([...declarations, ...routeDeclarations].join("").match(/[A-Za-z_$][\w$]*/g) ?? []).has("z");
1499
1742
  const parts = [generatedBanner()];
@@ -1633,6 +1876,7 @@ function nameRouteSchemas(routes, compileSchemas) {
1633
1876
  const query = declare("Query", gathers && queryFields !== undefined && route.queryFieldsSchema !== undefined
1634
1877
  ? route.querySchema?.replace(route.queryFieldsSchema, queryFields)
1635
1878
  : route.querySchema);
1879
+ const cookie = declare("Cookie", route.cookieSchema);
1636
1880
  const negotiating = shared.has(`${route.verb} ${route.path}`);
1637
1881
  const header = declare("Header", negotiating ? route.negotiatedHeaderSchema : route.headerSchema);
1638
1882
  // A raw body is read as text, never parsed, so it has no validator - the bytes are the contract.
@@ -1643,13 +1887,28 @@ function nameRouteSchemas(routes, compileSchemas) {
1643
1887
  */
1644
1888
  const declaredBodies = new Map();
1645
1889
  const arms = route.responses.map((response) => {
1646
- if (response.schema === undefined)
1647
- return { status: response.status, schema: undefined };
1890
+ const suffix = response.status === "default" ? "Default" : String(response.status);
1891
+ /**
1892
+ * The headers this status declares, as one object schema, so a server can check what it is
1893
+ * about to send the way it already checks the body.
1894
+ *
1895
+ * **A plain `z.object`, which strips.** A response carries headers the document does not
1896
+ * declare - `Content-Type` among them, which the server sets itself - and those are not a
1897
+ * contract violation. What is checked is that every header the document DOES declare carries
1898
+ * a value the document permits.
1899
+ */
1900
+ const headers = response.headers.length === 0
1901
+ ? undefined
1902
+ : declare(`Response${suffix}Headers`, `z.object({\n${response.headers
1903
+ .map((header) => `\t${objectKey(header.name)}: ${header.schema},`)
1904
+ .join("\n")}\n})`);
1905
+ if (response.schema === undefined) {
1906
+ return { status: response.status, schema: undefined, headers };
1907
+ }
1648
1908
  if (!declaredBodies.has(response.schema)) {
1649
- const suffix = response.status === "default" ? "Default" : String(response.status);
1650
1909
  declaredBodies.set(response.schema, declare(`Response${suffix}`, response.schema));
1651
1910
  }
1652
- return { status: response.status, schema: declaredBodies.get(response.schema) };
1911
+ return { status: response.status, schema: declaredBodies.get(response.schema), headers };
1653
1912
  });
1654
1913
  const responses = schemaConst(route.operationId, "Responses");
1655
1914
  declarations.push(`export const ${responses} = ${responseArmsOf(route, arms)} satisfies readonly ResponseArm[];`);
@@ -1659,6 +1918,8 @@ function nameRouteSchemas(routes, compileSchemas) {
1659
1918
  query,
1660
1919
  queryFields: gathers ? queryFields : query,
1661
1920
  header,
1921
+ selectedAccept: negotiating ? route.accept : undefined,
1922
+ cookie,
1662
1923
  body,
1663
1924
  arms,
1664
1925
  responses,
@@ -1691,118 +1952,127 @@ function responseArmsOf(route, arms) {
1691
1952
  });
1692
1953
  return `[${rendered.join(", ")}]`;
1693
1954
  }
1694
- const upperFirst = (value) => value.charAt(0).toUpperCase() + value.slice(1);
1695
1955
  /**
1696
- * The effective input of every operation, as TypeScript: its body, with the path, query and header
1697
- * parameters merged over it.
1956
+ * Every operation's input, as TypeScript, exactly as a handler receives it.
1957
+ *
1958
+ * **One placement rule, the one the server emitters render from.** A model body is merged with the
1959
+ * path, query, header and cookie parameters; a body the merge cannot express is named under its
1960
+ * parameter's name, optional where the document lets it be absent; a `bytes` body is handed over
1961
+ * unread. {@link requestBodyPlacementOf} decides all three, for this walk and for
1962
+ * {@link EmittedRoute} alike. This walk used to merge every body, so for a union, an array, an
1963
+ * indexed model, an optional body, a scalar or an enum, `WireInputs` described an input no handler is
1964
+ * given - and one consumer carried a shim re-flattening the handler's input to reach a backend typed
1965
+ * by it, after the divergence cost it 171 failing tests.
1966
+ *
1967
+ * **Every operation, whatever it takes and whatever it answers.** An operation with no input is
1968
+ * `Record<string, never>`, which is what a handler for it is passed; one whose only success is a
1969
+ * range is as much an operation as any other. Both used to be skipped, so `keyof WireInputs` could
1970
+ * not enumerate a document's operations, and a consumer named the missing ones by hand.
1698
1971
  *
1699
1972
  * **This used to drop any parameter literally named `companyId`.** One application carried its
1700
1973
  * tenant identifier in a context object rather than in the payload, and the emitter was taught to
1701
1974
  * match - so for every consumer, a path parameter with that name vanished from the generated input
1702
1975
  * type while the Zod validator went on requiring it. Two artefacts describing different shapes, from
1703
1976
  * a rule no document states.
1704
- *
1705
- * It is the same defect class this emitter exists to delete, and it hid better than most: the
1706
- * emitted assertions pair the validator against the contract types for MODELS, so a disagreement
1707
- * about a merged input type is not among the things they compare.
1708
1977
  */
1978
+ /** A body type the contract declares by name, rather than spells inline: a model, union or enum. */
1979
+ function declaresComponent(type) {
1980
+ return type.kind === "Model" || type.kind === "Union" || type.kind === "Enum";
1981
+ }
1709
1982
  function collectRequestTypes(program, registry, service) {
1710
1983
  const entries = [];
1711
- {
1712
- for (const operation of service.operations) {
1713
- if (successStatusOf(operation) === undefined)
1714
- continue;
1984
+ for (const operation of service.operations) {
1985
+ /**
1986
+ * **Walked at the operation's REQUEST visibility, which is the whole point of this file.**
1987
+ *
1988
+ * `resolveRequestVisibility` is `@typespec/http`'s own answer - POST is Create, PATCH is
1989
+ * Update, PUT is CreateOrUpdate - and it is what the Zod walk already uses at the route level.
1990
+ * Without it this walk ran at the default `Read`, so a POST body's contract type carried the
1991
+ * canonical property set while the validator beside it accepted the create projection. A
1992
+ * caller satisfying the published type sent read-only fields and a strict validator answered
1993
+ * 400.
1994
+ *
1995
+ * It is also what keeps `metadataInfo` scoped: the predicates `propertyToTs` consults live
1996
+ * behind `withVisibility`, and calling them outside one leaves a module-level singleton built
1997
+ * for the wrong program.
1998
+ */
1999
+ withVisibility(program, resolveRequestVisibility(program, operation.operation, operation.verb), () => {
2000
+ const body = requestBodyOf(operation);
2001
+ const placement = requestBodyPlacementOf(program, operation);
2002
+ /** The parts of a multipart body, which `requestBodyOf` deliberately does not return. */
2003
+ const parameterBody = operation.parameters.body;
1715
2004
  /**
1716
- * **Walked at the operation's REQUEST visibility, which is the whole point of this file.**
1717
- *
1718
- * `resolveRequestVisibility` is `@typespec/http`'s own answer - POST is Create, PATCH is
1719
- * Update, PUT is CreateOrUpdate - and it is what the Zod walk already uses at the route
1720
- * level. Without it this walk ran at the default `Read`, so a POST body's contract type
1721
- * carried the canonical property set while the validator beside it accepted the create
1722
- * projection. A caller satisfying the published type sent read-only fields and a strict
1723
- * validator answered 400.
2005
+ * Headers are part of the input too.
1724
2006
  *
1725
- * It is also what keeps `metadataInfo` scoped: the predicates `propertyToTs` consults live
1726
- * behind `withVisibility`, and calling them outside one leaves a module-level singleton
1727
- * built for the wrong program.
2007
+ * The webhook's three Standard-Webhooks headers are inside the MAC - they are not metadata
2008
+ * beside the payload, they are arguments to the verification. Leaving them out of the wire
2009
+ * type would describe an input the application cannot act on.
1728
2010
  */
1729
- withVisibility(program, resolveRequestVisibility(program, operation.operation, operation.verb), () => {
1730
- const body = requestBodyOf(operation);
1731
- /** The parts of a multipart body, which `requestBodyOf` deliberately does not return. */
1732
- const multipartBody = operation.parameters.body;
1733
- const multipart = multipartBody?.bodyKind === "multipart" ? { parts: multipartBody.parts } : undefined;
2011
+ const extraProperties = operation.parameters.parameters
2012
+ .filter((parameter) => parameter.type === "path" ||
2013
+ parameter.type === "query" ||
2014
+ parameter.type === "header" ||
2015
+ // The second half of the same omission: a validator with no matching type.
2016
+ parameter.type === "cookie")
1734
2017
  /**
1735
- * Headers are part of the input too.
1736
- *
1737
- * The webhook's three Standard-Webhooks headers are inside the MAC - they are not metadata
1738
- * beside the payload, they are arguments to the verification. Leaving them out of the wire
1739
- * type would describe an input the application cannot act on.
2018
+ * Named as the WIRE names it, which is what the emitted validator keys on.
2019
+ * `@header("x-thing") thing: string` is `"x-thing"` in `schemas.gen.ts`, so it has to be
2020
+ * `"x-thing"` here too: a consumer checking its handlers against these types otherwise
2021
+ * fails to typecheck against a server that is correct.
1740
2022
  */
1741
- const extraProperties = operation.parameters.parameters
1742
- .filter((parameter) => parameter.type === "path" ||
1743
- parameter.type === "query" ||
1744
- parameter.type === "header")
1745
- /**
1746
- * Named as the WIRE names it, which is what the emitted validator keys on.
1747
- * `@header("x-thing") thing: string` is `"x-thing"` in `schemas.gen.ts`, so it has to be
1748
- * `"x-thing"` here too: a consumer checking its handlers against these types otherwise
1749
- * fails to typecheck against a server that is correct.
1750
- */
1751
- .map((parameter) => registry.expressionForProperty(parameter.param, parameter.name));
1752
- // `return` rather than `continue`: this body is a callback now, and returning from it
1753
- // skips the operation exactly as the `continue` did.
1754
- if (body === undefined && extraProperties.length === 0)
1755
- return;
2023
+ .map((parameter) => registry.expressionForProperty(parameter.param, parameter.name));
2024
+ const fileContentType = fileContentTypeOf(operation);
2025
+ if (fileContentType !== undefined) {
2026
+ extraProperties.push(`${objectKey("Content-Type")}: ${registry.expressionFor(fileContentType.type)};`);
2027
+ }
2028
+ const parts = [];
2029
+ let streamsBody = false;
2030
+ let bodyRef;
2031
+ if (placement.rawBodyProperty !== undefined) {
1756
2032
  /**
1757
- * A `bytes` body reaches the use-case as a NAMED property holding the unparsed body, not as
1758
- * a bare value, so the wire type is `{ rawBody: ... }`, matching what a server actually
1759
- * builds. Referencing the scalar directly would type the whole input as that value and
1760
- * quietly drop the headers beside it.
2033
+ * **A binary body is the unread stream, and is spelled as one without naming
2034
+ * `ReadableStream`.** The server hands it over unread, so a backend it is forwarded to
2035
+ * receives a stream; a contract saying `ArrayBuffer` made a gateway read a 25 MiB upload
2036
+ * into an isolate to satisfy it. See `BYTE_STREAM`. A `bytes` body under a JSON type is
2037
+ * the text a signature covers, and arrives as a string.
1761
2038
  */
1762
- const rawBody = body?.kind === "Scalar" && body.name === "bytes"
1763
- ? (operation.parameters.body?.property?.name ?? "body")
1764
- : undefined;
2039
+ streamsBody = placement.rawBodyBinary;
2040
+ parts.push(`{\n\t${objectKey(placement.rawBodyProperty)}: ${streamsBody ? "ByteStream | null" : "string"};\n}`);
1765
2041
  /**
1766
- * **Raw binary is `ArrayBuffer`; base64 inside a JSON payload is `string`.** The same
1767
- * `isRawBinaryMediaType` rule the response side and the multipart parts already follow,
1768
- * applied here for the first time.
1769
- *
1770
- * This type said `string` for every `bytes` body whatever it was served as. A server reading
1771
- * an `application/octet-stream` body hands the handler bytes, so a consumer checking its
1772
- * handlers against these types could not typecheck against a correct server. It is the
1773
- * request half of the corruption already fixed on the reader: decoding raw bytes as text
1774
- * replaces every byte outside ASCII, and typing them as text is what invites it.
2042
+ * **The model a body nothing parses names is still declared**, as the document declares
2043
+ * it, though the input is the text or the bytes that arrived. 0.27.0 declared it, and a
2044
+ * consumer importing it lost the import when the body became raw.
1775
2045
  */
1776
- const rawBodyType = isRawBinaryMediaType([
1777
- ...(operation.parameters.body?.contentTypes ?? []),
1778
- ])
1779
- ? "ArrayBuffer"
1780
- : "string";
1781
- entries.push({
1782
- operationId: operationIdOf(program, operation.operation),
1783
- bodyRef: rawBody !== undefined
1784
- ? `{\n\t${rawBody}: ${rawBodyType};\n}`
1785
- : multipart !== undefined
1786
- ? multipartTsOf(multipart, registry)
1787
- : body === undefined
1788
- ? undefined
1789
- : registry.expressionFor(body),
1790
- extraProperties,
1791
- });
2046
+ if (body !== undefined && declaresComponent(body))
2047
+ registry.expressionFor(body);
2048
+ }
2049
+ else {
2050
+ const expression = parameterBody?.bodyKind === "multipart"
2051
+ ? multipartTsOf({ parts: parameterBody.parts }, registry)
2052
+ : body === undefined
2053
+ ? undefined
2054
+ : registry.expressionFor(body);
2055
+ if (parameterBody?.bodyKind !== "multipart")
2056
+ bodyRef = expression;
2057
+ if (expression !== undefined) {
2058
+ parts.push(placement.bodyProperty === undefined
2059
+ ? expression
2060
+ : `{\n\t${objectKey(placement.bodyProperty)}${placement.optionalBody ? "?" : ""}: ${expression.replaceAll("\n", "\n\t")};\n}`);
2061
+ }
2062
+ }
2063
+ if (extraProperties.length > 0) {
2064
+ parts.push(`{\n${extraProperties.map((line) => `\t${line}`).join("\n")}\n}`);
2065
+ }
2066
+ entries.push({
2067
+ operationId: operationIdOf(program, operation.operation),
2068
+ bodyRef,
2069
+ input: parts.length === 0 ? "Record<string, never>" : `Simplify<${parts.join(" & ")}>`,
2070
+ streamsBody,
1792
2071
  });
1793
- }
2072
+ });
1794
2073
  }
1795
2074
  return entries;
1796
2075
  }
1797
- /**
1798
- * The success response every operation declares, so a producer can be held to producing it.
1799
- *
1800
- * **Pinned fields only - these are PERMISSIVE shapes.** The gateway names the fields it depends on
1801
- * and lets a richer view through untouched, so this is a floor and not an equality: the
1802
- * domain's result must CONTAIN what the document promises, and may contain more. Asserting equality
1803
- * would turn every field a producer legitimately adds into a build failure, which is the same mistake
1804
- * as making a response schema strict.
1805
- */
1806
2076
  function collectResponseTypes(program, registry, service) {
1807
2077
  const entries = [];
1808
2078
  /**
@@ -1815,20 +2085,135 @@ function collectResponseTypes(program, registry, service) {
1815
2085
  */
1816
2086
  withVisibility(program, Visibility.Read, () => {
1817
2087
  for (const operation of service.operations) {
1818
- const status = successStatusOf(operation);
1819
- if (status === undefined)
1820
- continue;
1821
- const body = successBodyOf(operation, status);
1822
- if (body === undefined)
1823
- continue;
2088
+ /**
2089
+ * **The same responses, and the same members, the server emitters render.** This walk used
2090
+ * to ask for the lowest success status and its body, so an operation answering `200 Thing`
2091
+ * or `201 Other` published `Thing`, one answering `200 Thing` or `204` published `Thing`,
2092
+ * and one answering only `204` was absent - while the runtime beside it carried every arm.
2093
+ */
2094
+ const responses = inPrecedenceOrder(resolvedResponsesOf(program, operation));
2095
+ const hasBody = (response) => response.body !== undefined;
2096
+ let streams = false;
2097
+ const bodyRefs = [];
2098
+ /**
2099
+ * **What a producer SUPPLIES for a body, which is what the server emitters accept for it.** A
2100
+ * stream or a raw binary body is handed over as bytes and never parsed, so it is spelled as
2101
+ * bytes - `ByteStream` rather than the platform stream, for the same reason as a request body.
2102
+ * A model under a media type that is not JSON is what the producer serialised: text under a
2103
+ * textual media type, bytes under any other. Everything else is the producer's view of the
2104
+ * contract type.
2105
+ */
2106
+ const bodyOf = (member) => {
2107
+ // A model a body served unvalidated names is declared all the same, as the request side
2108
+ // declares one under a media type nothing parses: the document declares it either way.
2109
+ if (member.unvalidated &&
2110
+ member.response.body !== undefined &&
2111
+ declaresComponent(member.response.body)) {
2112
+ registry.expressionFor(member.response.body);
2113
+ }
2114
+ switch (member.serving) {
2115
+ case "none":
2116
+ return undefined;
2117
+ case "stream":
2118
+ streams = true;
2119
+ return "ByteStream";
2120
+ case "binary":
2121
+ streams = true;
2122
+ return "ByteStream | Uint8Array<ArrayBuffer> | ArrayBuffer";
2123
+ case "unvalidated":
2124
+ return "string";
2125
+ default: {
2126
+ if (member.response.body === undefined)
2127
+ return undefined;
2128
+ const ref = registry.expressionFor(member.response.body);
2129
+ bodyRefs.push(ref);
2130
+ return `Produced<${ref}>`;
2131
+ }
2132
+ }
2133
+ };
2134
+ const members = resultMembersOf(responses, hasBody).map((member) => {
2135
+ const body = bodyOf(member);
2136
+ const codes = statusCodesOf(member.response, responses, hasBody);
2137
+ const fields = [`readonly status: ${codes.length === 0 ? "never" : codes.join(" | ")}`];
2138
+ if (member.choosesMediaType && member.mediaType !== undefined) {
2139
+ fields.push(`readonly contentType: ${mediaTypeTypeOf(member.mediaType)}`);
2140
+ }
2141
+ fields.push(body === undefined ? "readonly body?: undefined" : `readonly body: ${body}`);
2142
+ const headers = member.response.headers;
2143
+ if (headers.length > 0) {
2144
+ /**
2145
+ * Keyed by the WIRE name, as the document publishes it. `headers` is optional only when
2146
+ * every header in it is, and an optional header admits an explicit `undefined`, because
2147
+ * a server drops one before it checks and sends the rest.
2148
+ */
2149
+ const every = headers.every(({ property }) => property.optional);
2150
+ const rendered = headers
2151
+ .map(({ name, property }) => property.optional
2152
+ ? `readonly ${objectKey(name)}?: ${propertyTypeToTs(program, property)} | undefined`
2153
+ : `readonly ${objectKey(name)}: ${propertyTypeToTs(program, property)}`)
2154
+ .join("; ");
2155
+ fields.push(`readonly headers${every ? "?" : ""}: { ${rendered} }`);
2156
+ }
2157
+ return { member, body, text: `{ ${fields.join("; ")} }` };
2158
+ });
2159
+ /**
2160
+ * A success is a `2xx` or `3xx` outcome, exact or ranged, as {@link successStatusesOf} holds.
2161
+ * `default` is not one: it is every status nothing else declares, which is mostly failures.
2162
+ */
2163
+ const outputs = [
2164
+ ...new Set(members
2165
+ .filter(({ member }) => isSuccessStatus(member.response.status))
2166
+ .map(({ body }) => body ?? "undefined")),
2167
+ ];
1824
2168
  entries.push({
1825
2169
  operationId: operationIdOf(program, operation.operation),
1826
- ref: registry.expressionFor(body),
2170
+ output: outputs.length === 0 ? "never" : outputs.join(" | "),
2171
+ result: members.map(({ text }) => text),
2172
+ streams,
2173
+ bodyRefs,
1827
2174
  });
1828
2175
  }
1829
2176
  });
1830
2177
  return entries;
1831
2178
  }
2179
+ /** A `2xx` or `3xx` status, exact or as a range: an outcome a handler returns rather than a failure. */
2180
+ function isSuccessStatus(status) {
2181
+ if (typeof status === "number")
2182
+ return status >= 200 && status < 400;
2183
+ return status === "2XX" || status === "3XX";
2184
+ }
2185
+ /**
2186
+ * **A stream of bytes, spelled as the reader every stream has rather than as `ReadableStream`.**
2187
+ *
2188
+ * The server hands a binary request body over unread, so the contract has to say "stream" - a
2189
+ * contract saying `ArrayBuffer` made a gateway read a 25 MiB upload into an isolate just to satisfy
2190
+ * a backend's type. But `requests.gen.ts` is framework-free, and a contracts package compiled under
2191
+ * `lib: ["ES2024"]` and no `types` cannot name `ReadableStream` at all (`TS2304`); only lib.dom,
2192
+ * lib.webworker, `@cloudflare/workers-types` and `@types/node` declare it.
2193
+ *
2194
+ * **This is the part of a stream every one of those declares, and nothing else.** Measured: a platform
2195
+ * `ReadableStream<Uint8Array> | null` is assignable to it under the default libs, lib.dom,
2196
+ * lib.webworker, workers-types and `@types/node`, on TypeScript 5.9.3 and 7.0.2, while an
2197
+ * `ArrayBuffer` is refused. `AsyncIterable<Uint8Array>`, the obvious alternative, is NOT satisfied
2198
+ * by lib.dom's stream on TypeScript 5.9 unless `dom.asynciterable` is also listed - which the usual
2199
+ * `["ES2020", "DOM", "DOM.Iterable"]` does not.
2200
+ *
2201
+ * The same precedent as a multipart file part: a type only an ambient library declares is spelled
2202
+ * structurally, so the emitted output compiles wherever it lands.
2203
+ */
2204
+ const BYTE_STREAM = `
2205
+ /**
2206
+ * A stream of bytes, as every platform's \`ReadableStream<Uint8Array>\` satisfies it - spelled
2207
+ * structurally so this file needs no DOM or runtime library to name it.
2208
+ */
2209
+ export interface ByteStream {
2210
+ getReader(): {
2211
+ read(): Promise<{ readonly done: boolean; readonly value?: Uint8Array | undefined }>;
2212
+ releaseLock(): void;
2213
+ cancel?(reason?: unknown): Promise<void>;
2214
+ };
2215
+ }
2216
+ `;
1832
2217
  /**
1833
2218
  * `requests.gen.ts` - the shape both sides are checked against.
1834
2219
  *
@@ -1861,29 +2246,29 @@ function renderRequestTypes(entries, responses, registry) {
1861
2246
  ? `\nexport interface ${declaration.name} ${declaration.source}\n`
1862
2247
  : `\nexport type ${declaration.name} = ${declaration.source};\n`;
1863
2248
  });
1864
- const inputs = entries.map((entry) => {
1865
- const parts = [];
1866
- if (entry.bodyRef !== undefined)
1867
- parts.push(entry.bodyRef);
1868
- if (entry.extraProperties.length > 0) {
1869
- parts.push(`{\n${entry.extraProperties.map((line) => `\t${line}`).join("\n")}\n}`);
1870
- }
1871
- return (`\n/** The effective input for \`${entry.operationId}\` - its body, with path and query parameters merged over it. */\n` +
1872
- `export type ${upperFirst(entry.operationId)}Input = Simplify<${parts.join(" & ")}>;\n`);
1873
- });
2249
+ const responseLookup = responses
2250
+ .map((entry) => `\t/** Every success body \`${entry.operationId}\` declares. */\n\t${entry.operationId}: ${entry.output};`)
2251
+ .join("\n");
1874
2252
  /**
1875
- * The same set again, keyed by operation id.
1876
- *
1877
- * TypeScript cannot index a module's *type* exports by a computed name - `typeof Contracts` carries
1878
- * only the value side, so `Contracts["AppointOfficerInput"]` does not resolve. Both sides'
1879
- * assertions are written as mapped types over every operation, and this is what makes that
1880
- * possible. Generated with the aliases above, so it cannot fall behind them.
2253
+ * **One member per declared response and media type, each pairing a status with its own body.**
2254
+ * The union a server emitter's handlers return, stated with nothing to import: a backend reached
2255
+ * over a service binding returns `WireResults[op]`, and a handler can hand it straight on.
1881
2256
  */
1882
- const responseLookup = responses
1883
- .map((entry) => `\t${entry.operationId}: Produced<${entry.ref}>;`)
2257
+ const resultLookup = responses
2258
+ .map((entry) => `\t/** Every response \`${entry.operationId}\` declares. */\n\t${entry.operationId}:${entry.result.length === 0 ? " never" : entry.result.map((member) => `\n\t\t| ${member}`).join("")};`)
1884
2259
  .join("\n");
2260
+ /**
2261
+ * **Keyed by operation id, and the only name an operation's input has.**
2262
+ *
2263
+ * Each input used to be declared a second time as an alias, `CreateUserInput` for `createUser`,
2264
+ * and this lookup referenced the aliases. That is a name a spec may well give its own request
2265
+ * model, so the two declarations collided (`TS2300`) and `requests.gen.ts` did not
2266
+ * compile for an operation as ordinary as `createUser(@body body: CreateUserInput)`. A type
2267
+ * exported under a name this emitter invents is a name a spec author can already be using.
2268
+ * `WireInputs["createUser"]` is the same type, and nothing a spec declares can collide with it.
2269
+ */
1885
2270
  const lookup = entries
1886
- .map((entry) => `\t${entry.operationId}: ${upperFirst(entry.operationId)}Input;`)
2271
+ .map((entry) => `\t/** The input for \`${entry.operationId}\`, as its handler receives it. */\n\t${entry.operationId}: ${entry.input.replaceAll("\n", "\n\t")};`)
1887
2272
  .join("\n");
1888
2273
  /**
1889
2274
  * **Emitted only where an operation has something to flatten.**
@@ -1895,9 +2280,9 @@ function renderRequestTypes(entries, responses, registry) {
1895
2280
  * `TS6196: 'Simplify' is declared but never used.` Third of its kind, after the unconditional
1896
2281
  * `zValidator` and `z` imports.
1897
2282
  *
1898
- * Decided from the aliases actually rendered rather than by searching the output for the name.
2283
+ * Decided from the inputs actually rendered rather than by searching the output for the name.
1899
2284
  */
1900
- const simplifyHelper = inputs.length === 0
2285
+ const simplifyHelper = !entries.some((entry) => entry.input.startsWith("Simplify<"))
1901
2286
  ? ""
1902
2287
  : `/**
1903
2288
  * Flattens an intersection into a single object type.
@@ -1933,14 +2318,19 @@ export type Produced<T> = T extends readonly (infer Element)[]
1933
2318
  : T extends object
1934
2319
  ? { readonly [K in keyof T]: Produced<T[K]> }
1935
2320
  : T;
1936
- ${declarations.join("")}${inputs.join("")}
1937
- /** Every operation's effective input, keyed by operation id - what both sides assert against. */
2321
+ ${entries.some((entry) => entry.streamsBody) || responses.some((entry) => entry.streams) ? BYTE_STREAM : ""}${declarations.join("")}
2322
+ /**
2323
+ * Every operation's input, keyed by operation id: what its handler receives, and what a caller of a
2324
+ * backend typed by it hands over. An operation that takes nothing is \`Record<string, never>\`.
2325
+ */
1938
2326
  export interface WireInputs {
1939
2327
  ${lookup}
1940
2328
  }
1941
2329
 
1942
2330
  /**
1943
- * Every operation's success response, keyed by operation id.
2331
+ * Every operation's success bodies, keyed by operation id: the body of each \`2xx\` and \`3xx\` response it
2332
+ * declares, \`undefined\` for one with no body, and \`never\` where it declares no success at all. Which
2333
+ * status goes with which body is \`WireResults\`.
1944
2334
  *
1945
2335
  * **A floor, not an equality.** These shapes are permissive: a caller pins the fields it depends
1946
2336
  * on and lets a richer view through, so a producer's result must CONTAIN what the document promises
@@ -1950,6 +2340,15 @@ ${lookup}
1950
2340
  export interface WireOutputs {
1951
2341
  ${responseLookup}
1952
2342
  }
2343
+
2344
+ /**
2345
+ * Every operation's result, keyed by operation id: one member per response it declares, each pairing
2346
+ * a status with that status's body and headers. A status no response declares does not compile, and
2347
+ * a \`4XX\` or \`default\` response is the codes it stands for, written out.
2348
+ */
2349
+ export interface WireResults {
2350
+ ${resultLookup}
2351
+ }
1953
2352
  `;
1954
2353
  }
1955
2354
  /**
@@ -1987,16 +2386,25 @@ ${responseLookup}
1987
2386
  * `Equals` here, not assignability: the two describe one shape, and a property present on one side
1988
2387
  * and absent from the other is exactly the drift this exists to catch.
1989
2388
  */
1990
- function renderWireAssertions(schemas, types, entries, contractsPackage) {
2389
+ function renderWireAssertions(schemas, types, entries, responses, contractsPackage) {
1991
2390
  // The assertions pair the emitted Zod against the request TYPES, which live in the contracts
1992
2391
  // package. With no package named there is nothing to import them from and nothing to pair - the
1993
2392
  // types are still written to `contracts-output-dir`, they simply have no shared specifier yet.
1994
2393
  if (contractsPackage === undefined)
1995
2394
  return undefined;
1996
- const requestModels = new Set(entries.map((entry) => entry.bodyRef).filter((ref) => ref !== undefined));
2395
+ /**
2396
+ * **Every model a body names, the responses' as well as the requests'.** This paired request
2397
+ * bodies only, so a model that is only ever SENT was compared with nothing; that was harmless
2398
+ * while the contract types described a single success body and nothing consumed it, and is not now
2399
+ * that `WireResults` states every response a backend returns.
2400
+ */
2401
+ const bodyModels = new Set([
2402
+ ...entries.map((entry) => entry.bodyRef).filter((ref) => ref !== undefined),
2403
+ ...responses.flatMap((entry) => entry.bodyRefs),
2404
+ ]);
1997
2405
  const paired = [];
1998
2406
  for (const declaration of types.declarations()) {
1999
- if (!requestModels.has(declaration.name))
2407
+ if (!bodyModels.has(declaration.name))
2000
2408
  continue;
2001
2409
  const schema = schemas
2002
2410
  .declarations()
@@ -2057,26 +2465,6 @@ type Identical<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends
2057
2465
  * dropped from a shape that has other keys, and kept - with its value type still mapped - from one
2058
2466
  * that does not.
2059
2467
  */
2060
- /**
2061
- * \u26a0\ufe0f **An optional property is \`?: T\` here and \`?: T | undefined\` in the contract type, and that is
2062
- * deliberate on both sides.**
2063
- *
2064
- * The validator emits \`.exactOptional()\`, so what ARRIVES is absent or a value - JSON cannot carry an
2065
- * \`undefined\` and the document says exactly that by leaving the property out of \`required\`. The
2066
- * contract type is the floor a PRODUCER supplies, and \`{ p: undefined }\` serialises identically to
2067
- * omitting \`p\`, so refusing it would cost every producer a conditional spread and buy the wire
2068
- * nothing. Narrowing THAT surface was tried in \`0.19.0\` and reverted: it broke
2069
- * \`(ctx, input) => ok(input)\`.
2070
- *
2071
- * So the two disagree on exactly one axis, on purpose, and this widens the inferred side to the
2072
- * producer floor before comparing - the same job \`Declared\` does for openness, in the same pass
2073
- * rather than a second deep one. Every other kind of drift still fails: a property that changes type,
2074
- * appears, disappears or changes optionality is untouched by this.
2075
- */
2076
- type Supplied<T, K extends keyof T> = {} extends Pick<T, K>
2077
- ? Declared<T[K]> | undefined
2078
- : Declared<T[K]>;
2079
-
2080
2468
  type DeclaredKeyOf<T> = keyof {
2081
2469
  [K in keyof T as string extends K
2082
2470
  ? never
@@ -2105,9 +2493,8 @@ type Declared<T> = T extends (...args: never[]) => unknown
2105
2493
  // equate a real dictionary with an empty object: a dictionary whose values are
2106
2494
  // \`never\` accepts nothing either.
2107
2495
  {}
2108
- : // A shape with no declared keys is a dictionary: it has no optional property to
2109
- // widen, and an index signature satisfies \`{} extends Pick<T, K>\` for every
2110
- // value type.
2496
+ : // A shape with no declared keys is a dictionary: its index signature is kept and
2497
+ // its value type mapped.
2111
2498
  { [K in keyof T]: Declared<T[K]> }
2112
2499
  : {
2113
2500
  [K in keyof T as string extends K
@@ -2116,13 +2503,13 @@ type Declared<T> = T extends (...args: never[]) => unknown
2116
2503
  ? never
2117
2504
  : symbol extends K
2118
2505
  ? never
2119
- : K]: Supplied<T, K>;
2506
+ : K]: Declared<T[K]>;
2120
2507
  }
2121
2508
  : T;
2122
2509
 
2123
2510
  /**
2124
- * \u26a0\ufe0f **The constraint is what fails the build.** \`Identical<A, B> & true\` merely produces an odd
2125
- * type when the two disagree, and a type alias to an odd type compiles fine \u2014 66 assertions that
2511
+ * **The constraint is what fails the build.** \`Identical<A, B> & true\` merely produces an odd
2512
+ * type when the two disagree, and a type alias to an odd type compiles fine - 66 assertions that
2126
2513
  * assert nothing. Only a constraint violation is an error.
2127
2514
  */
2128
2515
  type MustHold<T extends true> = T;
@@ -2251,6 +2638,8 @@ export async function emitHttpZod(context) {
2251
2638
  * there means a server emitter follows without knowing this rule exists.
2252
2639
  */
2253
2640
  const distinctServices = new Set(snapshots.map((snapshot) => snapshot.service.namespace.name));
2641
+ /** The services writing to the top-level `contracts-output-dir`: every one not naming its own. */
2642
+ const sharingContracts = [...distinctServices].filter((name) => options.services?.[name]?.["contracts-output-dir"] === undefined);
2254
2643
  /**
2255
2644
  * **Every service must seal the same way, because `@typespec/openapi3` has no per-service
2256
2645
  * options.**
@@ -2295,8 +2684,22 @@ export async function emitHttpZod(context) {
2295
2684
  (distinctServices.size > 1
2296
2685
  ? resolvePath(context.emitterOutputDir, serviceName)
2297
2686
  : context.emitterOutputDir);
2687
+ /**
2688
+ * **Split where two services would write one file, and nowhere else.** A shared contracts
2689
+ * directory was written once per service to one path, so the second service's
2690
+ * `requests.gen.ts` replaced the first's and a whole surface lost its contracts with no
2691
+ * diagnostic. Counted over the services that WRITE there: one service falling back to the
2692
+ * shared directory, beside others that name their own, collides with nothing, and splitting
2693
+ * it moved contracts a project already imported. A relative `contracts-package` is resolved
2694
+ * from each service's own `wire-contract.gen.ts`, so it names that service's contracts; a
2695
+ * package specifier has to be set per service.
2696
+ */
2697
+ const sharedContractsDir = options["contracts-output-dir"];
2298
2698
  const resolved = {
2299
- contractsOutputDir: perService?.["contracts-output-dir"] ?? options["contracts-output-dir"],
2699
+ contractsOutputDir: perService?.["contracts-output-dir"] ??
2700
+ (sharedContractsDir !== undefined && sharingContracts.length > 1
2701
+ ? resolvePath(sharedContractsDir, serviceName)
2702
+ : sharedContractsDir),
2300
2703
  contractsPackage: perService?.["contracts-package"] ?? options["contracts-package"],
2301
2704
  sealObjectSchemas: perService?.["seal-object-schemas"] ?? options["seal-object-schemas"] ?? false,
2302
2705
  compileSchemas: perService?.["compile-schemas"] ?? options["compile-schemas"] ?? false,
@@ -2362,8 +2765,15 @@ export async function emitHttpZod(context) {
2362
2765
  });
2363
2766
  if (resolved.contractsOutputDir !== undefined) {
2364
2767
  const types = new TypeRegistry(context.program);
2365
- const entries = collectRequestTypes(context.program, types, service);
2366
- const responses = collectResponseTypes(context.program, types, service);
2768
+ /**
2769
+ * **Inside the same stream scope as the route walk**, because both read the same response
2770
+ * records: outside it `streamedTypeOf` cannot see `@typespec/streams`, so an SSE body was
2771
+ * stated as the string the document describes rather than the stream a server sends.
2772
+ */
2773
+ const { entries, responses } = withStreamModule(streamModule, () => ({
2774
+ entries: collectRequestTypes(context.program, types, service),
2775
+ responses: collectResponseTypes(context.program, types, service),
2776
+ }));
2367
2777
  await emitFile(context.program, {
2368
2778
  path: resolvePath(resolved.contractsOutputDir, "requests.gen.ts"),
2369
2779
  content: renderRequestTypes(entries, responses, types),
@@ -2372,7 +2782,7 @@ export async function emitHttpZod(context) {
2372
2782
  path: resolvePath(resolved.contractsOutputDir, "vocabularies.gen.ts"),
2373
2783
  content: renderVocabularies(vocabularies),
2374
2784
  });
2375
- const assertions = renderWireAssertions(registry, types, entries, resolved.contractsPackage);
2785
+ const assertions = renderWireAssertions(registry, types, entries, responses, resolved.contractsPackage);
2376
2786
  if (assertions !== undefined) {
2377
2787
  await emitFile(context.program, {
2378
2788
  path: resolvePath(outputDir, "wire-contract.gen.ts"),
@@ -2380,7 +2790,12 @@ export async function emitHttpZod(context) {
2380
2790
  });
2381
2791
  }
2382
2792
  }
2383
- emitted.push({ service, routes, schemaNames: names, outputDir, options: resolved });
2793
+ // Still inside this service's id scope, so each operation resolves to the id its route carries.
2794
+ const operations = new Map(service.operations.map((operation) => [
2795
+ operationIdOf(context.program, operation.operation),
2796
+ operation,
2797
+ ]));
2798
+ emitted.push({ service, routes, schemaNames: names, operations, outputDir, options: resolved });
2384
2799
  }
2385
2800
  return emitted;
2386
2801
  }