Skip to content

HttpMiddleware.compression drops Content-Type from file responses on Node #8146

Description

@stefanofa

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

  1. fileResponse in packages/platform/node/src/NodeHttpPlatform.ts puts the type in the response headers only. The Raw body it creates has contentType undefined.
  2. 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.
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions