mirror of
https://github.com/lexmount/moli.git
synced 2026-10-05 08:00:56 +00:00
fix(fetch): include required headers in Blob URL responses
This commit is contained in:
@@ -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};
|
||||
}
|
||||
Reference in New Issue
Block a user