Skip to content

File responses cannot set a Content-Type: the contentType option is ignored and HttpStaticServer serves every file as application/octet-stream #8252

Description

@jachris

What version of Effect is running?

4.0.0-rc.115 (also reproduced on 4.0.0-rc.112)


What steps can reproduce the bug?

A file response keeps a content type only when it is supplied at construction, through headers. Every other route fails silently, including HttpStaticServer's own mime table.

bun add effect@4.0.0-rc.115 @effect/platform-bun@4.0.0-rc.115
bun run repro.ts
import { Effect, Layer } from "effect"
import {
  Etag, HttpPlatform, HttpRouter, HttpServerResponse, HttpStaticServer,
} from "effect/unstable/http"
import { BunServices } from "@effect/platform-bun"
import { mkdtempSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"

const root = mkdtempSync(join(tmpdir(), "static-"))
const file = join(root, "app.js")
writeFileSync(file, "console.log(1)")

const show = async (label: string, route: Layer.Layer<never, never, never>) => {
  const { handler, dispose } = HttpRouter.toWebHandler(
    route.pipe(
      Layer.provideMerge(HttpPlatform.layer),
      Layer.provideMerge(Etag.layerWeak),
      Layer.provideMerge(BunServices.layer),
    ) as never,
    { disableLogger: true },
  )
  const res = await handler(new Request("http://localhost/app.js"))
  console.log(label.padEnd(46), "->", res.headers.get("content-type"))
  await dispose()
}

await show("A. file(path, { contentType })",
  HttpRouter.add("GET", "/*", HttpServerResponse.file(file, { contentType: "text/javascript" }) as never))

await show("B. file(path, { headers: content-type })",
  HttpRouter.add("GET", "/*", HttpServerResponse.file(file, { headers: { "content-type": "text/javascript" } }) as never))

await show("C. HttpStaticServer.make({ root })",
  Layer.unwrap(Effect.map(HttpStaticServer.make({ root }), (files) => HttpRouter.add("GET", "/*", files as never))))

await show("D. file(path) then setHeaders Content-Type",
  HttpRouter.add("GET", "/*", Effect.map(HttpServerResponse.file(file), HttpServerResponse.setHeaders({ "Content-Type": "text/javascript" })) as never))

await show("E. file(path) then setHeaders content-type",
  HttpRouter.add("GET", "/*", Effect.map(HttpServerResponse.file(file), HttpServerResponse.setHeaders({ "content-type": "text/javascript" })) as never))

On Node, swap BunServices from @effect/platform-bun for NodeServices from @effect/platform-node; the code under test is platform independent.


What is the expected behavior?

A. file(path, { contentType })                 -> text/javascript
B. file(path, { headers: content-type })       -> text/javascript
C. HttpStaticServer.make({ root })             -> text/javascript; charset=utf-8
D. file(path) then setHeaders Content-Type     -> text/javascript
E. file(path) then setHeaders content-type     -> text/javascript

C should come from the built-in table, which already maps js to text/javascript; charset=utf-8.


What do you see instead?

A. file(path, { contentType })                 -> application/octet-stream
B. file(path, { headers: content-type })       -> text/javascript
C. HttpStaticServer.make({ root })             -> application/octet-stream
D. file(path) then setHeaders Content-Type     -> application/octet-stream
E. file(path) then setHeaders content-type     -> application/octet-stream

Only B is correct. A accepts an option that is never read; D and E show the header does not stick after construction, in either casing.


Additional information

The practical consequence is that HttpStaticServer cannot serve a JavaScript bundle or a stylesheet: a browser refuses the script with a MIME type error, and index.html is offered as a download rather than rendered.

Observations, offered without claiming a diagnosis:

  • HttpServerResponse.file's options type includes contentType, but HttpPlatform.fileResponse reads only status, statusText, headers, offset and bytesToRead, so nothing consumes it.
  • HttpStaticServer.setFileHeaders sets Content-Type and Accept-Ranges on an already-built response. Accept-Ranges arrives; Content-Type does not. So the mutation is not lost wholesale, only for this header.
  • setHeaders calls makeResponse(..., true), and the preferHeaders branch in HttpServerResponse.ts looks like it should keep a caller's content-type for a file body, which it does not.

Possibly adjacent to #8146 and #8148, which concerned Content-Type being dropped from file responses during compression. This happens with no compression involved.

Workaround, for anyone who hits it: pass the type through headers at construction and compute it yourself. That means bypassing HttpStaticServer, since its own table cannot be made to apply.

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