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
1 change: 1 addition & 0 deletions docs/runtime/networking/fetch.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,7 @@ const response = await fetch(url);
Bun's fetch implementation includes several specific error cases:

- Using a request body with GET/HEAD methods throws an error (which is expected for the fetch API)
- A `method` that is not an HTTP token (for example `"GET POST"`) rejects with a `TypeError`, and so does `"TRACK"`. An empty string is the same as no `method`
- Using the `proxy` and `unix` options together throws an error
- Connection failures reject with a `TypeError` whose `code` is the socket error (`ECONNREFUSED`, `ECONNRESET`, `ETIMEDOUT`, `ENOTFOUND`, ...). The message starts with the code.
- TLS certificate validation failures when `rejectUnauthorized` is true (or undefined)
Expand Down
40 changes: 35 additions & 5 deletions src/http/AsyncHTTP.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use bun_picohttp as picohttp;
use crate::headers::{self, Headers};
use crate::{
FetchRedirect, Flags, HTTPClient, HTTPRequestBody, HTTPVerboseLevel, InternalState, Method,
Signals, ThreadlocalAsyncHTTP,
MethodRef, Signals, ThreadlocalAsyncHTTP,
};
use crate::{HTTPClientResult, HTTPClientResultCallback};

Expand All @@ -31,7 +31,7 @@ pub struct AsyncHTTP<'a> {
pub response: Option<picohttp::Response<'static>>,
pub request_headers: headers::EntryList,
pub request_body: HTTPRequestBody<'a>,
pub(crate) method: Method,
pub(crate) method: MethodRef,
pub url: URL<'a>,
// Backref to the JS-thread `real` AsyncHTTP this HTTP-thread copy mirrors.
// Cleared in finalize. Same `'a` — the copy never outlives the original.
Expand Down Expand Up @@ -146,7 +146,7 @@ pub fn basic_authorization(url: &URL<'_>) -> Option<Vec<u8>> {
/// `HTTPClient` has no `Default` (it has a `Drop` impl with side-effects), so
/// this is the single place that enumerates the field set.
fn make_client<'a>(
method: Method,
method: MethodRef,
url: URL<'a>,
header_entries: headers::EntryList,
header_buf: &'a [u8],
Expand Down Expand Up @@ -285,7 +285,7 @@ impl<'a> AsyncHTTP<'a> {
/// The method the request was made with. A redirect only ever rewrites the
/// HTTP thread's copy (`client.method`), and only to GET.
#[inline]
pub fn method(&self) -> Method {
pub fn method(&self) -> MethodRef {
self.method
}

Expand Down Expand Up @@ -404,8 +404,32 @@ pub fn preconnect(url: URL<'static>, is_url_owned: bool) {
// ──────────────────────────────────────────────────────────────────────────

impl<'a> AsyncHTTP<'a> {
/// `method` is a verb, or a [`MethodRef`] whose token is in `headers_buf`.
#[inline]
pub fn init(
method: Method,
method: impl Into<MethodRef>,
url: URL<'a>,
headers: headers::EntryList,
headers_buf: &'a [u8],
request_body: &'a [u8],
callback: HTTPClientResultCallback,
redirect_type: FetchRedirect,
options: Options<'a>,
) -> AsyncHTTP<'a> {
Self::init_with_method(
method.into(),
url,
headers,
headers_buf,
request_body,
callback,
redirect_type,
options,
)
}

fn init_with_method(
method: MethodRef,
url: URL<'a>,
headers: headers::EntryList,
headers_buf: &'a [u8],
Expand All @@ -414,6 +438,12 @@ impl<'a> AsyncHTTP<'a> {
redirect_type: FetchRedirect,
options: Options<'a>,
) -> AsyncHTTP<'a> {
// The HTTP thread writes a token from `headers_buf` to the request line with no check.
assert!(
method.is_in(headers_buf),
"the request method is not a token in the request header buffer"
);

let async_http_id = if options
.signals
.as_ref()
Expand Down
4 changes: 4 additions & 0 deletions src/http/h3_client/encode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ pub(crate) fn write_request(
);

let request = client.build_request(body_len);
// lsxpack holds a field length in 16 bits: a longer method would go out as its first bytes.
if request.method.len() > usize::from(u16::MAX) {
return Err(crate::Error::HTTP3HeaderEncodingError);
}
if verbose != HTTPVerboseLevel::None {
let body = req_body.slice();
crate::print_request(
Expand Down
12 changes: 7 additions & 5 deletions src/http/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ pub type HttpClient<'a> = HTTPClient<'a>;
pub type AsyncHttp<'a> = AsyncHTTP<'a>;
pub type ThreadlocalAsyncHttp<'a> = ThreadlocalAsyncHTTP<'a>;
pub use bun_http_types::FetchRedirect::FetchRedirect;
pub use bun_http_types::Method::Method;
pub use bun_http_types::Method::{Method, MethodRef};
pub use bun_picohttp as picohttp;

#[repr(u8)]
Expand Down Expand Up @@ -750,7 +750,7 @@ impl ProxySettings {
// Intrusive raw-pointer backrefs (socket ext, h2/h3 streams) store the
// lifetime-erased `HTTPClient<'static>` form via [`HTTPClient::as_erased_ptr`].
pub struct HTTPClient<'a> {
pub(crate) method: Method,
pub(crate) method: MethodRef,
pub header_entries: headers::EntryList,
pub(crate) header_buf: &'a [u8],
pub(crate) url: URL<'a>,
Expand Down Expand Up @@ -2566,7 +2566,8 @@ impl<'a> HTTPClient<'a> {
header_count += 1;
}

if body_len > 0 || self.method.has_request_body() {
// A table verb that takes a body declares a length of 0. A token with no body: none.
if body_len > 0 || self.method.known().is_some_and(Method::has_request_body) {
if self.flags.is_streaming_request_body {
// `StreamFraming`, decided by the producer. An upgrade tunnels the bytes unframed.
let framing = match &self.state.original_request_body {
Expand Down Expand Up @@ -2617,7 +2618,8 @@ impl<'a> HTTPClient<'a> {
// the per-HTTP-thread `SHARED_REQUEST_HEADERS_BUF` static. Return as
// `'static` so callers don't pin `&mut self` for the rest of their fn.
picohttp::Request {
method: self.method.as_str().as_bytes(),
// SAFETY: a token borrows `self.header_buf`, which outlives the returned `Request`.
method: unsafe { bun_ptr::detach_lifetime(self.method.bytes(self.header_buf)) },
// SAFETY: `url.pathname` borrows `self.url`, which outlives the returned `Request`.
path: unsafe { bun_ptr::detach_lifetime(self.url.pathname) },
minor_version: 1,
Expand Down Expand Up @@ -5269,7 +5271,7 @@ impl<'a> HTTPClient<'a> {
&& self.method != Method::HEAD)
{
// - Set request's method to `GET` and request's body to null.
self.method = Method::GET;
self.method = Method::GET.into();

// https://github.com/oven-sh/bun/issues/6053
if self.header_entries.len() > 0 {
Expand Down
68 changes: 62 additions & 6 deletions src/http_jsc/method_jsc.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
//! JSC bridge for `bun_http_types::Method`. Keeps `bun_http_types` free of JSC types.

use bun_core::String as BunString;
use bun_http_types::Method::Method;
use bun_jsc::{JSGlobalObject, JSValue, JsResult, StringJsc as _};
use bun_http_types::Method::{Classified, Method, OwnedMethod};
use bun_jsc::{BuiltinName, ErrorCode, JSGlobalObject, JSValue, JsResult, StringJsc as _};

unsafe extern "C" {
// SAFETY (safe fn): `Method` is a `#[repr(uN)]` scalar; `JSGlobalObject` is an
Expand All @@ -17,12 +17,68 @@ unsafe extern "C" {
///
/// Lives here (not in `bun_http_types`) so the base crate stays JSC-free.
pub fn from_js(global_this: &JSGlobalObject, input: JSValue) -> JsResult<Option<Method>> {
let str = BunString::from_js(input, global_this)?;
let mut miss = BunString::EMPTY;
which(global_this, input, &mut miss)
}

/// [`from_js`] that leaves the string of a miss in `miss`, for the caller to read further.
#[inline(never)] // one copy of the table lookup in the binary for every caller
fn which(
global: &JSGlobalObject,
input: JSValue,
miss: &mut BunString,
) -> JsResult<Option<Method>> {
let str = BunString::from_js(input, global)?;
debug_assert!(str.tag() != bun_core::Tag::Dead);
// `Method::which` is keyed on `&[u8]`, so materialize UTF-8 and call it
// directly.
let hit = Method::which(str.to_utf8().slice());
if hit.is_none() {
*miss = str;
}
Ok(hit)
}

/// Not Fetch, on purpose: "patch" is PATCH, exact CONNECT and TRACE pass, `null` and "" are absent.
#[inline]
fn request_method_from_js(global: &JSGlobalObject, input: JSValue) -> JsResult<OwnedMethod> {
let mut miss = BunString::EMPTY;
match which(global, input, &mut miss)? {
Some(method) => Ok(method.into()),
None => request_method_miss(global, &miss),
}
}

#[cold]
fn request_method_miss(global: &JSGlobalObject, str: &BunString) -> JsResult<OwnedMethod> {
let utf8 = str.to_utf8();
Ok(Method::which(utf8.slice()))
let quoted = bun_core::fmt::quote(utf8.slice());
match Method::classify_miss(utf8.slice()) {
Classified::Known(method) => Ok(method.into()),
Classified::Token => Ok(OwnedMethod::Token(Box::from(utf8.slice()))),
Classified::Forbidden => Err(global
.err(
ErrorCode::INVALID_ARG_VALUE,
format_args!("{quoted} HTTP method is unsupported."),
)
.throw()),
Classified::Invalid => Err(global
.err(
ErrorCode::INVALID_ARG_VALUE,
format_args!("{quoted} is not a valid HTTP method."),
)
.throw()),
}
}

/// The method of a request from `init.method`, read once. `None`: the member is absent.
#[inline]
pub fn request_method_from_init(
global: &JSGlobalObject,
init: JSValue,
) -> JsResult<Option<OwnedMethod>> {
match init.fast_get_truthy(global, BuiltinName::method)? {
Some(value) => request_method_from_js(global, value).map(Some),
None => Ok(None),
}
}

/// Extension trait providing `.to_js()` on `Method` (lives in the `*_jsc` crate so the
Expand Down
Loading
Loading