diff --git a/core/runtime/src/fetch/body.rs b/core/runtime/src/fetch/body.rs new file mode 100644 index 00000000000..e36fba42b35 --- /dev/null +++ b/core/runtime/src/fetch/body.rs @@ -0,0 +1,35 @@ +use boa_engine::object::builtins::{JsPromise, JsUint8Array}; +use boa_engine::{Context, JsNativeError, JsString, JsValue}; +use std::rc::Rc; + +pub(super) fn bytes(body: Rc>, context: &mut Context) -> JsPromise { + JsPromise::from_async_fn( + async move |context| { + JsUint8Array::from_iter(body.iter().copied(), &mut context.borrow_mut()).map(Into::into) + }, + context, + ) +} + +pub(super) fn text(body: Rc>, context: &mut Context) -> JsPromise { + JsPromise::from_async_fn( + async move |_| { + let body = String::from_utf8_lossy(body.as_ref()); + Ok(JsString::from(body).into()) + }, + context, + ) +} + +pub(super) fn json(body: Rc>, context: &mut Context) -> JsPromise { + JsPromise::from_async_fn( + async move |context| { + let json_string = String::from_utf8_lossy(body.as_ref()); + let json = serde_json::from_str::(&json_string) + .map_err(|e| JsNativeError::syntax().with_message(e.to_string()))?; + + JsValue::from_json(&json, &mut context.borrow_mut()) + }, + context, + ) +} diff --git a/core/runtime/src/fetch/mod.rs b/core/runtime/src/fetch/mod.rs index f9b394fa6a2..eaea44d6bbe 100644 --- a/core/runtime/src/fetch/mod.rs +++ b/core/runtime/src/fetch/mod.rs @@ -24,6 +24,7 @@ use http::{HeaderName, HeaderValue, Request as HttpRequest, Request}; use std::cell::RefCell; use std::rc::Rc; +mod body; pub mod headers; pub mod headers_iterator; pub mod request; @@ -124,6 +125,9 @@ async fn fetch_inner( // The resource parsing is complicated, so we parse it in Rust here (instead of relying on // `TryFromJs` and friends). let mut signal = signal; + let mut source_request = None; + let mut reuse_source_body = false; + let body_overridden = options.as_ref().is_some_and(RequestInit::has_body); let request: Request> = match resource { Either::Left(url) => { @@ -144,7 +148,13 @@ async fn fetch_inner( return Err(js_error!(TypeError: "Request object is already in use")); }; + if !body_overridden { + request_ref.data().ensure_body_unused()?; + } + signal = signal.or_else(|| request_ref.data().signal()); + reuse_source_body = !body_overridden && request_ref.data().has_body(); + source_request = Some(request.clone()); request_ref.data().inner().clone() } }; @@ -167,6 +177,10 @@ async fn fetch_inner( request.headers_mut().append("Accept-Language", lang); } + if reuse_source_body && let Some(source_request) = source_request { + source_request.borrow().data().mark_body_used(); + } + let response = fetcher .fetch(JsRequest::from(request), signal.clone(), context) .await?; diff --git a/core/runtime/src/fetch/request.rs b/core/runtime/src/fetch/request.rs index 2991dfdd9a2..d38f1b56aab 100644 --- a/core/runtime/src/fetch/request.rs +++ b/core/runtime/src/fetch/request.rs @@ -4,19 +4,22 @@ //! //! [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/Request use super::HttpRequest; +use super::body; use super::headers::JsHeaders; +use boa_engine::object::builtins::JsPromise; use boa_engine::value::{Convert, TryFromJs}; use boa_engine::{ - Finalize, JsData, JsObject, JsResult, JsString, JsValue, Trace, boa_class, js_error, + Context, Finalize, JsData, JsObject, JsResult, JsString, JsValue, Trace, boa_class, js_error, }; use either::Either; +use std::cell::Cell; use std::mem; +use std::rc::Rc; /// A [RequestInit][mdn] object. This is a JavaScript object (not a /// class) that can be used as options for creating a [`JsRequest`]. /// /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/RequestInit -// TODO: This class does not contain all fields that are defined in the spec. #[derive(Debug, Clone, TryFromJs, Trace, Finalize)] pub struct RequestInit { body: Option, @@ -31,6 +34,10 @@ impl RequestInit { self.signal.take() } + pub(crate) fn has_body(&self) -> bool { + self.body.is_some() + } + /// Create an [`http::request::Builder`] object and return both the /// body specified by JavaScript and the builder. /// @@ -115,19 +122,32 @@ pub struct JsRequest { #[unsafe_ignore_trace] inner: HttpRequest>, signal: Option, + #[unsafe_ignore_trace] + has_body: bool, + #[unsafe_ignore_trace] + body_used: Cell, } impl JsRequest { + fn new(inner: HttpRequest>, signal: Option, has_body: bool) -> Self { + Self { + inner, + signal, + has_body, + body_used: Cell::new(false), + } + } + /// Get the inner `http::Request` object. This drops the body (if any). pub fn into_inner(mut self) -> HttpRequest> { mem::replace(&mut self.inner, HttpRequest::new(Vec::new())) } - /// Split this request into its HTTP request and abort signal. - fn into_parts(mut self) -> (HttpRequest>, Option) { + /// Split this request into its HTTP request, abort signal, and body state. + fn into_parts(mut self) -> (HttpRequest>, Option, bool) { let request = mem::replace(&mut self.inner, HttpRequest::new(Vec::new())); let signal = self.signal.take(); - (request, signal) + (request, signal, self.has_body) } /// Get a reference to the inner `http::Request` object. @@ -140,6 +160,44 @@ impl JsRequest { self.signal.clone() } + pub(crate) fn has_body(&self) -> bool { + self.has_body + } + + pub(crate) fn ensure_body_unused(&self) -> JsResult<()> { + if self.is_body_used() { + return Err(js_error!(TypeError: "Body has already been used")); + } + Ok(()) + } + + pub(crate) fn mark_body_used(&self) { + if self.has_body { + self.body_used.set(true); + } + } + + // The consume body algorithm, given an object that includes Body and an + // algorithm that converts bytes to a JavaScript value, runs these steps: + // 1. If object is unusable, then return a promise rejected with a TypeError. + // TODO: 2-3. Create a promise and wire its success and error steps. + // 4. If object's body is null, then run successSteps with an empty byte sequence. + // TODO: 5. Fully read object's body stream. + // + // Boa currently models request bodies as eagerly buffered bytes, so after + // checking whether the body is unusable, we can return the stored bytes + // directly to the callers that build the resulting promise. + // See . + fn consume_body(&self) -> JsResult>> { + self.ensure_body_unused()?; + self.mark_body_used(); + Ok(Rc::new(self.inner.body().clone())) + } + + fn is_body_used(&self) -> bool { + self.has_body && self.body_used.get() + } + /// Get the URI of the request. pub fn uri(&self) -> &http::Uri { self.inner.uri() @@ -154,7 +212,9 @@ impl JsRequest { input: Either, options: Option, ) -> JsResult { - let (request, signal) = match input { + let body_overridden = options.as_ref().is_some_and(RequestInit::has_body); + + let (request, signal, has_body) = match input { Either::Left(uri) => { let uri = http::Uri::try_from( uri.to_std_string() @@ -165,30 +225,30 @@ impl JsRequest { .uri(uri) .body(Vec::::new()) .map_err(|_| js_error!(Error: "Cannot construct request"))?; - (request, None) + (request, None, false) + } + Either::Right(r) => { + if !body_overridden { + r.ensure_body_unused()?; + } + r.into_parts() } - Either::Right(r) => r.into_parts(), }; if let Some(mut options) = options { let signal = options.take_signal().or(signal); let inner = options.into_request_builder(Some(request))?; - Ok(Self { inner, signal }) + Ok(Self::new(inner, signal, body_overridden || has_body)) } else { - Ok(Self { - inner: request, - signal, - }) + Ok(Self::new(request, signal, has_body)) } } } impl From>> for JsRequest { fn from(inner: HttpRequest>) -> Self { - Self { - inner, - signal: None, - } + let has_body = !inner.body().is_empty(); + Self::new(inner, None, has_body) } } @@ -203,23 +263,70 @@ impl JsRequest { input: Either, options: Option, ) -> JsResult { - // Need to use a match as `Either::map_right` does not have an equivalent - // `Either::map_right_ok`. + let body_overridden = options.as_ref().is_some_and(RequestInit::has_body); + let mut source_request = None; let input = match input { Either::Right(r) => { - if let Ok(request) = r.clone().downcast::() { - Either::Right(request.borrow().data().clone()) + if let Ok(request_obj) = r.clone().downcast::() { + { + let request_ref = request_obj.borrow(); + let request = request_ref.data(); + if !body_overridden { + request.ensure_body_unused()?; + } + source_request = Some(request_obj.clone()); + } + + let request = request_obj.borrow(); + Either::Right(request.data().clone()) } else { return Err(js_error!(TypeError: "invalid input argument")); } } Either::Left(i) => Either::Left(i), }; - JsRequest::create_from_js(input, options) + let request = JsRequest::create_from_js(input, options)?; + + if !body_overridden && let Some(source_request) = source_request { + source_request.borrow().data().mark_body_used(); + } + + Ok(request) } + /// Returns whether the request body has been consumed. + /// + /// See . + #[boa(getter)] + fn body_used(&self) -> bool { + // The bodyUsed getter steps are to return true if this's body is + // non-null and this's body's stream is disturbed; otherwise false. + self.is_body_used() + } + + /// Returns a copy of this request. + /// + /// See . #[boa(rename = "clone")] - fn clone_request(&self) -> Self { - self.clone() + fn clone_request(&self) -> JsResult { + // The clone() method steps are: + // 1. If this is unusable, then throw a TypeError. + // 2. Let clonedRequest be the result of cloning this's request. + // TODO: 4-6. Clone the associated signal by creating a dependent abort signal. + // 7. Return the cloned request object. + self.ensure_body_unused()?; + Ok(self.clone()) + } + + fn bytes(&self, context: &mut Context) -> JsResult { + Ok(body::bytes(self.consume_body()?, context)) + } + + fn text(&self, context: &mut Context) -> JsResult { + Ok(body::text(self.consume_body()?, context)) + } + + fn json(&self, context: &mut Context) -> JsResult { + Ok(body::json(self.consume_body()?, context)) } } diff --git a/core/runtime/src/fetch/response.rs b/core/runtime/src/fetch/response.rs index cd13f55eba5..a023935f1f0 100644 --- a/core/runtime/src/fetch/response.rs +++ b/core/runtime/src/fetch/response.rs @@ -4,9 +4,9 @@ //! See the [Response interface documentation][mdn] for more information. //! //! [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/Response - +use crate::fetch::body; use crate::fetch::headers::JsHeaders; -use boa_engine::object::builtins::{JsPromise, JsUint8Array}; +use boa_engine::object::builtins::JsPromise; use boa_engine::value::{Convert, TryFromJs, TryIntoJs}; use boa_engine::{ Context, JsData, JsNativeError, JsResult, JsString, JsValue, boa_class, js_error, js_str, @@ -459,38 +459,14 @@ impl JsResponse { } fn bytes(&self, context: &mut Context) -> JsPromise { - let body = self.body.clone(); - JsPromise::from_async_fn( - async move |context| { - JsUint8Array::from_iter(body.iter().copied(), &mut context.borrow_mut()) - .map(Into::into) - }, - context, - ) + body::bytes(self.body.clone(), context) } fn text(&self, context: &mut Context) -> JsPromise { - let body = self.body.clone(); - JsPromise::from_async_fn( - async move |_| { - let body = String::from_utf8_lossy(body.as_ref()); - Ok(JsString::from(body).into()) - }, - context, - ) + body::text(self.body.clone(), context) } fn json(&self, context: &mut Context) -> JsPromise { - let body = self.body.clone(); - JsPromise::from_async_fn( - async move |context| { - let json_string = String::from_utf8_lossy(body.as_ref()); - let json = serde_json::from_str::(&json_string) - .map_err(|e| JsNativeError::syntax().with_message(e.to_string()))?; - - JsValue::from_json(&json, &mut context.borrow_mut()) - }, - context, - ) + body::json(self.body.clone(), context) } } diff --git a/core/runtime/src/fetch/tests/request.rs b/core/runtime/src/fetch/tests/request.rs index b14a7c84859..e5c344b83a3 100644 --- a/core/runtime/src/fetch/tests/request.rs +++ b/core/runtime/src/fetch/tests/request.rs @@ -354,3 +354,251 @@ fn request_clone_signal_override() { }), ]); } + +#[test] +fn request_body_methods() { + run_test_actions([ + TestAction::harness(), + TestAction::inspect_context(|ctx| { + let fetcher = TestFetcher::default(); + crate::fetch::register(fetcher, None, ctx).expect("failed to register fetch"); + }), + TestAction::run( + r#" + globalThis.promise = (async () => { + const textRequest = new Request("http://unit.test", { + method: "POST", + body: "", + }); + assertEq(textRequest.bodyUsed, false); + assertEq(await textRequest.text(), ""); + assertEq(textRequest.bodyUsed, true); + + const bytesRequest = new Request("http://unit.test", { + method: "POST", + body: "hello", + }); + const bytes = await bytesRequest.bytes(); + assertEq(new TextDecoder().decode(bytes), "hello"); + assertEq(bytesRequest.bodyUsed, true); + + const jsonRequest = new Request("http://unit.test", { + method: "POST", + body: '{ "value": 1 }', + }); + const json = await jsonRequest.json(); + assertEq(json.value, 1); + assertEq(jsonRequest.bodyUsed, true); + })(); + "#, + ), + TestAction::inspect_context(|ctx| { + let promise = ctx.global_object().get(js_str!("promise"), ctx).unwrap(); + promise.as_promise().unwrap().await_blocking(ctx).unwrap(); + }), + ]); +} + +#[test] +fn request_without_body_is_not_disturbed_by_reads() { + run_test_actions([ + TestAction::harness(), + TestAction::inspect_context(|ctx| { + let fetcher = TestFetcher::default(); + crate::fetch::register(fetcher, None, ctx).expect("failed to register fetch"); + }), + TestAction::run( + r#" + globalThis.promise = (async () => { + const request = new Request("http://unit.test"); + assertEq(await request.text(), ""); + assertEq(await request.text(), ""); + assertEq(request.bodyUsed, false); + const cloned = request.clone(); + assertEq(cloned instanceof Request, true); + })(); + "#, + ), + TestAction::inspect_context(|ctx| { + let promise = ctx.global_object().get(js_str!("promise"), ctx).unwrap(); + promise.as_promise().unwrap().await_blocking(ctx).unwrap(); + }), + ]); +} + +#[test] +fn request_used_body_cannot_be_reused() { + run_test_actions([ + TestAction::harness(), + TestAction::inspect_context(|ctx| { + let fetcher = TestFetcher::default(); + crate::fetch::register(fetcher, None, ctx).expect("failed to register fetch"); + }), + TestAction::run( + r#" + globalThis.promise = (async () => { + const request = new Request("http://unit.test", { + method: "POST", + body: "payload", + }); + + assertEq(await request.text(), "payload"); + + for (const action of [ + () => request.clone(), + () => new Request(request), + ]) { + try { + action(); + throw Error("expected the call above to throw"); + } catch (e) { + if (!(e instanceof TypeError)) { + throw e; + } + } + } + + const overridden = new Request(request, { + method: "POST", + body: "override", + }); + assertEq(await overridden.text(), "override"); + })(); + "#, + ), + TestAction::inspect_context(|ctx| { + let promise = ctx.global_object().get(js_str!("promise"), ctx).unwrap(); + promise.as_promise().unwrap().await_blocking(ctx).unwrap(); + }), + ]); +} + +#[test] +fn request_constructor_consumes_source_body() { + run_test_actions([ + TestAction::harness(), + TestAction::inspect_context(|ctx| { + let fetcher = TestFetcher::default(); + crate::fetch::register(fetcher, None, ctx).expect("failed to register fetch"); + }), + TestAction::run( + r#" + globalThis.promise = (async () => { + const withBody = new Request("http://unit.test", { + method: "POST", + body: "payload", + }); + const copied = new Request(withBody); + assertEq(withBody.bodyUsed, true); + assertEq(await copied.text(), "payload"); + + const withEmptyBody = new Request("http://unit.test", { + method: "POST", + body: "", + }); + const copiedEmpty = new Request(withEmptyBody); + assertEq(withEmptyBody.bodyUsed, true); + assertEq(await copiedEmpty.text(), ""); + + const withoutBody = new Request("http://unit.test"); + const copiedWithoutBody = new Request(withoutBody); + assertEq(withoutBody.bodyUsed, false); + assertEq(await copiedWithoutBody.text(), ""); + + const overridden = new Request("http://unit.test", { + method: "POST", + body: "payload", + }); + const overrideCopy = new Request(overridden, { + method: "POST", + body: "override", + }); + assertEq(overridden.bodyUsed, false); + assertEq(await overrideCopy.text(), "override"); + })(); + "#, + ), + TestAction::inspect_context(|ctx| { + let promise = ctx.global_object().get(js_str!("promise"), ctx).unwrap(); + promise.as_promise().unwrap().await_blocking(ctx).unwrap(); + }), + ]); +} + +#[test] +fn request_constructor_does_not_consume_source_when_it_throws() { + run_test_actions([ + TestAction::harness(), + TestAction::inspect_context(|ctx| { + let fetcher = TestFetcher::default(); + crate::fetch::register(fetcher, None, ctx).expect("failed to register fetch"); + }), + TestAction::run( + r#" + const request = new Request("http://unit.test", { + method: "POST", + body: "payload", + }); + + try { + new Request(request, { method: "CONNECT" }); + throw Error("expected the call above to throw"); + } catch (e) { + if (!(e instanceof TypeError)) { + throw e; + } + } + + assertEq(request.bodyUsed, false); + "#, + ), + ]); +} + +#[test] +fn fetch_marks_request_body_used() { + run_test_actions([ + TestAction::harness(), + TestAction::inspect_context(|ctx| { + let mut fetcher = TestFetcher::default(); + fetcher.add_response( + Uri::from_static("http://unit.test"), + Response::new("ok".as_bytes().to_vec()), + ); + crate::fetch::register(fetcher, None, ctx).expect("failed to register fetch"); + }), + TestAction::run( + r#" + globalThis.promise = (async () => { + const request = new Request("http://unit.test", { + method: "POST", + body: "payload", + }); + + const response = await fetch(request); + assertEq(await response.text(), "ok"); + assertEq(request.bodyUsed, true); + + try { + await fetch(request); + throw Error("expected the call above to throw"); + } catch (e) { + if (!(e instanceof TypeError)) { + throw e; + } + } + + const overrideResponse = await fetch(request, { + method: "POST", + body: "override", + }); + assertEq(await overrideResponse.text(), "ok"); + })(); + "#, + ), + TestAction::inspect_context(|ctx| { + let promise = ctx.global_object().get(js_str!("promise"), ctx).unwrap(); + promise.as_promise().unwrap().await_blocking(ctx).unwrap(); + }), + ]); +}