Bun.serve: send Date on bodiless responses, and mirror GET's Content-Type on HEAD

Closed
RO
2mo ago
+266-9

Fixes #27512

Repro

const page = new Response("hello");
using server = Bun.serve({
  port: 0,
  static: { "/static": page },
  fetch: () => new Response("hello"),
});
requestDateContent-Type
GET /dynamicyestext/plain;charset=utf-8
HEAD /dynamicmissingapplication/octet-stream
GET /staticyestext/plain;charset=utf-8
HEAD /staticmissingtext/plain;charset=utf-8
GET /static + If-None-Match (304)missingtext/plain;charset=utf-8
a 413 from maxRequestBodySize (#27512)missingn/a

A cache keyed on Date cannot compute freshness, and a HEAD that advertises application/octet-stream tells a client the resource is a binary download when GET would have served text.

Cause

Two defects, both making a bodiless response describe itself differently than the GET it mirrors.

1. Date (RFC 9110 6.6.1). uws_res_end_without_body() hand-rolls the response terminator rather than going through internalEnd() — and internalEnd() is where writeMark() writes Date. Every other termination path reaches it; this one never did. HEAD, 304 and the bodiless error responses (413) terminate this way, so they shipped with no Date.

2. Content-Type on HEAD (RFC 9110 9.3.2). The HEAD renderer consumed the body into a local, but render_metadata() derives the Content-Type from self.blob, so it fell through to the bodiless default. The GET path assigns self.blob. Both HEAD arms (in-memory and Blob bodies) had this, so a string, a typed Blob, a sliced Blob and a Bun.file body were all affected.

Fix

  • Call writeMark() in uws_res_end_without_body() before the terminator. writeMark() is idempotent.
  • Assign this.blob in both HEAD arms before render_metadata(), as the GET path does. The Blob arm shares the store via dupe(); detach happens before end_without_body (which may finalize the context at refcount 1).

No duplicate Date for routes that set their own (RFC 9110 5.6.6)

Making writeMark() run on the bodiless path surfaced a pre-existing class bug: StaticRoute/FileRoute write baked headers via the raw write_header() path, which never sets HTTP_WROTE_DATE_HEADER. So a route whose Response carried a Date got a second one appended — and GET already double-stamped via internalEnd(). Before the fix:

request (route with a user Date)Date headers on main
GET /static, GET /file, HEAD /file2 (pre-existing)
HEAD /static1 → would become 2 under this PR

Added a mark_wrote_date_header() shim next to mark_wrote_content_length_header() (C + Rust + AnyResponse + H3) and call it from both writers when the baked headers include a Date. FileRoute's eager write_mark() before write_headers() is removed so the terminator stamps Date once, preferring the user's value; every FileRoute exit (bodiless, sendfile body, 304) still reaches a writeMark. After the fix, all six cells (static/file × GET/HEAD, with/without a user Date) emit exactly one Date, and a user-supplied value is preserved verbatim.

needs_content_length is already forced false on the HEAD path and needs_content_range is only set for file-backed bodies in do_sendfile, so assigning self.blob changes nothing but the Content-Type derivation.

node:http is unaffected

NodeHTTP.cpp sets HTTP_WROTE_DATE_HEADER up front because ServerResponse owns the header in JS (honoring res.sendDate / removeHeader("date")), so writeMark() stays a no-op there:

                HEAD /      -> 1 Date    GET /204 -> 1 Date
sendDate=false  HEAD /      -> 0 Date    GET /204 -> 0 Date

Verification

bun-serve-date.test.ts asserts Date on HEAD (dynamic + static), 304 (GET + HEAD), the 413 of #27512, and — via raw sockets, since fetch collapses duplicate headers — exactly one Date for static/file routes × GET/HEAD with and without a user Date.

bun-serve-head.test.ts (new) asserts HEAD reports the same status, Content-Type and Content-Length as GET with zero body bytes, across string / typed Blob / sliced Blob / Uint8Array / explicit Content-Type / bodiless / Bun.file bodies.

Suites run
SuiteResult
bun-serve-date, bun-serve-head, bun-serve-file, serve-if-none-match, bun-serve-static, bun-serve-routes194 pass, 0 fail
serve.test.ts238 pass, same 4 pre-existing container-env failures as main
node-http.test.ts127 pass, 1 pre-existing failure (also fails on main)

cargo clippy and cargo fmt clean on bun_uws and bun_runtime.

Not in scope

HTTP/3 never writes Date on any response, not just bodiless ones — Http3Response has no writeMark() equivalent. A separate gap in a separate code path.