Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/jsc/FetchHeaders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,11 @@ impl FetchHeaders {
value: &BunString,
global: &JSGlobalObject,
) -> JsResult<()> {
if self.fast_has(name_) {
// `fast_get` returns None for a zero-length value, so an empty entry is
// treated as absent and overwritten with the default (`Response.json`'s
// `application/json`, StaticRoute's was-string `text/plain`). `fast_has`
// would leave the empty value in place.
if self.fast_get(name_).is_some() {
Comment thread
robobun marked this conversation as resolved.
Outdated
return Ok(());
}

Expand Down
12 changes: 12 additions & 0 deletions src/jsc/bindings/NodeHTTP.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,15 @@ static void writeFetchHeadersToUWSResponse(WebCore::FetchHeaders& headers, uWS::
const auto& name = WebCore::httpHeaderNameString(header.key);
const auto& value = header.value;

// FetchHeaders strips leading/trailing HTTP whitespace, so an empty value here
// covers `""` and whitespace-only. Treat it as absent: don't write the line and
// don't set the wrote-this-header state bits, so the matching auto-header (Date,
// Content-Type from render_metadata) is emitted exactly once instead of alongside
// an empty duplicate.
if (value.isEmpty()) {
continue;
}
Comment thread
robobun marked this conversation as resolved.

// We have to tell uWS not to automatically insert a TransferEncoding or Date header.
// Otherwise, you get this when using Fastify;
//
Expand Down Expand Up @@ -1533,6 +1542,9 @@ static void writeFetchHeadersToH3Response(WebCore::FetchHeaders& headers, uWS::H
}

for (const auto& header : internalHeaders.commonHeaders()) {
if (header.value.isEmpty()) {
continue;
}
if (header.key == WebCore::HTTPHeaderName::ContentLength) {
if (!(data->state & uWS::Http3ResponseData::HTTP_WROTE_CONTENT_LENGTH_HEADER)) {
data->state |= uWS::Http3ResponseData::HTTP_WROTE_CONTENT_LENGTH_HEADER;
Expand Down
25 changes: 17 additions & 8 deletions src/runtime/server/FileRoute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,10 @@ impl FileRoute {
bun_core::heap::into_raw(Box::new(FileRoute {
ref_count: Cell::new(1),
server: Cell::new(opts.server),
has_last_modified_header: headers.get(b"last-modified").is_some(),
has_content_length_header: headers.get(b"content-length").is_some(),
has_content_range_header: headers.get(b"content-range").is_some(),
has_date_header: headers.get(b"date").is_some(),
has_last_modified_header: headers.get(b"last-modified").is_some_and(|v| !v.is_empty()),
has_content_length_header: headers.get(b"content-length").is_some_and(|v| !v.is_empty()),
has_content_range_header: headers.get(b"content-range").is_some_and(|v| !v.is_empty()),
has_date_header: headers.get(b"date").is_some_and(|v| !v.is_empty()),
blob,
headers,
status_code: opts.status_code,
Expand Down Expand Up @@ -180,10 +180,10 @@ impl FileRoute {
return Ok(Some(bun_core::heap::into_raw(Box::new(FileRoute {
ref_count: Cell::new(1),
server: Cell::new(None),
has_last_modified_header: headers.get(b"last-modified").is_some(),
has_content_length_header: headers.get(b"content-length").is_some(),
has_content_range_header: headers.get(b"content-range").is_some(),
has_date_header: headers.get(b"date").is_some(),
has_last_modified_header: headers.get(b"last-modified").is_some_and(|v| !v.is_empty()),
has_content_length_header: headers.get(b"content-length").is_some_and(|v| !v.is_empty()),
has_content_range_header: headers.get(b"content-range").is_some_and(|v| !v.is_empty()),
has_date_header: headers.get(b"date").is_some_and(|v| !v.is_empty()),
blob,
headers,
status_code,
Expand Down Expand Up @@ -230,6 +230,9 @@ impl FileRoute {
AnyResponse::SSL(s) => {
let s = bun_opaque::opaque_deref_mut(s);
for (name, value) in names.iter().zip(values) {
if value.length == 0 {
continue;
}
s.write_header(sp_slice(*name, buf), sp_slice(*value, buf));
}
if let Some(srv) = self.server.get() {
Expand All @@ -241,6 +244,9 @@ impl FileRoute {
AnyResponse::TCP(s) => {
let s = bun_opaque::opaque_deref_mut(s);
for (name, value) in names.iter().zip(values) {
if value.length == 0 {
continue;
}
s.write_header(sp_slice(*name, buf), sp_slice(*value, buf));
}
if let Some(srv) = self.server.get() {
Expand All @@ -252,6 +258,9 @@ impl FileRoute {
AnyResponse::H3(s) => {
let s = bun_opaque::opaque_deref_mut(s);
for (name, value) in names.iter().zip(values) {
if value.length == 0 {
continue;
}
s.write_header(sp_slice(*name, buf), sp_slice(*value, buf));
}
// tag == .H3 → no alt-svc header
Expand Down
7 changes: 5 additions & 2 deletions src/runtime/server/StaticRoute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@
}

let cached_blob_size = blob.size();
let has_date = headers.get(b"date").is_some();
let has_date = headers.get(b"date").is_some_and(|v| !v.is_empty());
bun_core::heap::into_raw(Box::new(StaticRoute {
ref_count: Cell::new(1),
blob,
Expand Down Expand Up @@ -244,7 +244,7 @@
}

let cached_blob_size = blob.size();
let has_date = headers.get(b"date").is_some();
let has_date = headers.get(b"date").is_some_and(|v| !v.is_empty());
Comment thread
robobun marked this conversation as resolved.
return Ok(Some(bun_core::heap::into_raw(Box::new(StaticRoute {
ref_count: Cell::new(1),
blob,
Expand Down Expand Up @@ -493,7 +493,10 @@
let buf = self.headers.buf.as_slice();

debug_assert_eq!(names.len(), values.len());
for (name, value) in names.iter().zip(values) {
if value.length == 0 {
continue;
}

Check warning on line 499 in src/runtime/server/StaticRoute.rs

View check run for this annotation

Claude / Claude Code Review

Static/file route empty-header handling still diverges from dynamic path

The follow-up commit's `value.length == 0` skip in `StaticRoute::do_write_headers` / `FileRoute::write_headers` applies to *every* snapshot entry, but `WebCore__FetchHeaders__copyTo` flattens common and uncommon headers together — so a static/file route now drops `x-custom: ""` entirely while the dynamic path still emits it (contradicting the PR's "Uncommon (custom) headers are left untouched" and its test, which only covers the dynamic path). Separately, `from_fetch_headers` (headers_jsc.rs:42)
Comment thread
robobun marked this conversation as resolved.
resp.write_header(
&buf[name.offset as usize..][..name.length as usize],
&buf[value.offset as usize..][..value.length as usize],
Expand Down
158 changes: 158 additions & 0 deletions test/js/bun/http/bun-serve-headers.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,164 @@
import { describe, expect, test } from "bun:test";
import { tempDir } from "harness";
import { once } from "node:events";
import * as net from "node:net";
import * as path from "node:path";

// An empty (or whitespace-only) header value must be treated as absent for the
// well-known headers Bun fills in automatically, so the response head carries
// exactly one Content-Type and exactly one Date (RFC 9110 forbids duplicate
// Content-Type; an origin server MUST send a valid Date).
describe("empty header value does not duplicate auto-headers", () => {
async function rawHead(makeResponse: () => Response): Promise<string> {
using server = Bun.serve({
port: 0,
hostname: "127.0.0.1",
development: false,
fetch: makeResponse,
});
const socket = net.connect(server.port, "127.0.0.1");
try {
socket.on("error", () => {});
await once(socket, "connect");
socket.write("GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n");
let raw = "";
await new Promise<void>(resolve => {
socket.on("data", c => (raw += c.toString("latin1")));
socket.on("close", resolve);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
return raw.split("\r\n\r\n")[0];
} finally {
socket.destroy();
}
}

const lines = (head: string, name: string) => head.split("\r\n").filter(l => l.toLowerCase().startsWith(name + ":"));

for (const [label, value] of [
["empty", ""],
["whitespace", " \t "],
] as const) {
test(`content-type: ${label}`, async () => {
const head = await rawHead(() => new Response("x", { headers: { "content-type": value } }));
const ct = lines(head, "content-type");
expect(ct).toHaveLength(1);
expect(ct[0].toLowerCase()).toBe("content-type: text/plain;charset=utf-8");
});

test(`date: ${label}`, async () => {
const head = await rawHead(() => new Response("x", { headers: { date: value } }));
const date = lines(head, "date");
expect(date).toHaveLength(1);
// auto Date is a valid IMF-fixdate, never an empty value
expect(date[0]).toMatch(/^Date: \S/);
expect(Number.isFinite(new Date(date[0].slice(6)).getTime())).toBe(true);
});
}

test("headers.set('content-type', '')", async () => {
const head = await rawHead(() => {
const r = new Response("x");
r.headers.set("content-type", "");
return r;
});
const ct = lines(head, "content-type");
expect(ct).toHaveLength(1);
expect(ct[0].toLowerCase()).toBe("content-type: text/plain;charset=utf-8");
});

test("Response.json with empty content-type", async () => {
const head = await rawHead(() => Response.json({ a: 1 }, { headers: { "content-type": "" } }));
const ct = lines(head, "content-type");
expect(ct).toHaveLength(1);
expect(ct[0].toLowerCase()).toBe("content-type: application/json;charset=utf-8");
});

test("both empty at once: one of each", async () => {
const head = await rawHead(() => new Response("x", { headers: { "content-type": "", "date": "" } }));
expect(lines(head, "content-type")).toHaveLength(1);
expect(lines(head, "date")).toHaveLength(1);
});

async function rawHeadStatic(response: Response): Promise<string> {
using server = Bun.serve({
port: 0,
hostname: "127.0.0.1",
development: false,
static: { "/": response },
fetch() {
return new Response("unreachable");
},
});
const socket = net.connect(server.port, "127.0.0.1");
try {
socket.on("error", () => {});
await once(socket, "connect");
socket.write("GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n");
let raw = "";
await new Promise<void>(resolve => {
socket.on("data", c => (raw += c.toString("latin1")));
socket.on("close", resolve);
});
return raw.split("\r\n\r\n")[0];
} finally {
socket.destroy();
}
}

Check warning on line 106 in test/js/bun/http/bun-serve-headers.test.ts

View check run for this annotation

Claude / Claude Code Review

rawHead / rawHeadStatic are near-duplicates

`rawHead` (lines 12-33) and `rawHeadStatic` (lines 82-106) are byte-for-byte identical from `net.connect(...)` through `return raw.split(...)` — only the `Bun.serve` options differ. Per REVIEW.md's "the second time a multi-line block appears in your diff, extract a named helper", factor the ~15 lines of socket plumbing into one helper (e.g. `rawHeadFrom(server)` or `rawHead(serveOptions)`) and call it from both.
Comment thread
robobun marked this conversation as resolved.

test("static route: content-type empty", async () => {
const head = await rawHeadStatic(new Response("x", { headers: { "content-type": "" } }));
const ct = lines(head, "content-type");
expect(ct).toHaveLength(1);
expect(ct[0].toLowerCase()).toBe("content-type: text/plain;charset=utf-8");
});

test("static route: date empty", async () => {
const head = await rawHeadStatic(new Response("x", { headers: { date: "" } }));
const date = lines(head, "date");
expect(date).toHaveLength(1);
expect(date[0]).toMatch(/^Date: \S/);
expect(Number.isFinite(new Date(date[0].slice(6)).getTime())).toBe(true);
});

test("static route: both empty", async () => {
const head = await rawHeadStatic(new Response("x", { headers: { "content-type": "", "date": "" } }));
const ct = lines(head, "content-type");
const date = lines(head, "date");
expect({ ct: ct.length, date: date.length }).toEqual({ ct: 1, date: 1 });
expect(ct[0].toLowerCase()).toBe("content-type: text/plain;charset=utf-8");
expect(date[0]).toMatch(/^Date: \S/);
});

test("static route: non-empty user values preserved", async () => {
const head = await rawHeadStatic(
new Response("x", { headers: { "content-type": "text/html", "date": "Sun, 06 Oct 2024 13:37:01 GMT" } }),
);
expect(lines(head, "content-type")).toEqual(["Content-Type: text/html"]);
expect(lines(head, "date")).toEqual(["Date: Sun, 06 Oct 2024 13:37:01 GMT"]);
});

test("file route: date empty", async () => {
using dir = tempDir("serve-file-empty-date", { "a.txt": "x" });
const head = await rawHeadStatic(new Response(Bun.file(path.join(String(dir), "a.txt")), { headers: { date: "" } }));
const date = lines(head, "date");
expect(date).toHaveLength(1);
expect(date[0]).toMatch(/^Date: \S/);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test("non-empty user values are still preserved", async () => {
const head = await rawHead(
() => new Response("x", { headers: { "content-type": "text/html", "date": "Sun, 06 Oct 2024 13:37:01 GMT" } }),
);
expect(lines(head, "content-type")).toEqual(["Content-Type: text/html"]);
expect(lines(head, "date")).toEqual(["Date: Sun, 06 Oct 2024 13:37:01 GMT"]);
});

test("uncommon header with empty value is unaffected", async () => {
const head = await rawHead(() => new Response("x", { headers: { "x-custom": "", "content-type": "text/html" } }));
expect(lines(head, "x-custom")).toEqual(["x-custom: "]);
expect(lines(head, "content-type")).toEqual(["Content-Type: text/html"]);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

// https://github.com/oven-sh/bun/issues/9180
test("weird headers", async () => {
Expand Down
Loading