Compare commits

...

3 Commits

Author SHA1 Message Date
autofix-ci[bot]
03d103fb51 [autofix.ci] apply automated fixes 2025-08-14 03:26:02 +00:00
Claude Bot
e20e2a6bd4 fix: Set IncomingMessage.url to empty string for HTTPS requests to match Node.js
Fixes issue #13820 where https.request() returned "/" for response.url
instead of an empty string like Node.js. The issue was that for
FetchResponse type IncomingMessage objects, the url property was
inheriting from the fetch Response.url which returns the pathname.

Changes:
- Modified IncomingMessage constructor to explicitly set url to "" for FetchResponse type
- Added comprehensive tests covering various request scenarios
- Ensures Node.js compatibility for response.url property

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-14 03:23:57 +00:00
Claude Bot
a7b24759d6 fix: preserve HTTP header arrays in getHeaders() like Node.js
This fixes issue #14113 where setHeader(['first', 'second']) was
returning 'first, second' instead of ['first', 'second'] in getHeaders().

Root cause:
- setHeader was converting arrays to joined strings for all purposes
- Node.js preserves arrays in getHeaders() but joins them for HTTP transmission
- Missing array preservation and proper copying for returned values

Changes:
- Modified OutgoingMessage.setHeader to preserve arrays in kOutHeaders
- Updated getHeaders() to return stored array/string values instead of Headers.toJSON()
- Added proper array copying to prevent reference sharing
- Updated removeHeader to clean both Headers and kOutHeaders
- Maintained HTTP transmission behavior (arrays still joined with ", ")

Tests:
- Added comprehensive regression tests covering all edge cases
- Tests array preservation, string handling, copying behavior
- Tests validation, case insensitivity, removal, and HTTP transmission
- Original failing test now correctly fails (indicating fix works)

Before:
setHeader('test', ['a', 'b'])
getHeaders() => { test: 'a, b' }  //  String

After:
setHeader('test', ['a', 'b'])
getHeaders() => { test: ['a', 'b'] }  //  Array (matches Node.js)

This improves Node.js compatibility for HTTP header handling, which is
important for libraries that depend on proper header array behavior.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-14 03:15:07 +00:00
4 changed files with 357 additions and 8 deletions

View File

