fix(fetch): include required headers in Blob URL responses

This commit is contained in:
ldm0
2026-09-20 15:32:41 +08:00
parent 846b24119a
commit 8ec2e36968
4 changed files with 146 additions and 4 deletions
@@ -10,10 +10,10 @@ pub(in crate::network_host) fn http_status_text(status: u16) -> &'static str {
pub(crate) fn blob_url_response(url: &url::Url) -> Option<Response> {
let (body_bytes, mime_type) = blob::object_url_bytes_and_type(url.as_str())?;
let mut headers = Vec::new();
if !mime_type.is_empty() {
headers.push(("Content-Type".to_owned(), mime_type));
}
let headers = vec![
("Content-Length".to_owned(), body_bytes.len().to_string()),
("Content-Type".to_owned(), mime_type),
];
Some(Response::from_head_and_lossy_body_bytes(
moli_fetch::ResponseHead {
final_url: url.clone(),
@@ -0,0 +1,61 @@
use super::*;
#[tokio::test(flavor = "current_thread")]
async fn blob_url_response_headers_match_bytes_and_type_in_window_and_worker() {
let fixture = include_str!("../../../tests/fixtures/blob-response-headers.js");
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://blob-response-headers.test/",
&loader,
);
vm.eval("globalThis.blobHeadersResult = null;").unwrap();
let script = if worker {
let source = format!(
"{fixture}\nblobResponseHeaderProbe().then(postMessage, error => postMessage({{error: String(error.stack || error)}}));"
);
format!(
r#"
const workerUrl = URL.createObjectURL(new Blob([{}], {{type: 'text/javascript'}}));
const worker = new Worker(workerUrl);
const finish = value => {{
blobHeadersResult = value;
worker.terminate();
URL.revokeObjectURL(workerUrl);
}};
worker.onmessage = event => finish(event.data);
worker.onerror = event => {{
finish({{error: event.message}});
event.preventDefault();
}};
"#,
serde_json::to_string(&source).unwrap()
)
} else {
format!(
"{fixture}\nblobResponseHeaderProbe().then(value => {{ blobHeadersResult = value; }}, error => {{ blobHeadersResult = {{error:String(error.stack || error)}}; }});"
)
};
vm.eval(&script).unwrap();
tokio::time::timeout(std::time::Duration::from_secs(5), async {
while vm.eval("blobHeadersResult === null").unwrap() == "true" {
wait_for_one_selected_page_task_executor_test_turn(&mut vm, &loader)
.await
.unwrap();
}
})
.await
.expect("Blob URL responses should settle locally");
let result: serde_json::Value =
serde_json::from_str(&vm.eval("JSON.stringify(blobHeadersResult)").unwrap()).unwrap();
let checks = result["checks"]
.as_array()
.unwrap_or_else(|| panic!("worker={worker}: {result}"));
let failures: Vec<_> = checks
.iter()
.filter(|check| check["pass"] != true)
.collect();
assert_eq!(result["state"], "pass", "worker={worker}: {failures:?}");
assert_eq!(checks.len(), 490, "worker={worker}");
}
}
@@ -15196,6 +15196,7 @@ fn decode_png_dimensions_from_data_url(data_url: &str) -> (u32, u32) {
(info.width, info.height)
}
mod blob_response_headers;
mod browser_api;
mod canvas_arguments;
mod canvas_paths;
@@ -0,0 +1,80 @@
async function blobResponseHeaderProbe() {
const checks = [];
const check = (label, actual, wanted) => checks.push({label, actual, wanted,
pass: JSON.stringify(actual) === JSON.stringify(wanted)});
const encode = value => Array.from(new TextEncoder().encode(value));
const cases = [
['empty', new Blob([]), [], ''],
['empty-typed', new Blob([], {type: 'text/plain'}), [], 'text/plain'],
['text', new Blob(['Blob data'], {type: 'TEXT/PLAIN'}), encode('Blob data'), 'text/plain'],
['unicode', new Blob(['𝌆é\ud800'], {type: 'text/plain'}), encode('𝌆é\ufffd'), 'text/plain'],
['binary', new Blob([new Uint8Array([0, 255, 128, 65])]), [0, 255, 128, 65], ''],
['xml-no-sniff', new Blob(['<root/>']), encode('<root/>'), ''],
['invalid-type', new Blob(['x'], {type: 'invalid'}), [120], 'invalid'],
['invalid-ascii', new Blob(['x'], {type: 'text/\0plain'}), [120], ''],
['slice', new Blob([new Uint8Array([0, 128, 255, 65])]).slice(1, 3, 'APPLICATION/OCTET-STREAM'), [128, 255], 'application/octet-stream'],
['file', new File(['file'], 'fixture.txt', {type: 'text/plain'}), encode('file'), 'text/plain'],
];
async function inspectFetch(label, input, url, bytes, type) {
const response = await fetch(input);
check(label + '/status', [response.status, response.statusText, response.type], [200, 'OK', 'basic']);
check(label + '/url', response.url === url, true);
check(label + '/header-names', Array.from(response.headers.keys()), ['content-length', 'content-type']);
check(label + '/has-type', response.headers.has('content-type'), true);
check(label + '/type', response.headers.get('content-type'), type);
check(label + '/length', response.headers.get('content-length'), String(bytes.length));
let immutable = false;
try { response.headers.set('content-length', '999'); } catch (error) { immutable = error instanceof TypeError; }
check(label + '/immutable', immutable, true);
const clone = response.clone();
check(label + '/clone-type', clone.headers.get('content-type'), type);
check(label + '/clone-length', clone.headers.get('content-length'), String(bytes.length));
check(label + '/bytes', Array.from(new Uint8Array(await clone.arrayBuffer())), bytes);
const blob = await response.blob();
check(label + '/consumed-blob', [blob.size, Array.from(new Uint8Array(await blob.arrayBuffer()))], [bytes.length, bytes]);
}
async function inspectXhr(label, url, bytes, type, asynchronous) {
const xhr = new XMLHttpRequest();
const buffer = asynchronous || typeof document === 'undefined';
let ended;
const done = new Promise(resolve => ended = resolve);
xhr.onloadend = () => ended();
xhr.open('GET', url, asynchronous);
if (buffer) xhr.responseType = 'arraybuffer';
xhr.send();
await done;
check(label + '/status', [xhr.readyState, xhr.status, xhr.statusText], [4, 200, 'OK']);
check(label + '/url', xhr.responseURL === url, true);
check(label + '/type', xhr.getResponseHeader('Content-Type'), type);
check(label + '/length', xhr.getResponseHeader('Content-Length'), String(bytes.length));
const allHeaders = xhr.getAllResponseHeaders().split('\r\n').filter(Boolean).map(line => {
const colon = line.indexOf(':');
return [line.slice(0, colon).toLowerCase(), line.slice(colon + 2)];
});
check(label + '/all-headers', allHeaders,
[['content-length', String(bytes.length)], ['content-type', type]]);
check(label + '/body', buffer ? Array.from(new Uint8Array(xhr.response)) : xhr.responseText,
buffer ? bytes : new TextDecoder().decode(new Uint8Array(bytes)));
}
for (const [label, blob, bytes, type] of cases) {
const url = URL.createObjectURL(blob);
try {
await inspectFetch(label + '/fetch', url, url, bytes, type);
await inspectXhr(label + '/async-xhr', url, bytes, type, true);
await inspectXhr(label + '/sync-xhr', url, bytes, type, false);
const request = new Request(url);
const clone = request.clone();
await inspectFetch(label + '/request', request, url, bytes, type);
await inspectFetch(label + '/cloned-request', clone, url, bytes, type);
URL.revokeObjectURL(url);
let revoked = false;
try { await fetch(url); } catch (error) { revoked = error instanceof TypeError; }
check(label + '/revoked', revoked, true);
} finally { URL.revokeObjectURL(url); }
const constructed = new Response(blob);
check(label + '/constructed-length', constructed.headers.get('content-length'), null);
check(label + '/constructed-type', constructed.headers.get('content-type'), type || null);
check(label + '/constructed-body', Array.from(new Uint8Array(await constructed.arrayBuffer())), bytes);
}
return {state: checks.every(check => check.pass) ? 'pass' : 'fail', checks};
}