mirror of
https://github.com/lexmount/moli.git
synced 2026-10-06 08:00:59 +00:00
fix(xhr): honor null and empty response bodies
Share Fetch body filtering with local responses and XHR, complete null-body streams at headers, and preserve replacement request progress when old completions arrive. Cover Window and Worker response types, synthetic null-body statuses, empty response event order, and request reuse.
This commit is contained in:
@@ -1846,6 +1846,12 @@ impl JsContextHost {
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
if crate::network_host::response_has_null_body(&state.request_method, state.head.status)
|
||||
{
|
||||
// Fetch disregards enqueuing into a null response body. Do not
|
||||
// retain, decode, or publish bytes from an invalid response.
|
||||
return None;
|
||||
}
|
||||
let context = v8::Local::new(scope, state.pending.execution_context.context_global()?);
|
||||
let xhr = v8::Local::new(scope, xhr);
|
||||
state.body_writer.append(bytes);
|
||||
|
||||
@@ -1051,7 +1051,10 @@ async fn local_blob_method_errors_reach_streaming_and_callback_consumers() -> Re
|
||||
data.status, 200,
|
||||
"data: must not inherit the blob method restriction"
|
||||
);
|
||||
assert_eq!(data.body_text(), "payload");
|
||||
assert_eq!(
|
||||
data.body_text(),
|
||||
if method == "HEAD" { "" } else { "payload" }
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ pub(crate) use self::response::{
|
||||
materialize_response_object_head,
|
||||
materialize_response_object_head_for_service_worker_respond_with,
|
||||
materialized_body_bytes_from_value, network_response_filter, response_constructor_callback,
|
||||
validate_cors_preflight_response, validate_cors_response_chain,
|
||||
response_has_null_body, validate_cors_preflight_response, validate_cors_response_chain,
|
||||
validate_cross_origin_embedder_and_document_isolation_policy,
|
||||
validate_cross_origin_resource_policy, validate_fetch_response_headers,
|
||||
validate_fetch_response_security_policy, validate_fetch_response_security_policy_with_body,
|
||||
|
||||
@@ -71,7 +71,7 @@ pub(crate) fn local_url_response_with_blob_entry(
|
||||
method: &str,
|
||||
entry: Option<&CapturedBlobUrl>,
|
||||
) -> Option<Result<Response, String>> {
|
||||
match url.scheme() {
|
||||
let result = match url.scheme() {
|
||||
"blob" if method != "GET" => {
|
||||
Some(Err(format!("blob URL fetch requires GET, got `{method}`")))
|
||||
}
|
||||
@@ -86,7 +86,16 @@ pub(crate) fn local_url_response_with_blob_entry(
|
||||
Some(data_url_response(url).ok_or_else(|| format!("data URL `{url}` is invalid")))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}?;
|
||||
Some(result.map(|response| {
|
||||
if !response_has_null_body(method, response.head().status) {
|
||||
return response;
|
||||
}
|
||||
// Apply the Fetch body filter before recording or delivering a local
|
||||
// response, while preserving its status, MIME type and other headers.
|
||||
let (head, _) = response.into_body();
|
||||
Response::from_head_and_lossy_body_bytes(head, Vec::new())
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -9,6 +9,11 @@ use super::headers::{
|
||||
};
|
||||
use super::*;
|
||||
|
||||
/// Fetch's main-fetch body filter, given an already normalized request method.
|
||||
pub(crate) fn response_has_null_body(method: &str, status: u16) -> bool {
|
||||
matches!(method, "HEAD" | "CONNECT") || matches!(status, 101 | 103 | 204 | 205 | 304)
|
||||
}
|
||||
|
||||
pub(crate) use self::bindings::response_constructor_callback;
|
||||
pub(in crate::network_host) use self::bindings::{ParsedResponseInit, parse_response_init};
|
||||
pub(super) use self::body_methods::install_response_body_methods;
|
||||
|
||||
@@ -15,12 +15,6 @@ pub(crate) struct FetchResponseRequest<'a> {
|
||||
pub(crate) mode: RequestMode,
|
||||
}
|
||||
|
||||
impl FetchResponseRequest<'_> {
|
||||
fn has_null_body(self, status: u16) -> bool {
|
||||
matches!(self.method, "HEAD" | "CONNECT") || matches!(status, 101 | 103 | 204 | 205 | 304)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_redirect_status(status: u16) -> bool {
|
||||
matches!(status, 301 | 302 | 303 | 307 | 308)
|
||||
}
|
||||
@@ -222,7 +216,7 @@ pub(crate) fn build_fetch_response_object_from_body_source_for_request_mode_with
|
||||
.map(FetchResponseFilter::from)
|
||||
.unwrap_or_else(|| response_filter(&request_origin, &head, request.mode));
|
||||
let obj = build_fetch_response_object_head(scope, &request_origin, &head, filter, None);
|
||||
let body_stream = if request.has_null_body(head.status) {
|
||||
let body_stream = if response_has_null_body(request.method, head.status) {
|
||||
None
|
||||
} else if filtered_response_exposes_body(filter) {
|
||||
network_body_stream_from_response_body(scope, obj, body)
|
||||
@@ -243,7 +237,7 @@ pub(crate) fn build_fetch_response_object_from_subresource_body_for_request_mode
|
||||
let request_origin = request_origin.into();
|
||||
let filter = response_filter(&request_origin, &head, request.mode);
|
||||
let obj = build_fetch_response_object_head(scope, &request_origin, &head, filter, None);
|
||||
let body_stream = if request.has_null_body(head.status) {
|
||||
let body_stream = if response_has_null_body(request.method, head.status) {
|
||||
None
|
||||
} else if filtered_response_exposes_body(filter) {
|
||||
Some(network_body_stream_from_subresource_body(scope, obj, body))
|
||||
@@ -333,7 +327,7 @@ fn build_fetch_response_object_from_stream_for_request_mode_with_surface_url<'s>
|
||||
filter,
|
||||
filtered_surface_url,
|
||||
);
|
||||
if request.has_null_body(head.status) {
|
||||
if response_has_null_body(request.method, head.status) {
|
||||
// Fetch nulls the internal body, including for filtered responses. Do
|
||||
// not register a body source: subsequent transport chunks and terminal
|
||||
// events then have no stream to enqueue into or retain bytes for.
|
||||
|
||||
@@ -61,7 +61,8 @@ pub(super) fn flush_xhr_streaming_progress(
|
||||
internal_id: u64,
|
||||
) -> bool {
|
||||
if !xhr_stream_is_current(scope, xhr, internal_id) {
|
||||
clear_xhr_progress_throttle(scope, xhr);
|
||||
// open()/abort() retire the old progress state. A stale completion
|
||||
// must not clear the timer or deferred progress of a newer request.
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -125,6 +125,13 @@ fn apply_xhr_response_body(
|
||||
status_text: Option<&str>,
|
||||
mode: XhrResponseDeliveryMode,
|
||||
) {
|
||||
// Fulfilled and worker responses can bypass the HTTP transport's body
|
||||
// filtering. Discard forbidden bytes before decoding any response type.
|
||||
let body_bytes = if xhr_response_has_null_body(scope, xhr, &head) {
|
||||
Vec::new()
|
||||
} else {
|
||||
body_bytes
|
||||
};
|
||||
let Some(response_type) = prepare_xhr_response(scope, xhr, &head, status_text, mode) else {
|
||||
return;
|
||||
};
|
||||
@@ -200,6 +207,7 @@ fn apply_xhr_response_body(
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns whether the transport state should remain registered.
|
||||
pub(crate) fn apply_xhr_streaming_response_head(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
xhr: v8::Local<'_, v8::Object>,
|
||||
@@ -211,8 +219,28 @@ pub(crate) fn apply_xhr_streaming_response_head(
|
||||
}
|
||||
set_xhr_response_head(scope, xhr, head, None);
|
||||
super::super::events::xhr_fire_readystatechange(scope, xhr, 2);
|
||||
!scope.is_execution_terminating()
|
||||
&& super::progress::xhr_stream_is_current(scope, xhr, internal_id)
|
||||
if scope.is_execution_terminating()
|
||||
|| !super::progress::xhr_stream_is_current(scope, xhr, internal_id)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if xhr_response_has_null_body(scope, xhr, head) {
|
||||
// XHR handles a null body's end immediately after HEADERS_RECEIVED.
|
||||
// Keep transport bookkeeping alive, but retire this XHR delivery so
|
||||
// subsequent chunks and terminal errors cannot alter its response.
|
||||
let generation = xhr_state_number_property(scope, xhr, XHR_OPEN_GENERATION_SLOT);
|
||||
apply_xhr_streaming_response_body_source(
|
||||
scope,
|
||||
xhr,
|
||||
head.clone(),
|
||||
moli_fetch::ResponseBody::materialized_bytes(Vec::new()),
|
||||
internal_id,
|
||||
);
|
||||
return !scope.is_execution_terminating()
|
||||
&& !xhr_is_aborted(scope, xhr)
|
||||
&& xhr_state_number_property(scope, xhr, XHR_OPEN_GENERATION_SLOT) == generation;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn apply_xhr_streaming_response_chunk(
|
||||
@@ -324,7 +352,7 @@ fn finish_xhr_response(
|
||||
matches!(mode, XhrResponseDeliveryMode::Buffered) && xhr_is_async(scope, xhr);
|
||||
set_xhr_state_string(scope, xhr, XHR_RESPONSE_TEXT_SLOT, response_text);
|
||||
set_xhr_state_value(scope, xhr, XHR_RESPONSE_SLOT, response_val);
|
||||
if dispatch_intermediate_events {
|
||||
if dispatch_intermediate_events && progress.loaded > 0.0 {
|
||||
super::super::events::xhr_fire_readystatechange(scope, xhr, 3);
|
||||
if scope.is_execution_terminating() {
|
||||
return;
|
||||
@@ -333,7 +361,11 @@ fn finish_xhr_response(
|
||||
return;
|
||||
}
|
||||
}
|
||||
if dispatch_intermediate_events {
|
||||
// A zero-byte stream has no chunk that could have dispatched progress.
|
||||
// It still needs the end-of-body event, without synthesizing LOADING.
|
||||
if xhr_is_async(scope, xhr)
|
||||
&& (matches!(mode, XhrResponseDeliveryMode::Buffered) || progress.loaded == 0.0)
|
||||
{
|
||||
xhr_dispatch_progress_event_with_length_computable(
|
||||
scope,
|
||||
xhr,
|
||||
@@ -382,12 +414,14 @@ fn xhr_response_progress(head: &moli_fetch::ResponseHead, loaded: f64) -> XhrRes
|
||||
if matches!(head.final_url.scheme(), "data" | "blob") {
|
||||
return XhrResponseProgress {
|
||||
loaded,
|
||||
length_computable: true,
|
||||
length_computable: loaded > 0.0,
|
||||
total: loaded,
|
||||
};
|
||||
}
|
||||
|
||||
let total = identity_encoded_content_length(&head.headers).map(|value| value as f64);
|
||||
let total = identity_encoded_content_length(&head.headers)
|
||||
.filter(|value| *value > 0)
|
||||
.map(|value| value as f64);
|
||||
XhrResponseProgress {
|
||||
loaded,
|
||||
length_computable: total.is_some(),
|
||||
@@ -449,6 +483,10 @@ fn parse_xhr_response_document<'s>(
|
||||
response_type: XmlHttpRequestResponseType,
|
||||
character_set: &str,
|
||||
) -> v8::Local<'s, v8::Value> {
|
||||
// An empty HTML body can create a Document, but a null body cannot.
|
||||
if xhr_response_has_null_body(scope, xhr, head) {
|
||||
return v8::null(scope).into();
|
||||
}
|
||||
// XHR defaults a missing or invalid response MIME type to text/xml.
|
||||
// Its XML MIME types include any +xml subtype, unlike DOMParser's enum.
|
||||
let mime = xhr_response_mime_essence(scope, xhr, &head.headers)
|
||||
@@ -463,6 +501,15 @@ fn parse_xhr_response_document<'s>(
|
||||
.unwrap_or_else(|| v8::null(scope).into())
|
||||
}
|
||||
|
||||
fn xhr_response_has_null_body(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
xhr: v8::Local<'_, v8::Object>,
|
||||
head: &moli_fetch::ResponseHead,
|
||||
) -> bool {
|
||||
let method = xhr_state_string_property(scope, xhr, XHR_METHOD_SLOT).unwrap_or_default();
|
||||
response_has_null_body(&method, head.status)
|
||||
}
|
||||
|
||||
fn build_xhr_response_document<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
xhr: v8::Local<'_, v8::Object>,
|
||||
|
||||
@@ -4925,14 +4925,14 @@ impl ScriptVm {
|
||||
event_source_parser: None,
|
||||
xhr_response: Some(xhr_response),
|
||||
});
|
||||
let remains_current =
|
||||
let keep_stream =
|
||||
crate::network_host::apply_xhr_streaming_response_head(
|
||||
scope,
|
||||
xhr,
|
||||
&observable_head,
|
||||
started.internal_id,
|
||||
);
|
||||
if !remains_current {
|
||||
if !keep_stream {
|
||||
// Registering the stream before dispatching state 2 lets abort()
|
||||
// retire the transport synchronously. open() and isolate
|
||||
// termination also invalidate the old XHR generation; retire
|
||||
|
||||
@@ -6,6 +6,7 @@ mod dom;
|
||||
mod file_input;
|
||||
mod forms;
|
||||
mod misc;
|
||||
mod null_body;
|
||||
mod open_validation;
|
||||
mod query_realms;
|
||||
mod response_type;
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
use super::*;
|
||||
use crate::util::v8str;
|
||||
|
||||
fn start_probe(vm: &mut crate::runtime::PageVmTaskExecutorTestHarness, probe: &str, worker: bool) {
|
||||
let source = if worker {
|
||||
let script = format!(
|
||||
"Promise.resolve().then(() => {probe}).then(value => {{ postMessage(value); close(); }}, error => {{ postMessage(String(error.stack || error)); close(); }});"
|
||||
);
|
||||
format!(
|
||||
r#"globalThis.__nullBodyResult = 'pending';
|
||||
const worker = new Worker(URL.createObjectURL(new Blob([{}], {{type: 'text/javascript'}})));
|
||||
worker.onmessage = event => {{ globalThis.__nullBodyResult = event.data; }};
|
||||
worker.onerror = event => {{ globalThis.__nullBodyResult = event.message; event.preventDefault(); }};"#,
|
||||
serde_json::to_string(&script).unwrap()
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
r#"globalThis.__nullBodyResult = 'pending';
|
||||
Promise.resolve().then(() => {probe}).then(
|
||||
value => {{ globalThis.__nullBodyResult = value; }},
|
||||
error => {{ globalThis.__nullBodyResult = String(error.stack || error); }});"#
|
||||
)
|
||||
};
|
||||
vm.eval(&source).expect("start null response body probe");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn xhr_null_body_data_urls_preserve_headers_and_response_types_in_window_and_worker() {
|
||||
for worker in [false, true] {
|
||||
let loader = static_http_loader(std::iter::empty::<String>());
|
||||
let mut vm =
|
||||
new_page_task_executor_test_vm_with_loader("https://xhr-null-body.test/", &loader);
|
||||
start_probe(
|
||||
&mut vm,
|
||||
r#"
|
||||
(async () => {
|
||||
const assert = (value, message) => { if (!value) throw new Error(message); };
|
||||
const worker = typeof document === 'undefined';
|
||||
for (const async of [true, false]) {
|
||||
const cases = [
|
||||
['', 'text/plain;charset=utf-8', 'hello'],
|
||||
['text', 'text/plain', 'hello'],
|
||||
['arraybuffer', 'application/octet-stream', '%00%FF'],
|
||||
['blob', 'application/octet-stream', '%00%FF'],
|
||||
['json', 'application/json', '%7B%22value%22%3A7%7D'],
|
||||
['document', 'text/html', '%3Cp%3Ehello']
|
||||
];
|
||||
for (const [type, mime, body] of cases) {
|
||||
if ((!async && !worker && type !== '') || (worker && type === 'document')) continue;
|
||||
const xhr = new XMLHttpRequest(), events = [];
|
||||
xhr.onreadystatechange = () => events.push(xhr.readyState);
|
||||
for (const name of ['loadstart', 'progress', 'load', 'loadend']) {
|
||||
xhr.addEventListener(name, e => events.push(`${name}:${e.loaded}:${e.total}:${e.lengthComputable}`));
|
||||
}
|
||||
const done = new Promise(resolve => xhr.addEventListener('loadend', resolve));
|
||||
const url = `data:${mime},${body}`;
|
||||
xhr.open('hEaD', url, async);
|
||||
if (async || worker) xhr.responseType = type;
|
||||
xhr.send();
|
||||
if (async) await done;
|
||||
assert(xhr.status === 200 && xhr.responseURL === url, 'HEAD status and URL');
|
||||
assert(xhr.getResponseHeader('content-type') === mime, 'HEAD preserves MIME');
|
||||
assert(xhr.getResponseHeader('content-length') === null, 'data URLs do not acquire Content-Length');
|
||||
if (type === '' || type === 'text') assert(xhr.responseText === '' && xhr.response === '', 'HEAD text is empty');
|
||||
else if (type === 'arraybuffer') assert(xhr.response instanceof ArrayBuffer && xhr.response.byteLength === 0, 'HEAD ArrayBuffer is empty');
|
||||
else if (type === 'blob') assert(xhr.response instanceof Blob && xhr.response.size === 0 && xhr.response.type === mime, 'HEAD Blob is empty and retains MIME');
|
||||
else assert(xhr.response === null, 'HEAD JSON and Document are null');
|
||||
if (!worker && (type === '' || type === 'document')) assert(xhr.responseXML === null, 'HEAD has no response Document');
|
||||
const expected = async
|
||||
? [1, 'loadstart:0:0:false', 2, 'progress:0:0:false', 4, 'load:0:0:false', 'loadend:0:0:false']
|
||||
: [1, 4, 'load:0:0:false', 'loadend:0:0:false'];
|
||||
assert(JSON.stringify(events) === JSON.stringify(expected), `HEAD ${type}/${async}: ${JSON.stringify(events)}`);
|
||||
}
|
||||
}
|
||||
for (const method of ['GET', 'POST', 'PUT', 'DELETE', 'UNICORN']) {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const done = new Promise(resolve => xhr.onloadend = resolve);
|
||||
xhr.open(method, 'data:text/plain;base64,aGVsbG8=');
|
||||
xhr.send();
|
||||
await done;
|
||||
assert(xhr.status === 200 && xhr.responseText === 'hello', method + ' keeps the data body');
|
||||
}
|
||||
if (!worker) {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const done = new Promise(resolve => xhr.onloadend = resolve);
|
||||
xhr.open('GET', 'data:text/html,');
|
||||
xhr.responseType = 'document';
|
||||
xhr.send();
|
||||
await done;
|
||||
assert(xhr.response instanceof Document && xhr.response.documentElement.localName === 'html', 'an empty non-null HTML body still creates a Document');
|
||||
}
|
||||
{
|
||||
const xhr = new XMLHttpRequest(), events = [];
|
||||
xhr.onprogress = () => { events.push('progress:' + xhr.readyState); xhr.abort(); };
|
||||
xhr.onabort = () => events.push('abort');
|
||||
xhr.onload = () => events.push('load');
|
||||
const done = new Promise(resolve => xhr.onloadend = () => { events.push('loadend'); resolve(); });
|
||||
xhr.open('HEAD', 'data:text/plain,hello');
|
||||
xhr.send();
|
||||
await done;
|
||||
assert(xhr.readyState === 0 && xhr.status === 0, 'abort from zero-byte progress clears the response');
|
||||
assert(events.join(',') === 'progress:2,abort,loadend', 'abort from progress prevents load: ' + events);
|
||||
}
|
||||
for (const url of ['data:text/plain,hello', 'data:application/octet-stream;base64,AP8=']) {
|
||||
const response = await fetch(url, {method: 'HEAD'});
|
||||
const clone = response.clone();
|
||||
assert(response.status === 200 && response.body === null && clone.body === null, 'Fetch keeps a null body');
|
||||
assert(await response.text() === '' && (await clone.arrayBuffer()).byteLength === 0, 'Fetch HEAD consumers are empty');
|
||||
assert(!response.bodyUsed && !clone.bodyUsed, 'null bodies remain undisturbed');
|
||||
}
|
||||
const blobURL = URL.createObjectURL(new Blob(['hello']));
|
||||
for (const url of [blobURL, 'data:missing-comma']) {
|
||||
const xhr = new XMLHttpRequest();
|
||||
let failed = false;
|
||||
const done = new Promise(resolve => xhr.onloadend = resolve);
|
||||
xhr.onerror = () => { failed = true; };
|
||||
xhr.open('HEAD', url);
|
||||
xhr.send();
|
||||
await done;
|
||||
assert(failed && xhr.status === 0, 'HEAD preserves local URL failures: ' + url);
|
||||
}
|
||||
URL.revokeObjectURL(blobURL);
|
||||
return 'ok';
|
||||
})()
|
||||
"#,
|
||||
worker,
|
||||
);
|
||||
advance_page_task_executor_until_eval_equals(
|
||||
&mut vm,
|
||||
&loader,
|
||||
"globalThis.__nullBodyResult",
|
||||
"ok",
|
||||
"Window/Worker HEAD data response",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xhr_null_body_statuses_discard_buffered_and_streamed_bytes() {
|
||||
for streaming in [false, true] {
|
||||
for (method, status) in [
|
||||
("HEAD", 200),
|
||||
("GET", 101),
|
||||
("GET", 103),
|
||||
("GET", 204),
|
||||
("POST", 205),
|
||||
("GET", 304),
|
||||
] {
|
||||
for response_type in ["", "text", "json", "arraybuffer", "blob", "document"] {
|
||||
let mut vm = new_storage_test_vm("https://xhr-null-status.test/");
|
||||
vm.set_fetch_subresource_interception(
|
||||
true,
|
||||
Some(crate::types::SubresourceResourceType::Xhr),
|
||||
);
|
||||
vm.eval(&format!(r#"
|
||||
globalThis.xhr = new XMLHttpRequest();
|
||||
globalThis.events = [];
|
||||
xhr.onreadystatechange = () => events.push(xhr.readyState);
|
||||
for (const name of ['progress', 'load', 'loadend'])
|
||||
xhr.addEventListener(name, e => events.push(`${{name}}:${{e.loaded}}:${{e.total}}:${{e.lengthComputable}}`));
|
||||
xhr.open('{method}', '/response');
|
||||
xhr.responseType = '{response_type}';
|
||||
xhr.send();
|
||||
"#)).unwrap();
|
||||
let pending = vm.take_pending_subresource_fetch_infos();
|
||||
assert_eq!(pending.len(), 1);
|
||||
let request = &pending[0];
|
||||
let head = moli_fetch::ResponseHead {
|
||||
final_url: request.url.clone(),
|
||||
status,
|
||||
status_text: Some("Preserved".to_owned()),
|
||||
headers: vec![
|
||||
("Content-Type".to_owned(), "text/html".to_owned()),
|
||||
("Content-Length".to_owned(), "7".to_owned()),
|
||||
],
|
||||
request_cookie_report: None,
|
||||
cookie_set_reports: Vec::new(),
|
||||
redirected: false,
|
||||
redirect_chain: Vec::new(),
|
||||
from_cache: false,
|
||||
negotiated_http_version: None,
|
||||
};
|
||||
if streaming {
|
||||
let body_source_id = crate::network_host::new_network_body_source_id();
|
||||
vm.start_streaming_async_subresource_fetch(
|
||||
crate::types::AsyncSubresourceStreamingStarted {
|
||||
skip_fetch_security_validation: false,
|
||||
response_filter: None,
|
||||
internal_id: request.internal_id,
|
||||
request_url: request.url.clone(),
|
||||
request_method: method.to_owned(),
|
||||
request_headers: Vec::new(),
|
||||
request_body: None,
|
||||
body_source_id,
|
||||
head,
|
||||
network_request_headers: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let complete_events =
|
||||
r#"[1,2,"progress:0:7:true",4,"load:0:7:true","loadend:0:7:true"]"#;
|
||||
assert_eq!(
|
||||
vm.eval("JSON.stringify(events)").unwrap(),
|
||||
complete_events,
|
||||
"a null body completes at headers, before transport EOF: {method}/{status}/{response_type}"
|
||||
);
|
||||
vm.append_streaming_async_subresource_fetch_chunk(
|
||||
body_source_id,
|
||||
b"<p>body".to_vec(),
|
||||
);
|
||||
assert_eq!(
|
||||
vm.eval("JSON.stringify(events)").unwrap(),
|
||||
complete_events,
|
||||
"null-body chunks must not expose additional events: {method}/{status}/{response_type}"
|
||||
);
|
||||
vm.finish_streaming_async_subresource_fetch(
|
||||
request.internal_id,
|
||||
body_source_id,
|
||||
if status == 205 {
|
||||
Err("invalid response body was truncated".to_owned())
|
||||
} else {
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
let context_ptr: *const v8::Global<v8::Context> = &vm.page_default_context;
|
||||
vm.renderer_document_isolate
|
||||
.with_entered_renderer_document_isolate(move |isolate| {
|
||||
let scope = std::pin::pin!(v8::HandleScope::new(isolate));
|
||||
let scope = &mut scope.init();
|
||||
let context = unsafe { v8::Local::new(scope, &*context_ptr) };
|
||||
let scope = &mut v8::ContextScope::new(scope, context);
|
||||
let xhr = context
|
||||
.global(scope)
|
||||
.get(scope, v8str(scope, "xhr").into())
|
||||
.unwrap()
|
||||
.to_object(scope)
|
||||
.unwrap();
|
||||
crate::network_host::apply_xhr_response_body_source(
|
||||
scope,
|
||||
xhr,
|
||||
head,
|
||||
moli_fetch::ResponseBody::materialized_bytes(b"<p>body".to_vec()),
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(vm.eval(r#"JSON.stringify([
|
||||
xhr.status, xhr.statusText, xhr.getResponseHeader('content-type'), xhr.getResponseHeader('content-length'),
|
||||
xhr.responseType === 'arraybuffer' ? xhr.response.byteLength === 0 :
|
||||
xhr.responseType === 'blob' ? xhr.response.size === 0 && xhr.response.type === 'text/html' :
|
||||
xhr.responseType === 'document' || xhr.responseType === 'json' ? xhr.response === null : xhr.responseText === '',
|
||||
events])"#).unwrap(), serde_json::json!([status, "Preserved", "text/html", "7", true,
|
||||
[1, 2, "progress:0:7:true", 4, "load:0:7:true", "loadend:0:7:true"]]).to_string(),
|
||||
"streaming={streaming}, {method}/{status}/{response_type}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn xhr_null_body_empty_http_and_data_responses_finish_without_loading() {
|
||||
for worker in [false, true] {
|
||||
let server = StaticHttpServer::spawn_with_bodies(vec![String::new()]).await;
|
||||
let url = server.base_url();
|
||||
let loader = static_http_loader(std::iter::empty::<String>());
|
||||
let mut vm = new_page_task_executor_test_vm_with_loader(url.as_str(), &loader);
|
||||
let probe = r#"
|
||||
(async () => {
|
||||
const assert = (value, message) => { if (!value) throw new Error(message); };
|
||||
for (const url of [HTTP_URL, 'data:text/plain,', URL.createObjectURL(new Blob([]))]) {
|
||||
const xhr = new XMLHttpRequest(), events = [];
|
||||
xhr.onreadystatechange = () => events.push(xhr.readyState);
|
||||
for (const name of ['loadstart', 'progress', 'load', 'loadend'])
|
||||
xhr.addEventListener(name, e => events.push(`${name}:${e.loaded}:${e.total}:${e.lengthComputable}`));
|
||||
const done = new Promise(resolve => xhr.onloadend = resolve);
|
||||
xhr.open('GET', url);
|
||||
xhr.send();
|
||||
await done;
|
||||
assert(xhr.status === 200 && xhr.responseText === '', 'empty response succeeds');
|
||||
assert(JSON.stringify(events) === JSON.stringify([1, 'loadstart:0:0:false', 2, 'progress:0:0:false', 4, 'load:0:0:false', 'loadend:0:0:false']), JSON.stringify(events));
|
||||
if (url.startsWith('blob:')) URL.revokeObjectURL(url);
|
||||
}
|
||||
return 'ok';
|
||||
})()
|
||||
"#.replace("HTTP_URL", &serde_json::to_string(url.as_str()).unwrap());
|
||||
start_probe(&mut vm, &probe, worker);
|
||||
advance_page_task_executor_until_eval_equals(
|
||||
&mut vm,
|
||||
&loader,
|
||||
"globalThis.__nullBodyResult",
|
||||
"ok",
|
||||
"empty HTTP/local XHR response",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(server.finish_targets().await, ["/"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xhr_null_body_late_completion_preserves_a_replacement_requests_progress() {
|
||||
let mut vm = new_storage_test_vm("https://xhr-null-body-reuse.test/");
|
||||
vm.set_fetch_subresource_interception(true, Some(crate::types::SubresourceResourceType::Xhr));
|
||||
vm.eval(
|
||||
r#"
|
||||
globalThis.xhr = new XMLHttpRequest();
|
||||
xhr.onprogress = event => progressEvents.push(event.loaded);
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut streams = Vec::new();
|
||||
for (method, path) in [("HEAD", "/head"), ("GET", "/replacement")] {
|
||||
vm.eval(&format!(
|
||||
"globalThis.progressEvents = []; xhr.open('{method}', '{path}'); xhr.send();"
|
||||
))
|
||||
.unwrap();
|
||||
let pending = vm.take_pending_subresource_fetch_infos();
|
||||
assert_eq!(pending.len(), 1);
|
||||
let request = &pending[0];
|
||||
let body_source_id = crate::network_host::new_network_body_source_id();
|
||||
vm.start_streaming_async_subresource_fetch(
|
||||
crate::types::AsyncSubresourceStreamingStarted {
|
||||
skip_fetch_security_validation: false,
|
||||
response_filter: None,
|
||||
internal_id: request.internal_id,
|
||||
request_url: request.url.clone(),
|
||||
request_method: method.to_owned(),
|
||||
request_headers: Vec::new(),
|
||||
request_body: None,
|
||||
body_source_id,
|
||||
head: moli_fetch::ResponseHead {
|
||||
final_url: request.url.clone(),
|
||||
status: 200,
|
||||
status_text: None,
|
||||
headers: vec![
|
||||
("Content-Type".to_owned(), "text/plain".to_owned()),
|
||||
("Content-Length".to_owned(), "3".to_owned()),
|
||||
],
|
||||
request_cookie_report: None,
|
||||
cookie_set_reports: Vec::new(),
|
||||
redirected: false,
|
||||
redirect_chain: Vec::new(),
|
||||
from_cache: false,
|
||||
negotiated_http_version: None,
|
||||
},
|
||||
network_request_headers: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
streams.push((request.internal_id, body_source_id));
|
||||
assert_eq!(
|
||||
vm.eval("xhr.readyState").unwrap(),
|
||||
if method == "HEAD" { "4" } else { "2" }
|
||||
);
|
||||
}
|
||||
let (old_id, old_body) = streams[0];
|
||||
let (new_id, new_body) = streams[1];
|
||||
vm.append_streaming_async_subresource_fetch_chunk(new_body, b"a".to_vec());
|
||||
vm.append_streaming_async_subresource_fetch_chunk(new_body, b"b".to_vec());
|
||||
vm.finish_streaming_async_subresource_fetch(old_id, old_body, Ok(()))
|
||||
.unwrap();
|
||||
vm.append_streaming_async_subresource_fetch_chunk(new_body, b"c".to_vec());
|
||||
assert_eq!(
|
||||
vm.eval("JSON.stringify([xhr.readyState, xhr.responseText, progressEvents])")
|
||||
.unwrap(),
|
||||
r#"[3,"abc",[1]]"#,
|
||||
"the old HEAD completion must not cancel the replacement's progress gate"
|
||||
);
|
||||
vm.finish_streaming_async_subresource_fetch(new_id, new_body, Ok(()))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
vm.eval("JSON.stringify([xhr.readyState, xhr.responseText, progressEvents])")
|
||||
.unwrap(),
|
||||
r#"[4,"abc",[1,3]]"#,
|
||||
"replacement completion still flushes its last deferred progress event"
|
||||
);
|
||||
}
|
||||
@@ -2880,8 +2880,14 @@ async fn worker_xmlhttprequest_uses_worker_script_base_url_and_event_target_list
|
||||
#[tokio::test]
|
||||
async fn worker_xhr_request_stage_interception_can_fulfill_synthetic_response() {
|
||||
ensure_v8();
|
||||
let mut handle = spawn_worker_with_request_client_and_network_policy(
|
||||
r#"
|
||||
for (status, expected_text) in [
|
||||
(200, "fulfilled-worker-xhr"),
|
||||
(204, ""),
|
||||
(205, ""),
|
||||
(304, ""),
|
||||
] {
|
||||
let mut handle = spawn_worker_with_request_client_and_network_policy(
|
||||
r#"
|
||||
onmessage = () => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.onloadend = () => {
|
||||
@@ -2897,54 +2903,55 @@ async fn worker_xhr_request_stage_interception_can_fulfill_synthetic_response()
|
||||
xhr.send("payload");
|
||||
};
|
||||
"#
|
||||
.into(),
|
||||
"http://example.test/worker/main.js".into(),
|
||||
ResourceRequestClient::new(&FetchConfig::default()).expect("worker xhr loader"),
|
||||
WorkerNetworkPolicy {
|
||||
network_partition_key: Some("credentialless-worker-xhr".to_owned()),
|
||||
..WorkerNetworkPolicy::default()
|
||||
},
|
||||
);
|
||||
handle.set_fetch_subresource_interception(true, Some(SubresourceResourceType::Xhr));
|
||||
handle.post_message(serialize_test_string("go"));
|
||||
.into(),
|
||||
"http://example.test/worker/main.js".into(),
|
||||
ResourceRequestClient::new(&FetchConfig::default()).expect("worker xhr loader"),
|
||||
WorkerNetworkPolicy {
|
||||
network_partition_key: Some("credentialless-worker-xhr".to_owned()),
|
||||
..WorkerNetworkPolicy::default()
|
||||
},
|
||||
);
|
||||
handle.set_fetch_subresource_interception(true, Some(SubresourceResourceType::Xhr));
|
||||
handle.post_message(serialize_test_string("go"));
|
||||
|
||||
let pending = timeout(TIMEOUT, handle.recv())
|
||||
.await
|
||||
.expect("timed out waiting for worker xhr pause")
|
||||
.expect("worker channel closed");
|
||||
let WorkerToParentMessage::PendingSubresourceFetch(pending) = pending else {
|
||||
panic!("expected worker xhr pause, got {pending:?}");
|
||||
};
|
||||
assert!(pending.info.network_request_handle.is_none());
|
||||
assert_eq!(pending.info.resource_type, SubresourceResourceType::Xhr);
|
||||
assert_eq!(
|
||||
pending.info.url.as_str(),
|
||||
"http://example.test/intercepted-worker-xhr"
|
||||
);
|
||||
assert_eq!(pending.info.request_body.as_deref(), Some("payload"));
|
||||
assert_eq!(
|
||||
pending.network_partition_key.as_deref(),
|
||||
Some("credentialless-worker-xhr")
|
||||
);
|
||||
let pending = timeout(TIMEOUT, handle.recv())
|
||||
.await
|
||||
.expect("timed out waiting for worker xhr pause")
|
||||
.expect("worker channel closed");
|
||||
let WorkerToParentMessage::PendingSubresourceFetch(pending) = pending else {
|
||||
panic!("expected worker xhr pause, got {pending:?}");
|
||||
};
|
||||
assert!(pending.info.network_request_handle.is_none());
|
||||
assert_eq!(pending.info.resource_type, SubresourceResourceType::Xhr);
|
||||
assert_eq!(
|
||||
pending.info.url.as_str(),
|
||||
"http://example.test/intercepted-worker-xhr"
|
||||
);
|
||||
assert_eq!(pending.info.request_body.as_deref(), Some("payload"));
|
||||
assert_eq!(
|
||||
pending.network_partition_key.as_deref(),
|
||||
Some("credentialless-worker-xhr")
|
||||
);
|
||||
|
||||
let request = pending_worker_xhr_continue(pending.fetch_id, 31, &pending.info, false);
|
||||
handle.fulfill_pending_xhr(
|
||||
request,
|
||||
204,
|
||||
vec![
|
||||
("content-type".to_owned(), "text/plain".to_owned()),
|
||||
(
|
||||
"x-worker-xhr-intercept".to_owned(),
|
||||
"request-stage".to_owned(),
|
||||
),
|
||||
],
|
||||
RendererSyntheticResponseBody::from_bytes(b"fulfilled-worker-xhr".to_vec()),
|
||||
);
|
||||
let request = pending_worker_xhr_continue(pending.fetch_id, 31, &pending.info, false);
|
||||
handle.fulfill_pending_xhr(
|
||||
request,
|
||||
status,
|
||||
vec![
|
||||
("content-type".to_owned(), "text/plain".to_owned()),
|
||||
(
|
||||
"x-worker-xhr-intercept".to_owned(),
|
||||
"request-stage".to_owned(),
|
||||
),
|
||||
],
|
||||
RendererSyntheticResponseBody::from_bytes(b"fulfilled-worker-xhr".to_vec()),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
recv_post_json(&mut handle).await,
|
||||
r#"{"readyState":4,"status":204,"header":"request-stage","text":"fulfilled-worker-xhr"}"#
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&recv_post_json(&mut handle).await).unwrap(),
|
||||
serde_json::json!({"readyState":4,"status":status,"header":"request-stage","text":expected_text})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user