@@ -147,6 +147,11 @@ function IncomingMessage(req, options = defaultIncomingOpts) {
if (!assignHeaders(this, req)) {
this[fakeSocketSymbol] = req;
}
// For fetch responses, set URL to empty string to match Node.js behavior
if (type === NodeHTTPIncomingRequestType.FetchResponse) {
this.url = "";
}
} else {
// Node defaults url and method to null.
this.url = "";

View File

@@ -245,9 +245,13 @@ const OutgoingMessagePrototype = {
},
getHeaders() {
const headers = this[headersSymbol];
if (!headers) return kEmptyObject;
return headers.toJSON();
const outHeaders = this[kOutHeaders];
if (!outHeaders) return kEmptyObject;
const result = Object.create(null);
for (const [key, value] of Object.entries(outHeaders)) {
result[key] = Array.isArray(value) ? [...value] : value;
}
return result;
},
removeHeader(name) {
@@ -256,8 +260,14 @@ const OutgoingMessagePrototype = {
throw $ERR_HTTP_HEADERS_SENT("remove");
}
const headers = this[headersSymbol];
if (!headers) return;
headers.delete(name);
if (headers) {
headers.delete(name);
}
// Also remove from our stored headers
const outHeaders = this[kOutHeaders];
if (outHeaders) {
delete outHeaders[name.toLowerCase()];
}
},
setHeader(name, value) {
@@ -265,9 +275,31 @@ const OutgoingMessagePrototype = {
throw $ERR_HTTP_HEADERS_SENT("set");
}
validateHeaderName(name);
validateHeaderValue(name, value);
const headers = (this[headersSymbol] ??= new Headers());
setHeader(headers, name, value);
// Handle arrays like Node.js - preserve them for getHeaders() but join for actual HTTP
if (Array.isArray(value)) {
// Validate each array element
for (const item of value) {
validateHeaderValue(name, item);
}
const headers = (this[headersSymbol] ??= new Headers());
// For the native Headers object, we join the array (for actual HTTP sending)
setHeader(headers, name, value.join(", "));
// But we also store the original array for getHeaders() compatibility
if (!this[kOutHeaders]) {
this[kOutHeaders] = Object.create(null);
}
this[kOutHeaders][name.toLowerCase()] = [...value];
} else {
validateHeaderValue(name, value);
const headers = (this[headersSymbol] ??= new Headers());
setHeader(headers, name, value);
// Store the string value for getHeaders()
if (!this[kOutHeaders]) {
this[kOutHeaders] = Object.create(null);
}
this[kOutHeaders][name.toLowerCase()] = value;
}
return this;
},
setHeaders(headers) {

View File

@@ -0,0 +1,182 @@
import { expect, test } from "bun:test";
import http from "node:http";
test("HTTP headers with arrays should be preserved like Node.js", () => {
const res = new http.OutgoingMessage();
// Set various types of headers
res.setHeader("array-header", ["first", "second"]);
res.setHeader("string-header", "single-value");
res.setHeader("array-single", ["single"]);
res.setHeader("array-multiple", ["one", "two", "three"]);
const headers = res.getHeaders();
// Arrays should be preserved as arrays
expect(headers["array-header"]).toEqual(["first", "second"]);
expect(headers["array-single"]).toEqual(["single"]);
expect(headers["array-multiple"]).toEqual(["one", "two", "three"]);
// Strings should remain strings
expect(headers["string-header"]).toBe("single-value");
});
test("HTTP header array validation should validate each element", () => {
const res = new http.OutgoingMessage();
// Valid arrays should work
expect(() => {
res.setHeader("valid-array", ["valid1", "valid2"]);
}).not.toThrow();
// Invalid array elements should throw
expect(() => {
res.setHeader("invalid-array", ["valid", "invalid\x00char"]);
}).toThrow();
});
test("HTTP header case insensitivity should work with arrays", () => {
const res = new http.OutgoingMessage();
res.setHeader("Content-Type", ["text/html", "charset=utf-8"]);
res.setHeader("content-type", ["application/json"]); // Should overwrite
const headers = res.getHeaders();
// Should have the last set value
expect(headers["content-type"]).toEqual(["application/json"]);
});
test("removeHeader should work with both string and array headers", () => {
const res = new http.OutgoingMessage();
res.setHeader("array-header", ["first", "second"]);
res.setHeader("string-header", "value");
let headers = res.getHeaders();
expect(headers["array-header"]).toEqual(["first", "second"]);
expect(headers["string-header"]).toBe("value");
// Remove headers
res.removeHeader("array-header");
res.removeHeader("string-header");
headers = res.getHeaders();
expect(headers["array-header"]).toBeUndefined();
expect(headers["string-header"]).toBeUndefined();
});
test("getHeaders should return a copy, not reference", () => {
const res = new http.OutgoingMessage();
res.setHeader("test-header", ["original", "value"]);
const headers1 = res.getHeaders();
const headers2 = res.getHeaders();
// Should be different objects
expect(headers1).not.toBe(headers2);
// But with same content
expect(headers1["test-header"]).toEqual(headers2["test-header"]);
// Modifying returned object shouldn't affect internal state
headers1["test-header"].push("modified");
const headers3 = res.getHeaders();
expect(headers3["test-header"]).toEqual(["original", "value"]);
});
test("HTTP header arrays should work with actual HTTP requests", async () => {
const server = http.createServer((req, res) => {
res.setHeader("Custom-Array", ["value1", "value2"]);
res.setHeader("Custom-String", "single-value");
const responseHeaders = res.getHeaders();
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
customArray: responseHeaders["custom-array"],
customString: responseHeaders["custom-string"],
}),
);
});
await new Promise<void>(resolve => {
server.listen(0, () => resolve());
});
const port = (server.address() as any)?.port;
try {
const response = await fetch(`http://localhost:${port}`);
const data = await response.json();
expect(data.customArray).toEqual(["value1", "value2"]);
expect(data.customString).toBe("single-value");
// Verify actual HTTP headers were sent correctly (joined for transmission)
const receivedCustomArray = response.headers.get("custom-array");
expect(receivedCustomArray).toBe("value1, value2");
} finally {
server.close();
}
});
test("Edge cases with empty arrays and special values", () => {
const res = new http.OutgoingMessage();
// Empty array
res.setHeader("empty-array", []);
// Array with empty string
res.setHeader("array-with-empty", ["", "value"]);
// Array with only empty strings
res.setHeader("only-empty", ["", ""]);
const headers = res.getHeaders();
expect(headers["empty-array"]).toEqual([]);
expect(headers["array-with-empty"]).toEqual(["", "value"]);
expect(headers["only-empty"]).toEqual(["", ""]);
});
test("setHeader overwrites previous values correctly", () => {
const res = new http.OutgoingMessage();
// Set initial values
res.setHeader("test-header", "string-value");
expect(res.getHeaders()["test-header"]).toBe("string-value");
// Overwrite with array
res.setHeader("test-header", ["array", "value"]);
expect(res.getHeaders()["test-header"]).toEqual(["array", "value"]);
// Overwrite back to string
res.setHeader("test-header", "new-string");
expect(res.getHeaders()["test-header"]).toBe("new-string");
});
test("Multiple headers with same name should behave like Node.js", () => {
const res = new http.OutgoingMessage();
// Test the set-cookie special case behavior
res.setHeader("set-cookie", ["cookie1=value1", "cookie2=value2"]);
const headers = res.getHeaders();
expect(headers["set-cookie"]).toEqual(["cookie1=value1", "cookie2=value2"]);
});
test("Header array preservation works with various HTTP methods", () => {
for (const method of ["GET", "POST", "PUT", "DELETE", "PATCH"]) {
const req = new http.OutgoingMessage();
req.method = method;
req.setHeader("custom-array", ["method", method]);
req.setHeader("custom-string", `single-${method}`);
const headers = req.getHeaders();
expect(headers["custom-array"]).toEqual(["method", method]);
expect(headers["custom-string"]).toBe(`single-${method}`);
}
});

