From f038c68f7a4138b2c0e7cf487c66d75868cc1c53 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:43:29 +0000 Subject: [PATCH] fetch, Request, server.fetch: stop sending GET for a method outside the method table A method string that was not an exact all-upper or all-lower entry of the method table became GET with no error, in fetch(), new Request() and server.fetch(). One reader now takes the method of a request from JS (method_jsc::request_method_from_init). A table hit is that verb, as before. After a miss, DELETE, GET, HEAD, OPTIONS, POST and PUT match in any case, every other RFC 9110 token is kept as written, and a string that is not a token throws a TypeError. So do TRACK, and CONNECT and TRACE in mixed case. Request.method is an OwnedMethod: a verb, or the bytes of a token. The HTTP client holds a Copy MethodRef whose token is a StringPointer into the request header buffer, and build_request writes it to the request line for HTTP/1.1, a proxy, HTTP/2 and HTTP/3. Response no longer stores a method. Request read its method from response::Init, which can hold a verb only. The HTTP/3 client rejects a method over 65,535 bytes. QPACK takes a 16-bit length, and a longer method would go out as its first bytes. Kept as before: all-lower table verbs are upper-cased, CONNECT and TRACE in the spellings of the table are sent, and "" and null name no method. --- docs/runtime/networking/fetch.mdx | 1 + src/http/AsyncHTTP.rs | 40 +- src/http/h3_client/encode.rs | 4 + src/http/lib.rs | 12 +- src/http_jsc/method_jsc.rs | 68 ++- src/http_types/Method.rs | 349 ++++++++++++++ src/runtime/api/html_rewriter.rs | 1 - src/runtime/server/server_body.rs | 15 +- src/runtime/webcore/Request.rs | 53 ++- src/runtime/webcore/Response.rs | 20 +- src/runtime/webcore/fetch.rs | 82 ++-- src/runtime/webcore/fetch/FetchTasklet.rs | 5 +- .../http/bun-serve-fetch-invalid-args.test.ts | 54 +++ test/js/bun/http/proxy-stress-matrix.test.ts | 14 +- test/js/web/fetch/fetch-args.test.ts | 435 +++++++++++++++++- test/js/web/fetch/fetch-http3-client.test.ts | 66 +++ test/js/web/fetch/fetch-redirect.test.ts | 66 +++ test/js/web/request/request.test.ts | 149 ++++++ 18 files changed, 1336 insertions(+), 98 deletions(-) diff --git a/docs/runtime/networking/fetch.mdx b/docs/runtime/networking/fetch.mdx index 280c77533d9d..a019a9adcbd9 100644 --- a/docs/runtime/networking/fetch.mdx +++ b/docs/runtime/networking/fetch.mdx @@ -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) diff --git a/src/http/AsyncHTTP.rs b/src/http/AsyncHTTP.rs index 31776836f9c5..1750e9d16328 100644 --- a/src/http/AsyncHTTP.rs +++ b/src/http/AsyncHTTP.rs @@ -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}; @@ -31,7 +31,7 @@ pub struct AsyncHTTP<'a> { pub response: Option>, 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. @@ -146,7 +146,7 @@ pub fn basic_authorization(url: &URL<'_>) -> Option> { /// `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], @@ -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 } @@ -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, + 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], @@ -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() diff --git a/src/http/h3_client/encode.rs b/src/http/h3_client/encode.rs index 3bad80ab23d4..3f6a70f45990 100644 --- a/src/http/h3_client/encode.rs +++ b/src/http/h3_client/encode.rs @@ -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( diff --git a/src/http/lib.rs b/src/http/lib.rs index 9c2525502805..c3c7349d6890 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -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)] @@ -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>, @@ -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 { @@ -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, @@ -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 { diff --git a/src/http_jsc/method_jsc.rs b/src/http_jsc/method_jsc.rs index f4ca5ac88823..c3dd85a9ea70 100644 --- a/src/http_jsc/method_jsc.rs +++ b/src/http_jsc/method_jsc.rs @@ -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 @@ -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> { - 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> { + 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 { + 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 { 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> { + 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 diff --git a/src/http_types/Method.rs b/src/http_types/Method.rs index 46f43db7c880..6b10feb6dc89 100644 --- a/src/http_types/Method.rs +++ b/src/http_types/Method.rs @@ -1,5 +1,8 @@ +use bun_core::StringPointer; use enumset::EnumSet; +use crate::ETag::Headers; + #[allow(non_camel_case_types)] #[repr(u8)] #[derive(enumset::EnumSetType, Debug)] @@ -224,6 +227,352 @@ impl Method { } } +/// RFC 9110 §5.6.2 `token`: one or more `tchar`. +pub fn is_token(bytes: &[u8]) -> bool { + !bytes.is_empty() + && bytes.iter().all(|&c| { + c.is_ascii_alphanumeric() + || matches!( + c, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) + }) +} + +/// What [`Method::classify_miss`] makes of a method string. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub enum Classified { + /// DELETE, GET, HEAD, OPTIONS, POST or PUT in mixed case. + Known(Method), + /// Any other token. It goes to the wire as written. + Token, + /// CONNECT, TRACE or TRACK. Fetch forbids these three in any case. + Forbidden, + /// Not a token, so not a method. + Invalid, +} + +impl Method { + /// How `fetch` and `Request` read a method string that [`Method::which`] does not have. + #[cold] + pub fn classify_miss(bytes: &[u8]) -> Classified { + const NORMALIZED: [Method; 6] = [ + Method::DELETE, + Method::GET, + Method::HEAD, + Method::OPTIONS, + Method::POST, + Method::PUT, + ]; + for method in NORMALIZED { + if bytes.eq_ignore_ascii_case(method.as_str().as_bytes()) { + return Classified::Known(method); + } + } + // https://fetch.spec.whatwg.org/#forbidden-method. Exact CONNECT and TRACE are table hits. + for forbidden in [&b"CONNECT"[..], b"TRACE", b"TRACK"] { + if bytes.eq_ignore_ascii_case(forbidden) { + return Classified::Forbidden; + } + } + if is_token(bytes) { + Classified::Token + } else { + Classified::Invalid + } + } +} + +/// A request method as JS names it: a verb of the table, or any other token as written. +#[derive(Clone)] +pub enum OwnedMethod { + Known(Method), + /// [`Method::classify_miss`] answered [`Classified::Token`] for these bytes. + Token(Box<[u8]>), +} + +impl From for OwnedMethod { + #[inline] + fn from(method: Method) -> Self { + OwnedMethod::Known(method) + } +} + +impl OwnedMethod { + #[inline] + pub fn known(&self) -> Option { + match self { + OwnedMethod::Known(method) => Some(*method), + OwnedMethod::Token(_) => None, + } + } + + /// The method as it goes to the wire. + pub fn as_bytes(&self) -> &[u8] { + match self { + OwnedMethod::Known(method) => method.as_str().as_bytes(), + OwnedMethod::Token(token) => token, + } + } + + /// For the HTTP client. A token is appended to `headers.buf`, which the client reads it from. + #[inline] + pub fn to_ref(&self, headers: &mut Headers) -> MethodRef { + match self { + OwnedMethod::Known(method) => MethodRef::Known(*method), + OwnedMethod::Token(token) => MethodRef::Token(store_token(token, headers)), + } + } +} + +#[cold] +fn store_token(token: &[u8], headers: &mut Headers) -> StringPointer { + // `StringPointer`s index `buf`, so its length fits one, as in `Headers::append`. + let offset = u32::try_from(headers.buf.len()).unwrap(); + headers.buf.extend_from_slice(token); + StringPointer { + offset, + length: u32::try_from(token.len()).unwrap(), + } +} + +impl core::fmt::Display for OwnedMethod { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + // A verb and a token are ASCII. + f.write_str(core::str::from_utf8(self.as_bytes()).map_err(|_| core::fmt::Error)?) + } +} + +/// The method the HTTP client holds: a verb, or a token in the request's header buffer. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub enum MethodRef { + Known(Method), + Token(StringPointer), +} + +impl From for MethodRef { + #[inline] + fn from(method: Method) -> Self { + MethodRef::Known(method) + } +} + +/// A token is never equal to a verb: the table lookup comes first. +impl PartialEq for MethodRef { + #[inline] + fn eq(&self, other: &Method) -> bool { + matches!(*self, MethodRef::Known(method) if method == *other) + } +} + +impl MethodRef { + #[inline] + pub fn known(self) -> Option { + match self { + MethodRef::Known(method) => Some(method), + MethodRef::Token(_) => None, + } + } + + /// The method as it goes to the wire. `header_buf` is the buffer that holds a token. + #[inline] + pub fn bytes(self, header_buf: &[u8]) -> &[u8] { + match self { + MethodRef::Known(method) => method.as_str().as_bytes(), + MethodRef::Token(ptr) => &header_buf[ptr.offset as usize..][..ptr.length as usize], + } + } + + /// Whether `header_buf` holds this method. A verb needs no buffer. + #[inline] + pub fn is_in(self, header_buf: &[u8]) -> bool { + match self { + MethodRef::Known(_) => true, + MethodRef::Token(ptr) => header_buf + .get(ptr.offset as usize..) + .and_then(|rest| rest.get(..ptr.length as usize)) + .is_some_and(is_token), + } + } + + /// Whether the response has a body. Only table verbs can say that it has none. + #[inline] + pub fn has_body(self) -> bool { + self.known().is_none_or(Method::has_body) + } + + /// Whether a request of this method may carry a body. A token may. + #[inline] + pub fn has_request_body(self) -> bool { + self.known().is_none_or(Method::has_request_body) + } + + /// Whether a request that failed on a reused connection is sent again. A token is not. + #[inline] + pub fn is_idempotent(self) -> bool { + self.known().is_some_and(Method::is_idempotent) + } +} + +#[cfg(test)] +mod request_method_tests { + use super::*; + + #[test] + fn is_token_is_the_rfc_9110_tchar_set() { + for c in 0..=u8::MAX { + let expected = c.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&c); + assert_eq!(is_token(&[c]), expected, "byte {c:#04x}"); + assert_eq!(is_token(&[b'A', c, b'Z']), expected, "byte {c:#04x} inside"); + } + assert!(!is_token(b"")); + } + + /// The table lookup, then the rest: what the JS bridge does. + fn classify(bytes: &[u8]) -> Classified { + match Method::which(bytes) { + Some(method) => Classified::Known(method), + None => Method::classify_miss(bytes), + } + } + + #[test] + fn classify_reads_a_method_as_fetch_does() { + use Classified::{Forbidden, Invalid, Known, Token}; + for m in enumset::EnumSet::::all() { + let upper = m.as_str(); + assert_eq!(classify(upper.as_bytes()), Known(m), "upper {upper}"); + let lower = upper.to_ascii_lowercase(); + assert_eq!(classify(lower.as_bytes()), Known(m), "lower {lower}"); + } + for (input, expected) in [ + // Any case of the six that Fetch normalizes. + (&b"Delete"[..], Known(Method::DELETE)), + (b"gEt", Known(Method::GET)), + (b"hEaD", Known(Method::HEAD)), + (b"oPtIoNs", Known(Method::OPTIONS)), + (b"pOsT", Known(Method::POST)), + (b"Put", Known(Method::PUT)), + // Every other token is kept as written, a mixed-case table verb too. + (b"PatCh", Token), + (b"Propfind", Token), + (b"BREW", Token), + (b"LIST", Token), + (b"M-search", Token), + (b"GETS", Token), + (b"TRACKS", Token), + (b"0", Token), + // The three that Fetch forbids, in the spellings the table lacks. + (b"Connect", Forbidden), + (b"Trace", Forbidden), + (b"TRACK", Forbidden), + (b"track", Forbidden), + (b"tRaCk", Forbidden), + (b"", Invalid), + (b"GET POST", Invalid), + (b" GET", Invalid), + (b"GET\r\n", Invalid), + (b"G\0T", Invalid), + (b"caf\xc3\xa9", Invalid), + (b"(GET)", Invalid), + ] { + assert_eq!(classify(input), expected, "{:?}", bstr::BStr::new(input)); + } + } + + #[test] + fn owned_method_holds_a_verb_or_a_token() { + // `Request` holds one of these: with it the struct is 112 bytes. + assert_eq!(size_of::(), 16); + for m in enumset::EnumSet::::all() { + let owned = OwnedMethod::from(m); + assert_eq!(owned.known(), Some(m)); + assert_eq!(owned.as_bytes(), m.as_str().as_bytes()); + assert_eq!(owned.clone().known(), Some(m)); + let mut headers = Headers::default(); + assert_eq!(owned.to_ref(&mut headers), MethodRef::Known(m)); + assert!(headers.buf.is_empty()); + } + let owned = OwnedMethod::Token(Box::from(&b"PatCh"[..])); + assert_eq!(owned.known(), None); + assert_eq!(owned.as_bytes(), b"PatCh"); + assert_eq!(owned.to_string(), "PatCh"); + assert_eq!(owned.clone().as_bytes(), b"PatCh"); + } + + #[test] + fn method_ref_token_points_after_the_headers() { + let mut headers = Headers::default(); + headers.append(b"Accept", b"*/*"); + let before = headers.buf.clone(); + + let method = OwnedMethod::Token(Box::from(&b"BREW"[..])).to_ref(&mut headers); + assert_eq!( + method, + MethodRef::Token(StringPointer { + offset: before.len() as u32, + length: 4 + }) + ); + assert_eq!(method.known(), None); + assert_eq!(method.bytes(&headers.buf), b"BREW"); + assert!(method.is_in(&headers.buf)); + // The bytes of the headers do not move. + assert_eq!(&headers.buf[..before.len()], &before[..]); + assert_eq!(headers.get(b"accept"), Some(&b"*/*"[..])); + + // A pointer that is not for this buffer, or that does not name a token. + assert!(!method.is_in(&before)); + let not_a_token = MethodRef::Token(StringPointer { + offset: 0, + length: before.len() as u32, + }); + assert!(!not_a_token.is_in(&headers.buf)); + let overflows = MethodRef::Token(StringPointer { + offset: u32::MAX, + length: u32::MAX, + }); + assert!(!overflows.is_in(&headers.buf)); + + assert_eq!(MethodRef::from(Method::PUT).bytes(b""), b"PUT"); + assert!(MethodRef::from(Method::PUT).is_in(b"")); + } + + #[test] + fn method_ref_treats_a_token_as_an_unknown_method() { + let token = MethodRef::Token(StringPointer { + offset: 0, + length: 4, + }); + // It may carry a body, its response has one, and it is not sent twice. + assert!(token.has_request_body()); + assert!(token.has_body()); + assert!(!token.is_idempotent()); + for m in enumset::EnumSet::::all() { + let known = MethodRef::from(m); + assert_eq!(known.known(), Some(m)); + assert_eq!(known.has_request_body(), m.has_request_body(), "{m:?}"); + assert_eq!(known.has_body(), m.has_body(), "{m:?}"); + assert_eq!(known.is_idempotent(), m.is_idempotent(), "{m:?}"); + assert!(known == m); + assert!(token != m); + } + } +} + #[derive(Copy, Clone, PartialEq, Eq, Debug)] pub enum Optional { Any, diff --git a/src/runtime/api/html_rewriter.rs b/src/runtime/api/html_rewriter.rs index a500f86d5249..8951a34894cc 100644 --- a/src/runtime/api/html_rewriter.rs +++ b/src/runtime/api/html_rewriter.rs @@ -1147,7 +1147,6 @@ impl RewriterPipe { .set(Some(unsafe { RefPtr::init_ref(result.as_ptr()) })); result_ref.set_init( - original.get_method(), original.get_init_status_code(), original.get_init_status_text().clone(), ); diff --git a/src/runtime/server/server_body.rs b/src/runtime/server/server_body.rs index 102a1c6ca94e..7da4eb99e559 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -21,6 +21,8 @@ use bun_collections::HashMap; use bun_core::{EncodedSlice, String as BunString, strings}; use bun_core::{Output, fmt as bun_fmt}; use bun_http::{self as http, Method, MimeType}; +use bun_http_jsc::method_jsc; +use bun_http_types::Method::OwnedMethod; use bun_jsc::Debugger::DebuggerId; use bun_jsc::uuid::UUID; use bun_jsc::{ @@ -2258,7 +2260,7 @@ where } let mut headers: Option = None; - let mut method = Method::GET; + let mut method = OwnedMethod::from(Method::GET); // SAFETY: bun_vm() returns the live per-thread VM singleton. let mut args = jsc::ArgumentsSlice::init(ctx.bun_vm(), arguments); @@ -2298,9 +2300,14 @@ where if arguments.len() >= 2 && arguments[1].is_object() { let opts = arguments[1]; - if let Some(method_) = opts.fast_get(ctx, jsc::BuiltinName::Method)? { - let slice_ = method_.to_utf8(ctx)?; - method = Method::which(slice_.slice()).unwrap_or(method); + match method_jsc::request_method_from_init(ctx, opts) { + Ok(Some(method_)) => method = method_, + Ok(None) => {} + Err(err) => { + return Ok( + JSPromise::rejected_promise_with_caught_exception(ctx, err)?.to_js() + ); + } } if let Some(headers_) = opts.fast_get(ctx, jsc::BuiltinName::Headers)? { diff --git a/src/runtime/webcore/Request.rs b/src/runtime/webcore/Request.rs index ca3ae8efdc8f..22b3c9c376f7 100644 --- a/src/runtime/webcore/Request.rs +++ b/src/runtime/webcore/Request.rs @@ -21,11 +21,11 @@ use bun_core::{String as BunString, strings}; use bun_http_jsc::fetch_enums_jsc::{ fetch_cache_mode_to_js, fetch_redirect_to_js, fetch_request_mode_to_js, }; -use bun_http_jsc::method_jsc::MethodJsc as _; +use bun_http_jsc::method_jsc::{self, MethodJsc as _}; use bun_http_types::FetchCacheMode::FetchCacheMode; use bun_http_types::FetchRedirect::FetchRedirect; use bun_http_types::FetchRequestMode::FetchRequestMode; -use bun_http_types::Method::Method; +use bun_http_types::Method::{Method, OwnedMethod}; use bun_jsc::AbortSignalRef; use bun_jsc::EncodedSliceJsc as _; use bun_jsc::StringJsc as _; @@ -96,10 +96,10 @@ pub(crate) struct Request { /// once before `Box::from_raw().drop()` (which would otherwise re-run it). body: ManuallyDrop, js_ref: JsCell, - pub method: Method, - pub(crate) flags: Flags, + pub method: OwnedMethod, pub(crate) request_context: AnyRequestContext, pub(crate) weak_ptr_data: WeakPtrData, + pub(crate) flags: Flags, // We must report a consistent value for this reported_estimated_size: Cell, } @@ -120,6 +120,9 @@ pub(crate) struct Flags { bun_core::assert_ffi_layout!(Flags, 4, 1; redirect @ 0, cache @ 1, mode @ 2, https @ 3); +// `flags` fills the padding after `weak_ptr_data`: 112 bytes is the mimalloc block that 104 had. +bun_core::assert_ffi_layout!(Request, 112, 8; method @ 64, flags @ 100); + impl Default for Flags { fn default() -> Self { Self { @@ -418,7 +421,7 @@ impl Request { url: BunString, headers: Option, body: BodyHiveHandle, - method: Method, + method: OwnedMethod, ) -> Request { Request { url: JsCell::new(url), @@ -560,7 +563,7 @@ impl Request { )?; // Wire-form token (e.g. "M-SEARCH"), not the Rust Debug variant identifier. - writer.write_str(self.method.as_str())?; + write!(writer, "{}", self.method)?; writer.write_str("\"")?; formatter .print_comma::<_, ENABLE_ANSI_COLORS>(writer) @@ -701,7 +704,16 @@ impl Request { } pub(crate) fn get_method(&self, global_this: &JSGlobalObject) -> JSValue { - self.method.to_js(global_this) + match &self.method { + OwnedMethod::Known(method) => method.to_js(global_this), + OwnedMethod::Token(token) => Self::method_token_to_js(token, global_this), + } + } + + #[cold] + fn method_token_to_js(token: &[u8], global_this: &JSGlobalObject) -> JSValue { + // A token is ASCII. + bun_core::EncodedSlice::latin1(token).to_js(global_this) } pub(crate) fn get_mode(&self, global_this: &JSGlobalObject) -> JSValue { @@ -993,7 +1005,7 @@ impl Request { signal: JsCell::new(None), body: ManuallyDrop::new(body), js_ref: JsCell::new(JsRef::init_weak(this_value)), - method: Method::GET, + method: Method::GET.into(), flags: Flags::default(), request_context: AnyRequestContext::NULL, weak_ptr_data: WeakPtrData::EMPTY, @@ -1105,7 +1117,7 @@ impl Request { } if !fields.contains(Fields::Method) { - req.method = request.method; + req.method = request.method.clone(); fields.insert(Fields::Method); } @@ -1154,11 +1166,6 @@ impl Request { if let Some(response) = value.as_direct::() { // SAFETY: `as_direct` returned a live `*mut Response` owned by the JS wrapper. let response = unsafe { &mut *response }; - if !fields.contains(Fields::Method) { - req.method = response.get_method(); - fields.insert(Fields::Method); - } - if !fields.contains(Fields::Headers) { if let Some(headers) = response.get_init_headers_mut() { // The flag is set unconditionally once `getInitHeaders()` yielded a @@ -1329,7 +1336,13 @@ impl Request { }); if method_check { if !fields.contains(Fields::Method) { - req.method = response_init.method; + req.method = match method_jsc::request_method_from_init( + cx.global(), + value, + ) { + Ok(method) => method.unwrap_or(OwnedMethod::Known(Method::GET)), + Err(e) => bail!(Err(e)), + }; fields.insert(Fields::Method); } } @@ -1484,6 +1497,8 @@ impl Request { } else { self.url.get().clone() }; + let method = self.method.clone(); + debug_assert!(req.method.known().is_some()); // `ptr::write` is a raw bit-overwrite — no destructors run on the old // `*req`, so the Drop impl on `JsRef` doesn't fire on the caller's @@ -1492,7 +1507,7 @@ impl Request { // `clone()` seeds it with a dangling sentinel, and `construct_into` // releases its seed via the ptr-equality arm of its `cleanup`. // `url` was taken above (preserve_url) or is the empty - // sentinel; remaining incoming fields are None/weak/Copy by contract. + // sentinel; `method` is a verb (asserted above); the rest are None/weak/Copy by contract. // SAFETY: `req` is a valid &mut, fully initialized by the caller; // nothing between here and the write can panic. unsafe { @@ -1504,7 +1519,7 @@ impl Request { signal: JsCell::new(None), body: ManuallyDrop::new(body), js_ref: JsCell::new(JsRef::empty()), - method: self.method, + method, flags: self.flags, request_context: AnyRequestContext::NULL, weak_ptr_data: WeakPtrData::EMPTY, @@ -1536,7 +1551,7 @@ impl Request { BodyHiveHandle::from_raw(NonNull::dangling().as_ptr()) }), js_ref: JsCell::new(JsRef::empty()), - method: Method::GET, + method: Method::GET.into(), flags: Flags::default(), request_context: AnyRequestContext::NULL, weak_ptr_data: WeakPtrData::EMPTY, @@ -1562,7 +1577,7 @@ impl Request { signal: JsCell::new(signal), body: ManuallyDrop::new(body), js_ref: JsCell::new(JsRef::empty()), - method, + method: method.into(), flags: Flags { https, ..Flags::default() diff --git a/src/runtime/webcore/Response.rs b/src/runtime/webcore/Response.rs index cfb5970bc8fd..236cf5ecab78 100644 --- a/src/runtime/webcore/Response.rs +++ b/src/runtime/webcore/Response.rs @@ -322,9 +322,8 @@ impl Response { } #[inline] - pub(crate) fn set_init(&self, method: Method, status_code: u16, status_text: BunString) { + pub(crate) fn set_init(&self, status_code: u16, status_text: BunString) { self.init.with_mut(|init| { - init.method = method; init.status_code = status_code; init.status_text = status_text; }); @@ -404,11 +403,6 @@ impl Response { self.init.with_mut(|init| init.headers.take()) } - #[inline] - pub(crate) fn get_method(&self) -> Method { - self.init.get().method - } - pub(crate) fn estimated_size(this: &Response) -> usize { this.reported_estimated_size.get() } @@ -1186,7 +1180,6 @@ pub(crate) struct Init { pub(crate) headers: Option, pub(crate) status_code: u16, pub(crate) status_text: BunString, - pub method: Method, } impl Default for Init { @@ -1195,7 +1188,6 @@ impl Default for Init { headers: None, status_code: 0, status_text: BunString::EMPTY, - method: Method::GET, } } } @@ -1213,7 +1205,6 @@ impl Init { headers, status_code: self.status_code, status_text: self.status_text.clone(), - method: self.method, }) } @@ -1249,7 +1240,6 @@ impl Init { result.headers = headers.clone_this(global_this)?; } - result.method = req.method; return Ok(Some(result)); } @@ -1300,14 +1290,6 @@ impl Init { result.status_text = status_text.to_bun_string(global_this)?; } - if let Some(method_value) = - response_init.fast_get_truthy(global_this, BuiltinName::method)? - { - if let Some(method) = bun_http_jsc::method_jsc::from_js(global_this, method_value)? { - result.method = method; - } - } - Ok(Some(result)) } } diff --git a/src/runtime/webcore/fetch.rs b/src/runtime/webcore/fetch.rs index 9f37c17b4258..8386b93bb616 100644 --- a/src/runtime/webcore/fetch.rs +++ b/src/runtime/webcore/fetch.rs @@ -52,7 +52,7 @@ use bun_core::{String as BunString, Tag as BunStringTag}; use bun_http::http_request_body::StreamFraming; use bun_http::{self as http, FetchRedirect, Headers, HeadersExt as _, MimeType}; use bun_http_jsc::method_jsc; -use bun_http_types::Method::Method; +use bun_http_types::Method::{Method, OwnedMethod}; use bun_jsc::{HTTPHeaderName, StringJsc as _, SysErrorJsc as _, URLJsc as _}; use bun_sys::FdExt as _; // `FromJsEnum for FetchRedirect` lives in bun_http_jsc; importing the impl crate @@ -562,7 +562,30 @@ fn fetch_impl( return Ok(JSPromise::rejected_promise(global_this, err).to_js()); } + // The init's method, else the Request's, else the `{ url, method }` input's, else GET. + let extract_method = move || -> JsResult { + if let Some(options) = options_object { + if let Some(method) = method_jsc::request_method_from_init(global_this, options)? { + return Ok(method); + } + } + + if let Some(req) = request_mut!() { + return Ok(req.method.clone()); + } + + if let Some(request_init) = request_init_object { + if let Some(method) = method_jsc::request_method_from_init(global_this, request_init)? { + return Ok(method); + } + } + + Ok(Method::GET.into()) + }; + if url_str.starts_with_ascii(b"data:") { + // A data: URL answers every method, and still none that is not a method. + extract_method()?; return Ok(data_url_response(url_str, global_this)); } @@ -590,26 +613,7 @@ fn fetch_impl( // **Start with the harmless ones.** // "method" - let mut method = 'extract_method: { - if let Some(options) = options_object { - if let Some(method_) = options.fast_get_truthy(global_this, jsc::BuiltinName::method)? { - break 'extract_method method_jsc::from_js(global_this, method_)?; - } - } - - if let Some(req) = request_mut!() { - break 'extract_method Some(req.method); - } - - if let Some(req) = request_init_object { - if let Some(method_) = req.fast_get_truthy(global_this, jsc::BuiltinName::method)? { - break 'extract_method method_jsc::from_js(global_this, method_)?; - } - } - - break 'extract_method None; - } - .unwrap_or(Method::GET); + let mut method = extract_method()?; // "decompress: boolean" disable_decompression = 'extract_disable_decompression: { @@ -1466,8 +1470,12 @@ fn fetch_impl( // WHATWG Fetch step 36 forbids a body for GET/HEAD; Bun additionally // rejects TRACE (RFC 9110 §9.3.8 "MUST NOT send content") since it does - // not enforce forbidden methods. has_request_body() encodes exactly that. - if !ALLOW_GET_BODY && !method.has_request_body() && body.has_body() && !upgraded_connection { + // not forbid TRACE itself. has_request_body() encodes that for a verb of the table. + if !ALLOW_GET_BODY + && method.known().is_some_and(|verb| !verb.has_request_body()) + && body.has_body() + && !upgraded_connection + { let err = global_this.to_type_error( jsc::ErrorCode::INVALID_ARG_VALUE, format_args!("fetch() request with GET/HEAD method cannot have body."), @@ -1757,7 +1765,7 @@ fn fetch_impl( // we cannot direct stream to s3 we need to use multi part upload // `defer body.ReadableStream.deinit()` → Drop on `body` scope exit. - if method != Method::PUT && method != Method::POST { + if !matches!(method.known(), Some(Method::PUT | Method::POST)) { return Ok(JSPromise::rejected_promise( global_this, global_this.create_error_instance(format_args!( @@ -1826,14 +1834,24 @@ fn fetch_impl( // url/url_proxy_buffer ownership moved into s3_stream above. return Ok(promise_value); } - if method == Method::POST { - method = Method::PUT; - } + let sign_method = match method.known() { + Some(Method::POST) => Method::PUT, + Some(verb) => verb, + // S3 signs verbs of the table only. + None => { + return Ok(JSPromise::rejected_promise( + global_this, + s3::get_js_sign_error(bun_s3_signing::Error::InvalidMethod, global_this), + ) + .to_js()); + } + }; + method = sign_method.into(); let mut result = match credentials_with_options.credentials.sign_request::( &SignOptions { path: url.s3_path(), - method, + method: sign_method, ..Default::default() }, None, @@ -1950,10 +1968,12 @@ fn fetch_impl( } else { None }; + let mut request_headers = headers.take().unwrap_or_default(); let fetch_options = FetchOptions { - method, + // A method outside the table goes to the HTTP thread in the header buffer. + method: method.to_ref(&mut request_headers), url: url_static, - headers: headers.take().unwrap_or_default(), + headers: request_headers, body, stream_framing, disable_keepalive, @@ -2032,7 +2052,6 @@ impl<'a> S3StreamWrapper<'a> { s3::S3UploadResult::Success => { let response = Box::new(Response::init( response::Init { - method: Method::PUT, status_code: 200, ..Default::default() }, @@ -2050,7 +2069,6 @@ impl<'a> S3StreamWrapper<'a> { s3::S3UploadResult::Failure(err) => { let response = Box::new(Response::init( response::Init { - method: Method::PUT, status_code: 500, status_text: BunString::create_atom_if_possible(err.code), ..Default::default() diff --git a/src/runtime/webcore/fetch/FetchTasklet.rs b/src/runtime/webcore/fetch/FetchTasklet.rs index 47483f238bbf..0a45e3f25ab6 100644 --- a/src/runtime/webcore/fetch/FetchTasklet.rs +++ b/src/runtime/webcore/fetch/FetchTasklet.rs @@ -10,11 +10,11 @@ use bun_event_loop::{ Task, Taskable, }; use bun_http as http; -use bun_http::Method; use bun_http::{ AsyncHTTP, CertificateInfo, FetchRedirect, HTTPClientResult, HTTPResponseMetadata, Headers, Signals, ThreadSafeStreamBuffer, }; +use bun_http::{Method, MethodRef}; use bun_io::KeepAlive; use bun_jsc::bun_string_jsc; use bun_jsc::debugger::AsyncTaskTracker; @@ -2771,7 +2771,8 @@ impl FetchTasklet { } pub(crate) struct FetchOptions { - pub method: Method, + /// A token points into `headers.buf`. + pub method: MethodRef, pub(crate) headers: Headers, pub(crate) body: HTTPRequestBody, /// For a `ReadableStream` body; `transfer_encoding` points into `headers.buf`. diff --git a/test/js/bun/http/bun-serve-fetch-invalid-args.test.ts b/test/js/bun/http/bun-serve-fetch-invalid-args.test.ts index 9a4ea4973af0..a8646b811ef6 100644 --- a/test/js/bun/http/bun-serve-fetch-invalid-args.test.ts +++ b/test/js/bun/http/bun-serve-fetch-invalid-args.test.ts @@ -63,6 +63,54 @@ test("server.fetch rejects instead of throwing when the body cannot be converted await expect((server as any).fetch("/", { body: body as any })).rejects.toBe(error); }); +test("server.fetch hands the handler the method it is given", async () => { + using server = Bun.serve({ + port: 0, + fetch: request => new Response(request.method), + }); + const seen: Record = {}; + // DELETE, GET, HEAD, OPTIONS, POST and PUT are normalized from any case. Every + // other token is the method as written. + for (const method of ["Put", "pOsT", "PatCh", "Propfind", "BREW"]) { + seen[method] = await (await (server as any).fetch("/", { method })).text(); + } + seen["Request"] = await (await server.fetch(new Request(server.url.href, { method: "BREW" }))).text(); + expect(seen).toEqual({ + Put: "PUT", + pOsT: "POST", + PatCh: "PatCh", + Propfind: "Propfind", + BREW: "BREW", + Request: "BREW", + }); +}); + +test("server.fetch rejects a method that is not a token, and one that Fetch forbids", async () => { + let calls = 0; + using server = Bun.serve({ + port: 0, + fetch() { + calls++; + return new Response("Hello World!"); + }, + }); + // Not an async function: a thrown error would leave this callback. + const errorOf = (method: string): Promise => + (server as any).fetch("/", { method }).then( + () => "no error", + (e: any) => ({ name: e.name, code: e.code, message: e.message }), + ); + const notTokens = ["GET POST", " GET", "GET\r\nX-Injected: 1", "caf\u00e9"]; + // "CONNECT" and "TRACE" are in the method table and stay accepted. + const forbidden = ["TRACK", "Trace", "Connect"]; + const typeError = (message: string) => ({ name: "TypeError", code: "ERR_INVALID_ARG_VALUE", message }); + expect(await Promise.all([...notTokens, ...forbidden].map(errorOf))).toEqual([ + ...notTokens.map(method => typeError(`${JSON.stringify(method)} is not a valid HTTP method.`)), + ...forbidden.map(method => typeError(`${JSON.stringify(method)} HTTP method is unsupported.`)), + ]); + expect(calls).toBe(0); +}); + // server.fetch() returns an already-rejected promise for all of these. Like any // other rejected promise, it has to be reported when nothing handles it. describe.concurrent("server.fetch early rejections are tracked", () => { @@ -108,6 +156,12 @@ describe.concurrent("server.fetch early rejections are tracked", () => { `const body = []; Object.defineProperty(body, 0, { get() { throw new Error("body getter threw"); } }); server.fetch("/", { body })`, "body getter threw", ], + [ + "a method that is not a token", + respond, + `server.fetch("/", { method: "GET POST" })`, + '"GET POST" is not a valid HTTP method.', + ], ])("%s is reported as an unhandled rejection", async (_, serveOptions, call, expected) => { const { stderr, exitCode } = await runChild(` using server = Bun.serve({ port: 0, ${serveOptions} }); diff --git a/test/js/bun/http/proxy-stress-matrix.test.ts b/test/js/bun/http/proxy-stress-matrix.test.ts index 141096e6c3e1..78a38aa01963 100644 --- a/test/js/bun/http/proxy-stress-matrix.test.ts +++ b/test/js/bun/http/proxy-stress-matrix.test.ts @@ -359,11 +359,13 @@ describe("hop-by-hop headers", () => { }); // ───────────────────────────────────────────────────────────────────────────── -// HEAD / DELETE / PUT / PATCH / OPTIONS through every proxy combination. +// HEAD / DELETE / PUT / PATCH / OPTIONS through every proxy combination, and +// two methods that fetch sends as written: BREW and PatCh. // ───────────────────────────────────────────────────────────────────────────── describe("method matrix", () => { - const METHODS = ["HEAD", "DELETE", "PUT", "PATCH", "OPTIONS"] as const; + const METHODS = ["HEAD", "DELETE", "PUT", "PATCH", "OPTIONS", "BREW", "PatCh"] as const; + const WITH_BODY: readonly string[] = ["PUT", "PATCH", "PatCh"]; for (const { proxyTls, originTls } of cartesian({ proxyTls: [false, true], originTls: [false, true] } as const)) { for (const method of METHODS) { test.concurrent( @@ -371,10 +373,11 @@ describe("method matrix", () => { async () => { await using origin = await createAdversarialOrigin({ tls: originTls, body: "m" }); const proxy = sharedProxy(proxyTls); + const body = WITH_BODY.includes(method) ? "body" : undefined; const res = await fetch(origin.url, { method, - body: method === "PUT" || method === "PATCH" ? "body" : undefined, + body, proxy: proxy.url, keepalive: false, tls: laxTls, @@ -384,7 +387,10 @@ describe("method matrix", () => { if (method !== "HEAD") { expect(await res.text()).toBe("m"); } - expect(origin.requests[0].method).toBe(method); + expect({ method: origin.requests[0].method, body: origin.requests[0].body.toString() }).toEqual({ + method, + body: body ?? "", + }); }, ); } diff --git a/test/js/web/fetch/fetch-args.test.ts b/test/js/web/fetch/fetch-args.test.ts index 619908e8fffd..cb42d6a81adc 100644 --- a/test/js/web/fetch/fetch-args.test.ts +++ b/test/js/web/fetch/fetch-args.test.ts @@ -1,7 +1,13 @@ import { TCPSocketListener } from "bun"; import { afterAll, beforeAll, describe, expect, mock, spyOn, test } from "bun:test"; -import { bunEnv, bunExe, expectRssDeltaBelow, tempDir } from "harness"; +import { bunEnv, bunExe, expectRssDeltaBelow, tempDir, tls } from "harness"; +import nodeFetch from "node-fetch"; +import { once } from "node:events"; +import http2 from "node:http2"; import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { serve as serveS3 } from "s3-server"; +import { request as undiciRequest } from "undici"; let server; let requestCount = 0; @@ -220,6 +226,17 @@ describe.concurrent("fetch() early rejections are reported when unhandled", () = `fetch("s3://bucket/key", { method: "DELETE", body: new ReadableStream() })`, "Only POST and PUT do support body when using S3", ], + [ + // The endpoint refuses connections: nothing may be signed and sent for this method. + "s3: a method that S3 does not sign", + `fetch("s3://bucket/key", { method: "BREW", s3: { accessKeyId: "a", secretAccessKey: "b", endpoint: "http://127.0.0.1:1" } })`, + "Method must be GET, PUT, DELETE or HEAD when using s3:// protocol", + ], + [ + "a method that is not a token", + `fetch("http://127.0.0.1:1/", { method: "GET POST" })`, + '"GET POST" is not a valid HTTP method.', + ], ]; test.each(cases)("%s", async (_name, code, expectedStderr) => { @@ -514,3 +531,419 @@ describe("does not send a request when", () => { }); } }); + +// https://fetch.spec.whatwg.org/#methods: a method is any RFC 9110 token. +// The server is a raw socket, because Bun.serve does not take a method outside its own table. +describe("fetch() method", () => { + function listen() { + const requests: { line: string; contentLength?: string; transferEncoding?: string; body: string }[] = []; + let sockets = 0; + const listener = Bun.listen<{ received: string; done: boolean }>({ + hostname: "127.0.0.1", + port: 0, + socket: { + open(socket) { + sockets++; + socket.data = { received: "", done: false }; + }, + data(socket, chunk) { + const state = socket.data; + if (state.done) return; + state.received += chunk.toString("latin1"); + const headEnd = state.received.indexOf("\r\n\r\n"); + if (headEnd === -1) return; + const [line, ...headers] = state.received.slice(0, headEnd).split("\r\n"); + const header = (name: string) => + headers + .find(h => h.toLowerCase().startsWith(name + ":")) + ?.slice(name.length + 1) + .trim(); + const contentLength = header("content-length"); + const transferEncoding = header("transfer-encoding"); + const body = state.received.slice(headEnd + 4); + if (transferEncoding ? !body.endsWith("0\r\n\r\n") : body.length < Number(contentLength ?? 0)) return; + state.done = true; + requests.push({ line, contentLength, transferEncoding, body }); + socket.end("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + }, + }, + }); + return { + url: `http://127.0.0.1:${listener.port}/`, + requests, + get sockets() { + return sockets; + }, + [Symbol.dispose]() { + listener.stop(true); + }, + }; + } + const requestLines = (server: ReturnType) => server.requests.map(request => request.line); + + type Send = (url: string, method: any) => Promise; + const native: [name: string, send: Send][] = [ + ["fetch(url, { method })", (url, method) => fetch(url, { method })], + ["fetch(new Request(url, { method }))", (url, method) => fetch(new Request(url, { method }))], + ["fetch({ url, method })", (url, method) => fetch({ url, method } as any)], + [ + "fetch(new Request(url, { method: 'DELETE' }), { method })", + (url, method) => fetch(new Request(url, { method: "DELETE" }), { method }), + ], + ["Bun.fetch(url, { method })", (url, method) => Bun.fetch(url, { method })], + [ + "new Bun.FetchSession().fetch(url, { method })", + async (url, method) => { + using session = new Bun.FetchSession(); + return await session.fetch(url, { method }); + }, + ], + ]; + // The npm package puts every method in upper case. Only spellings that it + // and this replacement send alike go through it here. + const viaNodeFetch: [name: string, send: Send] = [ + "node-fetch", + (url, method) => nodeFetch(url, { method }) as unknown as Promise, + ]; + + // https://fetch.spec.whatwg.org/#concept-method-normalize: these six, in any case. + const normalized: [method: unknown, onTheWire: string][] = [ + ["Delete", "DELETE"], + ["gEt", "GET"], + ["hEaD", "HEAD"], + ["oPtIoNs", "OPTIONS"], + ["pOsT", "POST"], + ["Put", "PUT"], + ]; + const upperCaseTokens: [method: unknown, onTheWire: string][] = [ + ["BREW", "BREW"], + ["LIST", "LIST"], + ]; + // Every other token goes out as written. A value that is not a string is converted to one. + const asWritten: [method: unknown, onTheWire: string][] = [ + ["PatCh", "PatCh"], + ["Propfind", "Propfind"], + ["M-search", "M-search"], + ["!#$%&'*+-.^_`|~09AZaz", "!#$%&'*+-.^_`|~09AZaz"], + [false, "false"], + [0, "0"], + [123, "123"], + ]; + + describe.each([ + ...native.map(entry => [...entry, [...normalized, ...upperCaseTokens, ...asWritten]] as const), + [...viaNodeFetch, [...normalized, ...upperCaseTokens]] as const, + ])("%s", (_, send, sent) => { + test("sends the method it is given", async () => { + using server = listen(); + for (const [method] of sent) { + await (await send(server.url, method)).arrayBuffer(); + } + expect(requestLines(server)).toEqual(sent.map(([, onTheWire]) => `${onTheWire} / HTTP/1.1`)); + }); + + test("rejects a method that is not a token or that Fetch forbids, and sends nothing", async () => { + using server = listen(); + const rejected: [method: unknown, message: string][] = [ + ["GET POST", `"GET POST" is not a valid HTTP method.`], + [" GET", `" GET" is not a valid HTTP method.`], + ["GET ", `"GET " is not a valid HTTP method.`], + ["GET\r\nX-Injected: 1", `"GET\\r\\nX-Injected: 1" is not a valid HTTP method.`], + ["G\0T", `"G\\u0000T" is not a valid HTTP method.`], + ["caf\u00e9", `"caf\u00e9" is not a valid HTTP method.`], + ["(GET)", `"(GET)" is not a valid HTTP method.`], + [[], `"" is not a valid HTTP method.`], + [{}, `"[object Object]" is not a valid HTTP method.`], + // https://fetch.spec.whatwg.org/#forbidden-method + ["TRACK", `"TRACK" HTTP method is unsupported.`], + ["track", `"track" HTTP method is unsupported.`], + ["Trace", `"Trace" HTTP method is unsupported.`], + ["Connect", `"Connect" HTTP method is unsupported.`], + ]; + const errors: unknown[] = []; + for (const [method] of rejected) { + // `new Request()` throws and `fetch()` rejects. + errors.push( + await (async () => send(server.url, method))().then( + () => "no error", + e => ({ name: e.name, code: e.code, message: e.message }), + ), + ); + } + expect(errors).toEqual( + rejected.map(([, message]) => ({ name: "TypeError", code: "ERR_INVALID_ARG_VALUE", message })), + ); + // The server saw none of them: the one request after them is its first socket. + await (await fetch(server.url)).arrayBuffer(); + expect({ sockets: server.sockets, requests: requestLines(server) }).toEqual({ + sockets: 1, + requests: ["GET / HTTP/1.1"], + }); + }); + }); + + test("undici.request() sends a token", async () => { + using server = listen(); + const { statusCode, body } = await undiciRequest(server.url, { method: "BREW" }); + await body.text(); + expect({ statusCode, requests: requestLines(server) }).toEqual({ statusCode: 200, requests: ["BREW / HTTP/1.1"] }); + }); + + test("a Request keeps its token for every fetch()", async () => { + using server = listen(); + const request = new Request(server.url, { method: "BREW" }); + for (const send of [ + () => fetch(request), + () => fetch(request), + () => fetch(request.clone()), + () => fetch(new Request(request)), + () => fetch(request, {}), + () => fetch(request, { method: "" }), + () => fetch(request, { method: "Put" }), + () => fetch(request, { method: "PatCh" }), + ]) { + await (await send()).arrayBuffer(); + } + expect(requestLines(server)).toEqual([ + "BREW / HTTP/1.1", + "BREW / HTTP/1.1", + "BREW / HTTP/1.1", + "BREW / HTTP/1.1", + "BREW / HTTP/1.1", + "BREW / HTTP/1.1", + "PUT / HTTP/1.1", + "PatCh / HTTP/1.1", + ]); + }); + + test("a token carries a body, and declares no length when it has none", async () => { + using server = listen(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("coffee")); + controller.close(); + }, + }); + for (const init of [ + { method: "BREW" }, + { method: "BREW", body: "coffee" }, + { method: "BREW", body: stream }, + // A verb of the table that takes a body still declares an empty one. + { method: "POST" }, + ]) { + await (await fetch(server.url, init)).arrayBuffer(); + } + expect(server.requests).toEqual([ + { line: "BREW / HTTP/1.1", contentLength: undefined, transferEncoding: undefined, body: "" }, + { line: "BREW / HTTP/1.1", contentLength: "6", transferEncoding: undefined, body: "coffee" }, + { + line: "BREW / HTTP/1.1", + contentLength: undefined, + transferEncoding: "chunked", + body: "6\r\ncoffee\r\n0\r\n\r\n", + }, + { line: "POST / HTTP/1.1", contentLength: "0", transferEncoding: undefined, body: "" }, + ]); + }); + + test("a token is sent on a reused connection and is not sent again after a reset", async () => { + // Answers on one connection, and closes it unanswered for /reset. + const lines: string[][] = []; + using listener = Bun.listen<{ received: string; lines: string[] }>({ + hostname: "127.0.0.1", + port: 0, + socket: { + open(socket) { + socket.data = { received: "", lines: [] }; + lines.push(socket.data.lines); + }, + data(socket, chunk) { + const state = socket.data; + state.received += chunk.toString("latin1"); + for (;;) { + const headEnd = state.received.indexOf("\r\n\r\n"); + if (headEnd === -1) return; + const head = state.received.slice(0, headEnd).split("\r\n"); + const length = Number(head.find(h => h.toLowerCase().startsWith("content-length:"))?.split(":")[1] ?? 0); + if (state.received.length < headEnd + 4 + length) return; + state.received = state.received.slice(headEnd + 4 + length); + state.lines.push(head[0]); + if (head[0].includes(" /reset ")) return socket.terminate(); + socket.write("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"); + } + }, + }, + }); + const url = `http://127.0.0.1:${listener.port}`; + const results: string[] = []; + for (const [path, init] of [ + ["/1", { method: "BREW" }], + ["/2", { method: "BREW", body: "coffee" }], + ["/3", {}], + ["/reset", { method: "BREW" }], + ] as const) { + results.push( + await fetch(url + path, init).then( + res => res.text(), + e => e.code, + ), + ); + } + expect({ results, lines }).toEqual({ + results: ["ok", "ok", "ok", "ECONNRESET"], + lines: [["BREW /1 HTTP/1.1", "BREW /2 HTTP/1.1", "GET /3 HTTP/1.1", "BREW /reset HTTP/1.1"]], + }); + }); + + test("a token goes to an HTTP proxy", async () => { + using proxy = listen(); + // The proxy answers, so the host of the URL is never resolved. + await (await fetch("http://method.test/path", { method: "BREW", proxy: proxy.url })).arrayBuffer(); + expect(requestLines(proxy)).toEqual(["BREW http://method.test/path HTTP/1.1"]); + }); + + test("a token is the :method pseudo-header over HTTP/2", async () => { + const server = http2.createSecureServer({ ...tls, allowHTTP1: false }, (req, res) => { + let body = ""; + req.setEncoding("utf8"); + req.on("data", chunk => (body += chunk)); + req.on("end", () => res.end(JSON.stringify({ method: req.headers[":method"], body }))); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + try { + // The session closes its connection with the test, so the server can close. + using session = new Bun.FetchSession(); + const url = `https://127.0.0.1:${(server.address() as { port: number }).port}/`; + const seen: unknown[] = []; + for (const init of [{ method: "BREW" }, { method: "PatCh", body: "the payload" }, { method: "Put" }]) { + const response = await session.fetch(url, { ...init, protocol: "http2", tls: { rejectUnauthorized: false } }); + seen.push(await response.json()); + } + expect(seen).toEqual([ + { method: "BREW", body: "" }, + { method: "PatCh", body: "the payload" }, + { method: "PUT", body: "" }, + ]); + } finally { + server.close(); + } + }); + + // S3 signs DELETE, GET, HEAD and PUT, and a POST is a PUT. Each row starts with an + // object that holds "original" and ends with what the object holds after the call. + test("an s3: URL takes its verbs in any case and refuses every other method", async () => { + await using server = serveS3({ buckets: ["buntest"] }); + const s3 = server.clientOptions(); + const client = new Bun.S3Client(server.clientOptions("buntest")); + const stream = () => + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("streamed")); + controller.close(); + }, + }); + const invalidMethod = { + code: "ERR_S3_INVALID_METHOD", + message: "Method must be GET, PUT, DELETE or HEAD when using s3:// protocol", + }; + const rows: [method: string, body: BodyInit | undefined, expected: object][] = [ + ["Delete", undefined, { status: 204, text: "", after: null }], + ["hEaD", undefined, { status: 200, text: "", after: "original" }], + ["Put", "new", { status: 200, text: "", after: "new" }], + ["POST", "new", { status: 200, text: "", after: "new" }], + ["pOsT", "new", { status: 200, text: "", after: "new" }], + ["pOsT", stream(), { status: 200, text: "", after: "streamed" }], + ["PatCh", undefined, { ...invalidMethod, after: "original" }], + ["BREW", "new", { ...invalidMethod, after: "original" }], + [ + "BREW", + stream(), + { code: undefined, message: "Only POST and PUT do support body when using S3", after: "original" }, + ], + ]; + const seen: object[] = []; + for (const [index, [method, body]] of rows.entries()) { + const key = `key-${index}`; + await client.write(key, "original"); + const outcome = await fetch(`s3://buntest/${key}`, { method, body, s3 }).then( + async response => ({ status: response.status, text: await response.text() }), + e => ({ code: e.code, message: e.message }), + ); + const file = client.file(key); + seen.push({ ...outcome, after: (await file.exists()) ? await file.text() : null }); + } + expect(seen).toEqual(rows.map(([, , expected]) => expected)); + }); + + // These schemes answer without a request. The method is still checked. + test.each([ + // https://fetch.spec.whatwg.org/#scheme-fetch: a data: URL answers every method. + ["data:", () => "data:text/plain,hi", "BREW"], + // Fetch answers a blob: URL for GET only, so only GET is asserted to answer for these two. + ["file:", () => pathToFileURL(join(import.meta.dir, "fixture.html")).href, "GET"], + ["blob:", () => URL.createObjectURL(new Blob(["hi"])), "GET"], + ])("a %s URL rejects a method that is not a token", async (_, makeUrl, answered) => { + const url = makeUrl(); + const response = await fetch(url, { method: answered }); + expect(response.status).toBe(200); + await response.arrayBuffer(); + const error = await fetch(url, { method: "GET POST" }).then( + () => "no error", + e => ({ name: e.name, code: e.code, message: e.message }), + ); + expect(error).toEqual({ + name: "TypeError", + code: "ERR_INVALID_ARG_VALUE", + message: `"GET POST" is not a valid HTTP method.`, + }); + }); + + test("undefined, null and the empty string name no method", async () => { + using server = listen(); + for (const method of [undefined, null, ""]) { + await (await fetch(server.url, { method } as any)).arrayBuffer(); + } + expect(requestLines(server)).toEqual(["GET / HTTP/1.1", "GET / HTTP/1.1", "GET / HTTP/1.1"]); + }); + + test("a method of the table keeps its treatment", async () => { + using server = listen(); + // All-lower spellings go out in upper case, and CONNECT and TRACE are sent. + for (const method of ["patch", "propfind", "m-search", "CONNECT", "connect", "TRACE", "trace"]) { + await (await fetch(server.url, { method })).arrayBuffer(); + } + expect(requestLines(server)).toEqual([ + "PATCH / HTTP/1.1", + "PROPFIND / HTTP/1.1", + "M-SEARCH / HTTP/1.1", + "CONNECT / HTTP/1.1", + "CONNECT / HTTP/1.1", + "TRACE / HTTP/1.1", + "TRACE / HTTP/1.1", + ]); + }); + + // Bun.serve answers a method of its table only (#6556). It can refuse the + // other two, but it must not run the handler with a method that was not sent. + test("a Bun.serve handler does not see GET for another method over HTTP/1.1", async () => { + const seen: string[] = []; + using server = Bun.serve({ + port: 0, + fetch(request) { + seen.push(request.method); + return new Response(request.method); + }, + }); + const methods = ["PATCH", "PURGE", "PatCh", "custom"]; + for (const method of methods) { + await fetch(server.url, { method }).then( + res => res.arrayBuffer(), + () => {}, + ); + } + expect({ + inTheTable: seen.slice(0, 2), + notSent: seen.filter(method => !methods.includes(method)), + }).toEqual({ inTheTable: ["PATCH", "PURGE"], notSent: [] }); + }); +}); diff --git a/test/js/web/fetch/fetch-http3-client.test.ts b/test/js/web/fetch/fetch-http3-client.test.ts index dfbd650a0a2d..1ee329e5217f 100644 --- a/test/js/web/fetch/fetch-http3-client.test.ts +++ b/test/js/web/fetch/fetch-http3-client.test.ts @@ -394,6 +394,72 @@ describe("fetch protocol: http3", () => { } }); + // A node:quic origin hands over the pseudo-headers as they arrive. Bun.serve + // cannot be the origin: it reports a method that its table does not have as + // another method. + test("a method outside the table is the :method pseudo-header as written", async () => { + const decoder = new TextDecoder(); + const origin = await listen( + async (session: any) => { + session.onstream = (stream: any) => stream.closed.catch(() => {}); + await session.closed.catch(() => {}); + }, + { + sni: { "*": { keys: [createPrivateKey(tls.key)], certs: [Buffer.from(tls.cert)] } }, + transportParams: { maxIdleTimeout: 5 }, + async onheaders(this: any, received: Record) { + let body = ""; + for await (const chunks of this) for (const chunk of chunks) body += decoder.decode(chunk, { stream: true }); + this.sendHeaders({ ":status": "204", "x-method": received[":method"], "x-body": body }, { terminal: true }); + }, + }, + ); + try { + const got: { method: string | null; body: string | null }[] = []; + for (const init of [{ method: "BREW" }, { method: "PatCh", body: "the payload" }, { method: "Put" }]) { + const { headers } = await fetch(`https://127.0.0.1:${origin.address.port}/`, { ...h3, ...init }); + got.push({ method: headers.get("x-method"), body: headers.get("x-body") }); + } + expect(got).toEqual([ + { method: "BREW", body: "" }, + { method: "PatCh", body: "the payload" }, + { method: "PUT", body: "" }, + ]); + } finally { + // Not close(): it waits for the session that fetch() keeps in its pool. + await origin.destroy(); + } + }); + + // QPACK encodes a field from a 16-bit length. A longer method that went out + // cut to the low 16 bits of its length would be `DELETE` in the second row. + test("a method over 65,535 bytes does not go out as its first bytes", async () => { + // An origin of its own, closed with the test: the client gives up these + // two streams before it sends their HEADERS. + await using origin = Bun.serve({ + port: 0, + tls, + http3: true, + http1: false, + fetch: req => new Response(null, { headers: { "x-method": req.method } }), + }); + const url = `https://127.0.0.1:${origin.port}/`; + const tooLong = Buffer.alloc(65536, "X").toString(); + const outcomes: object[] = []; + for (const method of [tooLong, "DELETE" + tooLong]) { + outcomes.push( + await fetch(url, { ...h3, method }).then( + res => ({ arrivedAs: res.headers.get("x-method") }), + err => ({ code: err.code }), + ), + ); + } + expect(outcomes).toEqual([{ code: "HTTP3HeaderEncodingError" }, { code: "HTTP3HeaderEncodingError" }]); + + const res = await fetch(url, { ...h3, method: "DELETE" }); + expect(res.headers.get("x-method")).toBe("DELETE"); + }); + test("long path + many request headers (>32)", async () => { const path = "/echo?" + Buffer.alloc(2000, "p").toString(); const headers: Record = {}; diff --git a/test/js/web/fetch/fetch-redirect.test.ts b/test/js/web/fetch/fetch-redirect.test.ts index afa015ef195e..7ab0795cd0e4 100644 --- a/test/js/web/fetch/fetch-redirect.test.ts +++ b/test/js/web/fetch/fetch-redirect.test.ts @@ -390,3 +390,69 @@ describe("fetch() follows a redirect whose Location scheme is not lowercase", () } }); }); + +// https://fetch.spec.whatwg.org/#http-redirect-fetch step 12: a 301 or 302 changes +// POST to GET, and a 303 changes every method but GET and HEAD. A method outside +// Bun's table is none of those verbs. +describe("fetch() redirect of a method outside the table", () => { + // Answers /start with the redirect and everything else with 200. Returns what it received. + async function run(status: number, init: RequestInit) { + const requests: { line: string; body: string }[] = []; + const sockets = new Set(); + const server = net.createServer(socket => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + socket.on("error", () => {}); + let received = ""; + socket.on("data", chunk => { + received += chunk.toString("latin1"); + const headEnd = received.indexOf("\r\n\r\n"); + if (headEnd === -1) return; + const [line, ...headers] = received.slice(0, headEnd).split("\r\n"); + const contentLength = headers.find(h => h.toLowerCase().startsWith("content-length:"))?.split(":")[1]; + const body = received.slice(headEnd + 4); + if (body.length < Number(contentLength ?? 0)) return; + received = ""; + requests.push({ line, body }); + socket.end( + line.includes(" /start ") + ? `HTTP/1.1 ${status} Redirect\r\nLocation: /final\r\nContent-Length: 0\r\nConnection: close\r\n\r\n` + : "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); + }); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + const { port } = server.address() as net.AddressInfo; + try { + const res = await fetch(`http://127.0.0.1:${port}/start`, init); + await res.arrayBuffer(); + return { status: res.status, redirected: res.redirected, requests }; + } finally { + for (const s of sockets) s.destroy(); + server.close(); + await once(server, "close"); + } + } + + it.concurrent.each([301, 302, 307, 308])("%d keeps the method and the body", async status => { + expect(await run(status, { method: "BREW", body: "coffee" })).toEqual({ + status: 200, + redirected: true, + requests: [ + { line: "BREW /start HTTP/1.1", body: "coffee" }, + { line: "BREW /final HTTP/1.1", body: "coffee" }, + ], + }); + }); + + it("303 changes the method to GET and drops the body", async () => { + expect(await run(303, { method: "BREW", body: "coffee" })).toEqual({ + status: 200, + redirected: true, + requests: [ + { line: "BREW /start HTTP/1.1", body: "coffee" }, + { line: "GET /final HTTP/1.1", body: "" }, + ], + }); + }); +}); diff --git a/test/js/web/request/request.test.ts b/test/js/web/request/request.test.ts index 85e42bf8e420..f6f6e87ec228 100644 --- a/test/js/web/request/request.test.ts +++ b/test/js/web/request/request.test.ts @@ -175,3 +175,152 @@ describe("RequestInit signal presence", () => { }); }); }); + +// https://fetch.spec.whatwg.org/#dom-request: a method is any RFC 9110 token. +describe("Request method", () => { + const url = "http://example.com/"; + + // https://fetch.spec.whatwg.org/#concept-method-normalize + test("DELETE, GET, HEAD, OPTIONS, POST and PUT are normalized from any case", () => { + const methods = ["Delete", "gEt", "hEaD", "oPtIoNs", "pOsT", "Put"]; + expect(methods.map(method => new Request(url, { method }).method)).toEqual([ + "DELETE", + "GET", + "HEAD", + "OPTIONS", + "POST", + "PUT", + ]); + }); + + const tokens = ["PatCh", "Propfind", "BREW", "LIST", "M-search", "!#$%&'*+-.^_`|~09AZaz"]; + + test.each(tokens)("every other token is the method as written: %p", method => { + const request = new Request(url, { method }); + expect({ + method: request.method, + again: request.method, + clone: request.clone().method, + cloneOfClone: request.clone().clone().method, + copy: new Request(request).method, + copyWithInit: new Request(request, { headers: { a: "b" } }).method, + asInit: new Request(url, request).method, + fromObject: new Request({ url, method } as any).method, + overDelete: new Request(new Request(url, { method: "DELETE" }), { method }).method, + replaced: new Request(request, { method: "Put" }).method, + }).toEqual({ + method, + again: method, + clone: method, + cloneOfClone: method, + copy: method, + copyWithInit: method, + asInit: method, + fromObject: method, + overDelete: method, + replaced: "PUT", + }); + }); + + // WebIDL converts the member to a string. `false` is what `cond && "POST"` gives. + test.each([ + [false, "false"], + [0, "0"], + [NaN, "NaN"], + [0n, "0"], + [123, "123"], + ])("a method that is not a string is converted to one: %p", (method, expected) => { + expect(new Request(url, { method } as any).method).toBe(expected); + }); + + test("console.log shows a token", () => { + expect(Bun.inspect(new Request(url, { method: "BREW" }))).toContain('method: "BREW"'); + }); + + test("a token outlives the Request it was cloned from", () => { + let request = new Request(url, { method: "BREW" }); + for (let i = 0; i < 64; i++) { + request = i % 2 ? request.clone() : new Request(request); + } + Bun.gc(true); + expect(request.method).toBe("BREW"); + }); + + const construct: [string, (method: any) => Request][] = [ + ["new Request(url, { method })", method => new Request(url, { method })], + ["new Request(request, { method })", method => new Request(new Request(url, { method: "DELETE" }), { method })], + ["new Request({ url, method })", method => new Request({ url, method } as any)], + ]; + + const notTokens: [method: unknown, asString: string][] = [ + ["GET POST", "GET POST"], + [" GET", " GET"], + ["GET ", "GET "], + ["GET\r\nX-Injected: 1", "GET\r\nX-Injected: 1"], + ["G\0T", "G\0T"], + ["caf\u00e9", "caf\u00e9"], + ["\u0100", "\u0100"], + ["(GET)", "(GET)"], + ["GET/1", "GET/1"], + [[], ""], + [{}, "[object Object]"], + [new String(""), ""], + ]; + + describe.each(construct)("%s", (_, construct) => { + test.each(notTokens)("throws a TypeError for a method that is not a token: %p", (method, asString) => { + expect(() => construct(method)).toThrow( + expect.objectContaining({ + name: "TypeError", + code: "ERR_INVALID_ARG_VALUE", + message: `${JSON.stringify(asString)} is not a valid HTTP method.`, + }), + ); + }); + + // https://fetch.spec.whatwg.org/#forbidden-method. Bun's table holds CONNECT + // and TRACE in all-upper and all-lower case. Those two spellings stay accepted. + test.each(["TRACK", "track", "tRaCk", "Trace", "Connect"])("throws a TypeError for %p", method => { + expect(() => construct(method)).toThrow( + expect.objectContaining({ + name: "TypeError", + code: "ERR_INVALID_ARG_VALUE", + message: `${JSON.stringify(method)} HTTP method is unsupported.`, + }), + ); + }); + }); + + test("undefined, null and the empty string name no method", () => { + expect([undefined, null, ""].map(method => new Request(url, { method } as any).method)).toEqual([ + "GET", + "GET", + "GET", + ]); + }); + + test("a method of the table keeps its treatment", () => { + // All-lower spellings are upper-cased, and CONNECT and TRACE are accepted. + const methods = ["patch", "propfind", "m-search", "CONNECT", "connect", "TRACE", "trace"]; + expect(methods.map(method => new Request(url, { method }).method)).toEqual([ + "PATCH", + "PROPFIND", + "M-SEARCH", + "CONNECT", + "CONNECT", + "TRACE", + "TRACE", + ]); + }); + + test("a Response does not give a Request a method", () => { + // A ResponseInit has no `method`. One with it must still construct. + const response = new Response("", { method: "POST" } as any); + const invalid = new Response("", { method: "GET POST" } as any); + expect({ + response: new Request(url, response).method, + invalid: new Request(url, invalid).method, + fromRequest: new Request(url, new Response("", new Request(url, { method: "BREW" }) as any)).method, + }).toEqual({ response: "GET", invalid: "GET", fromRequest: "GET" }); + }); +});