Repository navigation
File responses cannot set a Content-Type: the contentType option is ignored and HttpStaticServer serves every file as application/octet-stream #8252
Description
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.tsimport { 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 includescontentType, butHttpPlatform.fileResponsereads onlystatus,statusText,headers,offsetandbytesToRead, so nothing consumes it.HttpStaticServer.setFileHeaderssetsContent-TypeandAccept-Rangeson an already-built response.Accept-Rangesarrives;Content-Typedoes not. So the mutation is not lost wholesale, only for this header.setHeaderscallsmakeResponse(..., true), and thepreferHeadersbranch inHttpServerResponse.tslooks like it should keep a caller'scontent-typefor 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