View File

@@ -0,0 +1,130 @@
import { expect, test } from "bun:test";
import { request as httpRequest } from "http";
import { request as httpsRequest } from "https";
test("https.request URL property should be empty string like Node.js - issue #13820", async () => {
const url = await new Promise<string>((resolve, reject) => {
const req = httpsRequest("https://google.com", res => {
resolve(res.url);
res.resume(); // Drain response to avoid hanging
});
req.on("error", reject);
req.setTimeout(5000, () => reject(new Error("Timeout")));
req.end();
});
// Node.js returns empty string, not "/"
expect(url).toBe("");
});
test("https.request URL property for root path with explicit slash", async () => {
const url = await new Promise<string>((resolve, reject) => {
const req = httpsRequest("https://google.com/", res => {
resolve(res.url);
res.resume();
});
req.on("error", reject);
req.setTimeout(5000, () => reject(new Error("Timeout")));
req.end();
});
expect(url).toBe("");
});
test("https.request URL property for path", async () => {
const url = await new Promise<string>((resolve, reject) => {
const req = httpsRequest("https://httpbin.org/json", res => {
resolve(res.url);
res.resume();
});
req.on("error", reject);
req.setTimeout(5000, () => reject(new Error("Timeout")));
req.end();
});
expect(url).toBe("");
});
test("http.request URL property should also be empty string", async () => {
const url = await new Promise<string>((resolve, reject) => {
const req = httpRequest("http://httpbin.org/json", res => {
resolve(res.url);
res.resume();
});
req.on("error", reject);
req.setTimeout(5000, () => reject(new Error("Timeout")));
req.end();
});
expect(url).toBe("");
});
test("https.request URL property with redirect", async () => {
const url = await new Promise<string>((resolve, reject) => {
const req = httpsRequest("https://httpbin.org/redirect/1", res => {
resolve(res.url);
res.resume();
});
req.on("error", reject);
req.setTimeout(5000, () => reject(new Error("Timeout")));
req.end();
});
// Even after redirect, URL should still be empty string (Node.js behavior)
expect(url).toBe("");
});
test("https.request URL property consistency across multiple requests", async () => {
const urls = await Promise.all([
new Promise<string>((resolve, reject) => {
const req = httpsRequest("https://httpbin.org/status/200", res => {
resolve(res.url);
res.resume();
});
req.on("error", reject);
req.setTimeout(5000, () => reject(new Error("Timeout")));
req.end();
}),
new Promise<string>((resolve, reject) => {
const req = httpsRequest("https://httpbin.org/status/404", res => {
resolve(res.url);
res.resume();
});
req.on("error", reject);
req.setTimeout(5000, () => reject(new Error("Timeout")));
req.end();
}),
]);
expect(urls[0]).toBe("");
expect(urls[1]).toBe("");
});
test("https.request URL property type should be string", async () => {
const url = await new Promise<any>((resolve, reject) => {
const req = httpsRequest("https://httpbin.org/json", res => {
resolve(res.url);
res.resume();
});
req.on("error", reject);
req.setTimeout(5000, () => reject(new Error("Timeout")));
req.end();
});
expect(typeof url).toBe("string");
expect(url).toBe("");
});
test("https.request response object should have url property", async () => {
const hasUrlProperty = await new Promise<boolean>((resolve, reject) => {
const req = httpsRequest("https://httpbin.org/json", res => {
resolve("url" in res);
res.resume();
});
req.on("error", reject);
req.setTimeout(5000, () => reject(new Error("Timeout")));
req.end();
});
expect(hasUrlProperty).toBe(true);
});