Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
30 changes: 21 additions & 9 deletions packages/bun-types/fetch.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,29 +34,29 @@ declare module "bun" {

interface BunHeadersOverride extends LibOrFallbackHeaders {
/**
* Converts {@link Headers} to a plain JavaScript object.
* Convert {@link Headers} to a plain JavaScript object.
*
* About 10x faster than `Object.fromEntries(headers.entries())`.
* About 10x faster than `Object.fromEntries(headers.entries())`
*
* Called when you run `JSON.stringify(headers)`.
* Called when you run `JSON.stringify(headers)`
*
* Does not preserve insertion order. Well-known header names are lowercased; other header names are left as-is.
* Does not preserve insertion order. Well-known header names are lowercased. Other header names are left as-is.
*/
toJSON(): Record<string, string> & { "set-cookie"?: string[] };

/**
* The number of headers.
* Get the total number of headers
*/
readonly count: number;

/**
* Gets all values for the given header name.
* Get all headers matching the name
*
* Only `"Set-Cookie"` is supported. Any other header name returns an empty array.
* Only supports `"Set-Cookie"`. All other headers are empty arrays.
*
* @param name The header name
* @param name - The header name to get
*
* @returns The header's values
* @returns An array of header values
*
* @example
* ```ts
Expand All @@ -67,6 +67,18 @@ declare module "bun" {
* ```
*/
getAll(name: "set-cookie" | "Set-Cookie"): string[];

/**
* Remove all headers.
*
* @example
* ```ts
* const headers = new Headers({ "Content-Type": "text/plain" });
* headers.clear();
* headers.has("Content-Type"); // false
* ```
*/
clear(): void;
}

interface BunRequestOverride extends LibOrFallbackRequest {
Expand Down
74 changes: 25 additions & 49 deletions src/jsc/bindings/webcore/FetchHeaders.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -43,35 +43,11 @@ static void removePrivilegedNoCORSRequestHeaders(HTTPHeaderMap& headers)
headers.remove(HTTPHeaderName::Range);
}

// String::trim takes a function pointer and dispatches through
// StringImpl::trimMatchedCharacters, which is an indirect call per probed
// character. Header values are almost always already free of leading/trailing
// HTTP whitespace, so do a cheap inline check on the first/last code unit and
// only fall back to the real trim when something actually needs stripping.
static inline String trimHTTPSpaceIfNeeded(const String& value)
{
if (value.isEmpty() || (!isHTTPSpace(value[0]) && !isHTTPSpace(value[value.length() - 1])))
return value;
return value.trim(isHTTPSpace);
}

// Like trimHTTPSpaceIfNeeded, but avoids the ref-count round-trip on the
// returned String in the common no-trim case by aliasing the input. When a trim
// is actually needed, the trimmed result is parked in `storage` (which must
// outlive the returned reference) and a reference to it is returned.
static inline const String& trimHTTPSpaceIfNeeded(const String& value, String& storage)
{
if (value.isEmpty() || (!isHTTPSpace(value[0]) && !isHTTPSpace(value[value.length() - 1])))
return value;
storage = value.trim(isHTTPSpace);
return storage;
}

static ExceptionOr<bool> canWriteHeader(const HTTPHeaderName name, const String& value, const String& combinedValue, FetchHeaders::Guard guard)
{
ASSERT(value.isEmpty() || (!isHTTPSpace(value[0]) && !isHTTPSpace(value[value.length() - 1])));
if (!isValidHTTPHeaderValue((value)))
return Exception { TypeError, makeString("Header '"_s, httpHeaderNameString(name), "' has invalid value: '"_s, value, "'"_s) };
return Exception { TypeError, makeString("Header '"_s, name, "' has invalid value: '"_s, value, "'"_s) };
if (guard == FetchHeaders::Guard::Immutable)
return Exception { TypeError, "Headers object's guard is 'immutable'"_s };
return true;
Expand All @@ -91,14 +67,8 @@ static ExceptionOr<bool> canWriteHeader(const String& name, const String& value,

static ExceptionOr<void> appendToHeaderMap(const String& name, const String& value, HTTPHeaderMap& headers, FetchHeaders::Guard guard)
{
// The common path here is a brand-new header with no leading/trailing HTTP
// whitespace. Avoid taking ownership (and the atomic ref-count round-trip
// that comes with it) of the value String unless we actually have to trim
// or merge with an existing header.
String trimStorage;
const String& normalizedValue = trimHTTPSpaceIfNeeded(value, trimStorage);
String combinedTemp;
const String* valueToSet = &normalizedValue;
String normalizedValue = value.trim(isHTTPSpace);
String combinedValue = normalizedValue;
HTTPHeaderName headerName;
if (findHTTPHeaderName(name, headerName)) {
auto index = headers.indexOf(headerName);
Expand All @@ -107,24 +77,23 @@ static ExceptionOr<void> appendToHeaderMap(const String& name, const String& val
if (index.isValid()) {
auto existing = headers.getIndex(index);
if (headerName == HTTPHeaderName::Cookie) {
combinedTemp = makeString(existing, "; "_s, normalizedValue);
combinedValue = makeString(existing, "; "_s, normalizedValue);
} else {
combinedTemp = makeString(existing, ", "_s, normalizedValue);
combinedValue = makeString(existing, ", "_s, normalizedValue);
}
valueToSet = &combinedTemp;
}
}

auto canWriteResult = canWriteHeader(headerName, normalizedValue, *valueToSet, guard);
auto canWriteResult = canWriteHeader(headerName, normalizedValue, combinedValue, guard);

if (canWriteResult.hasException())
return canWriteResult.releaseException();
if (!canWriteResult.releaseReturnValue())
return {};

if (headerName != HTTPHeaderName::SetCookie) {
if (!headers.setIndex(index, *valueToSet))
headers.set(headerName, *valueToSet);
if (!headers.setIndex(index, combinedValue))
headers.set(headerName, combinedValue);
} else {
headers.add(headerName, normalizedValue);
}
Expand All @@ -133,17 +102,16 @@ static ExceptionOr<void> appendToHeaderMap(const String& name, const String& val
}
auto index = headers.indexOf(name);
if (index.isValid()) {
combinedTemp = makeString(headers.getIndex(index), ", "_s, normalizedValue);
valueToSet = &combinedTemp;
combinedValue = makeString(headers.getIndex(index), ", "_s, normalizedValue);
}
auto canWriteResult = canWriteHeader(name, normalizedValue, *valueToSet, guard);
auto canWriteResult = canWriteHeader(name, normalizedValue, combinedValue, guard);
if (canWriteResult.hasException())
return canWriteResult.releaseException();
if (!canWriteResult.releaseReturnValue())
return {};

if (!headers.setIndex(index, *valueToSet))
headers.set(name, *valueToSet);
if (!headers.setIndex(index, combinedValue))
headers.set(name, combinedValue);

// if (guard == FetchHeaders::Guard::RequestNoCors)
// removePrivilegedNoCORSRequestHeaders(headers);
Expand All @@ -153,8 +121,7 @@ static ExceptionOr<void> appendToHeaderMap(const String& name, const String& val

static ExceptionOr<void> appendToHeaderMap(const HTTPHeaderMap::HTTPHeaderMapConstIterator::KeyValue& header, HTTPHeaderMap& headers, FetchHeaders::Guard guard)
{
String trimStorage;
const String& normalizedValue = trimHTTPSpaceIfNeeded(header.value, trimStorage);
String normalizedValue = header.value.trim(isHTTPSpace);
auto canWriteResult = canWriteHeader(header.key, normalizedValue, header.value, guard);
if (canWriteResult.hasException())
return canWriteResult.releaseException();
Expand Down Expand Up @@ -258,6 +225,15 @@ ExceptionOr<void> FetchHeaders::remove(const StringView name)
return {};
}

ExceptionOr<void> FetchHeaders::clear()
{
if (m_guard == FetchHeaders::Guard::Immutable)
return Exception { TypeError, "Headers object's guard is 'immutable'"_s };
++m_updateCounter;
m_headers.clear();
return { };
}

size_t FetchHeaders::memoryCost() const
{
return m_headers.memoryCost() + sizeof(*this);
Expand Down Expand Up @@ -286,7 +262,7 @@ ExceptionOr<bool> FetchHeaders::has(const StringView name) const

ExceptionOr<void> FetchHeaders::set(const HTTPHeaderName name, const String& value)
{
String normalizedValue = trimHTTPSpaceIfNeeded(value);
String normalizedValue = value.trim(isHTTPSpace);
auto canWriteResult = canWriteHeader(name, normalizedValue, normalizedValue, m_guard);
if (canWriteResult.hasException())
return canWriteResult.releaseException();
Expand All @@ -304,7 +280,7 @@ ExceptionOr<void> FetchHeaders::set(const HTTPHeaderName name, const String& val

ExceptionOr<void> FetchHeaders::set(const String& name, const String& value)
{
String normalizedValue = trimHTTPSpaceIfNeeded(value);
String normalizedValue = value.trim(isHTTPSpace);
auto canWriteResult = canWriteHeader(name, normalizedValue, normalizedValue, m_guard);
if (canWriteResult.hasException())
return canWriteResult.releaseException();
Expand All @@ -323,7 +299,7 @@ ExceptionOr<void> FetchHeaders::set(const String& name, const String& value)
void FetchHeaders::filterAndFill(const HTTPHeaderMap& headers, Guard guard)
{
for (auto& header : headers) {
String normalizedValue = trimHTTPSpaceIfNeeded(header.value);
String normalizedValue = header.value.trim(isHTTPSpace);
auto canWriteResult = canWriteHeader(header.key, normalizedValue, header.value, guard);
if (canWriteResult.hasException())
continue;
Expand Down
1 change: 1 addition & 0 deletions src/jsc/bindings/webcore/FetchHeaders.h
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ class FetchHeaders : public RefCounted<FetchHeaders> {

ExceptionOr<void> append(const String& name, const String& value);
ExceptionOr<void> remove(const StringView);
ExceptionOr<void> clear();
ExceptionOr<String> get(const StringView) const;
ExceptionOr<bool> has(const StringView) const;
ExceptionOr<void> set(const String& name, const String& value);
Expand Down
1 change: 1 addition & 0 deletions src/jsc/bindings/webcore/FetchHeaders.idl
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ typedef (sequence<sequence<ByteString>> or record<ByteString, ByteString>) Heade
ByteString? get(ByteString name);
boolean has(ByteString name);
undefined set(ByteString name, ByteString value);
undefined clear();

iterable<ByteString, ByteString>;
};
23 changes: 12 additions & 11 deletions src/jsc/bindings/webcore/HTTPHeaderMap.h
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,6 @@ namespace WebCore {

// FIXME: Not every header fits into a map. Notably, multiple Set-Cookie header fields are needed to set multiple cookies.

// ASCII-lowercase a header name. Equivalent to String::convertToASCIILowercase
// but routes both the 8-bit and 16-bit paths through Highway SIMD kernels so
// the scan and copy don't depend on the build's -march. Returns the original
// String (no allocation) when it is already lowercase, matching the WTF
// behavior.
String lowercaseHeaderName(const String &);

class HTTPHeaderMap {
public:
struct CommonHeader {
Expand Down Expand Up @@ -74,7 +67,7 @@ class HTTPHeaderMap {
bool operator==(const UncommonHeader &other) const { return key == other.key && value == other.value; }
};

typedef Vector<CommonHeader, 2, CrashOnOverflow, 6> CommonHeadersVector;
typedef Vector<CommonHeader, 0, CrashOnOverflow, 6> CommonHeadersVector;
typedef Vector<UncommonHeader, 0, CrashOnOverflow, 0> UncommonHeadersVector;

class HTTPHeaderMapConstIterator {
Expand Down Expand Up @@ -108,7 +101,7 @@ class HTTPHeaderMap {
return WTF::httpHeaderNameStringImpl(keyAsHTTPHeaderName.value());
}

return lowercaseHeaderName(key);
return key.convertToASCIILowercase();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Keep regression coverage for the changed uncommon-header lowercase path.

  • src/jsc/bindings/webcore/HTTPHeaderMap.h#L104-L104: retain validation that convertToASCIILowercase() normalizes uncommon names during iteration.
  • test/js/web/fetch/headers.test.ts#L549-L550: replace the removed implementation-specific tests with public entries(), keys(), and iterator assertions using an uppercase uncommon header.

As per coding guidelines, behavioral changes require automated coverage and existing safety nets must remain protected.

📍 Affects 2 files
  • src/jsc/bindings/webcore/HTTPHeaderMap.h#L104-L104 (this comment)
  • test/js/web/fetch/headers.test.ts#L549-L550
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jsc/bindings/webcore/HTTPHeaderMap.h` at line 104, Retain the
convertToASCIILowercase() normalization in HTTPHeaderMap.h at lines 104-104 and
preserve coverage for uncommon header names during iteration. In
test/js/web/fetch/headers.test.ts at lines 549-550, replace the removed
implementation-specific tests with public Headers entries(), keys(), and
iterator assertions using an uppercase uncommon header.

Source: Coding guidelines

}
};

Expand Down Expand Up @@ -181,6 +174,7 @@ class HTTPHeaderMap {
{
m_commonHeaders.clear();
m_uncommonHeaders.clear();
m_setCookieHeaders.clear();
}

void shrinkToFit()
Expand Down Expand Up @@ -270,8 +264,9 @@ class HTTPHeaderMap {
template<class Encoder> void encode(Encoder &) const;
template<class Decoder> [[nodiscard]] static bool decode(Decoder &, HTTPHeaderMap &);
void setUncommonHeader(const String &name, const String &value);
void addUncommonHeader(const String &name, const String &value);
void addUncommonHeaderCloneName(const StringView name, const String &value);
void setUncommonHeaderCloneName(const StringView name, const String &value);
void addUncommonHeader(const String& name, const String& value);
void addUncommonHeaderCloneName(const StringView name, const String& value);

private:
WEBCORE_EXPORT String getUncommonHeader(const StringView name) const;
Expand Down Expand Up @@ -326,6 +321,7 @@ void HTTPHeaderMap::encode(Encoder &encoder) const
{
encoder << m_commonHeaders;
encoder << m_uncommonHeaders;
encoder << m_setCookieHeaders;
}

template<class Decoder>
Expand All @@ -337,7 +333,12 @@ bool HTTPHeaderMap::decode(Decoder &decoder, HTTPHeaderMap &headerMap)
if (!decoder.decode(headerMap.m_uncommonHeaders))
return false;

if (!decoder.decode(headerMap.m_setCookieHeaders))
return false;

return true;
}

String lowercaseHeaderName(const String& name);

} // namespace WebCore
33 changes: 21 additions & 12 deletions src/jsc/bindings/webcore/JSFetchHeaders.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ static JSC_DECLARE_HOST_FUNCTION(jsFetchHeadersPrototypeFunction_delete);
static JSC_DECLARE_HOST_FUNCTION(jsFetchHeadersPrototypeFunction_get);
static JSC_DECLARE_HOST_FUNCTION(jsFetchHeadersPrototypeFunction_has);
static JSC_DECLARE_HOST_FUNCTION(jsFetchHeadersPrototypeFunction_set);
static JSC_DECLARE_HOST_FUNCTION(jsFetchHeadersPrototypeFunction_clear);
static JSC_DECLARE_HOST_FUNCTION(jsFetchHeadersPrototypeFunction_entries);
static JSC_DECLARE_HOST_FUNCTION(jsFetchHeadersPrototypeFunction_keys);
static JSC_DECLARE_HOST_FUNCTION(jsFetchHeadersPrototypeFunction_values);
Expand Down Expand Up @@ -309,6 +310,7 @@ static const HashTableValue JSFetchHeadersPrototypeTableValues[] = {
{ "getAll"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsFetchHeadersPrototypeFunction_getAll, 1 } },
{ "has"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsFetchHeadersPrototypeFunction_has, 1 } },
{ "set"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsFetchHeadersPrototypeFunction_set, 2 } },
{ "clear"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsFetchHeadersPrototypeFunction_clear, 0 } },
{ "entries"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsFetchHeadersPrototypeFunction_entries, 0 } },
{ "keys"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsFetchHeadersPrototypeFunction_keys, 0 } },
{ "values"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsFetchHeadersPrototypeFunction_values, 0 } },
Expand Down Expand Up @@ -444,6 +446,21 @@ JSC_DEFINE_HOST_FUNCTION(jsFetchHeadersPrototypeFunction_delete, (JSGlobalObject
return IDLOperation<JSFetchHeaders>::call<jsFetchHeadersPrototypeFunction_deleteBody>(*lexicalGlobalObject, *callFrame, "delete");
}

static inline JSC::EncodedJSValue jsFetchHeadersPrototypeFunction_clearBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation<JSFetchHeaders>::ClassParameter castedThis)
{
auto& vm = JSC::getVM(lexicalGlobalObject);
auto throwScope = DECLARE_THROW_SCOPE(vm);
UNUSED_PARAM(throwScope);
UNUSED_PARAM(callFrame);
auto& impl = castedThis->wrapped();
RELEASE_AND_RETURN(throwScope, JSValue::encode(toJS<IDLUndefined>(*lexicalGlobalObject, throwScope, [&]() -> decltype(auto) { return impl.clear(); })));
}

JSC_DEFINE_HOST_FUNCTION(jsFetchHeadersPrototypeFunction_clear, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame))
{
return IDLOperation<JSFetchHeaders>::call<jsFetchHeadersPrototypeFunction_clearBody>(*lexicalGlobalObject, *callFrame, "clear");
}

static inline JSC::EncodedJSValue jsFetchHeadersPrototypeFunction_getBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation<JSFetchHeaders>::ClassParameter castedThis)
{
auto& vm = JSC::getVM(lexicalGlobalObject);
Expand Down Expand Up @@ -597,19 +614,11 @@ JSC_DEFINE_HOST_FUNCTION(jsFetchHeaders_getRawKeys, (JSC::JSGlobalObject * lexic
}

FetchHeaders& headers = thisObject->wrapped();
// HTTPHeaderMap's iterator covers only the common and uncommon segments;
// set-cookie values live in their own segment, so size() (which counts
// every cookie) used to leave trailing holes in the array. Size for one
// entry per unique name and append "set-cookie" explicitly.
JSArray* outArray = JSC::JSArray::create(vm, lexicalGlobalObject->arrayStructureForIndexingTypeDuringAllocation(JSC::ArrayWithContiguous), headers.sizeAfterJoiningSetCookieHeader());

unsigned int i = 0;
for (const auto& header : headers.internalHeaders()) {
JSArray* outArray = JSC::JSArray::create(vm, lexicalGlobalObject->arrayStructureForIndexingTypeDuringAllocation(JSC::ArrayWithContiguous), headers.size());

for (unsigned int i = 0; const auto& header : headers.internalHeaders()) {
outArray->putDirectIndex(lexicalGlobalObject, i++, jsString(vm, header.name()));
}
Comment on lines +617 to 621

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
ast-grep outline src/jsc/bindings/webcore/JSFetchHeaders.cpp --match 'jsFetchHeaders_getRawKeys' --view expanded
rg -n -C3 'putDirectIndex\(' src/jsc/bindings/webcore/JSFetchHeaders.cpp

Repository: oven-sh/bun

Length of output: 1390


🏁 Script executed:

sed -n '600,630p' src/jsc/bindings/webcore/JSFetchHeaders.cpp

Repository: oven-sh/bun

Length of output: 1434


Propagate exceptions after each header insert. putDirectIndex can leave a pending exception; add RETURN_IF_EXCEPTION(scope, {}); inside the loop so this path matches the surrounding array-construction code.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jsc/bindings/webcore/JSFetchHeaders.cpp` around lines 617 - 621, Update
the header-construction loop in the JSArray creation path to call
RETURN_IF_EXCEPTION(scope, {}) immediately after each outArray->putDirectIndex
insertion. Preserve the existing iteration and header-name conversion while
propagating any pending exception before continuing.

Source: Coding guidelines

if (!headers.internalHeaders().getSetCookieHeaders().isEmpty()) {
outArray->putDirectIndex(lexicalGlobalObject, i++, jsString(vm, WTF::httpHeaderNameDefaultCaseStringImpl(HTTPHeaderName::SetCookie)));
}

RELEASE_AND_RETURN(scope, JSValue::encode(outArray));
}
Expand Down Expand Up @@ -704,7 +713,7 @@ JSC::JSValue getInternalProperties(JSC::VM& vm, JSGlobalObject* lexicalGlobalObj
for (const auto& it : vec) {
const auto& name = it.key;
const auto& value = it.value;
obj->putDirectMayBeIndex(lexicalGlobalObject, Identifier::fromString(vm, lowercaseHeaderName(name)), jsString(vm, value));
obj->putDirectMayBeIndex(lexicalGlobalObject, Identifier::fromString(vm, name.convertToASCIILowercase()), jsString(vm, value));
}
}

Expand Down
Loading