What happens
A response built with HttpServerResponse.file loses its Content-Type header when HttpMiddleware.compression() compresses it on the Node platform. The client receives content-encoding: br (or gzip) and no content-type. When the response also carries X-Content-Type-Options: nosniff, Chromium renders a served HTML file as plain text and refuses a served CSS file as a stylesheet.
Version: effect 4.0.0-rc.112 with @effect/platform-node from the same release. The same code is on main at c8349ed.
Reproduction
A router with one file route behind the compression middleware, served with NodeHttpServer.layerTest, and a plain node:http request so the headers are read as sent:
import * as NodeHttp from "node:http";
import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";
import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { HttpMiddleware, HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http";
// page.html: any HTML file larger than 1 KiB, the middleware's default minimum size.
const routes = HttpRouter.add(
"GET",
"/page",
HttpServerResponse.file("page.html", { headers: { "content-type": "text/html; charset=utf-8" } }),
).pipe(Layer.provide(HttpRouter.middleware(HttpMiddleware.compression(), { global: true })));
const layer = HttpRouter.serve(routes, { disableLogger: true }).pipe(
Layer.provideMerge(NodeHttpServer.layerTest),
Layer.provideMerge(NodeHttpPlatform.layer),
Layer.provideMerge(NodeServices.layer),
);
Effect.gen(function* () {
const address = (yield* HttpServer.HttpServer).address as HttpServer.TcpAddress;
const headers = yield* Effect.promise(
() =>
new Promise<NodeHttp.IncomingHttpHeaders>((resolve, reject) =>
NodeHttp.get(
{ host: "127.0.0.1", port: address.port, path: "/page", headers: { "accept-encoding": "br" } },
(response) => {
response.resume();
response.on("end", () => resolve(response.headers));
},
).on("error", reject),
),
);
console.log(headers["content-encoding"], headers["content-type"]);
}).pipe(Effect.provide(layer), Effect.runPromise);
Prints br undefined. Without the accept-encoding header it prints undefined text/html; charset=utf-8. A response whose body carries its own type, for example HttpServerResponse.text(html, { contentType: "text/html" }), keeps content-type: text/html under compression, because the Uint8Array path copies body.contentType.
Cause
fileResponse in packages/platform/node/src/NodeHttpPlatform.ts puts the type in the response headers only. The Raw body it creates has contentType undefined.
compressResponse in the same file builds the compressed body as HttpBody.raw(readable.pipe(transform), { contentType: body.contentType }) for a Raw body, and HttpBody.stream(..., body.contentType) for a Stream body. Both copy the body's undefined type.
compressedBody calls HttpServerResponse.setBody, whose updateHeaders (packages/effect/src/unstable/http/internal/httpBody.ts) removes content-type when the new body has none.
The header the route set is gone before wrapCompression adds content-encoding.
Expected
The compressed response keeps the content type the uncompressed response had.
Suggested fix
In compressResponse, carry response.headers["content-type"] ?? body.contentType into the new body for the Stream and Raw cases. Alternatively, updateHeaders could keep an existing content-type header when the new body carries none.
Workaround
Cache-Control: no-transform on the affected responses. The middleware honours it and leaves them uncompressed.
What happens
A response built with
HttpServerResponse.fileloses itsContent-Typeheader whenHttpMiddleware.compression()compresses it on the Node platform. The client receivescontent-encoding: br(orgzip) and nocontent-type. When the response also carriesX-Content-Type-Options: nosniff, Chromium renders a served HTML file as plain text and refuses a served CSS file as a stylesheet.Version:
effect4.0.0-rc.112 with@effect/platform-nodefrom the same release. The same code is onmainat c8349ed.Reproduction
A router with one file route behind the compression middleware, served with
NodeHttpServer.layerTest, and a plainnode:httprequest so the headers are read as sent:Prints
br undefined. Without theaccept-encodingheader it printsundefined text/html; charset=utf-8. A response whose body carries its own type, for exampleHttpServerResponse.text(html, { contentType: "text/html" }), keepscontent-type: text/htmlunder compression, because theUint8Arraypath copiesbody.contentType.Cause
fileResponseinpackages/platform/node/src/NodeHttpPlatform.tsputs the type in the response headers only. TheRawbody it creates hascontentTypeundefined.compressResponsein the same file builds the compressed body asHttpBody.raw(readable.pipe(transform), { contentType: body.contentType })for aRawbody, andHttpBody.stream(..., body.contentType)for aStreambody. Both copy the body's undefined type.compressedBodycallsHttpServerResponse.setBody, whoseupdateHeaders(packages/effect/src/unstable/http/internal/httpBody.ts) removescontent-typewhen the new body has none.The header the route set is gone before
wrapCompressionaddscontent-encoding.Expected
The compressed response keeps the content type the uncompressed response had.
Suggested fix
In
compressResponse, carryresponse.headers["content-type"] ?? body.contentTypeinto the new body for theStreamandRawcases. Alternatively,updateHeaderscould keep an existingcontent-typeheader when the new body carries none.Workaround
Cache-Control: no-transformon the affected responses. The middleware honours it and leaves them uncompressed.