fix(fetch): inherit Headers methods and observe initializer iterators

Use the intrinsic Headers prototype for internally created header objects. Remove own method copies and platform-object initializer shortcuts so author iterator overrides, exceptions and record properties remain observable.

Source: 66434f2877, c229deb38c
This commit is contained in:
ldm0
2026-09-16 14:43:14 +08:00
committed by Donough Liu
parent 31fbadca9b
commit 2a3aa78f8d
15 changed files with 252 additions and 104 deletions
@@ -15,7 +15,7 @@ pub(crate) use request_body::{
form_data_entries_multipart_body_with_prefix, form_data_request_body,
};
pub(crate) use serialize::form_data_entries_to_string_pairs;
pub(in crate::context_bootstrap) use storage::{form_data_entries, form_data_is_object};
pub(in crate::context_bootstrap) use storage::form_data_is_object;
pub(super) use template::build_form_data_constructor_template;
pub(crate) fn form_data_object_from_entries<'s>(
@@ -6,8 +6,5 @@ mod callbacks;
mod helpers;
mod template;
pub(super) use helpers::{
apply_url_update, callback_arg_url_like_string, callback_value_string, url_href_slot,
url_object_value,
};
pub(super) use helpers::{apply_url_update, callback_value_string, url_object_value};
pub(super) use template::build_url_constructor_template;
@@ -11,21 +11,6 @@ pub(in crate::context_bootstrap) fn callback_value_string(
.map(|value| value.to_rust_string_lossy(scope))
}
pub(in crate::context_bootstrap) fn callback_arg_url_like_string<'s>(
scope: &mut v8::PinScope<'s, '_>,
value: v8::Local<'s, v8::Value>,
) -> Option<String> {
if value.is_null_or_undefined() {
return None;
}
if let Ok(object) = v8::Local::<v8::Object>::try_from(value)
&& let Some(href) = url_href_slot(scope, object)
{
return Some(href);
}
callback_value_string(scope, value)
}
pub(super) fn resolve_url_constructor_input(
input: &str,
base: Option<&str>,
@@ -1,7 +1,4 @@
use super::form_data_runtime::{form_data_entries, form_data_is_object};
use super::url_form::{
apply_url_update, callback_arg_url_like_string, callback_value_string, url_object_value,
};
use super::url_form::{apply_url_update, callback_value_string, url_object_value};
use super::*;
use crate::util::{get_private_object, get_private_value, set_private_value};
use crate::web_api_interfaces;
@@ -1,6 +1,3 @@
use super::storage::{url_search_params_is_object, url_search_params_pairs};
use super::*;
use crate::context_bootstrap::url_form::url_href_slot;
use crate::webidl;
use moli_url::search_params::{SearchParamPair, parse_search_params};
@@ -38,17 +35,6 @@ pub(super) fn url_search_params_pairs_from_constructor<'s>(
return Some(Vec::new());
}
if let Ok(object) = v8::Local::<v8::Object>::try_from(value) {
if form_data_is_object(scope, object) {
return Some(
form_data_entries(scope, object)
.into_iter()
.filter_map(|(key, value)| {
callback_value_string(scope, v8::Local::new(scope, &value))
.map(|value| (key, value))
})
.collect(),
);
}
let sequence = match webidl::convert_optional_sequence::<UrlSearchParamsSequencePair>(
scope,
value,
@@ -64,17 +50,6 @@ pub(super) fn url_search_params_pairs_from_constructor<'s>(
if let Some(sequence) = sequence {
return Some(sequence.0.into_iter().map(|pair| pair.0).collect());
}
if url_search_params_is_object(scope, object) {
return Some(url_search_params_pairs(scope, object));
}
if url_href_slot(scope, object).is_some() {
return Some(
callback_arg_url_like_string(scope, value)
.as_deref()
.map(parse_search_params)
.unwrap_or_default(),
);
}
return record_string_pairs(scope, object.into());
}
Some(
@@ -5,7 +5,6 @@ mod store;
use super::*;
pub(crate) use self::bindings::headers_constructor_callback;
pub(super) use self::methods::install_headers_object_methods;
pub(crate) use self::methods::install_headers_template_bindings;
pub(crate) use self::store::headers_entries;
pub(crate) use self::store::{HeadersGuard, filter_headers_for_guard};
@@ -15,7 +15,7 @@ pub(super) use self::iteration::{
pub(super) use self::mutation::{
headers_append_callback, headers_delete_callback, headers_set_callback,
};
use moli_webapi_declare::{WebApiFunctionTemplate, WebApiObject};
use moli_webapi_declare::WebApiFunctionTemplate;
pub(super) fn require_headers_receiver<'s>(
scope: &mut v8::PinScope<'s, '_>,
@@ -28,33 +28,6 @@ pub(super) fn require_headers_receiver<'s>(
None
}
#[derive(Default, WebApiObject)]
#[webapi(interface = web_api_interfaces::Headers)]
struct HeadersObjectMethodsDeclaration {
#[webapi(method, enumerable, length = 1, callback = headers_get_callback)]
get: (),
#[webapi(method, enumerable, length = 1, callback = headers_has_callback)]
has: (),
#[webapi(method, enumerable, length = 0, callback = headers_get_set_cookie_callback)]
get_set_cookie: (),
#[webapi(method, enumerable, length = 2, callback = headers_set_callback)]
set: (),
#[webapi(method, enumerable, length = 1, callback = headers_delete_callback)]
delete: (),
#[webapi(method, enumerable, length = 2, callback = headers_append_callback)]
append: (),
#[webapi(method, enumerable, length = 0, callback = headers_keys_callback)]
keys: (),
#[webapi(method, enumerable, length = 0, callback = headers_values_callback)]
values: (),
#[webapi(method, enumerable, length = 0, callback = headers_entries_callback)]
entries: (),
#[webapi(alias = "entries", symbol = "iterator", enumerable)]
iterator: (),
#[webapi(method, enumerable, length = 1, callback = headers_for_each_callback)]
for_each: (),
}
#[derive(WebApiFunctionTemplate)]
#[webapi(interface = web_api_interfaces::Headers, enumerable)]
struct HeadersPrototypeMethodsDeclaration {
@@ -82,15 +55,6 @@ struct HeadersPrototypeMethodsDeclaration {
for_each: (),
}
pub(in crate::network_host) fn install_headers_object_methods<'s>(
scope: &mut v8::PinScope<'s, '_>,
headers_obj: v8::Local<'s, v8::Object>,
) {
HeadersObjectMethodsDeclaration::default()
.initialize(scope, headers_obj)
.expect("Headers object methods declaration should initialize");
}
pub(crate) fn install_headers_template_bindings<'s>(
scope: &mut v8::PinScope<'s, '_, ()>,
template: v8::Local<'s, v8::FunctionTemplate>,
@@ -1,4 +1,4 @@
use super::entries::{headers_entries_if_present, normalized_header_entry_or_throw};
use super::entries::normalized_header_entry_or_throw;
use crate::webidl;
pub(in crate::network_host) fn headers_entries_from_init<'s>(
@@ -24,10 +24,6 @@ pub(in crate::network_host) fn headers_entries_from_init<'s>(
return Ok(entries);
}
if let Some(entries) = headers_entries_if_present(scope, init_obj) {
return Ok(entries);
}
let record = webidl::convert::<webidl::Record<webidl::ByteString, webidl::ByteString>>(
scope,
init_obj.into(),
@@ -7,7 +7,7 @@ use crate::web_api_interfaces;
use moli_webapi_declare::WebApiObject;
#[derive(WebApiObject)]
#[webapi(interface = web_api_interfaces::Headers, prototype = "Object")]
#[webapi(interface = web_api_interfaces::Headers)]
struct HeadersStorageDeclaration {
#[webapi(slot = HEADERS_ENTRIES_SLOT)]
entries: String,
@@ -4,7 +4,6 @@ mod input;
use super::headers::{
build_headers_object_with_state, filter_headers_for_guard, headers_entries_from_init,
install_headers_object_methods,
};
use super::*;
@@ -102,7 +102,6 @@ pub(crate) fn request_constructor_callback<'s>(
request_headers_guard_for_mode(&state.mode),
false,
);
install_headers_object_methods(scope, headers_obj);
let body_value = body_buffer
.and_then(|buffer| {
@@ -5,7 +5,7 @@ mod materialize;
use super::headers::{
HeadersGuard, build_headers_object_with_state, filter_headers_for_guard, headers_entries,
headers_entries_from_init, install_headers_object_methods,
headers_entries_from_init,
};
use super::*;
@@ -80,7 +80,6 @@ pub(super) fn install_response_headers(
let entries = filter_headers_for_guard(&entries, HeadersGuard::Response);
let headers_obj =
build_headers_object_with_state(scope, &entries, HeadersGuard::Response, false);
install_headers_object_methods(scope, headers_obj);
set_response_slot_value(scope, obj, RESPONSE_HEADERS_SLOT, headers_obj.into());
}
@@ -366,7 +366,6 @@ fn build_fetch_response_object_head<'s>(
let internal_headers = filter_headers_for_guard(&head.headers, HeadersGuard::Response);
let internal_headers_obj =
build_headers_object_with_state(scope, &internal_headers, HeadersGuard::Response, true);
install_headers_object_methods(scope, internal_headers_obj);
set_response_slot_value(
scope,
obj,
@@ -399,7 +398,6 @@ fn finish_fetch_response_object_with_body_stream<'s>(
let headers = filter_headers_for_guard(header_entries, HeadersGuard::Response);
let headers_obj =
build_headers_object_with_state(scope, &headers, HeadersGuard::Response, true);
install_headers_object_methods(scope, headers_obj);
set_response_slot_value(scope, obj, RESPONSE_HEADERS_SLOT, headers_obj.into());
let body_value = if !filtered_response_exposes_body(filter) {
@@ -442,7 +440,6 @@ pub(crate) fn build_filtered_cached_response_object<'s>(
let headers = filter_headers_for_guard(&[], HeadersGuard::Response);
let headers_obj =
build_headers_object_with_state(scope, &headers, HeadersGuard::Response, true);
install_headers_object_methods(scope, headers_obj);
set_response_slot_value(scope, obj, RESPONSE_HEADERS_SLOT, headers_obj.into());
set_filtered_response_internal_body_from_bytes(scope, obj, body);
FetchResponseBodyDeclaration::new(String::new(), v8::null(scope).into())
@@ -6702,6 +6702,133 @@ fn vtt_cue_constructor_applies_webidl_conversion() {
);
}
#[test]
fn webidl_initializer_unions_observe_iterators_on_platform_objects() {
let mut vm = new_storage_test_vm("https://initializer-union.test/");
let result = vm.eval(r#"
(() => {
const check = (condition, label) => { if (!condition) throw new Error(label); };
const equal = (actual, expected, label) => check(
JSON.stringify(actual) === JSON.stringify(expected), label + ': ' + JSON.stringify(actual));
const factories = [
['Headers', () => new Headers([['x-original', 'original']])],
['URLSearchParams', () => new URLSearchParams([['x-original', 'original']])],
['FormData', () => {
const form = new FormData();
form.append('x-original', 'original');
return form;
}]
];
const consumers = [
['Headers', input => new Headers(input)],
['URLSearchParams', input => new URLSearchParams(input)],
['Request', input => new Request('https://initializer-union.test/', {headers: input}).headers],
['Response', input => new Response(null, {headers: input}).headers]
];
for (const [target, create] of consumers) {
for (const [source, factory] of factories) {
for (const mode of ['original', 'custom', 'undefined', 'null', 'throw', 'noncallable']) {
const label = target + ' from ' + source + ' with ' + mode + ' iterator';
const input = factory();
const original = input[Symbol.iterator];
const marker = {};
const log = [];
Object.defineProperty(input, 'x-record', {enumerable: true, get() {
log.push('record');
return 'record';
}});
Object.defineProperty(input, Symbol.iterator, {get() {
log.push('iterator');
if (mode === 'throw') throw marker;
if (mode === 'noncallable') return 42;
if (mode === 'undefined') return undefined;
if (mode === 'null') return null;
if (mode === 'original') return original;
return function*() {
check(this === input, label + ' receiver');
log.push('iterate');
yield ['x-custom', 'custom'];
};
}});
if (mode === 'throw' || mode === 'noncallable') {
let caught;
try { create(input); } catch (error) { caught = error; }
check(mode === 'throw' ? caught === marker : caught instanceof TypeError, label + ' exception');
} else {
const expected = mode === 'original' ? [['x-original', 'original']] :
mode === 'custom' ? [['x-custom', 'custom']] : [['x-record', 'record']];
equal(Array.from(create(input)), expected, label);
}
const expectedLog = ['iterator'];
if (mode === 'custom') expectedLog.push('iterate');
if (mode === 'undefined' || mode === 'null') expectedLog.push('record');
equal(log, expectedLog, label + ' conversion order');
}
}
}
return 'ok';
})()
"#).unwrap();
assert_eq!(result, "ok");
}
#[test]
fn webidl_initializer_unions_convert_url_objects_as_records() {
let mut vm = new_storage_test_vm("https://initializer-record.test/");
let result = vm
.eval(
r#"
(() => {
for (const Constructor of [Headers, URLSearchParams]) {
const url = new URL('https://initializer-record.test/path?query=value');
Object.defineProperty(url, Symbol.toPrimitive, {value() {
throw new Error('URL object must use record conversion');
}});
if (Array.from(new Constructor(url)).length !== 0) throw new Error('empty URL record');
url['x-record'] = 'record';
if (JSON.stringify(Array.from(new Constructor(url))) !== '[["x-record","record"]]') {
throw new Error(Constructor.name + ' must use own URL properties');
}
}
return 'ok';
})()
"#,
)
.unwrap();
assert_eq!(result, "ok");
}
#[test]
fn url_search_params_form_data_initializers_convert_file_values_through_the_iterator() {
let mut vm = new_storage_test_vm("https://form-data-initializer.test/");
let result = vm.eval(r#"
(() => {
const file = new File(['contents'], 'entry.txt');
const form = new FormData();
form.append('a', 'first');
form.append('file', file);
form.append('a', 'second');
const params = new URLSearchParams(form);
form.append('later', 'ignored');
if (JSON.stringify(Array.from(params)) !== '[["a","first"],["file","[object File]"],["a","second"]]') {
throw new Error('FormData entries must be copied in iteration order with File stringification');
}
const marker = {};
let conversions = 0;
Object.defineProperty(file, Symbol.toPrimitive, {value(hint) {
if (hint !== 'string') throw new Error('USVString must use the string hint');
conversions++;
throw marker;
}});
let caught;
try { new URLSearchParams(form); } catch (error) { caught = error; }
if (caught !== marker || conversions !== 1) throw new Error('File conversion exception must propagate');
return 'ok';
})()
"#).unwrap();
assert_eq!(result, "ok");
}
#[test]
fn headers_methods_parse_webidl_arguments() {
let mut vm = new_storage_test_vm("https://headers-methods.test/");
@@ -7029,6 +7156,117 @@ async fn form_data_and_url_search_params_for_each_use_callback_relevant_realm()
);
}
#[test]
fn request_and_response_headers_share_intrinsic_prototype_methods() {
let mut vm = new_storage_test_vm("https://headers-object-prototype.test/");
let result = vm
.eval(
r#"
(() => {
const check = (value, label) => { if (!value) throw new Error(label); };
const NativeHeaders = Headers;
const prototype = NativeHeaders.prototype;
const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'Headers');
const nativeGet = prototype.get;
let lookups = 0;
Object.defineProperty(globalThis, 'Headers', {configurable: true, get() {
lookups++;
throw new Error('public Headers lookup');
}});
try {
const request = new Request('/request', {headers: {'x-test': 'request'}});
const streamRequest = new Request('/stream', {method: 'POST', duplex: 'half',
body: new ReadableStream({start(controller) {controller.close();}})});
const response = new Response('body', {headers: {'x-test': 'response'}});
const error = Response.error();
const redirect = Response.redirect('/target');
const holders = [request, request.clone(), streamRequest, streamRequest.clone(),
response, response.clone(), error, error.clone(), redirect, redirect.clone()];
const methods = ['get', 'has', 'getSetCookie', 'set', 'delete', 'append',
'keys', 'values', 'entries', 'forEach', Symbol.iterator];
for (const holder of holders) {
const headers = holder.headers;
check(headers === holder.headers, 'same Headers object');
check(Object.getPrototypeOf(headers) === prototype && headers instanceof NativeHeaders,
'intrinsic Headers prototype');
check(Object.prototype.toString.call(headers) === '[object Headers]', 'Headers class');
check(Reflect.ownKeys(headers).length === 0, 'no own methods');
for (const key of methods) check(headers[key] === prototype[key], String(key));
}
check(nativeGet.call(request.headers, 'x-test') === 'request', 'request entries');
check(nativeGet.call(response.headers, 'x-test') === 'response', 'response entries');
prototype.get = function() { return this; };
for (const holder of holders) check(holder.headers.get() === holder.headers,
'prototype method changes must remain observable');
const fresh = new Response('fresh').headers;
check(fresh.get() === fresh, 'new objects share updated prototype methods');
check(lookups === 0, 'factory must not consult replaced global');
return 'ok';
} finally {
prototype.get = nativeGet;
Object.defineProperty(globalThis, 'Headers', descriptor);
}
})()
"#,
)
.unwrap();
assert_eq!(result, "ok");
}
#[tokio::test]
async fn response_headers_keep_receiver_realm_across_borrowed_getters() {
let mut vm = new_storage_test_vm("https://headers-owner-realm.test/");
vm.eval(
r#"
const root = document.documentElement || document.appendChild(document.createElement('html'));
const body = document.body || root.appendChild(document.createElement('body'));
globalThis.headersFrame = document.createElement('iframe');
body.appendChild(headersFrame);
"#,
)
.unwrap();
assert_initial_about_blank_child_completed_synchronously_for_test(&mut vm, "Headers realm")
.await;
let _ = materialize_single_child_default_realm_for_test(&mut vm, "Headers realm");
let result = vm.eval(r#"
(() => {
const check = (value, label) => { if (!value) throw new Error(label); };
const child = headersFrame.contentWindow;
const parentHeaders = Headers;
const childHeaders = child.Headers;
const parentResponse = new Response('parent', {headers: {'x-realm': 'parent'}});
const childResponse = new child.Response('child', {headers: {'x-realm': 'child'}});
const parentGetter = Object.getOwnPropertyDescriptor(Response.prototype, 'headers').get;
const childGetter = Object.getOwnPropertyDescriptor(child.Response.prototype, 'headers').get;
const descriptors = [globalThis, child].map(realm => Object.getOwnPropertyDescriptor(realm, 'Headers'));
for (const realm of [globalThis, child]) Object.defineProperty(realm, 'Headers', {
configurable: true, get() { throw new Error('public Headers lookup'); }
});
try {
for (const [response, ctor, getter, value] of [
[parentResponse, parentHeaders, childGetter, 'parent'],
[childResponse, childHeaders, parentGetter, 'child']
]) {
check(getter.call(response) === response.headers, 'borrowed getter returns associated Headers');
for (const entry of [response]) {
const headers = entry.headers;
check(Object.getPrototypeOf(headers) === ctor.prototype && headers instanceof ctor,
'Headers must use the response realm');
check(headers.get === ctor.prototype.get && Reflect.ownKeys(headers).length === 0,
'Headers must share realm prototype methods');
check(headers.get('x-realm') === value, 'cross-realm entries');
check(ctor.prototype.get.call(headers, 'x-realm') === value, 'branded receiver');
}
}
return 'ok';
} finally {
[globalThis, child].forEach((realm, index) => Object.defineProperty(realm, 'Headers', descriptors[index]));
}
})()
"#).unwrap();
assert_eq!(result, "ok");
}
#[test]
fn headers_prototype_methods_are_declared_operations() {
let mut vm = new_storage_test_vm("https://headers-prototype-methods.test/");
@@ -7164,7 +7402,7 @@ fn headers_methods_reject_incompatible_receivers() {
}
#[test]
fn headers_declared_methods_preserve_descriptors_and_iterator_alias() {
fn response_headers_inherit_declared_methods_and_iterator_alias() {
let mut vm = new_storage_test_vm("https://headers-declared-methods.test/");
let result = vm
@@ -7172,6 +7410,9 @@ fn headers_declared_methods_preserve_descriptors_and_iterator_alias() {
r#"
(() => {
const headers = new Response(null, { headers: [['X-A', '1']] }).headers;
const prototype = Object.getPrototypeOf(headers);
if (prototype !== Headers.prototype) throw new Error('Headers must use the intrinsic prototype');
if (Reflect.ownKeys(headers).length !== 0) throw new Error('Headers methods must be inherited');
const descriptors = [
['get', 1],
['has', 1],
@@ -7184,7 +7425,7 @@ fn headers_declared_methods_preserve_descriptors_and_iterator_alias() {
['entries', 0],
['forEach', 1],
].map(([name, expectedLength]) => {
const descriptor = Object.getOwnPropertyDescriptor(headers, name);
const descriptor = Object.getOwnPropertyDescriptor(prototype, name);
return [
name,
typeof descriptor?.value,
@@ -7196,7 +7437,7 @@ fn headers_declared_methods_preserve_descriptors_and_iterator_alias() {
descriptor?.configurable,
].join(':');
});
const iteratorDescriptor = Object.getOwnPropertyDescriptor(headers, Symbol.iterator);
const iteratorDescriptor = Object.getOwnPropertyDescriptor(prototype, Symbol.iterator);
const iterated = Array.from(headers).map(([name, value]) => `${name}=${value}`).join(',');
return JSON.stringify({
descriptors,
@@ -7218,7 +7459,7 @@ fn headers_declared_methods_preserve_descriptors_and_iterator_alias() {
assert_eq!(
result,
r#"{"descriptors":["get:function:get:1:1:true:true:true","has:function:has:1:1:true:true:true","getSetCookie:function:getSetCookie:0:0:true:true:true","set:function:set:2:2:true:true:true","delete:function:delete:1:1:true:true:true","append:function:append:2:2:true:true:true","keys:function:keys:0:0:true:true:true","values:function:values:0:0:true:true:true","entries:function:entries:0:0:true:true:true","forEach:function:forEach:1:1:true:true:true"],"iterator":"function:true:entries:0:true:true:true:x-a=1"}"#
r#"{"descriptors":["get:function:get:1:1:true:true:true","has:function:has:1:1:true:true:true","getSetCookie:function:getSetCookie:0:0:true:true:true","set:function:set:2:2:true:true:true","delete:function:delete:1:1:true:true:true","append:function:append:2:2:true:true:true","keys:function:keys:0:0:true:true:true","values:function:values:0:0:true:true:true","entries:function:entries:0:0:true:true:true","forEach:function:forEach:1:1:true:true:true"],"iterator":"function:true:entries:0:false:true:true:x-a=1"}"#
);
}