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:
ldm0
2026-09-14 18:51:16 +08:00
parent db79f4a746
commit 9b6de149ba
12 changed files with 522 additions and 69 deletions
@@ -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(())
}
+1 -1
View File
@@ -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]