From 0f17ef3f7686b75c932a2d8c42f076fdaf38657d Mon Sep 17 00:00:00 2001 From: Will Temple Date: Tue, 26 May 2026 12:08:50 -0400 Subject: [PATCH 1/3] hsjs: fix handling of bytes/file bodies --- .../http-server-js/src/http/server/index.ts | 306 +++++++++++++++--- packages/http-server-js/test/scalar.test.ts | 111 +++++++ 2 files changed, 377 insertions(+), 40 deletions(-) diff --git a/packages/http-server-js/src/http/server/index.ts b/packages/http-server-js/src/http/server/index.ts index 9eca8ed4316..1458affbb50 100644 --- a/packages/http-server-js/src/http/server/index.ts +++ b/packages/http-server-js/src/http/server/index.ts @@ -12,12 +12,11 @@ import { import { $ } from "@typespec/compiler/typekit"; import { HttpOperation, + HttpOperationFileBody, HttpOperationParameter, + HttpOperationResponseContent, getHeaderFieldName, getHttpOperation, - isBody, - isHeader, - isStatusCode, } from "@typespec/http"; import { createOrGetModuleForNamespace } from "../../common/namespace.js"; import { emitTypeReference, isValueLiteralType } from "../../common/reference.js"; @@ -469,7 +468,14 @@ function* emitRawServerOperation( yield ""; yield* indent( - emitResultProcessing(ctx, createNamer(operationNameCase), names, op.returnType, module), + emitResultProcessing( + ctx, + createNamer(operationNameCase), + names, + operation, + op.returnType, + module, + ), ); yield "}"; @@ -522,12 +528,20 @@ function* emitResultProcessing( ctx: HttpContext, namer: Namer, names: Names, + operation: HttpOperation, t: Type, module: Module, ): Iterable { if (t.kind !== "Union") { // Single target type - yield* emitResultProcessingForType(ctx, namer, names, t, module); + yield* emitResultProcessingForType( + ctx, + namer, + names, + t, + getResponseContentForType(operation, t), + module, + ); } else { const codeTree = differentiateUnion(ctx, module, t); @@ -537,11 +551,32 @@ function* emitResultProcessing( return names.result + "." + parseCase(p.name).camelCase; }, // We mapped the output directly in the code tree input, so we can just return it. - renderResult: (t) => emitResultProcessingForType(ctx, namer, names, t, module), + renderResult: (t) => + emitResultProcessingForType( + ctx, + namer, + names, + t, + getResponseContentForType(operation, t), + module, + ), }); } } +function getResponseContentForType( + operation: HttpOperation, + target: Type, +): HttpOperationResponseContent | undefined { + for (const response of operation.responses) { + if (response.type === target) { + return response.responses.find((candidate) => candidate.body) ?? response.responses[0]; + } + } + + return undefined; +} + /** * Emit the result-processing code for a single response type. * @@ -554,6 +589,7 @@ function* emitResultProcessingForType( namer: Namer, names: Names, target: Type, + responseContent: HttpOperationResponseContent | undefined, module: Module, ): Iterable { if (target.kind === "Intrinsic") { @@ -581,6 +617,13 @@ function* emitResultProcessingForType( } if (target.kind === "Scalar" || isValueLiteralType(target)) { + if ( + responseContent && + (yield* emitRawResponseBody(ctx, names, responseContent, names.result)) + ) { + return; + } + const serializationRequired = target.kind === "Scalar" && isSerializationRequired(ctx, module, target, "application/json"); @@ -603,44 +646,90 @@ function* emitResultProcessingForType( throw new UnimplementedError(`result processing for type kind '${target.kind}'`); } - const body = [...target.properties.values()].find((p) => isBody(ctx.program, p)); + const body = responseContent?.body; + const responseProperties = responseContent?.properties ?? []; + const bodyMetadataProperty = responseProperties.find( + (property) => + property.kind === "body" || property.kind === "bodyRoot" || property.kind === "multipartBody", + ); + const hasResolvedContentTypeHeader = responseProperties.some( + (property) => + property.kind === "contentType" || + (property.kind === "header" && property.options.name.toLowerCase() === "content-type"), + ); - for (const property of target.properties.values()) { - if (isHeader(ctx.program, property)) { - const headerName = getHeaderFieldName(ctx.program, property); - yield `${names.ctx}.response.setHeader(${JSON.stringify(headerName.toLowerCase())}, ${names.result}.${parseCase(property.name).camelCase});`; - if (!body) yield `delete (${names.result} as any).${parseCase(property.name).camelCase};`; - } else if (isStatusCode(ctx.program, property)) { - if (isUnspeakable(property.name)) { - if (!isValueLiteralType(property.type)) { - reportDiagnostic(ctx.program, { - code: "unspeakable-status-code", - target: property, - format: { - name: property.name, - }, - }); - continue; + for (const property of responseProperties) { + switch (property.kind) { + case "header": { + const headerValue = isValueLiteralType(property.property.type) + ? getValueLiteralExpression(property.property.type) + : getPropertyPathExpression(names.result, property.path); + yield `${names.ctx}.response.setHeader(${JSON.stringify(property.options.name.toLowerCase())}, ${headerValue});`; + if (!body) { + yield* emitDeleteForPath(names.result, property.path); } + break; + } + case "contentType": { + const contentTypeValue = isValueLiteralType(property.property.type) + ? getValueLiteralExpression(property.property.type) + : getPropertyPathExpression(names.result, property.path); + yield `${names.ctx}.response.setHeader("content-type", ${contentTypeValue});`; + if (!body) { + yield* emitDeleteForPath(names.result, property.path); + } + break; + } + case "statusCode": { + if (isUnspeakable(property.property.name)) { + if (!isValueLiteralType(property.property.type)) { + reportDiagnostic(ctx.program, { + code: "unspeakable-status-code", + target: property.property, + format: { + name: property.property.name, + }, + }); + continue; + } - compilerAssert(property.type.kind === "Number", "Status code must be a number."); - - yield `${names.ctx}.response.statusCode = ${property.type.valueAsString};`; - } else { - yield `${names.ctx}.response.statusCode = ${names.result}.${parseCase(property.name).camelCase};`; - if (!body) yield `delete (${names.result} as any).${parseCase(property.name).camelCase};`; + compilerAssert(property.property.type.kind === "Number", "Status code must be a number."); + yield `${names.ctx}.response.statusCode = ${property.property.type.valueAsString};`; + } else { + const statusCodeValue = isValueLiteralType(property.property.type) + ? getValueLiteralExpression(property.property.type) + : getPropertyPathExpression(names.result, property.path); + yield `${names.ctx}.response.statusCode = ${statusCodeValue};`; + if (!body) { + yield* emitDeleteForPath(names.result, property.path); + } + } + break; } } } const allMetadataIsRemoved = !body && - [...target.properties.values()].every((p) => { - return isHeader(ctx.program, p) || isStatusCode(ctx.program, p); - }); + responseProperties.every( + (property) => + property.kind === "header" || + property.kind === "contentType" || + property.kind === "statusCode", + ); if (body) { - const bodyCase = parseCase(body.name); + const bodyExpression = bodyMetadataProperty + ? getPropertyPathExpression(names.result, bodyMetadataProperty.path) + : names.result; + + if ( + responseContent && + (yield* emitRawResponseBody(ctx, names, responseContent, bodyExpression)) + ) { + return; + } + const serializationRequired = isSerializationRequired( ctx, module, @@ -649,16 +738,14 @@ function* emitResultProcessingForType( ); requireSerialization(ctx, body.type, "application/json"); - yield `${names.ctx}.response.setHeader("content-type", "application/json");`; + if (!hasResolvedContentTypeHeader) { + yield `${names.ctx}.response.setHeader("content-type", "application/json");`; + } if (serializationRequired) { - const typeReference = emitTypeReference(ctx, body.type, body, module, { - altName: namer.getAltName("Body"), - requireDeclaration: true, - }); - yield `${names.ctx}.response.end(globalThis.JSON.stringify(${typeReference}.toJsonObject(${names.result}.${bodyCase.camelCase})))`; + yield `${names.ctx}.response.end(globalThis.JSON.stringify(${transposeExpressionToJson(ctx, body.type, bodyExpression, module)}));`; } else { - yield `${names.ctx}.response.end(globalThis.JSON.stringify(${names.result}.${bodyCase.camelCase}));`; + yield `${names.ctx}.response.end(globalThis.JSON.stringify(${bodyExpression}));`; } } else if (isArrayModelType(target)) { const itemType = target.indexer.value; @@ -697,6 +784,13 @@ function* emitResultProcessingForType( yield `${names.ctx}.response.end(globalThis.JSON.stringify(${names.result}));`; } } else { + if ( + responseContent && + (yield* emitRawResponseBody(ctx, names, responseContent, names.result)) + ) { + return; + } + if (allMetadataIsRemoved) { yield `${names.ctx}.response.end();`; } else { @@ -723,6 +817,138 @@ function* emitResultProcessingForType( } } +function* emitRawResponseBody( + ctx: HttpContext, + names: Names, + responseContent: HttpOperationResponseContent, + bodyExpression: string, +): Generator { + const body = responseContent.body; + + if (!body) { + return false; + } + + function emitsResolvedContentType( + property: ModelProperty | undefined, + ): property is ModelProperty { + return ( + !!property && + responseContent.properties.some( + (candidate) => + candidate.property === property && + (candidate.kind === "contentType" || + (candidate.kind === "header" && + candidate.options.name.toLowerCase() === "content-type")), + ) + ); + } + + function emitsResolvedHeader(property: ModelProperty | undefined): property is ModelProperty { + return ( + !!property && + responseContent.properties.some( + (candidate) => candidate.property === property && candidate.kind === "header", + ) + ); + } + + if (body.bodyKind === "file") { + const fileBody = body as HttpOperationFileBody; + const contentTypeProperty = fileBody.contentTypeProperty as ModelProperty; + const filenameProperty = fileBody.filename as ModelProperty; + + if (!emitsResolvedContentType(contentTypeProperty)) { + const fallbackContentType = JSON.stringify( + fileBody.contentTypes[0] ?? "application/octet-stream", + ); + yield `${names.ctx}.response.setHeader("content-type", ${bodyExpression}.contentType ?? ${fallbackContentType});`; + } + + if (emitsResolvedHeader(filenameProperty)) { + const headerName = getHeaderFieldName(ctx.program, filenameProperty).toLowerCase(); + yield `${names.ctx}.response.setHeader(${JSON.stringify(headerName)}, ${bodyExpression}.filename);`; + } else { + yield `if (${bodyExpression}.filename !== undefined) {`; + yield ` ${names.ctx}.response.setHeader("content-disposition", \`attachment; filename="\${${bodyExpression}.filename}"\`);`; + yield `}`; + } + + yield `${names.ctx}.response.end(${bodyExpression}.contents);`; + return true; + } + + if ( + body.bodyKind === "single" && + ctx.program.checker.isStdType(body.type, "bytes") && + !body.contentTypes.some(isJsonContentType) + ) { + if (!emitsResolvedContentType(body.contentTypeProperty)) { + const contentType = body.contentTypes[0] ?? "application/octet-stream"; + yield `${names.ctx}.response.setHeader("content-type", ${JSON.stringify(contentType)});`; + } + + yield `${names.ctx}.response.end(${bodyExpression});`; + return true; + } + + return false; +} + +function isJsonContentType(contentType: string): boolean { + return ( + contentType === "application/json" || + contentType === "text/json" || + contentType.endsWith("+json") + ); +} + +function getValueLiteralExpression(type: Type): string { + compilerAssert(isValueLiteralType(type), "Expected a value literal type."); + + switch (type.kind) { + case "String": + case "Boolean": + return JSON.stringify(type.value); + case "Number": + return type.valueAsString; + default: + compilerAssert(false, `Unsupported value literal type '${type.kind}'.`); + } +} + +function getPropertyPathExpression(root: string, path: readonly (string | number)[]): string { + let expression = root; + + for (const segment of path) { + expression = + typeof segment === "number" + ? `${expression}[${segment}]` + : `${expression}.${parseCase(segment).camelCase}`; + } + + return expression; +} + +function* emitDeleteForPath( + root: string, + path: readonly (string | number)[], +): Generator { + if (path.length === 0) { + return; + } + + const parentExpression = + path.length === 1 ? root : getPropertyPathExpression(root, path.slice(0, path.length - 1)); + const leaf = path[path.length - 1]!; + + if (typeof leaf === "number") { + yield `delete (${parentExpression} as any)[${leaf}];`; + } else { + yield `delete (${parentExpression} as any).${parseCase(leaf).camelCase};`; + } +} + /** * Emit code that binds a given header parameter to a variable. * diff --git a/packages/http-server-js/test/scalar.test.ts b/packages/http-server-js/test/scalar.test.ts index b3c786dadf6..11e12d16147 100644 --- a/packages/http-server-js/test/scalar.test.ts +++ b/packages/http-server-js/test/scalar.test.ts @@ -209,6 +209,117 @@ describe("scalar", () => { expect(serverRaw).toMatch(/response\.end\(globalThis\.JSON\.stringify\(__result_\d+\)\);/); }); + it("emits raw bytes responses for bare bytes return types", async () => { + const { outputs } = await HttpServerEmitterTester.compile(` + @service(#{ title: "Example" }) + @route("/") + namespace Example { + @get op read(): bytes; + } + `); + + const serverRaw = outputs["src/generated/http/operations/server-raw.ts"]; + + expect(serverRaw).toBeDefined(); + expect(serverRaw).toContain('response.setHeader("content-type", "application/octet-stream");'); + expect(serverRaw).toMatch(/response\.end\(__result_\d+\);/); + expect(serverRaw).not.toContain("JSON.stringify"); + }); + + it("preserves custom content-type for bytes response bodies", async () => { + const { outputs } = await HttpServerEmitterTester.compile(` + @service(#{ title: "Example" }) + @route("/") + namespace Example { + @get op exportFile(): { + @statusCode statusCode: 200; + @header contentType: "application/zip"; + @body content: bytes; + }; + } + `); + + const serverRaw = outputs["src/generated/http/operations/server-raw.ts"]; + + expect(serverRaw).toBeDefined(); + expect(serverRaw).toMatch(/response\.setHeader\("content-type", "application\/zip"\);/); + expect(serverRaw).toMatch(/response\.end\(__result_\d+\.content\);/); + expect(serverRaw).not.toMatch( + /response\.setHeader\("content-type", __result_\d+\.contentType\);/, + ); + expect(serverRaw).not.toContain("toJsonObject"); + expect(serverRaw).not.toContain("JSON.stringify"); + }); + + it("keeps JSON bytes responses on the JSON serialization path", async () => { + const { outputs } = await HttpServerEmitterTester.compile(` + @service(#{ title: "Example" }) + @route("/") + namespace Example { + @get op read(): { + @header contentType: "application/json"; + @body data: bytes; + }; + } + `); + + const serverRaw = outputs["src/generated/http/operations/server-raw.ts"]; + + expect(serverRaw).toBeDefined(); + expect(serverRaw).toMatch(/response\.setHeader\("content-type", "application\/json"\);/); + expect(serverRaw).toContain("JSON.stringify"); + expect(serverRaw).toMatch(/toString\(['"]base64['"]\)/); + expect(serverRaw).not.toMatch( + /response\.setHeader\("content-type", __result_\d+\.contentType\);/, + ); + expect(serverRaw).not.toMatch(/response\.end\(__result_\d+\.data\);/); + }); + + it("emits raw Http.File responses", async () => { + const { outputs } = await HttpServerEmitterTester.compile(` + @service(#{ title: "Example" }) + @route("/") + namespace Example { + @get op download(): Http.File<"application/zip">; + } + `); + + const serverRaw = outputs["src/generated/http/operations/server-raw.ts"]; + + expect(serverRaw).toBeDefined(); + expect(serverRaw).toMatch( + /response\.setHeader\(\s*"content-type",\s*__result_\d+\.contentType \?\? "application\/zip",?\s*\);/, + ); + expect(serverRaw).toMatch(/response\.end\(__result_\d+\.contents\);/); + expect(serverRaw).toMatch(/response\.setHeader\(\s*"content-disposition",/); + expect(serverRaw).not.toContain("JSON.stringify"); + }); + + it("keeps structured JSON File responses on the JSON serialization path", async () => { + const { outputs } = await HttpServerEmitterTester.compile(` + @service(#{ title: "Example" }) + @route("/") + namespace Example { + #suppress "@typespec/http/http-file-structured" "Structured file JSON response regression test" + @get op download(): { + @header contentType: "application/json"; + @body file: Http.File<"text/plain", string>; + }; + } + `); + + const serverRaw = outputs["src/generated/http/operations/server-raw.ts"]; + + expect(serverRaw).toBeDefined(); + expect(serverRaw).toMatch(/response\.setHeader\("content-type", "application\/json"\)/); + expect(serverRaw).toContain("JSON.stringify"); + expect(serverRaw).not.toMatch( + /response\.setHeader\("content-type", __result_\d+\.contentType\);/, + ); + expect(serverRaw).not.toContain('response.setHeader("content-disposition",'); + expect(serverRaw).not.toMatch(/response\.end\(__result_\d+\.file\.contents\);/); + }); + describe("date/time/duration types", () => { describe("mode: temporal", () => { const options: JsEmitterOptions = { From 12de28cf6e08c808447353d5746b585becca41c3 Mon Sep 17 00:00:00 2001 From: Will Temple Date: Tue, 26 May 2026 12:48:42 -0400 Subject: [PATCH 2/3] Format --- packages/http-server-js/test/header.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/http-server-js/test/header.test.ts b/packages/http-server-js/test/header.test.ts index 82a5f7aac08..b5b2b3dce39 100644 --- a/packages/http-server-js/test/header.test.ts +++ b/packages/http-server-js/test/header.test.ts @@ -29,7 +29,7 @@ describe("headers", () => { }); it("formats attachment filenames without invalid header characters", () => { - const headerValue = formatContentDispositionAttachment('bad\r\nX-Test: yup\0.zip'); + const headerValue = formatContentDispositionAttachment("bad\r\nX-Test: yup\0.zip"); const response = new http.ServerResponse({ method: "GET" } as any); assert.doesNotThrow(() => { From cd3e012e0b3780771a68a76929a70ab85f6f7355 Mon Sep 17 00:00:00 2001 From: Will Temple Date: Tue, 26 May 2026 12:51:14 -0400 Subject: [PATCH 3/3] chronus --- .../changes/hsjs-bytes-body-payload-2026-4-26-12-49-56.md | 7 +++++++ .../changes/hsjs-bytes-body-payload-2026-4-26-12-51-4.md | 7 +++++++ 2 files changed, 14 insertions(+) create mode 100644 .chronus/changes/hsjs-bytes-body-payload-2026-4-26-12-49-56.md create mode 100644 .chronus/changes/hsjs-bytes-body-payload-2026-4-26-12-51-4.md diff --git a/.chronus/changes/hsjs-bytes-body-payload-2026-4-26-12-49-56.md b/.chronus/changes/hsjs-bytes-body-payload-2026-4-26-12-49-56.md new file mode 100644 index 00000000000..3933aed052a --- /dev/null +++ b/.chronus/changes/hsjs-bytes-body-payload-2026-4-26-12-49-56.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/http-server-js" +--- + +Fixed an issue with handling of `bytes` response bodies with content-types other than "application/json" that would cause http-server-js to emit an invalid attempt to call `Uint8Array.toJsonObject`. \ No newline at end of file diff --git a/.chronus/changes/hsjs-bytes-body-payload-2026-4-26-12-51-4.md b/.chronus/changes/hsjs-bytes-body-payload-2026-4-26-12-51-4.md new file mode 100644 index 00000000000..1fc60ad70b5 --- /dev/null +++ b/.chronus/changes/hsjs-bytes-body-payload-2026-4-26-12-51-4.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/http-server-js" +--- + +Added support for `Http.File` response bodies. File bodies are treated as _raw_ bytes, and the `filename` is represented in the `Content-Disposition` header. \ No newline at end of file