Implement initial version of navigator.sendBeacon (#38301)

Gated behind the feature flag `dom_navigator_sendbeacon_enabled` as the
`keep-alive` fetch parameter is crucial for real-life use cases such as
analytics requests.

Part of #4577
Part of #38302

Signed-off-by: Tim van der Lippe <tvanderlippe@gmail.com>
This commit is contained in:
Tim van der Lippe 2025-08-02 17:14:07 +02:00 committed by GitHub
parent 181f97879d
commit dbb886fad2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 237 additions and 29 deletions

View file

@ -99,6 +99,7 @@ pub struct Preferences {
pub dom_microdata_testing_enabled: bool, pub dom_microdata_testing_enabled: bool,
pub dom_mouse_event_which_enabled: bool, pub dom_mouse_event_which_enabled: bool,
pub dom_mutation_observer_enabled: bool, pub dom_mutation_observer_enabled: bool,
pub dom_navigator_sendbeacon_enabled: bool,
pub dom_notification_enabled: bool, pub dom_notification_enabled: bool,
pub dom_offscreen_canvas_enabled: bool, pub dom_offscreen_canvas_enabled: bool,
pub dom_permissions_enabled: bool, pub dom_permissions_enabled: bool,
@ -279,6 +280,7 @@ impl Preferences {
dom_microdata_testing_enabled: false, dom_microdata_testing_enabled: false,
dom_mouse_event_which_enabled: false, dom_mouse_event_which_enabled: false,
dom_mutation_observer_enabled: true, dom_mutation_observer_enabled: true,
dom_navigator_sendbeacon_enabled: false,
dom_notification_enabled: false, dom_notification_enabled: false,
dom_offscreen_canvas_enabled: false, dom_offscreen_canvas_enabled: false,
dom_permissions_enabled: false, dom_permissions_enabled: false,

View file

@ -4,28 +4,45 @@
use std::cell::Cell; use std::cell::Cell;
use std::convert::TryInto; use std::convert::TryInto;
use std::sync::LazyLock; use std::sync::{Arc, LazyLock, Mutex};
use dom_struct::dom_struct; use dom_struct::dom_struct;
use headers::HeaderMap;
use http::header::{self, HeaderValue};
use js::rust::MutableHandleValue; use js::rust::MutableHandleValue;
use net_traits::request::{
CredentialsMode, Destination, RequestBuilder, RequestId, RequestMode,
is_cors_safelisted_request_content_type,
};
use net_traits::{
FetchMetadata, FetchResponseListener, NetworkError, ResourceFetchTiming, ResourceTimingType,
};
use servo_config::pref; use servo_config::pref;
use servo_url::ServoUrl;
use crate::body::Extractable;
use crate::dom::bindings::cell::DomRefCell; use crate::dom::bindings::cell::DomRefCell;
use crate::dom::bindings::codegen::Bindings::NavigatorBinding::NavigatorMethods; use crate::dom::bindings::codegen::Bindings::NavigatorBinding::NavigatorMethods;
use crate::dom::bindings::codegen::Bindings::WindowBinding::Window_Binding::WindowMethods; use crate::dom::bindings::codegen::Bindings::WindowBinding::Window_Binding::WindowMethods;
use crate::dom::bindings::codegen::Bindings::XMLHttpRequestBinding::BodyInit;
use crate::dom::bindings::error::{Error, Fallible};
use crate::dom::bindings::refcounted::Trusted;
use crate::dom::bindings::reflector::{DomGlobal, Reflector, reflect_dom_object}; use crate::dom::bindings::reflector::{DomGlobal, Reflector, reflect_dom_object};
use crate::dom::bindings::root::{DomRoot, MutNullableDom}; use crate::dom::bindings::root::{DomRoot, MutNullableDom};
use crate::dom::bindings::str::DOMString; use crate::dom::bindings::str::{DOMString, USVString};
use crate::dom::bindings::utils::to_frozen_array; use crate::dom::bindings::utils::to_frozen_array;
#[cfg(feature = "bluetooth")] #[cfg(feature = "bluetooth")]
use crate::dom::bluetooth::Bluetooth; use crate::dom::bluetooth::Bluetooth;
use crate::dom::clipboard::Clipboard; use crate::dom::clipboard::Clipboard;
use crate::dom::csp::{GlobalCspReporting, Violation};
use crate::dom::gamepad::Gamepad; use crate::dom::gamepad::Gamepad;
use crate::dom::gamepadevent::GamepadEventType; use crate::dom::gamepadevent::GamepadEventType;
use crate::dom::globalscope::GlobalScope;
use crate::dom::mediadevices::MediaDevices; use crate::dom::mediadevices::MediaDevices;
use crate::dom::mediasession::MediaSession; use crate::dom::mediasession::MediaSession;
use crate::dom::mimetypearray::MimeTypeArray; use crate::dom::mimetypearray::MimeTypeArray;
use crate::dom::navigatorinfo; use crate::dom::navigatorinfo;
use crate::dom::performanceresourcetiming::InitiatorType;
use crate::dom::permissions::Permissions; use crate::dom::permissions::Permissions;
use crate::dom::pluginarray::PluginArray; use crate::dom::pluginarray::PluginArray;
use crate::dom::serviceworkercontainer::ServiceWorkerContainer; use crate::dom::serviceworkercontainer::ServiceWorkerContainer;
@ -35,6 +52,7 @@ use crate::dom::webgpu::gpu::GPU;
use crate::dom::window::Window; use crate::dom::window::Window;
#[cfg(feature = "webxr")] #[cfg(feature = "webxr")]
use crate::dom::xrsystem::XRSystem; use crate::dom::xrsystem::XRSystem;
use crate::network_listener::{PreInvoke, ResourceTimingListener, submit_timing};
use crate::script_runtime::{CanGc, JSContext}; use crate::script_runtime::{CanGc, JSContext};
pub(super) fn hardware_concurrency() -> u64 { pub(super) fn hardware_concurrency() -> u64 {
@ -320,9 +338,159 @@ impl NavigatorMethods<crate::DomTypeHolder> for Navigator {
.or_init(|| Clipboard::new(&self.global(), CanGc::note())) .or_init(|| Clipboard::new(&self.global(), CanGc::note()))
} }
/// <https://w3c.github.io/beacon/#sec-processing-model>
fn SendBeacon(&self, url: USVString, data: Option<BodyInit>, can_gc: CanGc) -> Fallible<bool> {
let global = self.global();
// Step 1. Set base to this's relevant settings object's API base URL.
let base = global.api_base_url();
// Step 2. Set origin to this's relevant settings object's origin.
let origin = global.origin().immutable().clone();
// Step 3. Set parsedUrl to the result of the URL parser steps with url and base.
// If the algorithm returns an error, or if parsedUrl's scheme is not "http" or "https",
// throw a "TypeError" exception and terminate these steps.
let Ok(url) = ServoUrl::parse_with_base(Some(&base), &url) else {
return Err(Error::Type("Cannot parse URL".to_owned()));
};
if !matches!(url.scheme(), "http" | "https") {
return Err(Error::Type("URL is not http(s)".to_owned()));
}
let mut request_body = None;
// Step 4. Let headerList be an empty list.
let mut headers = HeaderMap::with_capacity(1);
// Step 5. Let corsMode be "no-cors".
let mut cors_mode = RequestMode::NoCors;
// Step 6. If data is not null:
if let Some(data) = data {
// Step 6.1. Set transmittedData and contentType to the result of extracting data's byte stream
// with the keepalive flag set.
let extracted_body = data.extract(&global, can_gc)?;
// Step 6.2. If the amount of data that can be queued to be sent by keepalive enabled requests
// is exceeded by the size of transmittedData (as defined in HTTP-network-or-cache fetch),
// set the return value to false and terminate these steps.
if let Some(total_bytes) = extracted_body.total_bytes {
if total_bytes > 64 * 1024 {
return Ok(false);
}
}
// Step 6.3. If contentType is not null:
if let Some(content_type) = extracted_body.content_type.as_ref() {
// Set corsMode to "cors".
cors_mode = RequestMode::CorsMode;
// If contentType value is a CORS-safelisted request-header value for the Content-Type header,
// set corsMode to "no-cors".
if is_cors_safelisted_request_content_type(content_type.as_bytes()) {
cors_mode = RequestMode::NoCors;
}
// Append a Content-Type header with value contentType to headerList.
//
// We cannot use typed header insertion with `mime::Mime` parsing here,
// since it lowercases `charset=UTF-8`: https://github.com/hyperium/mime/issues/116
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_str(content_type).unwrap(),
);
}
request_body = Some(extracted_body.into_net_request_body().0);
}
// Step 7.1. Let req be a new request, initialized as follows:
let request = RequestBuilder::new(None, url.clone(), global.get_referrer())
.mode(cors_mode)
.destination(Destination::None)
.policy_container(global.policy_container())
.insecure_requests_policy(global.insecure_requests_policy())
.has_trustworthy_ancestor_origin(global.has_trustworthy_ancestor_or_current_origin())
.method(http::Method::POST)
.body(request_body)
.origin(origin)
// TODO: Set keep-alive flag
.credentials_mode(CredentialsMode::Include)
.headers(headers);
// Step 7.2. Fetch req.
global.fetch(
request,
Arc::new(Mutex::new(BeaconFetchListener {
url,
global: Trusted::new(&global),
resource_timing: ResourceFetchTiming::new(ResourceTimingType::None),
})),
global.task_manager().networking_task_source().into(),
);
// Step 7. Set the return value to true, return the sendBeacon() call,
// and continue to run the following steps in parallel:
Ok(true)
}
/// <https://servo.org/internal-no-spec> /// <https://servo.org/internal-no-spec>
fn Servo(&self) -> DomRoot<ServoInternals> { fn Servo(&self) -> DomRoot<ServoInternals> {
self.servo_internals self.servo_internals
.or_init(|| ServoInternals::new(&self.global(), CanGc::note())) .or_init(|| ServoInternals::new(&self.global(), CanGc::note()))
} }
} }
struct BeaconFetchListener {
/// URL of this request.
url: ServoUrl,
/// Timing data for this resource.
resource_timing: ResourceFetchTiming,
/// The global object fetching the report uri violation
global: Trusted<GlobalScope>,
}
impl FetchResponseListener for BeaconFetchListener {
fn process_request_body(&mut self, _: RequestId) {}
fn process_request_eof(&mut self, _: RequestId) {}
fn process_response(
&mut self,
_: RequestId,
fetch_metadata: Result<FetchMetadata, NetworkError>,
) {
_ = fetch_metadata;
}
fn process_response_chunk(&mut self, _: RequestId, chunk: Vec<u8>) {
_ = chunk;
}
fn process_response_eof(
&mut self,
_: RequestId,
response: Result<ResourceFetchTiming, NetworkError>,
) {
_ = response;
}
fn resource_timing_mut(&mut self) -> &mut ResourceFetchTiming {
&mut self.resource_timing
}
fn resource_timing(&self) -> &ResourceFetchTiming {
&self.resource_timing
}
fn submit_resource_timing(&mut self) {
submit_timing(self, CanGc::note())
}
fn process_csp_violations(&mut self, _request_id: RequestId, violations: Vec<Violation>) {
let global = self.resource_timing_global();
global.report_csp_violations(violations, None, None);
}
}
impl ResourceTimingListener for BeaconFetchListener {
fn resource_timing_information(&self) -> (InitiatorType, ServoUrl) {
(InitiatorType::Beacon, self.url.clone())
}
fn resource_timing_global(&self) -> DomRoot<GlobalScope> {
self.global.root()
}
}
impl PreInvoke for BeaconFetchListener {
fn should_invoke(&self) -> bool {
true
}
}

View file

@ -23,9 +23,10 @@ use crate::script_runtime::CanGc;
// TODO Cross origin resources MUST BE INCLUDED as PerformanceResourceTiming objects // TODO Cross origin resources MUST BE INCLUDED as PerformanceResourceTiming objects
// https://w3c.github.io/resource-timing/#sec-cross-origin-resources // https://w3c.github.io/resource-timing/#sec-cross-origin-resources
// TODO CSS, Beacon // TODO CSS
#[derive(Debug, JSTraceable, MallocSizeOf, PartialEq)] #[derive(Debug, JSTraceable, MallocSizeOf, PartialEq)]
pub(crate) enum InitiatorType { pub(crate) enum InitiatorType {
Beacon,
LocalName(String), LocalName(String),
Navigation, Navigation,
XMLHttpRequest, XMLHttpRequest,
@ -191,6 +192,7 @@ impl PerformanceResourceTimingMethods<crate::DomTypeHolder> for PerformanceResou
// https://w3c.github.io/resource-timing/#dom-performanceresourcetiming-initiatortype // https://w3c.github.io/resource-timing/#dom-performanceresourcetiming-initiatortype
fn InitiatorType(&self) -> DOMString { fn InitiatorType(&self) -> DOMString {
match self.initiator_type { match self.initiator_type {
InitiatorType::Beacon => DOMString::from("beacon"),
InitiatorType::LocalName(ref n) => DOMString::from(n.clone()), InitiatorType::LocalName(ref n) => DOMString::from(n.clone()),
InitiatorType::Navigation => DOMString::from("navigation"), InitiatorType::Navigation => DOMString::from("navigation"),
InitiatorType::XMLHttpRequest => DOMString::from("xmlhttprequest"), InitiatorType::XMLHttpRequest => DOMString::from("xmlhttprequest"),

View file

@ -503,7 +503,7 @@ DOMInterfaces = {
'Navigator': { 'Navigator': {
'inRealms': ['GetVRDisplays'], 'inRealms': ['GetVRDisplays'],
'canGc': ['Languages'], 'canGc': ['Languages', 'SendBeacon'],
}, },
'Node': { 'Node': {

View file

@ -81,3 +81,9 @@ interface mixin NavigatorConcurrentHardware {
partial interface Navigator { partial interface Navigator {
[SecureContext, SameObject, Pref="dom_async_clipboard_enabled"] readonly attribute Clipboard clipboard; [SecureContext, SameObject, Pref="dom_async_clipboard_enabled"] readonly attribute Clipboard clipboard;
}; };
// https://w3c.github.io/beacon/#sendbeacon-method
partial interface Navigator {
[Throws, Pref="dom_navigator_sendbeacon_enabled"]
boolean sendBeacon(USVString url, optional BodyInit? data = null);
};

View file

@ -801,7 +801,7 @@ fn is_cors_safelisted_language(value: &[u8]) -> bool {
// https://fetch.spec.whatwg.org/#cors-safelisted-request-header // https://fetch.spec.whatwg.org/#cors-safelisted-request-header
// subclause `content-type` // subclause `content-type`
fn is_cors_safelisted_request_content_type(value: &[u8]) -> bool { pub fn is_cors_safelisted_request_content_type(value: &[u8]) -> bool {
// step 1 // step 1
if value.iter().any(is_cors_unsafe_request_header_byte) { if value.iter().any(is_cors_unsafe_request_header_byte) {
return false; return false;

View file

@ -591,6 +591,7 @@ pub(crate) fn parse_command_line_arguments(args: Vec<String>) -> ArgumentParsing
"dom_fontface_enabled", "dom_fontface_enabled",
"dom_intersection_observer_enabled", "dom_intersection_observer_enabled",
"dom_mouse_event_which_enabled", "dom_mouse_event_which_enabled",
"dom_navigator_sendbeacon_enabled",
"dom_notification_enabled", "dom_notification_enabled",
"dom_offscreen_canvas_enabled", "dom_offscreen_canvas_enabled",
"dom_permissions_enabled", "dom_permissions_enabled",

View file

@ -3,6 +3,8 @@ skip: true
skip: false skip: false
[_webgl] [_webgl]
skip: false skip: false
[beacon]
skip: false
[clipboard-apis] [clipboard-apis]
skip: false skip: false
[console] [console]

View file

@ -0,0 +1,12 @@
[beacon-basic.https.window.html]
[Payload size restriction should be accumulated: type = string]
expected: FAIL
[Payload size restriction should be accumulated: type = arraybuffer]
expected: FAIL
[Payload size restriction should be accumulated: type = blob]
expected: FAIL
[sendBeacon() with a stream does not work due to the keepalive flag being set]
expected: FAIL

View file

@ -0,0 +1,6 @@
[beacon-cors.https.window.html]
[cross-origin, non-CORS-safelisted: failure case (without redirect)]
expected: FAIL
[cross-origin, non-CORS-safelisted[credentials=false\]]
expected: FAIL

View file

@ -0,0 +1,4 @@
[header-referrer-no-referrer-when-downgrade.https.html]
expected: TIMEOUT
[Test referer header http://web-platform.test:8000/beacon/resources/]
expected: TIMEOUT

View file

@ -0,0 +1,4 @@
[header-referrer-strict-origin-when-cross-origin.https.html]
expected: TIMEOUT
[Test referer header http://www1.web-platform.test:8000/beacon/resources/]
expected: TIMEOUT

View file

@ -0,0 +1,4 @@
[header-referrer-strict-origin.https.html]
expected: TIMEOUT
[Test referer header http://web-platform.test:8000/beacon/resources/]
expected: TIMEOUT

View file

@ -0,0 +1,4 @@
[header-referrer-unsafe-url.https.html]
expected: TIMEOUT
[Test referer header http://web-platform.test:8000/beacon/resources/]
expected: TIMEOUT

View file

@ -1,3 +0,0 @@
[connect-src-beacon-allowed.sub.html]
[Expecting logs: ["Pass"\]]
expected: NOTRUN

View file

@ -1,3 +0,0 @@
[connect-src-beacon-blocked.sub.html]
[Expecting logs: ["Pass", "violated-directive=connect-src"\]]
expected: NOTRUN

View file

@ -1,3 +0,0 @@
[connect-src-beacon-redirect-to-blocked.sub.html]
[Expecting logs: ["violated-directive=connect-src"\]]
expected: NOTRUN

View file

@ -14,9 +14,6 @@
[source : unpaired surrogate codepoint should be replaced with U+FFFD] [source : unpaired surrogate codepoint should be replaced with U+FFFD]
expected: FAIL expected: FAIL
[sendBeacon URL: unpaired surrogate codepoint should not make any exceptions.]
expected: FAIL
[RegisterProtocolHandler URL: unpaired surrogate codepoint should not make any exceptions.] [RegisterProtocolHandler URL: unpaired surrogate codepoint should not make any exceptions.]
expected: FAIL expected: FAIL

View file

@ -1,6 +1,6 @@
[beacon.https.html] [beacon.https.html]
[Mixed-Content: Expects allowed for beacon to same-https origin and keep-scheme redirection from https context.] [Mixed-Content: Expects blocked for beacon to cross-https origin and swap-scheme redirection from https context.]
expected: FAIL expected: FAIL
[Mixed-Content: Expects allowed for beacon to same-https origin and no-redirect redirection from https context.] [Mixed-Content: Expects blocked for beacon to same-https origin and swap-scheme redirection from https context.]
expected: FAIL expected: FAIL

View file

@ -1,3 +0,0 @@
[beacon.https.html]
[Mixed-Content: Expects allowed for beacon to same-https origin and no-redirect redirection from https context.]
expected: FAIL

View file

@ -1,6 +0,0 @@
[beacon.https.html]
[Mixed-Content: Expects allowed for beacon to same-https origin and keep-scheme redirection from https context.]
expected: FAIL
[Mixed-Content: Expects allowed for beacon to same-https origin and no-redirect redirection from https context.]
expected: FAIL

View file

@ -1,2 +1,16 @@
[misc.html] [misc.html]
expected: ERROR expected: TIMEOUT
[The initiator type for <body background> must be 'body']
expected: FAIL
[The initiator type for <input type='image'> must be 'input']
expected: FAIL
[The initiator type for <object type='image/png'> must be 'object']
expected: FAIL
[The initiator type for for fetch() must be 'fetch']
expected: FAIL
[The initiator type for new EventSource() must be 'other']
expected: TIMEOUT