From 8ec2e369687fac5fba4f0b8afbdbca673db0752d Mon Sep 17 00:00:00 2001 From: ldm0 Date: Sun, 20 Sep 2026 15:32:41 +0800 Subject: [PATCH] fix(fetch): include required headers in Blob URL responses --- .../src/network_host/browser_response.rs | 8 +- .../script_vm/tests/blob_response_headers.rs | 61 ++++++++++++++ moli-renderer-v8/src/script_vm/tests/mod.rs | 1 + .../tests/fixtures/blob-response-headers.js | 80 +++++++++++++++++++ 4 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 moli-renderer-v8/src/script_vm/tests/blob_response_headers.rs create mode 100644 moli-renderer-v8/tests/fixtures/blob-response-headers.js diff --git a/moli-renderer-v8/src/network_host/browser_response.rs b/moli-renderer-v8/src/network_host/browser_response.rs index 151521872b..7b20b44e2e 100644 --- a/moli-renderer-v8/src/network_host/browser_response.rs +++ b/moli-renderer-v8/src/network_host/browser_response.rs @@ -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 { 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(), diff --git a/moli-renderer-v8/src/script_vm/tests/blob_response_headers.rs b/moli-renderer-v8/src/script_vm/tests/blob_response_headers.rs new file mode 100644 index 0000000000..75705bd92a --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/blob_response_headers.rs @@ -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::()); + 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}"); + } +} diff --git a/moli-renderer-v8/src/script_vm/tests/mod.rs b/moli-renderer-v8/src/script_vm/tests/mod.rs index 3ade5223b5..adc89900ff 100644 --- a/moli-renderer-v8/src/script_vm/tests/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/mod.rs @@ -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; diff --git a/moli-renderer-v8/tests/fixtures/blob-response-headers.js b/moli-renderer-v8/tests/fixtures/blob-response-headers.js new file mode 100644 index 0000000000..61d64819f6 --- /dev/null +++ b/moli-renderer-v8/tests/fixtures/blob-response-headers.js @@ -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(['']), encode(''), ''], + ['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}; +}