diff --git a/moli-core/tests/fetch_behaviors/cache_timing.rs b/moli-core/tests/fetch_behaviors/cache_timing.rs
index c8e05f14a4..15a689d240 100644
--- a/moli-core/tests/fetch_behaviors/cache_timing.rs
+++ b/moli-core/tests/fetch_behaviors/cache_timing.rs
@@ -96,21 +96,28 @@ async fn resource_timing_distinguishes_network_local_and_revalidated_responses()
let browser = Browser::new(config)?;
let mut page = browser.fetch(&format!("http://{addr}/")).await?;
let fixture = include_str!("../fixtures/cache-resource-timing.js");
- for context in ["top", "child"] {
+ for (context, transport) in [
+ ("top", "xhr"),
+ ("child", "xhr"),
+ ("top", "fetch"),
+ ("child", "fetch"),
+ ] {
+ let fixture = format!(
+ "(() => {{globalThis.cacheTimingContext = '{context}-{transport}';globalThis.cacheTimingTransport = '{transport}';return {fixture};}})()"
+ );
let script = if context == "top" {
- fixture.to_owned()
+ fixture.clone()
} else {
format!(
r#"(async () => {{
const frame = document.createElement('iframe');
frame.srcdoc = '
child';
await new Promise(resolve => {{ frame.onload=resolve; document.body.append(frame); }});
- frame.contentWindow.cacheTimingContext = 'child';
const result = await frame.contentWindow.eval({});
frame.remove();
return result;
}})()"#,
- serde_json::to_string(fixture)?
+ serde_json::to_string(&fixture)?
)
};
let result = tokio::time::timeout(
@@ -155,7 +162,7 @@ async fn resource_timing_distinguishes_network_local_and_revalidated_responses()
{"padding":0,"transfer":306,"encoded":6},
{"padding":4096,"transfer":306,"encoded":6}
]}),
- "{context}"
+ "{context}, {transport}"
);
}
let _ = shutdown.send(());
diff --git a/moli-core/tests/fixtures/cache-resource-timing.js b/moli-core/tests/fixtures/cache-resource-timing.js
index ea8de41573..c7215bb98b 100644
--- a/moli-core/tests/fixtures/cache-resource-timing.js
+++ b/moli-core/tests/fixtures/cache-resource-timing.js
@@ -7,6 +7,8 @@
request.send();
});
const context = globalThis.cacheTimingContext || 'top';
+ const request = globalThis.cacheTimingTransport === 'fetch'
+ ? url => fetch(url).then(response => response.text()) : xhr;
const resourceUrl = (path, parameters) => {
const url = new URL(path, document.baseURI);
url.search = new URLSearchParams({...parameters, context});
@@ -17,7 +19,7 @@
const url = resourceUrl('/probe-cache', {mode});
const observations = [];
for (let i = 0; i < 3; i++) {
- const body = await xhr(url);
+ const body = await request(url);
await new Promise(resolve => setTimeout(resolve, 0));
const entries = performance.getEntriesByName(url);
const entry = entries[entries.length - 1];
@@ -36,7 +38,7 @@
const headers = [];
for (const padding of [0, 4096]) {
const url = resourceUrl('/probe-cache', {mode: 'padding', padding});
- await xhr(url);
+ await request(url);
await new Promise(resolve => setTimeout(resolve, 0));
const entry = performance.getEntriesByName(url)[0];
headers.push({padding, transfer: entry.transferSize, encoded: entry.encodedBodySize});
diff --git a/moli-renderer-v8/src/context_bootstrap.rs b/moli-renderer-v8/src/context_bootstrap.rs
index a87e1ffb24..b12b21d9d4 100644
--- a/moli-renderer-v8/src/context_bootstrap.rs
+++ b/moli-renderer-v8/src/context_bootstrap.rs
@@ -447,10 +447,10 @@ pub(crate) use self::notification_runtime::{
notification_options_payload,
};
pub(crate) use self::performance_runtime::{
- ResourcePerformanceEntry, bind_document_performance, bind_window_performance_seed,
- current_performance_time_origin, increment_performance_event_count,
- project_document_lifecycle_timing, record_resource_performance_entry,
- run_resource_timing_buffer_full_task,
+ FetchResourceTiming, ResourcePerformanceEntry, bind_document_performance,
+ bind_window_performance_seed, current_performance_time_origin,
+ increment_performance_event_count, project_document_lifecycle_timing,
+ record_resource_performance_entry, run_resource_timing_buffer_full_task,
};
use self::range_live::{
update_live_ranges_for_character_data_edit, update_live_ranges_for_character_data_reset,
diff --git a/moli-renderer-v8/src/context_bootstrap/performance_runtime.rs b/moli-renderer-v8/src/context_bootstrap/performance_runtime.rs
index 1148a44cd4..09266f7140 100644
--- a/moli-renderer-v8/src/context_bootstrap/performance_runtime.rs
+++ b/moli-renderer-v8/src/context_bootstrap/performance_runtime.rs
@@ -2,6 +2,8 @@ use super::*;
use crate::util::{get_private_value, set_private_value};
mod entries;
+mod fetch_timing;
+pub(crate) use fetch_timing::FetchResourceTiming;
mod install;
mod lazy_subobjects;
mod marks_measures;
diff --git a/moli-renderer-v8/src/context_bootstrap/performance_runtime/fetch_timing.rs b/moli-renderer-v8/src/context_bootstrap/performance_runtime/fetch_timing.rs
new file mode 100644
index 0000000000..b43b27c15b
--- /dev/null
+++ b/moli-renderer-v8/src/context_bootstrap/performance_runtime/fetch_timing.rs
@@ -0,0 +1,91 @@
+use super::ResourcePerformanceEntry;
+use crate::types::AsyncSubresourceFetchResponseFilter;
+use moli_fetch::{FetchUrlList, RequestMode, ResponseCacheState, ResponseHead};
+use moli_url::WebOrigin;
+use url::Url;
+
+/// Request-time metadata, independent of the callback or Realm that happens to
+/// be active when the body finishes. This must not retain any V8 objects.
+#[derive(Clone)]
+pub(crate) struct FetchResourceTiming {
+ start_unix_millis: f64,
+ initiator_url: String,
+}
+
+impl FetchResourceTiming {
+ pub(crate) fn capture(scope: &mut v8::PinScope<'_, '_>, origin: &WebOrigin) -> Self {
+ Self {
+ start_unix_millis: crate::context_bootstrap::unix_epoch_millis(),
+ initiator_url: crate::resource_initiator::current_url(scope, origin),
+ }
+ }
+
+ pub(crate) fn completed(
+ &self,
+ url: &Url,
+ origin: &WebOrigin,
+ mode: RequestMode,
+ head: &ResponseHead,
+ body_size: usize,
+ filter: Option,
+ ) -> ResourcePerformanceEntry {
+ let filter =
+ filter.or_else(|| crate::network_host::network_response_filter(origin, head, mode));
+ let readable = filter.is_none_or(|filter| filter.is_readable());
+ let mut entry = ResourcePerformanceEntry::from_streaming_network_response(
+ url.as_str(),
+ "fetch",
+ Some(self.start_unix_millis),
+ head,
+ body_size,
+ );
+ // CORS controls response metadata and script attribution. TAO controls
+ // timing/body sizes separately, including for opaque responses.
+ if readable && head.cache_state == ResponseCacheState::None {
+ entry.initiator_url.clone_from(&self.initiator_url);
+ }
+ if !readable {
+ entry.response_status = 0.0;
+ entry.content_type.clear();
+ }
+ if !timing_allowed(origin, head) {
+ entry.transfer_size = 0.0;
+ entry.encoded_body_size = 0.0;
+ entry.decoded_body_size = 0.0;
+ }
+ entry
+ }
+
+ pub(crate) fn failed(&self, url: &Url) -> ResourcePerformanceEntry {
+ ResourcePerformanceEntry::from_network_failure(
+ url.as_str(),
+ "fetch",
+ Some(self.start_unix_millis),
+ )
+ }
+}
+
+fn timing_allowed(origin: &WebOrigin, head: &ResponseHead) -> bool {
+ // Once a redirect has failed TAO, a later response cannot restore access.
+ // Likewise, returning home does not reset cross-origin response tainting.
+ let mut cross_origin = false;
+ for (index, redirect) in head.redirect_chain.iter().enumerate() {
+ cross_origin |= !origin.same_origin_url(&redirect.from_url);
+ let urls = FetchUrlList::new(&redirect.from_url, &head.redirect_chain[..index]);
+ if cross_origin && !allows_timing(&redirect.headers, &urls.serialized_origin(origin)) {
+ return false;
+ }
+ }
+ cross_origin |= !origin.same_origin_url(&head.final_url);
+ !cross_origin || allows_timing(&head.headers, &head.url_list().serialized_origin(origin))
+}
+
+fn allows_timing(headers: &[(String, Vec)], origin: &str) -> bool {
+ headers
+ .iter()
+ .filter(|(name, _)| name.eq_ignore_ascii_case("timing-allow-origin"))
+ .filter_map(|(_, value)| std::str::from_utf8(value).ok())
+ .flat_map(|value| value.split(','))
+ .map(|value| value.trim_matches(|ch: char| ch.is_ascii_whitespace()))
+ .any(|value| value == "*" || value == origin)
+}
diff --git a/moli-renderer-v8/src/native_bridge/context_host/resource_loading.rs b/moli-renderer-v8/src/native_bridge/context_host/resource_loading.rs
index 001d9c6c4f..76a661a94d 100644
--- a/moli-renderer-v8/src/native_bridge/context_host/resource_loading.rs
+++ b/moli-renderer-v8/src/native_bridge/context_host/resource_loading.rs
@@ -601,6 +601,7 @@ impl JsContextHost {
&mut self,
fetch_context: super::WindowFetchContext,
resolver: v8::Global,
+ resource_timing: crate::context_bootstrap::FetchResourceTiming,
keepalive: bool,
connect_policy: crate::document_runtime::DocumentConnectPolicySnapshot,
csp_report_context: crate::network_host::WindowCspReportRequestContext,
@@ -641,6 +642,7 @@ impl JsContextHost {
continuation: PendingSubresourceContinuation::Fetch(
crate::types::PendingWindowFetchContinuation::new(
resolver,
+ resource_timing,
keepalive,
cache_mode,
connect_policy,
@@ -886,6 +888,7 @@ impl JsContextHost {
&mut self,
fetch_context: super::WindowFetchContext,
resolver: v8::Global,
+ resource_timing: crate::context_bootstrap::FetchResourceTiming,
keepalive: bool,
connect_policy: crate::document_runtime::DocumentConnectPolicySnapshot,
csp_report_context: crate::network_host::WindowCspReportRequestContext,
@@ -931,6 +934,7 @@ impl JsContextHost {
continuation: PendingSubresourceContinuation::Fetch(
crate::types::PendingWindowFetchContinuation::new(
resolver,
+ resource_timing,
keepalive,
cache_mode,
connect_policy,
diff --git a/moli-renderer-v8/src/network_host/fetch/bindings/paths.rs b/moli-renderer-v8/src/network_host/fetch/bindings/paths.rs
index b8798341b4..7d28348b11 100644
--- a/moli-renderer-v8/src/network_host/fetch/bindings/paths.rs
+++ b/moli-renderer-v8/src/network_host/fetch/bindings/paths.rs
@@ -23,6 +23,7 @@ pub(super) fn record_intercepted_fetch(
host.record_pending_subresource_fetch(
prepared.fetch_context,
v8::Global::new(scope, resolver),
+ prepared.resource_timing,
prepared.keepalive,
prepared.connect_policy,
prepared.csp_report_context,
@@ -243,6 +244,7 @@ pub(super) fn spawn_network_fetch(
let internal_id = host.record_async_subresource_fetch(
prepared.fetch_context,
v8::Global::new(scope, resolver),
+ prepared.resource_timing,
prepared.keepalive,
prepared.connect_policy,
prepared.csp_report_context,
diff --git a/moli-renderer-v8/src/network_host/fetch/bindings/request.rs b/moli-renderer-v8/src/network_host/fetch/bindings/request.rs
index 233bfa852f..ca826cf071 100644
--- a/moli-renderer-v8/src/network_host/fetch/bindings/request.rs
+++ b/moli-renderer-v8/src/network_host/fetch/bindings/request.rs
@@ -59,6 +59,7 @@ pub(super) struct PreparedWindowFetchRequest {
pub(super) csp_report_context: crate::network_host::WindowCspReportRequestContext,
pub(super) document_url: url::Url,
pub(super) request_origin: moli_url::WebOrigin,
+ pub(super) resource_timing: crate::context_bootstrap::FetchResourceTiming,
pub(super) network_partition_key: Option,
pub(super) document_referrer_policy: Option,
pub(super) policy_context: crate::types::SubresourcePolicyContext,
@@ -130,6 +131,10 @@ pub(super) fn prepare_window_fetch_request<'s>(
let resolved_url = resolve_context_url(&base_url, &parsed.url, None)?;
Ok(PreparedWindowFetchRequest {
+ resource_timing: crate::context_bootstrap::FetchResourceTiming::capture(
+ scope,
+ &request_origin,
+ ),
frame_id,
fetch_context,
resource_loader,
diff --git a/moli-renderer-v8/src/network_host/fetch/bindings/service_worker.rs b/moli-renderer-v8/src/network_host/fetch/bindings/service_worker.rs
index 9008b99c8d..1f6bc6232a 100644
--- a/moli-renderer-v8/src/network_host/fetch/bindings/service_worker.rs
+++ b/moli-renderer-v8/src/network_host/fetch/bindings/service_worker.rs
@@ -52,6 +52,7 @@ pub(super) fn dispatch_service_worker_fetch(
let internal_id = host.record_async_subresource_fetch(
prepared.fetch_context.duplicate(scope),
v8::Global::new(scope, resolver),
+ prepared.resource_timing.clone(),
prepared.keepalive,
prepared.connect_policy.clone(),
prepared.csp_report_context.clone(),
diff --git a/moli-renderer-v8/src/script_vm/subresource_fetch/fetch_dispatch.rs b/moli-renderer-v8/src/script_vm/subresource_fetch/fetch_dispatch.rs
index 338ca8317c..bd6112c2fd 100644
--- a/moli-renderer-v8/src/script_vm/subresource_fetch/fetch_dispatch.rs
+++ b/moli-renderer-v8/src/script_vm/subresource_fetch/fetch_dispatch.rs
@@ -657,6 +657,21 @@ impl ScriptVm {
trace_fields,
record_started,
);
+ if let PendingSubresourceContinuation::Fetch(fetch) = &pending.continuation {
+ crate::context_bootstrap::record_resource_performance_entry(
+ scope,
+ fetch.resource_timing().completed(
+ &pending.info.url,
+ &pending.request_origin,
+ pending.request_mode,
+ &response.head(),
+ response.body_bytes().len(),
+ opaque_response_blocked
+ .then_some(AsyncSubresourceFetchResponseFilter::Opaque)
+ .or(response_filter),
+ ),
+ );
+ }
let mut observable_response = response;
if matches!(
pending.info.resource_type,
@@ -878,6 +893,10 @@ impl ScriptVm {
moli_trace::cdp_runtime_trace_enabled().then(Instant::now);
match pending.continuation {
PendingSubresourceContinuation::Fetch(fetch) => {
+ crate::context_bootstrap::record_resource_performance_entry(
+ scope,
+ fetch.resource_timing().failed(&pending.info.url),
+ );
let resolver = fetch
.into_resolver()
.expect("detached keepalive failure is handled before V8 entry");
diff --git a/moli-renderer-v8/src/script_vm/subresource_fetch/streaming.rs b/moli-renderer-v8/src/script_vm/subresource_fetch/streaming.rs
index a7d521c813..f63ca5b247 100644
--- a/moli-renderer-v8/src/script_vm/subresource_fetch/streaming.rs
+++ b/moli-renderer-v8/src/script_vm/subresource_fetch/streaming.rs
@@ -727,6 +727,10 @@ impl ScriptVm {
.record_subresource_network(network_record);
match &pending.continuation {
PendingSubresourceContinuation::Fetch(fetch) => {
+ crate::context_bootstrap::record_resource_performance_entry(
+ scope,
+ fetch.resource_timing().failed(&pending.info.url),
+ );
let resolver = fetch
.resolver()
.expect("detached keepalive stream is handled before V8 entry");
@@ -1799,6 +1803,19 @@ impl ScriptVm {
trace_fields,
record_started,
);
+ if let PendingSubresourceContinuation::Fetch(fetch) = &streaming.pending.continuation {
+ crate::context_bootstrap::record_resource_performance_entry(
+ scope,
+ fetch.resource_timing().completed(
+ &streaming.pending.info.url,
+ &streaming.pending.request_origin,
+ streaming.pending.request_mode,
+ &streaming.head,
+ response_body_size,
+ streaming.response_filter,
+ ),
+ );
+ }
if let PendingSubresourceContinuation::Xhr { xhr, initiator_url, .. } =
streaming.pending.continuation
&& let Some(response_body) = xhr_delivery_body
@@ -1851,6 +1868,12 @@ impl ScriptVm {
}
}
Err(error_text) => {
+ if let PendingSubresourceContinuation::Fetch(fetch) = &streaming.pending.continuation {
+ crate::context_bootstrap::record_resource_performance_entry(
+ scope,
+ fetch.resource_timing().failed(&streaming.pending.info.url),
+ );
+ }
let error_started =
moli_trace::cdp_runtime_trace_enabled().then(Instant::now);
crate::network_host::error_pending_network_body_stream(
diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/xhr.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/xhr.rs
index 1a1fd43f54..7a08b7abef 100644
--- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/xhr.rs
+++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/xhr.rs
@@ -157,6 +157,10 @@ fn pending_fetch_continuation<'s>(
crate::types::PendingSubresourceContinuation::Fetch(
crate::types::PendingWindowFetchContinuation::new(
v8::Global::new(scope, resolver),
+ crate::context_bootstrap::FetchResourceTiming::capture(
+ scope,
+ &host.document_url().into(),
+ ),
false,
moli_fetch::RequestCacheMode::Default,
crate::document_runtime::DocumentConnectPolicySnapshot::from_policy_container(
diff --git a/moli-renderer-v8/src/script_vm/tests/mod.rs b/moli-renderer-v8/src/script_vm/tests/mod.rs
index fba936f2fd..b63c0fdcf4 100644
--- a/moli-renderer-v8/src/script_vm/tests/mod.rs
+++ b/moli-renderer-v8/src/script_vm/tests/mod.rs
@@ -327,6 +327,7 @@ fn register_pending_window_fetch_for_test(
let internal_id = host.record_async_subresource_fetch(
fetch_context,
v8::Global::new(scope, resolver),
+ crate::context_bootstrap::FetchResourceTiming::capture(scope, &(&url).into()),
keepalive,
connect_policy,
csp_report_context,
@@ -500,6 +501,7 @@ fn register_pending_window_fetch_with_connect_policy_for_test(
let internal_id = host.record_async_subresource_fetch(
fetch_context,
v8::Global::new(scope, resolver),
+ crate::context_bootstrap::FetchResourceTiming::capture(scope, &(&document_url).into()),
keepalive,
crate::document_runtime::DocumentConnectPolicySnapshot::from_policy_container(&policy),
csp_report_context,
diff --git a/moli-renderer-v8/src/script_vm/tests/resource_initiator.rs b/moli-renderer-v8/src/script_vm/tests/resource_initiator.rs
index 15327c9e9b..141ec61c5b 100644
--- a/moli-renderer-v8/src/script_vm/tests/resource_initiator.rs
+++ b/moli-renderer-v8/src/script_vm/tests/resource_initiator.rs
@@ -2,6 +2,7 @@ use super::*;
use crate::script_provenance::CompiledStringProvenance;
mod event_handler_getter;
+mod fetch;
mod geolocation_watch;
mod realm_lifetime;
diff --git a/moli-renderer-v8/src/script_vm/tests/resource_initiator/fetch.rs b/moli-renderer-v8/src/script_vm/tests/resource_initiator/fetch.rs
new file mode 100644
index 0000000000..dc255eebe7
--- /dev/null
+++ b/moli-renderer-v8/src/script_vm/tests/resource_initiator/fetch.rs
@@ -0,0 +1,345 @@
+use super::*;
+
+fn finish_fetch(
+ vm: &mut ScriptVm,
+ request: &crate::types::PendingSubresourceFetchInfo,
+ response: crate::protocol_types::NavigationResponse,
+ streaming: bool,
+) {
+ if streaming {
+ let body_source_id = crate::network_host::new_network_body_source_id();
+ vm.start_streaming_async_subresource_fetch(
+ crate::types::AsyncSubresourceStreamingStarted {
+ internal_id: request.internal_id,
+ request_url: request.url.clone(),
+ request_method: request.method.clone(),
+ request_headers: request.request_headers.clone(),
+ request_body: None,
+ skip_fetch_security_validation: false,
+ response_filter: None,
+ body_source_id,
+ head: response.head(),
+ network_request_headers: None,
+ },
+ )
+ .unwrap();
+ assert_eq!(
+ vm.eval("String(performance.getEntriesByType('resource').length)")
+ .unwrap(),
+ "0",
+ "headers must not publish an unfinished Fetch"
+ );
+ vm.append_streaming_async_subresource_fetch_chunk(
+ body_source_id,
+ response.body_bytes().to_vec(),
+ );
+ assert_eq!(
+ vm.eval("String(performance.getEntriesByType('resource').length)")
+ .unwrap(),
+ "0",
+ "body chunks must not publish an unfinished Fetch"
+ );
+ vm.finish_streaming_async_subresource_fetch(request.internal_id, body_source_id, Ok(()))
+ .unwrap();
+ } else {
+ vm.complete_async_subresource_fetch(crate::types::AsyncSubresourceFetchCompletion {
+ internal_id: request.internal_id,
+ request_url: request.url.clone(),
+ request_method: request.method.clone(),
+ request_headers: request.request_headers.clone(),
+ request_body: None,
+ response_status_text: None,
+ skip_fetch_security_validation: false,
+ response_filter: None,
+ network_error_text: None,
+ result: Ok(response).into(),
+ })
+ .unwrap();
+ }
+}
+
+#[test]
+fn fetch_resource_timing_publishes_once_after_buffered_and_streaming_body_completion() {
+ let document_url = Url::parse("https://fetch-timing.test/page").unwrap();
+ let caller = document_url.join("caller.js").unwrap();
+ for streaming in [false, true] {
+ let mut vm = new_storage_test_vm(document_url.as_str());
+ vm.set_fetch_subresource_interception(
+ true,
+ Some(crate::types::SubresourceResourceType::Fetch),
+ );
+ execute_with_source(
+ &mut vm,
+ r#"
+ globalThis.observed = [];
+ globalThis.observer = new PerformanceObserver(list => observed.push(...list.getEntries()));
+ observer.observe({type:'resource'});
+ globalThis.fetchResponse = null;
+ globalThis.started = performance.now();
+ fetch('/asset').then(response => fetchResponse = response);
+ "#,
+ &caller,
+ &document_url,
+ );
+ let requests = vm.take_pending_subresource_fetch_infos();
+ assert_eq!(requests.len(), 1);
+ let request = &requests[0];
+ finish_fetch(
+ &mut vm,
+ request,
+ crate::protocol_types::NavigationResponse::from_text_body(
+ request.url.clone(),
+ 200,
+ vec![("Content-Type".to_owned(), b"text/plain".to_vec())],
+ "body".to_owned(),
+ ),
+ streaming,
+ );
+ assert_eq!(vm.eval("String(fetchResponse.status)").unwrap(), "200");
+ assert_eq!(vm.eval(r#"JSON.stringify({
+ entries:performance.getEntriesByType('resource').map(entry => ({
+ name:entry.name, type:entry.initiatorType, source:entry.initiatorUrl,
+ jsonSource:entry.toJSON().initiatorUrl, status:entry.responseStatus,
+ encoded:entry.encodedBodySize, decoded:entry.decodedBodySize,
+ transfer:entry.transferSize, native:entry instanceof PerformanceResourceTiming,
+ start:entry.startTime >= started && entry.startTime <= performance.now()
+ })),
+ observer:[...observed, ...observer.takeRecords()].map(entry => entry.name)
+ })"#).unwrap(), serde_json::json!({
+ "entries":[{"name":request.url.as_str(),"type":"fetch","source":caller.as_str(),
+ "jsonSource":caller.as_str(),"status":200,"encoded":4,"decoded":4,"transfer":304,"native":true,"start":true}],
+ "observer":[request.url.as_str()]
+ }).to_string(), "streaming={streaming}");
+ }
+}
+
+#[tokio::test]
+async fn fetch_resource_timing_retains_initiators_through_real_network_and_lazy_performance() {
+ let server = StaticHttpServer::spawn_with_responder(
+ 3,
+ "Content-Type: text/plain\r\nCache-Control: no-store\r\n",
+ |_, _| "ok".to_owned(),
+ )
+ .await;
+ let base = server.base_url();
+ let loader = static_http_loader(std::iter::empty::());
+ let mut vm = new_storage_page_task_executor_test_vm_with_loader(base.as_str(), &loader);
+ execute_with_source(
+ &mut vm,
+ r#"
+ globalThis.fetchDone = 0;
+ globalThis.get = path => fetch(path).then(response => response.text()).then(() => fetchDone++);
+ "#,
+ &base.join("library.js").unwrap(),
+ &base,
+ );
+ let caller = base.join("caller.js?version=2").unwrap();
+ execute_with_source(
+ &mut vm,
+ r#"
+ get('/direct');
+ Promise.resolve().then(() => get('/promise'));
+ setTimeout(() => get('/timer'), 0);
+ "#,
+ &caller,
+ &base,
+ );
+ execute_with_source(
+ &mut vm,
+ "globalThis.laterScript = true;",
+ &base.join("later.js").unwrap(),
+ &base,
+ );
+ advance_page_task_executor_until_eval_equals(
+ &mut vm,
+ &loader,
+ "String(fetchDone)",
+ "3",
+ "real Fetch body completion",
+ )
+ .await;
+ assert_eq!(vm.eval(r#"JSON.stringify(performance.getEntriesByType('resource')
+ .map(entry => [new URL(entry.name).pathname, entry.initiatorType, entry.initiatorUrl, entry.toJSON().initiatorUrl]).sort())"#).unwrap(),
+ serde_json::json!([
+ ["/direct","fetch",caller.as_str(),caller.as_str()],
+ ["/promise","fetch",caller.as_str(),caller.as_str()],
+ ["/timer","fetch",caller.as_str(),caller.as_str()]
+ ]).to_string());
+ assert_eq!(server.finish().await.len(), 3);
+}
+
+#[test]
+fn fetch_resource_timing_reports_failed_network_requests() {
+ let base = Url::parse("https://failed-fetch-timing.test/page").unwrap();
+ let mut vm = new_storage_test_vm(base.as_str());
+ vm.set_fetch_subresource_interception(true, Some(crate::types::SubresourceResourceType::Fetch));
+ execute_with_source(
+ &mut vm,
+ "fetch('/failed').catch(error => globalThis.fetchError = error.name);",
+ &base,
+ &base,
+ );
+ let request = vm.take_pending_subresource_fetch_infos().pop().unwrap();
+ vm.complete_async_subresource_fetch(crate::types::AsyncSubresourceFetchCompletion {
+ internal_id: request.internal_id,
+ request_url: request.url.clone(),
+ request_method: request.method,
+ request_headers: request.request_headers,
+ request_body: None,
+ response_status_text: None,
+ skip_fetch_security_validation: false,
+ response_filter: None,
+ network_error_text: None,
+ result: Err("network failed".to_owned()).into(),
+ })
+ .unwrap();
+ assert_eq!(vm.eval("fetchError").unwrap(), "TypeError");
+ assert_eq!(
+ vm.eval(
+ r#"JSON.stringify(performance.getEntriesByType('resource')
+ .map(entry => [entry.name,entry.initiatorType,entry.responseStatus,entry.transferSize]))"#
+ )
+ .unwrap(),
+ serde_json::json!([[request.url.as_str(), "fetch", 0, 0]]).to_string()
+ );
+}
+
+#[test]
+fn fetch_resource_timing_separates_cors_attribution_from_timing_allow_origin() {
+ let document = Url::parse("https://fetch-timing.test/page").unwrap();
+ let caller = document.join("caller.js").unwrap();
+ let streaming_modes: &[bool] = &[false, true];
+ // The production transport buffers no-cors responses for ORB body sniffing.
+ let buffered: &[bool] = &[false];
+ for (modes, mode, tao, expected_source, expected_size, expected_status) in [
+ (streaming_modes, "cors", None, caller.as_str(), 0, 200),
+ (streaming_modes, "cors", Some("*"), caller.as_str(), 4, 200),
+ (
+ streaming_modes,
+ "cors",
+ Some("https://fetch-timing.test"),
+ caller.as_str(),
+ 4,
+ 200,
+ ),
+ (
+ streaming_modes,
+ "cors",
+ Some("https://other.test"),
+ caller.as_str(),
+ 0,
+ 200,
+ ),
+ (buffered, "no-cors", None, "", 0, 0),
+ (buffered, "no-cors", Some("*"), "", 4, 0),
+ ] {
+ for &streaming in modes {
+ let mut vm = new_storage_test_vm(document.as_str());
+ vm.set_fetch_subresource_interception(
+ true,
+ Some(crate::types::SubresourceResourceType::Fetch),
+ );
+ execute_with_source(
+ &mut vm,
+ &format!(
+ "fetch('https://cross-origin.test/asset', {{mode:'{mode}'}}).then(response => globalThis.fetchResponse = response);"
+ ),
+ &caller,
+ &document,
+ );
+ let request = vm.take_pending_subresource_fetch_infos().pop().unwrap();
+ let mut headers = vec![
+ ("Content-Type".to_owned(), b"text/plain".to_vec()),
+ ("Access-Control-Allow-Origin".to_owned(), b"*".to_vec()),
+ ];
+ if let Some(tao) = tao {
+ headers.push(("Timing-Allow-Origin".to_owned(), tao.as_bytes().to_vec()));
+ }
+ finish_fetch(
+ &mut vm,
+ &request,
+ crate::protocol_types::NavigationResponse::from_text_body(
+ request.url.clone(),
+ 200,
+ headers,
+ "body".to_owned(),
+ ),
+ streaming,
+ );
+ assert_eq!(
+ vm.eval(
+ r#"JSON.stringify(performance.getEntriesByType('resource').map(entry => [
+ entry.name, entry.initiatorType, entry.initiatorUrl, entry.responseStatus,
+ entry.encodedBodySize, entry.decodedBodySize, entry.transferSize, entry.contentType
+ ]))"#
+ )
+ .unwrap(),
+ serde_json::json!([[
+ request.url.as_str(),
+ "fetch",
+ expected_source,
+ expected_status,
+ expected_size,
+ expected_size,
+ if expected_size == 0 { 0 } else { 304 },
+ if mode == "cors" { "text/plain" } else { "" }
+ ]])
+ .to_string(),
+ "streaming={streaming}, mode={mode}, tao={tao:?}"
+ );
+ }
+ }
+}
+
+#[test]
+fn fetch_resource_timing_keeps_cache_hits_and_unknown_script_sources_unattributed() {
+ let document = Url::parse("https://fetch-cache-timing.test/page").unwrap();
+ let caller = document.join("caller.js").unwrap();
+ for streaming in [false, true] {
+ for (cache, transfer) in [
+ (moli_fetch::ResponseCacheState::None, 304),
+ (moli_fetch::ResponseCacheState::Local, 0),
+ (moli_fetch::ResponseCacheState::Validated, 300),
+ ] {
+ for known_source in [false, true] {
+ let mut vm = new_storage_test_vm(document.as_str());
+ vm.set_fetch_subresource_interception(
+ true,
+ Some(crate::types::SubresourceResourceType::Fetch),
+ );
+ let script =
+ "fetch('/asset').then(response => globalThis.fetchResponse = response);";
+ if known_source {
+ execute_with_source(&mut vm, script, &caller, &document);
+ } else {
+ vm.exec(script, None).unwrap();
+ }
+ let request = vm.take_pending_subresource_fetch_infos().pop().unwrap();
+ let mut response = crate::protocol_types::NavigationResponse::from_text_body(
+ request.url.clone(),
+ 200,
+ Vec::new(),
+ "body".to_owned(),
+ );
+ response.cache_state = cache;
+ response.from_cache = cache != moli_fetch::ResponseCacheState::None;
+ finish_fetch(&mut vm, &request, response, streaming);
+ let source = if known_source && cache == moli_fetch::ResponseCacheState::None {
+ caller.as_str()
+ } else {
+ ""
+ };
+ assert_eq!(
+ vm.eval(
+ r#"JSON.stringify(performance.getEntriesByType('resource').map(entry => [
+ entry.initiatorUrl, entry.encodedBodySize, entry.transferSize
+ ]))"#
+ )
+ .unwrap(),
+ serde_json::json!([[source, 4, transfer]]).to_string(),
+ "streaming={streaming}, cache={cache:?}, known_source={known_source}"
+ );
+ }
+ }
+ }
+}
diff --git a/moli-renderer-v8/src/types.rs b/moli-renderer-v8/src/types.rs
index 9320bada05..68cc9614c0 100644
--- a/moli-renderer-v8/src/types.rs
+++ b/moli-renderer-v8/src/types.rs
@@ -246,6 +246,7 @@ impl PendingSubresourceContinuation {
pub(super) struct PendingWindowFetchContinuation {
promise: PendingWindowFetchPromise,
+ resource_timing: crate::context_bootstrap::FetchResourceTiming,
keepalive: bool,
cache_mode: moli_fetch::RequestCacheMode,
connect_policy: crate::document_runtime::DocumentConnectPolicySnapshot,
@@ -260,6 +261,7 @@ enum PendingWindowFetchPromise {
impl PendingWindowFetchContinuation {
pub(super) fn new(
resolver: v8::Global,
+ resource_timing: crate::context_bootstrap::FetchResourceTiming,
keepalive: bool,
cache_mode: moli_fetch::RequestCacheMode,
connect_policy: crate::document_runtime::DocumentConnectPolicySnapshot,
@@ -267,6 +269,7 @@ impl PendingWindowFetchContinuation {
) -> Self {
Self {
promise: PendingWindowFetchPromise::Active(resolver),
+ resource_timing,
keepalive,
cache_mode,
connect_policy,
@@ -278,6 +281,10 @@ impl PendingWindowFetchContinuation {
self.cache_mode
}
+ pub(super) fn resource_timing(&self) -> &crate::context_bootstrap::FetchResourceTiming {
+ &self.resource_timing
+ }
+
pub(super) fn keepalive(&self) -> bool {
self.keepalive
}