diff --git a/Cargo.lock b/Cargo.lock index be9ff5555cc..3690ce8c583 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -581,7 +581,6 @@ dependencies = [ "boa_gc", "boa_wintertc", "bus", - "bytemuck", "either", "futures", "futures-lite", @@ -592,7 +591,6 @@ dependencies = [ "rustc-hash 2.1.3", "serde_json", "temp-env", - "test-case", "textwrap", "url", ] @@ -648,11 +646,14 @@ dependencies = [ "base64 0.23.1", "boa_engine", "boa_gc", + "bytemuck", "comfy-table", "futures-lite", "indoc", "rustc-hash 2.1.3", + "test-case", "textwrap", + "url", ] [[package]] diff --git a/core/runtime/Cargo.toml b/core/runtime/Cargo.toml index 94cebc4f9c2..a325c883d27 100644 --- a/core/runtime/Cargo.toml +++ b/core/runtime/Cargo.toml @@ -15,7 +15,6 @@ boa_engine.workspace = true boa_gc.workspace = true boa_wintertc.workspace = true bus = { workspace = true, optional = true } -bytemuck.workspace = true either = { workspace = true, optional = true } futures = "0.3.32" futures-lite.workspace = true @@ -28,7 +27,6 @@ url = { workspace = true, optional = true } [dev-dependencies] indoc.workspace = true rstest.workspace = true -test-case.workspace = true textwrap.workspace = true temp-env.workspace = true @@ -49,7 +47,7 @@ default = [ ] all = ["default", "reqwest-blocking"] -url = ["dep:url"] +url = ["boa_wintertc/url"] fetch = [ "dep:url", "dep:either", diff --git a/core/runtime/src/abort/mod.rs b/core/runtime/src/abort/mod.rs deleted file mode 100644 index c6b05ef4b23..00000000000 --- a/core/runtime/src/abort/mod.rs +++ /dev/null @@ -1,236 +0,0 @@ -//! `AbortController` and `AbortSignal` Web API implementations. - -use boa_engine::class::Class; -use boa_engine::job::GenericJob; -use boa_engine::object::builtins::JsFunction; -use boa_engine::realm::Realm; -use boa_engine::{ - Context, Finalize, JsData, JsError, JsNativeError, JsObject, JsResult, JsString, JsValue, - Trace, boa_class, boa_module, js_error, js_string, -}; -use boa_gc::GcRefCell; -use std::cell::Cell; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; - -#[cfg(test)] -mod tests; - -/// Cancellation token for cooperative abort. -#[derive(Debug, Clone)] -pub struct CancellationToken(Arc); - -impl CancellationToken { - fn new() -> Self { - Self(Arc::new(AtomicBool::new(false))) - } - - /// Cancel the token. - pub fn cancel(&self) { - self.0.store(true, Ordering::Release); - } - - /// Returns `true` if cancelled. - #[must_use] - pub fn is_cancelled(&self) -> bool { - self.0.load(Ordering::Acquire) - } -} - -fn make_abort_error(context: &mut Context) -> JsValue { - let obj = JsNativeError::error() - .with_message("signal is aborted without reason") - .into_opaque(context); - obj.set(js_string!("name"), js_string!("AbortError"), false, context) - .ok(); - obj.into() -} - -/// The JavaScript `AbortSignal` class. -#[derive(Debug, Clone, JsData, Trace, Finalize)] -pub struct JsAbortSignal { - #[unsafe_ignore_trace] - aborted: Cell, - reason: GcRefCell>, - listeners: GcRefCell>, - #[unsafe_ignore_trace] - cancel_token: CancellationToken, -} - -#[derive(Debug, Clone, Trace, Finalize)] -struct AbortEventListener { - event_type: JsString, - callback: JsFunction, -} - -impl Default for JsAbortSignal { - fn default() -> Self { - Self { - aborted: Cell::new(false), - reason: GcRefCell::default(), - listeners: GcRefCell::default(), - cancel_token: CancellationToken::new(), - } - } -} - -impl JsAbortSignal { - /// # Errors - /// - /// Returns an error if the signal has already been aborted. - pub fn signal_abort(&self, reason: JsValue, context: &mut Context) -> JsResult<()> { - if self.aborted.get() { - return Ok(()); - } - self.aborted.set(true); - *self.reason.borrow_mut() = Some(reason); - - let abort = js_string!("abort"); - let listeners: Vec = self - .listeners - .borrow() - .iter() - .filter(|listener| listener.event_type == abort) - .map(|listener| listener.callback.clone()) - .collect(); - - let realm = context.realm().clone(); - for listener in listeners { - context.enqueue_job( - GenericJob::new( - move |context| { - listener.call(&JsValue::undefined(), &[], context)?; - Ok(JsValue::undefined()) - }, - realm.clone(), - ) - .into(), - ); - } - - self.cancel_token.cancel(); - - Ok(()) - } - - /// Returns `true` if this signal has been aborted. - #[must_use] - pub fn is_aborted(&self) -> bool { - self.aborted.get() - } - - /// Returns the abort reason. - pub fn abort_reason(&self, context: &mut Context) -> JsValue { - if !self.aborted.get() { - return JsValue::undefined(); - } - self.reason - .borrow() - .clone() - .unwrap_or_else(|| make_abort_error(context)) - } - - /// Returns the cancellation token. - #[must_use] - pub fn cancellation_token(&self) -> CancellationToken { - self.cancel_token.clone() - } -} - -#[boa_class(rename = "AbortSignal")] -#[boa(rename_all = "camelCase")] -impl JsAbortSignal { - #[boa(constructor)] - fn constructor() -> JsResult { - Err(JsNativeError::typ() - .with_message("Illegal constructor") - .into()) - } - - #[boa(getter)] - fn aborted(&self) -> bool { - self.aborted.get() - } - - #[boa(getter)] - fn reason(&self, context: &mut Context) -> JsValue { - self.abort_reason(context) - } - - fn throw_if_aborted(&self, context: &mut Context) -> JsResult<()> { - if self.aborted.get() { - Err(JsError::from_opaque(self.abort_reason(context))) - } else { - Ok(()) - } - } - - fn add_event_listener( - &self, - event_type: JsString, - callback: JsFunction, - _context: &mut Context, - ) { - { - let listeners = self.listeners.borrow(); - if listeners.iter().any(|listener| { - listener.event_type == event_type && JsObject::equals(&listener.callback, &callback) - }) { - return; - } - } - self.listeners.borrow_mut().push(AbortEventListener { - event_type, - callback, - }); - } - - fn remove_event_listener(&self, event_type: JsString, callback: JsFunction) { - self.listeners.borrow_mut().retain(|listener| { - listener.event_type != event_type || !JsObject::equals(&listener.callback, &callback) - }); - } -} - -/// The JavaScript `AbortController` class. -#[derive(Debug, Clone, JsData, Trace, Finalize)] -pub struct JsAbortController { - signal: JsObject, -} - -#[boa_class(rename = "AbortController")] -#[boa(rename_all = "camelCase")] -impl JsAbortController { - #[boa(constructor)] - fn constructor(context: &mut Context) -> JsResult { - let signal_obj = Class::from_data(JsAbortSignal::default(), context)?; - Ok(Self { signal: signal_obj }) - } - - #[boa(getter)] - fn signal(&self) -> JsObject { - self.signal.clone() - } - - fn abort(&self, reason: Option, context: &mut Context) -> JsResult<()> { - let abort_reason = reason.unwrap_or_else(|| make_abort_error(context)); - - let Some(signal) = self.signal.downcast_ref::() else { - return Err(js_error!(TypeError: "AbortController: invalid signal object")); - }; - signal.signal_abort(abort_reason, context) - } -} - -/// `AbortController` and `AbortSignal` module. -#[boa_module] -pub mod js_module { - type JsAbortController = super::JsAbortController; - type JsAbortSignal = super::JsAbortSignal; -} - -/// # Errors -/// Returns an error if registration fails. -pub fn register(realm: Option, context: &mut Context) -> JsResult<()> { - js_module::boa_register(realm, context) -} diff --git a/core/runtime/src/extensions.rs b/core/runtime/src/extensions.rs index 4c08cbbfa3a..87bc0a90ff9 100644 --- a/core/runtime/src/extensions.rs +++ b/core/runtime/src/extensions.rs @@ -43,7 +43,7 @@ pub struct EncodingExtension; impl RuntimeExtension for EncodingExtension { fn register(self, realm: Option, context: &mut Context) -> JsResult<()> { - crate::text::register(realm, context)?; + boa_wintertc::encoding::register(realm, context)?; Ok(()) } } @@ -76,7 +76,7 @@ pub struct UrlExtension; #[cfg(feature = "url")] impl RuntimeExtension for UrlExtension { fn register(self, realm: Option, context: &mut Context) -> JsResult<()> { - crate::url::Url::register(realm, context) + boa_wintertc::url::Url::register(realm, context) } } @@ -129,7 +129,7 @@ pub struct AbortControllerExtension; #[cfg(feature = "fetch")] impl RuntimeExtension for AbortControllerExtension { fn register(self, realm: Option, context: &mut Context) -> JsResult<()> { - crate::abort::register(realm, context) + boa_wintertc::abort::register(realm, context) } } diff --git a/core/runtime/src/lib.rs b/core/runtime/src/lib.rs index 9dcba1ff4f1..76df9ac8515 100644 --- a/core/runtime/src/lib.rs +++ b/core/runtime/src/lib.rs @@ -97,12 +97,15 @@ //! [`boa_wintertc`] crate. They are re-exported from `boa_runtime` so existing users keep a single, //! unchanged import path: //! +//! - [`abort`](abort/index.html) — `AbortController` and `AbortSignal` (requires the `fetch` feature) //! - [`base64`] — `atob` and `btoa` //! - [`clone`] — `structuredClone` //! - [`console`] — the `console` object -//! - [`microtask`] — `queueMicrotask` //! - [`interval`] — the timer APIs (`setTimeout`, `clearTimeout`, `setInterval`, `clearInterval`), //! kept under their historical `interval` name +//! - [`microtask`] — `queueMicrotask` +//! - [`text`] — `TextEncoder` and `TextDecoder`, kept under their historical `text` name +//! - [`url`](url/index.html) — the `URL` class (requires the `url` feature) //! //! The [`store`] module holds the serialization core backing `structuredClone`. See each //! re-exported module for its full API documentation. @@ -131,7 +134,8 @@ pub use boa_wintertc::base64; pub use console::{Console, ConsoleState, DefaultLogger, Logger, NullLogger}; #[cfg(feature = "fetch")] -pub mod abort; +#[doc(inline)] +pub use boa_wintertc::abort; #[doc(inline)] pub use boa_wintertc::clone; @@ -152,9 +156,11 @@ pub use boa_wintertc::store; /// Support for the `$262` test262 harness object. #[cfg(feature = "test262")] pub mod test262; -pub mod text; +#[doc(inline)] +pub use boa_wintertc::encoding as text; #[cfg(feature = "url")] -pub mod url; +#[doc(inline)] +pub use boa_wintertc::url; #[cfg(feature = "process")] use crate::extensions::ProcessExtension; diff --git a/core/runtime/src/text/mod.rs b/core/runtime/src/text/mod.rs deleted file mode 100644 index 9a6b7a6ef7a..00000000000 --- a/core/runtime/src/text/mod.rs +++ /dev/null @@ -1,268 +0,0 @@ -//! Module implementing JavaScript classes to handle text encoding and decoding. -//! -//! See for more information. - -use boa_engine::object::builtins::{JsArrayBuffer, JsDataView, JsTypedArray, JsUint8Array}; -use boa_engine::realm::Realm; -use boa_engine::value::TryFromJs; -use boa_engine::{ - Context, Finalize, JsData, JsResult, JsString, JsValue, Trace, boa_class, boa_module, js_error, - js_string, -}; - -#[cfg(test)] -mod tests; - -mod encodings; - -/// Options for the [`TextDecoder`] constructor. -#[derive(Debug, Default, Clone, Copy, TryFromJs)] -pub struct TextDecoderOptions { - #[boa(rename = "ignoreBOM")] - ignore_bom: Option, -} - -/// The character encoding used by [`TextDecoder`]. -#[derive(Debug, Default, Clone, Copy)] -pub enum Encoding { - /// UTF-8 encoding. - #[default] - Utf8, - /// UTF-16 little endian encoding. - Utf16Le, - /// UTF-16 big endian encoding. - Utf16Be, -} - -const TEXT_DECODER_LABELS: &[(&str, Encoding)] = &[ - ("unicode-1-1-utf-8", Encoding::Utf8), - ("unicode11utf8", Encoding::Utf8), - ("unicode20utf8", Encoding::Utf8), - ("utf-8", Encoding::Utf8), - ("utf8", Encoding::Utf8), - ("x-unicode20utf8", Encoding::Utf8), - ("unicodefffe", Encoding::Utf16Be), - ("utf-16be", Encoding::Utf16Be), - ("csunicode", Encoding::Utf16Le), - ("iso-10646-ucs-2", Encoding::Utf16Le), - ("ucs-2", Encoding::Utf16Le), - ("unicode", Encoding::Utf16Le), - ("unicodefeff", Encoding::Utf16Le), - ("utf-16", Encoding::Utf16Le), - ("utf-16le", Encoding::Utf16Le), -]; - -#[inline] -fn resolve_text_decoder_label(label: &str) -> Option { - let label = label.trim_matches(['\u{0009}', '\u{000A}', '\u{000C}', '\u{000D}', '\u{0020}']); - - TEXT_DECODER_LABELS - .iter() - .find_map(|(supported, encoding)| { - label.eq_ignore_ascii_case(supported).then_some(*encoding) - }) -} - -/// The [`TextDecoder`][mdn] class represents an encoder for a specific method, that is -/// a specific character encoding, like `utf-8`. -/// -/// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder -#[derive(Debug, Default, Clone, JsData, Trace, Finalize)] -pub struct TextDecoder { - #[unsafe_ignore_trace] - encoding: Encoding, - #[unsafe_ignore_trace] - ignore_bom: bool, -} - -#[boa_class] -impl TextDecoder { - /// The [`TextDecoder()`][mdn] constructor returns a new `TextDecoder` object. - /// - /// # Errors - /// This will return an error if the encoding or options are invalid or unsupported. - /// - /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder/TextDecoder - #[boa(constructor)] - pub fn constructor( - encoding: Option, - options: Option, - ) -> JsResult { - let ignore_bom = options.and_then(|o| o.ignore_bom).unwrap_or(false); - - let encoding = match encoding { - Some(enc) => { - let label = enc.to_std_string_lossy(); - resolve_text_decoder_label(&label).ok_or_else( - || js_error!(RangeError: "The given encoding '{}' is not supported.", label), - )? - } - None => Encoding::default(), - }; - - Ok(Self { - encoding, - ignore_bom, - }) - } - - /// The [`TextDecoder.encoding`][mdn] read-only property returns a string containing - /// the name of the character encoding that this decoder will use. - /// - /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder/encoding - #[boa(getter)] - #[must_use] - pub fn encoding(&self) -> JsString { - match self.encoding { - Encoding::Utf8 => js_string!("utf-8"), - Encoding::Utf16Le => js_string!("utf-16le"), - Encoding::Utf16Be => js_string!("utf-16be"), - } - } - - /// The [`TextDecoder.ignoreBOM`][mdn] read-only property returns a `bool` indicating - /// whether the BOM (byte order mark) is ignored. - /// - /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder/ignoreBOM - #[boa(getter)] - #[boa(rename = "ignoreBOM")] - #[must_use] - pub fn ignore_bom(&self) -> bool { - self.ignore_bom - } - - /// The [`TextDecoder.decode()`][mdn] method returns a string containing text decoded from the - /// buffer passed as a parameter. - /// - /// If `buffer` is omitted or `undefined`, this returns an empty string. - /// - /// `buffer` can be an `ArrayBuffer`, a `TypedArray` or a `DataView`. - /// - /// # Errors - /// Any error that arises during decoding the specific encoding. - /// - /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder/decode - pub fn decode(&self, buffer: JsValue, context: &mut Context) -> JsResult { - if buffer.is_undefined() { - return Ok(js_string!("")); - } - - let mut range = None; - let array_buffer = if let Ok(array_buffer) = JsArrayBuffer::try_from_js(&buffer, context) { - array_buffer - } else if let Ok(typed_array) = JsTypedArray::try_from_js(&buffer, context) { - let Some(obj) = typed_array.buffer(context)?.as_object() else { - return Err(js_error!(TypeError: "Invalid buffer backing TypedArray.")); - }; - - let offset = typed_array.byte_offset(context)?; - let length = typed_array.byte_length(context)?; - - range = Some(offset..offset + length); - - JsArrayBuffer::from_object(obj)? - } else if let Ok(data_view) = JsDataView::try_from_js(&buffer, context) { - let Some(obj) = data_view.buffer(context)?.as_object() else { - return Err(js_error!(TypeError: "Invalid buffer backing DataView.")); - }; - - let offset = usize::try_from(data_view.byte_offset(context)?) - .map_err(|_| js_error!(RangeError: "DataView offset exceeds addressable size."))?; - let length = usize::try_from(data_view.byte_length(context)?) - .map_err(|_| js_error!(RangeError: "DataView length exceeds addressable size."))?; - - range = Some(offset..offset + length); - - JsArrayBuffer::from_object(obj)? - } else { - return Err(js_error!( - TypeError: "Argument 1 must be an ArrayBuffer, TypedArray or DataView." - )); - }; - - let strip_bom = !self.ignore_bom; - - let Some(full_data) = array_buffer.data() else { - return Err(js_error!(TypeError: "cannot decode a detached ArrayBuffer")); - }; - - let data: &[u8] = if let Some(range) = range { - full_data.get(range).ok_or_else( - // We do not say invalid range here, as both subarray(10, 5) and subarray("a", "b") - // are valid JS, it would just an empty array. If this error occurs, it most likely means something else - // is wrong - || js_error!(RangeError: "The range for the underlying ArrayBuffer can not be accessed."), - )? - } else { - &full_data - }; - - Ok(match self.encoding { - Encoding::Utf8 => encodings::utf8::decode(data, strip_bom), - Encoding::Utf16Le => encodings::utf16le::decode(data, strip_bom), - Encoding::Utf16Be => { - let owned = data.to_vec(); - encodings::utf16be::decode(owned, strip_bom) - } - }) - } -} - -/// The `TextEncoder`[mdn] class represents a UTF-8 encoder. -/// -/// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder -#[derive(Debug, Default, Clone, JsData, Trace, Finalize)] -pub struct TextEncoder; - -#[boa_class] -impl TextEncoder { - /// The [`TextEncoder()`][mdn] constructor returns a newly created `TextEncoder` object. - /// - /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder/TextEncoder - #[boa(constructor)] - #[must_use] - pub fn constructor() -> Self { - Self - } - - /// The [`TextEncoder.encoding`][mdn] read-only property returns a string containing - /// the name of the encoding algorithm used by the specific encoder. - /// - /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder/encoding - #[boa(getter)] - #[must_use] - fn encoding() -> JsString { - js_string!("utf-8") - } - - /// The [`TextEncoder.encode()`][mdn] method takes a string as input, and returns - /// a `Uint8Array` containing the string encoded using UTF-8. - /// - /// # Errors - /// This will error if there is an issue creating the `Uint8Array` or encoding - /// the string itself. - /// - /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder/encode - pub fn encode(&self, text: Option, context: &mut Context) -> JsResult { - let Some(text) = text else { - return JsUint8Array::from_iter([], context); - }; - - JsUint8Array::from_iter(encodings::utf8::encode(&text), context) - } -} - -/// JavaScript module containing the text encoding/decoding classes. -#[boa_module] -pub mod js_module { - type TextDecoder = super::TextDecoder; - type TextEncoder = super::TextEncoder; -} - -/// Register both `TextDecoder` and `TextEncoder` classes into the realm/context. -/// -/// # Errors -/// This will error if the context or realm cannot register the class. -pub fn register(realm: Option, context: &mut Context) -> JsResult<()> { - js_module::boa_register(realm, context) -} diff --git a/core/runtime/src/url.rs b/core/runtime/src/url.rs deleted file mode 100644 index f39de1bd2c8..00000000000 --- a/core/runtime/src/url.rs +++ /dev/null @@ -1,916 +0,0 @@ -//! Boa's implementation of JavaScript's `URL` Web API class. -//! -//! The `URL` class can be instantiated from any global object. -//! This relies on the `url` feature. -//! -//! More information: -//! - [MDN documentation][mdn] -//! - [WHATWG `URL` specification][spec] -//! -//! Implemented sections in this file: -//! - `URL.searchParams`: -//! - `URLSearchParams` constructor and methods: -//! -//! [spec]: https://url.spec.whatwg.org/ -//! [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/URL -#![allow(clippy::needless_pass_by_value)] - -#[cfg(test)] -mod tests; - -use boa_engine::builtins::iterable::{IteratorRecord, create_iter_result_object}; -use boa_engine::builtins::object::OrdinaryObject; -use boa_engine::class::Class; -use boa_engine::interop::{JsClass, TryFromJsArgument}; -use boa_engine::object::{ - ObjectInitializer, WeakJsObject, - builtins::{JsArray, TypedJsFunction}, -}; -use boa_engine::property::Attribute; -use boa_engine::realm::Realm; -use boa_engine::value::Convert; -use boa_engine::{ - Context, Finalize, JsData, JsError, JsObject, JsResult, JsString, JsSymbol, JsValue, Trace, - boa_class, boa_module, js_error, js_string, native_function::NativeFunction, -}; -use std::collections::HashMap; -use std::fmt::Display; -use std::sync::{Arc, PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard}; - -/// A callback function for the `URLSearchParams.prototype.forEach` method. -pub type SearchParamsForEachCallback = TypedJsFunction<(JsString, JsString, JsObject), ()>; - -#[derive(Debug, Clone, Copy)] -enum UrlSearchParamsIteratorKind { - Key, - Value, - KeyAndValue, -} - -fn to_usv_string(string: &JsString) -> JsString { - JsString::from(string.to_std_string_lossy()) -} - -fn to_usv_string_value(value: &JsValue, context: &mut Context) -> JsResult { - value - .to_string(context) - .map(|string| to_usv_string(&string)) -} - -fn parse_search_params(input: &JsString) -> Vec<(JsString, JsString)> { - let input = input.to_std_string_lossy(); - let input = input.strip_prefix('?').unwrap_or(&input); - - url::form_urlencoded::parse(input.as_bytes()) - .map(|(name, value)| { - ( - JsString::from(name.as_ref()), - JsString::from(value.as_ref()), - ) - }) - .collect() -} - -fn serialize_search_params(params: &[(JsString, JsString)]) -> String { - let mut serializer = url::form_urlencoded::Serializer::new(String::new()); - - for (name, value) in params { - let name = name.to_std_string_lossy(); - let value = value.to_std_string_lossy(); - serializer.append_pair(&name, &value); - } - - serializer.finish() -} - -#[derive(Debug, Clone)] -struct SharedUrl(Arc>); - -impl SharedUrl { - fn new(url: url::Url) -> Self { - Self(Arc::new(RwLock::new(url))) - } - - fn read(&self) -> RwLockReadGuard<'_, url::Url> { - self.0.read().unwrap_or_else(PoisonError::into_inner) - } - - fn write(&self) -> RwLockWriteGuard<'_, url::Url> { - self.0.write().unwrap_or_else(PoisonError::into_inner) - } - - fn key(&self) -> usize { - Arc::as_ptr(&self.0) as usize - } - - fn into_url(self) -> url::Url { - match Arc::try_unwrap(self.0) { - Ok(url) => url.into_inner().unwrap_or_else(PoisonError::into_inner), - Err(url) => url.read().unwrap_or_else(PoisonError::into_inner).clone(), - } - } -} - -#[derive(Debug, Trace, Finalize, JsData)] -struct UrlSearchParamsCacheEntry { - owner: WeakJsObject, - search_params: JsObject, -} - -#[derive(Debug, Trace, Finalize, JsData)] -struct UrlSearchParamsCache { - entries: HashMap, - next_sweep: usize, -} - -impl Default for UrlSearchParamsCache { - fn default() -> Self { - Self { - entries: HashMap::new(), - next_sweep: 64, - } - } -} - -impl UrlSearchParamsCache { - fn get(&self, key: usize) -> Option> { - self.entries - .get(&key) - .map(|entry| entry.search_params.clone()) - } - - fn insert( - &mut self, - key: usize, - owner: &JsObject, - search_params: JsObject, - ) { - if self.entries.len() >= self.next_sweep { - self.entries.retain(|_, entry| entry.owner.is_upgradable()); - self.next_sweep = self.entries.len().saturating_mul(2).max(64); - } - - self.entries.insert( - key, - UrlSearchParamsCacheEntry { - owner: WeakJsObject::new(owner), - search_params, - }, - ); - } -} - -/// Captures whether an optional argument was actually supplied. -/// -/// This is used for overloads where an explicit `undefined` must not be treated -/// the same as an omitted argument. -#[derive(Debug, Clone)] -struct OptionalArg(Option); - -impl<'a> TryFromJsArgument<'a> for OptionalArg { - fn try_from_js_argument( - _: &'a JsValue, - rest: &'a [JsValue], - _: &mut Context, - ) -> JsResult<(Self, &'a [JsValue])> { - match rest.split_first() { - Some((first, rest)) => Ok((Self(Some(first.clone())), rest)), - None => Ok((Self(None), rest)), - } - } -} - -fn close_iterator_with_error( - iterator: &IteratorRecord, - error: JsError, - context: &mut Context, -) -> JsResult { - match iterator.close(Err(error), context) { - Ok(_) => unreachable!("iterator close with error completion should not succeed"), - Err(err) => Err(err), - } -} - -fn collect_sequence_item_pair( - item: &JsObject, - context: &mut Context, -) -> JsResult<(JsString, JsString)> { - let Some(iterator_method) = item.get_method(JsSymbol::iterator(), context)? else { - return Err(js_error!( - TypeError: "URLSearchParams constructor expects each sequence item to be an iterable pair" - )); - }; - - let item_value = JsValue::from(item.clone()); - let mut pair_iterator = item_value.get_iterator_from_method(&iterator_method, context)?; - - let Some(name) = pair_iterator.step_value(context)? else { - return Err(js_error!( - TypeError: "URLSearchParams constructor expects each sequence item to contain exactly two values" - )); - }; - let name = match to_usv_string_value(&name, context) { - Ok(name) => name, - Err(err) => return close_iterator_with_error(&pair_iterator, err, context), - }; - - let Some(value) = pair_iterator.step_value(context)? else { - return Err(js_error!( - TypeError: "URLSearchParams constructor expects each sequence item to contain exactly two values" - )); - }; - let value = match to_usv_string_value(&value, context) { - Ok(value) => value, - Err(err) => return close_iterator_with_error(&pair_iterator, err, context), - }; - - if pair_iterator.step_value(context)?.is_some() { - return close_iterator_with_error( - &pair_iterator, - js_error!( - TypeError: "URLSearchParams constructor expects each sequence item to contain exactly two values" - ), - context, - ); - } - - Ok((name, value)) -} - -fn collect_sequence_pairs( - init: &JsValue, - iterator_method: &JsObject, - context: &mut Context, -) -> JsResult> { - let mut items = init.get_iterator_from_method(iterator_method, context)?; - let mut pairs = Vec::new(); - - while let Some(item) = items.step_value(context)? { - let Some(item_object) = item.as_object() else { - return close_iterator_with_error( - &items, - js_error!( - TypeError: "URLSearchParams constructor expects each sequence item to be an iterable pair" - ), - context, - ); - }; - - let pair = match collect_sequence_item_pair(&item_object, context) { - Ok(pair) => pair, - Err(err) => return close_iterator_with_error(&items, err, context), - }; - pairs.push(pair); - } - - Ok(pairs) -} - -fn collect_record_pairs( - object: &JsObject, - context: &mut Context, -) -> JsResult> { - let keys = object.own_property_keys(context)?; - let mut pairs = Vec::new(); - - for key in keys { - let enumerable = OrdinaryObject::property_is_enumerable( - &object.clone().into(), - &[key.clone().into()], - context, - )? - .to_boolean(); - - if !enumerable { - continue; - } - - let name = to_usv_string_value(&JsValue::from(key.clone()), context)?; - let value = to_usv_string_value(&object.get(key, context)?, context)?; - pairs.push((name, value)); - } - - Ok(pairs) -} - -/// The `URLSearchParams` class represents the query portion of a URL. -#[derive(Debug, JsData, Trace, Finalize)] -pub struct UrlSearchParams { - list: Vec<(JsString, JsString)>, - #[unsafe_ignore_trace] - url: Option, -} - -impl UrlSearchParams { - fn from_url(url: SharedUrl, context: &mut Context) -> JsResult> { - Self::from_data( - Self { - list: Vec::new(), - url: Some(url), - }, - context, - )? - .downcast::() - .map_err(|_| js_error!(Error: "URLSearchParams class should be registered")) - } - - fn pairs(&self) -> Vec<(JsString, JsString)> { - if let Some(url) = &self.url { - let url = url.read(); - return url - .query_pairs() - .map(|(name, value)| { - ( - JsString::from(name.as_ref()), - JsString::from(value.as_ref()), - ) - }) - .collect(); - } - - self.list.clone() - } - - fn update(&mut self, pairs: Vec<(JsString, JsString)>) { - if let Some(url) = &self.url { - let mut url = url.write(); - - if pairs.is_empty() { - url.set_query(None); - } else { - let query = serialize_search_params(&pairs); - url.set_query(Some(&query)); - } - return; - } - - self.list = pairs; - } -} - -#[derive(Debug, JsData, Trace, Finalize)] -struct UrlSearchParamsIterator { - search_params: JsObject, - next_index: usize, - #[unsafe_ignore_trace] - kind: UrlSearchParamsIteratorKind, - done: bool, -} - -impl UrlSearchParamsIterator { - fn create( - search_params: JsObject, - kind: UrlSearchParamsIteratorKind, - context: &mut Context, - ) -> JsValue { - ObjectInitializer::with_native_data_and_proto( - Self { - search_params, - next_index: 0, - kind, - done: false, - }, - context.intrinsics().constructors().iterator().prototype(), - context, - ) - .function( - NativeFunction::from_fn_ptr(Self::next), - js_string!("next"), - 0, - ) - .function( - NativeFunction::from_fn_ptr(Self::iterator), - JsSymbol::iterator(), - 0, - ) - .property( - JsSymbol::to_string_tag(), - js_string!("URLSearchParams Iterator"), - Attribute::CONFIGURABLE, - ) - .build() - .into() - } - - #[allow(clippy::unnecessary_wraps)] - fn iterator(this: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { - Ok(this.clone()) - } - - fn next(this: &JsValue, _: &[JsValue], context: &mut Context) -> JsResult { - let object = this - .as_object() - .ok_or_else(|| js_error!(TypeError: "`this` is not a URLSearchParams iterator"))?; - let mut iterator = object - .downcast_mut::() - .ok_or_else(|| js_error!(TypeError: "`this` is not a URLSearchParams iterator"))?; - - if iterator.done { - return Ok(create_iter_result_object( - JsValue::undefined(), - true, - context, - )); - } - - let pair = iterator - .search_params - .borrow() - .data() - .pairs() - .get(iterator.next_index) - .cloned(); - - let Some((name, value)) = pair else { - iterator.done = true; - return Ok(create_iter_result_object( - JsValue::undefined(), - true, - context, - )); - }; - - iterator.next_index += 1; - - let result: JsValue = match iterator.kind { - UrlSearchParamsIteratorKind::Key => name.into(), - UrlSearchParamsIteratorKind::Value => value.into(), - UrlSearchParamsIteratorKind::KeyAndValue => { - JsArray::from_iter([name.into(), value.into()], context).into() - } - }; - - Ok(create_iter_result_object(result, false, context)) - } -} - -#[boa_class(rename = "URLSearchParams")] -#[boa(rename_all = "camelCase")] -impl UrlSearchParams { - /// WHATWG URL: - /// - /// This implements the constructor branches for: - /// - empty / null init - /// - sequence input via @@iterator - /// - record input via enumerable own properties - /// - string input parsed as application/x-www-form-urlencoded - #[boa(constructor)] - fn constructor(init: JsValue, context: &mut Context) -> JsResult { - let list = if init.is_undefined() || init.is_null() { - Vec::new() - } else if let Some(object) = init.as_object() { - if let Some(iterator_method) = object.get_method(JsSymbol::iterator(), context)? { - collect_sequence_pairs(&init, &iterator_method, context)? - } else { - collect_record_pairs(&object, context)? - } - } else { - parse_search_params(&to_usv_string_value(&init, context)?) - }; - - Ok(Self { list, url: None }) - } - - #[boa(getter)] - fn size(&self) -> usize { - self.pairs().len() - } - - fn append(&mut self, name: Convert, value: Convert) { - let mut pairs = self.pairs(); - pairs.push((to_usv_string(&name.0), to_usv_string(&value.0))); - self.update(pairs); - } - - fn delete( - &mut self, - name: Convert, - value: OptionalArg, - context: &mut Context, - ) -> JsResult<()> { - // WHATWG URL: - // The second argument only participates when it was actually supplied. - let name = to_usv_string(&name.0); - let value = value - .0 - .as_ref() - .map(|value| value.try_js_into::>(context)) - .transpose()? - .map(|value| to_usv_string(&value.0)); - let mut pairs = self.pairs(); - - match value { - Some(value) => { - pairs.retain(|(existing_name, existing_value)| { - existing_name != &name || existing_value != &value - }); - } - None => { - pairs.retain(|(existing_name, _)| existing_name != &name); - } - } - - self.update(pairs); - Ok(()) - } - - fn entries(this: JsClass, context: &mut Context) -> JsValue { - UrlSearchParamsIterator::create( - this.inner(), - UrlSearchParamsIteratorKind::KeyAndValue, - context, - ) - } - - #[boa(method)] - fn for_each( - this: JsClass, - callback: SearchParamsForEachCallback, - this_arg: Option, - context: &mut Context, - ) -> JsResult<()> { - let object = this.inner().upcast(); - let this_arg = this_arg.unwrap_or_default(); - let mut index = 0usize; - - loop { - let pair = { - let params = this.borrow(); - params.pairs().get(index).cloned() - }; - let Some((name, value)) = pair else { - break; - }; - - callback.call_with_this(&this_arg, context, (value, name, object.clone()))?; - index += 1; - } - - Ok(()) - } - - fn get(&self, name: Convert) -> JsValue { - let name = to_usv_string(&name.0); - self.pairs() - .into_iter() - .find_map(|(existing_name, value)| (existing_name == name).then_some(value.into())) - .unwrap_or_else(JsValue::null) - } - - fn get_all(&self, name: Convert) -> Vec { - let name = to_usv_string(&name.0); - self.pairs() - .into_iter() - .filter_map(|(existing_name, value)| (existing_name == name).then_some(value)) - .collect() - } - - fn has( - &self, - name: Convert, - value: OptionalArg, - context: &mut Context, - ) -> JsResult { - // WHATWG URL: - // The second argument only participates when it was actually supplied. - let name = to_usv_string(&name.0); - let value = value - .0 - .as_ref() - .map(|value| value.try_js_into::>(context)) - .transpose()? - .map(|value| to_usv_string(&value.0)); - - Ok(match value { - Some(value) => self - .pairs() - .into_iter() - .any(|(existing_name, existing_value)| { - existing_name == name && existing_value == value - }), - None => self - .pairs() - .into_iter() - .any(|(existing_name, _)| existing_name == name), - }) - } - - #[boa(symbol = "iterator")] - fn iterator(this: JsClass, context: &mut Context) -> JsValue { - Self::entries(this, context) - } - - fn keys(this: JsClass, context: &mut Context) -> JsValue { - UrlSearchParamsIterator::create(this.inner(), UrlSearchParamsIteratorKind::Key, context) - } - - fn set(&mut self, name: Convert, value: Convert) { - let name = to_usv_string(&name.0); - let value = to_usv_string(&value.0); - let mut found = false; - let mut result = Vec::with_capacity(self.pairs().len() + 1); - - for (existing_name, existing_value) in self.pairs() { - if existing_name == name { - if !found { - result.push((existing_name, value.clone())); - found = true; - } - } else { - result.push((existing_name, existing_value)); - } - } - - if !found { - result.push((name, value)); - } - - self.update(result); - } - - fn sort(&mut self) { - let mut pairs = self.pairs(); - pairs.sort_by(|(left, _), (right, _)| left.cmp(right)); - self.update(pairs); - } - - fn to_string(&self) -> JsString { - JsString::from(serialize_search_params(&self.pairs())) - } - - fn values(this: JsClass, context: &mut Context) -> JsValue { - UrlSearchParamsIterator::create(this.inner(), UrlSearchParamsIteratorKind::Value, context) - } -} - -/// The `URL` class represents a (properly parsed) Uniform Resource Locator. -#[derive(Debug, JsData, Trace, Finalize)] -#[boa_gc(unsafe_no_drop)] -pub struct Url(#[unsafe_ignore_trace] SharedUrl); - -impl Clone for Url { - fn clone(&self) -> Self { - Self(SharedUrl::new(self.0.read().clone())) - } -} - -impl Url { - /// Register the `URL` class into the realm. Pass `None` for the realm to - /// register globally. - /// - /// # Errors - /// This will error if the context or realm cannot register the class. - pub fn register(realm: Option, context: &mut Context) -> JsResult<()> { - js_module::boa_register(realm, context) - } - - /// Create a native `Url` value from Rust code. - /// - /// # Errors - /// Returns an error if `url` cannot be parsed against the optional `base`. - pub fn new(Convert(ref url): Convert, base: Option>) -> JsResult { - Self::parse_url(url, base.as_ref().map(|base| base.0.as_str())).map(Self::from) - } - - /// Create a JavaScript `URL` object from native `Url` data. - /// - /// # Errors - /// Returns an error if the object cannot be allocated. - pub fn from_data(data: Self, context: &mut Context) -> JsResult { - ::from_data(data, context) - } - - fn parse_url(url: &str, base: Option<&str>) -> JsResult { - if let Some(base) = base { - let base_url = url::Url::parse(base) - .map_err(|e| js_error!(TypeError: "Failed to parse base URL: {}", e))?; - - base_url - .join(url) - .map_err(|e| js_error!(TypeError: "Failed to parse URL: {}", e)) - } else { - url::Url::parse(url).map_err(|e| js_error!(TypeError: "Failed to parse URL: {}", e)) - } - } -} - -impl Display for Url { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", *self.0.read()) - } -} - -impl From for Url { - fn from(url: url::Url) -> Self { - Self(SharedUrl::new(url)) - } -} - -impl From for url::Url { - fn from(url: Url) -> Self { - url.0.into_url() - } -} - -#[boa_class(rename = "URL")] -#[boa(rename_all = "camelCase")] -impl Url { - /// Create a new `URL` object. Meant to be called from the JavaScript constructor. - /// - /// # Errors - /// Any errors that might occur during URL parsing. - #[boa(constructor)] - pub fn constructor( - Convert(ref url): Convert, - base: Option>, - ) -> JsResult { - Self::parse_url(url, base.as_ref().map(|base| base.0.as_str())).map(Self::from) - } - - #[boa(getter)] - fn hash(&self) -> JsString { - JsString::from(url::quirks::hash(&self.0.read())) - } - - #[boa(setter)] - #[boa(rename = "hash")] - fn set_hash(&mut self, value: Convert) { - url::quirks::set_hash(&mut self.0.write(), &value.0); - } - - #[boa(getter)] - fn hostname(&self) -> JsString { - JsString::from(url::quirks::hostname(&self.0.read())) - } - - #[boa(setter)] - #[boa(rename = "hostname")] - fn set_hostname(&mut self, value: Convert) { - let _ = url::quirks::set_hostname(&mut self.0.write(), &value.0); - } - - #[boa(getter)] - fn host(&self) -> JsString { - JsString::from(url::quirks::host(&self.0.read())) - } - - #[boa(setter)] - #[boa(rename = "host")] - fn set_host(&mut self, value: Convert) { - let _ = url::quirks::set_host(&mut self.0.write(), &value.0); - } - - #[boa(getter)] - fn href(&self) -> JsString { - JsString::from(url::quirks::href(&self.0.read())) - } - - #[boa(setter)] - #[boa(rename = "href")] - fn set_href(&mut self, value: Convert) -> JsResult<()> { - url::quirks::set_href(&mut self.0.write(), &value.0) - .map_err(|e| js_error!(TypeError: "Failed to set href: {}", e)) - } - - #[boa(getter)] - fn origin(&self) -> JsString { - JsString::from(url::quirks::origin(&self.0.read())) - } - - #[boa(getter)] - fn password(&self) -> JsString { - JsString::from(url::quirks::password(&self.0.read())) - } - - #[boa(setter)] - #[boa(rename = "password")] - fn set_password(&mut self, value: Convert) { - let _ = url::quirks::set_password(&mut self.0.write(), &value.0); - } - - #[boa(getter)] - fn pathname(&self) -> JsString { - JsString::from(url::quirks::pathname(&self.0.read())) - } - - #[boa(setter)] - #[boa(rename = "pathname")] - fn set_pathname(&mut self, value: Convert) { - let () = url::quirks::set_pathname(&mut self.0.write(), &value.0); - } - - #[boa(getter)] - fn port(&self) -> JsString { - JsString::from(url::quirks::port(&self.0.read())) - } - - #[boa(setter)] - #[boa(rename = "port")] - fn set_port(&mut self, value: Convert) { - let _ = url::quirks::set_port(&mut self.0.write(), &value.0.to_std_string_lossy()); - } - - #[boa(getter)] - fn protocol(&self) -> JsString { - JsString::from(url::quirks::protocol(&self.0.read())) - } - - #[boa(setter)] - #[boa(rename = "protocol")] - fn set_protocol(&mut self, value: Convert) { - let _ = url::quirks::set_protocol(&mut self.0.write(), &value.0); - } - - #[boa(getter)] - fn search(&self) -> JsString { - JsString::from(url::quirks::search(&self.0.read())) - } - - #[boa(setter)] - #[boa(rename = "search")] - fn set_search(&mut self, value: Convert) { - url::quirks::set_search(&mut self.0.write(), &value.0); - } - - #[boa(getter)] - fn search_params(this: JsClass, context: &mut Context) -> JsResult { - let (key, url) = { - let url = this.borrow(); - (url.0.key(), url.0.clone()) - }; - - if let Some(search_params) = context - .get_data::() - .and_then(|cache| cache.get(key)) - { - return Ok(search_params.upcast()); - } - - let search_params = UrlSearchParams::from_url(url, context)?; - let owner = this.inner(); - - if let Some(cache) = context.host_defined_mut().get_mut::() { - cache.insert(key, &owner, search_params.clone()); - } else { - let mut cache = UrlSearchParamsCache::default(); - cache.insert(key, &owner, search_params.clone()); - context.insert_data(cache); - } - - Ok(search_params.upcast()) - } - - #[boa(getter)] - fn username(&self) -> JsString { - JsString::from(self.0.read().username()) - } - - #[boa(setter)] - #[boa(rename = "username")] - fn set_username(&mut self, value: Convert) { - let _ = self.0.write().set_username(&value.0); - } - - fn to_string(&self) -> JsString { - JsString::from(self.0.read().as_str()) - } - - #[boa(rename = "toJSON")] - fn to_json(&self) -> JsString { - JsString::from(self.0.read().as_str()) - } - - #[boa(static)] - fn create_object_url() -> JsResult<()> { - Err(js_error!(Error: "URL.createObjectURL is not implemented")) - } - - #[boa(static)] - fn can_parse(url: Convert, base: Option>) -> bool { - Self::parse_url(&url.0, base.as_ref().map(|base| base.0.as_str())).is_ok() - } - - #[boa(static)] - fn parse( - url: Convert, - base: Option>, - context: &mut Context, - ) -> JsResult { - Self::parse_url(&url.0, base.as_ref().map(|base| base.0.as_str())) - .map_or(Ok(JsValue::null()), |url| { - Self::from_data(Self::from(url), context).map(JsValue::from) - }) - } - - #[boa(static)] - fn revoke_object_url() -> JsResult<()> { - Err(js_error!(Error: "URL.revokeObjectURL is not implemented")) - } -} - -/// JavaScript module containing the Url class. -#[boa_module] -pub mod js_module { - type Url = super::Url; - type UrlSearchParams = super::UrlSearchParams; -} diff --git a/core/wintertc/Cargo.toml b/core/wintertc/Cargo.toml index 89ef7a34463..9b81c401f22 100644 --- a/core/wintertc/Cargo.toml +++ b/core/wintertc/Cargo.toml @@ -14,11 +14,14 @@ rust-version.workspace = true boa_engine.workspace = true boa_gc.workspace = true base64.workspace = true +bytemuck.workspace = true comfy-table.workspace = true rustc-hash = { workspace = true, features = ["std"] } +url = { workspace = true, optional = true } [dev-dependencies] indoc.workspace = true +test-case.workspace = true textwrap.workspace = true futures-lite.workspace = true @@ -30,5 +33,5 @@ all-features = true [features] default = [] -url = [] +url = ["dep:url"] fetch = [] diff --git a/core/wintertc/src/abort/mod.rs b/core/wintertc/src/abort/mod.rs index d025e4e2813..5cb6236a64d 100644 --- a/core/wintertc/src/abort/mod.rs +++ b/core/wintertc/src/abort/mod.rs @@ -1,23 +1,242 @@ -//! TC55 `AbortController` and `AbortSignal` implementation. +//! `AbortController` and `AbortSignal` Web API implementations. //! //! Spec: //! //! # TC55 Status //! //! `AbortController` and `AbortSignal` are required in the `WinterTC` TC55 Minimum Common Web API. -//! -//! # TODO -//! -//! - Migrate `AbortController` and `AbortSignal` from `boa_runtime::abort`. -/// Register `AbortController` and `AbortSignal` into the given context. -/// +use boa_engine::class::Class; +use boa_engine::job::GenericJob; +use boa_engine::object::builtins::JsFunction; +use boa_engine::realm::Realm; +use boa_engine::{ + Context, Finalize, JsData, JsError, JsNativeError, JsObject, JsResult, JsString, JsValue, + Trace, boa_class, boa_module, js_error, js_string, +}; +use boa_gc::GcRefCell; +use std::cell::Cell; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +#[cfg(test)] +mod tests; + +/// Cancellation token for cooperative abort. +#[derive(Debug, Clone)] +pub struct CancellationToken(Arc); + +impl CancellationToken { + fn new() -> Self { + Self(Arc::new(AtomicBool::new(false))) + } + + /// Cancel the token. + pub fn cancel(&self) { + self.0.store(true, Ordering::Release); + } + + /// Returns `true` if cancelled. + #[must_use] + pub fn is_cancelled(&self) -> bool { + self.0.load(Ordering::Acquire) + } +} + +fn make_abort_error(context: &mut Context) -> JsValue { + let obj = JsNativeError::error() + .with_message("signal is aborted without reason") + .into_opaque(context); + obj.set(js_string!("name"), js_string!("AbortError"), false, context) + .ok(); + obj.into() +} + +/// The JavaScript `AbortSignal` class. +#[derive(Debug, Clone, JsData, Trace, Finalize)] +pub struct JsAbortSignal { + #[unsafe_ignore_trace] + aborted: Cell, + reason: GcRefCell>, + listeners: GcRefCell>, + #[unsafe_ignore_trace] + cancel_token: CancellationToken, +} + +#[derive(Debug, Clone, Trace, Finalize)] +struct AbortEventListener { + event_type: JsString, + callback: JsFunction, +} + +impl Default for JsAbortSignal { + fn default() -> Self { + Self { + aborted: Cell::new(false), + reason: GcRefCell::default(), + listeners: GcRefCell::default(), + cancel_token: CancellationToken::new(), + } + } +} + +impl JsAbortSignal { + /// # Errors + /// + /// Returns an error if the signal has already been aborted. + pub fn signal_abort(&self, reason: JsValue, context: &mut Context) -> JsResult<()> { + if self.aborted.get() { + return Ok(()); + } + self.aborted.set(true); + *self.reason.borrow_mut() = Some(reason); + + let abort = js_string!("abort"); + let listeners: Vec = self + .listeners + .borrow() + .iter() + .filter(|listener| listener.event_type == abort) + .map(|listener| listener.callback.clone()) + .collect(); + + let realm = context.realm().clone(); + for listener in listeners { + context.enqueue_job( + GenericJob::new( + move |context| { + listener.call(&JsValue::undefined(), &[], context)?; + Ok(JsValue::undefined()) + }, + realm.clone(), + ) + .into(), + ); + } + + self.cancel_token.cancel(); + + Ok(()) + } + + /// Returns `true` if this signal has been aborted. + #[must_use] + pub fn is_aborted(&self) -> bool { + self.aborted.get() + } + + /// Returns the abort reason. + pub fn abort_reason(&self, context: &mut Context) -> JsValue { + if !self.aborted.get() { + return JsValue::undefined(); + } + self.reason + .borrow() + .clone() + .unwrap_or_else(|| make_abort_error(context)) + } + + /// Returns the cancellation token. + #[must_use] + pub fn cancellation_token(&self) -> CancellationToken { + self.cancel_token.clone() + } +} + +#[boa_class(rename = "AbortSignal")] +#[boa(rename_all = "camelCase")] +impl JsAbortSignal { + #[boa(constructor)] + fn constructor() -> JsResult { + Err(JsNativeError::typ() + .with_message("Illegal constructor") + .into()) + } + + #[boa(getter)] + fn aborted(&self) -> bool { + self.aborted.get() + } + + #[boa(getter)] + fn reason(&self, context: &mut Context) -> JsValue { + self.abort_reason(context) + } + + fn throw_if_aborted(&self, context: &mut Context) -> JsResult<()> { + if self.aborted.get() { + Err(JsError::from_opaque(self.abort_reason(context))) + } else { + Ok(()) + } + } + + fn add_event_listener( + &self, + event_type: JsString, + callback: JsFunction, + _context: &mut Context, + ) { + { + let listeners = self.listeners.borrow(); + if listeners.iter().any(|listener| { + listener.event_type == event_type && JsObject::equals(&listener.callback, &callback) + }) { + return; + } + } + self.listeners.borrow_mut().push(AbortEventListener { + event_type, + callback, + }); + } + + fn remove_event_listener(&self, event_type: JsString, callback: JsFunction) { + self.listeners.borrow_mut().retain(|listener| { + listener.event_type != event_type || !JsObject::equals(&listener.callback, &callback) + }); + } +} + +/// The JavaScript `AbortController` class. +#[derive(Debug, Clone, JsData, Trace, Finalize)] +pub struct JsAbortController { + signal: JsObject, +} + +#[boa_class(rename = "AbortController")] +#[boa(rename_all = "camelCase")] +impl JsAbortController { + #[boa(constructor)] + fn constructor(context: &mut Context) -> JsResult { + let signal_obj = Class::from_data(JsAbortSignal::default(), context)?; + Ok(Self { signal: signal_obj }) + } + + #[boa(getter)] + fn signal(&self) -> JsObject { + self.signal.clone() + } + + fn abort(&self, reason: Option, context: &mut Context) -> JsResult<()> { + let abort_reason = reason.unwrap_or_else(|| make_abort_error(context)); + + let Some(signal) = self.signal.downcast_ref::() else { + return Err(js_error!(TypeError: "AbortController: invalid signal object")); + }; + signal.signal_abort(abort_reason, context) + } +} + +/// `AbortController` and `AbortSignal` module. +#[boa_module] +pub mod js_module { + type JsAbortController = super::JsAbortController; + type JsAbortSignal = super::JsAbortSignal; +} + /// # Errors -/// -/// Returns a [`boa_engine::JsError`] if registration fails. -pub fn register( - _realm: Option, - _ctx: &mut boa_engine::Context, -) -> boa_engine::JsResult<()> { - Ok(()) +/// Returns an error if registration fails. +pub fn register(realm: Option, context: &mut Context) -> JsResult<()> { + js_module::boa_register(realm, context) } diff --git a/core/runtime/src/abort/tests.rs b/core/wintertc/src/abort/tests.rs similarity index 100% rename from core/runtime/src/abort/tests.rs rename to core/wintertc/src/abort/tests.rs diff --git a/core/runtime/src/text/encodings.rs b/core/wintertc/src/encoding/encodings.rs similarity index 100% rename from core/runtime/src/text/encodings.rs rename to core/wintertc/src/encoding/encodings.rs diff --git a/core/wintertc/src/encoding/mod.rs b/core/wintertc/src/encoding/mod.rs index dbb4508f259..d344310692d 100644 --- a/core/wintertc/src/encoding/mod.rs +++ b/core/wintertc/src/encoding/mod.rs @@ -1,27 +1,280 @@ -//! TC55 encoding APIs: `TextEncoder`, `TextDecoder`, `TextEncoderStream`, `TextDecoderStream`. +//! Module implementing JavaScript classes to handle text encoding and decoding. +//! +//! See for more information. //! //! Spec: //! //! # TC55 Status //! -//! `TextEncoder`, `TextDecoder`, `TextEncoderStream`, and `TextDecoderStream` are required -//! in the `WinterTC` TC55 Minimum Common Web API. +//! `TextEncoder`, `TextDecoder`, `TextEncoderStream`, and `TextDecoderStream` are required in the +//! `WinterTC` TC55 Minimum Common Web API. `TextEncoder` and `TextDecoder` are implemented here. //! //! # TODO //! -//! - Migrate `TextEncoder` and `TextDecoder` from `boa_runtime::text`. //! - Implement `TextEncoderStream`. //! - Implement `TextDecoderStream`. -/// Register encoding globals (`TextEncoder`, `TextDecoder`, `TextEncoderStream`, -/// `TextDecoderStream`) into the given context. +use boa_engine::object::builtins::{JsArrayBuffer, JsDataView, JsTypedArray, JsUint8Array}; +use boa_engine::realm::Realm; +use boa_engine::value::TryFromJs; +use boa_engine::{ + Context, Finalize, JsData, JsResult, JsString, JsValue, Trace, boa_class, boa_module, js_error, + js_string, +}; + +#[cfg(test)] +mod tests; + +mod encodings; + +/// Options for the [`TextDecoder`] constructor. +#[derive(Debug, Default, Clone, Copy, TryFromJs)] +pub struct TextDecoderOptions { + #[boa(rename = "ignoreBOM")] + ignore_bom: Option, +} + +/// The character encoding used by [`TextDecoder`]. +#[derive(Debug, Default, Clone, Copy)] +pub enum Encoding { + /// UTF-8 encoding. + #[default] + Utf8, + /// UTF-16 little endian encoding. + Utf16Le, + /// UTF-16 big endian encoding. + Utf16Be, +} + +const TEXT_DECODER_LABELS: &[(&str, Encoding)] = &[ + ("unicode-1-1-utf-8", Encoding::Utf8), + ("unicode11utf8", Encoding::Utf8), + ("unicode20utf8", Encoding::Utf8), + ("utf-8", Encoding::Utf8), + ("utf8", Encoding::Utf8), + ("x-unicode20utf8", Encoding::Utf8), + ("unicodefffe", Encoding::Utf16Be), + ("utf-16be", Encoding::Utf16Be), + ("csunicode", Encoding::Utf16Le), + ("iso-10646-ucs-2", Encoding::Utf16Le), + ("ucs-2", Encoding::Utf16Le), + ("unicode", Encoding::Utf16Le), + ("unicodefeff", Encoding::Utf16Le), + ("utf-16", Encoding::Utf16Le), + ("utf-16le", Encoding::Utf16Le), +]; + +#[inline] +fn resolve_text_decoder_label(label: &str) -> Option { + let label = label.trim_matches(['\u{0009}', '\u{000A}', '\u{000C}', '\u{000D}', '\u{0020}']); + + TEXT_DECODER_LABELS + .iter() + .find_map(|(supported, encoding)| { + label.eq_ignore_ascii_case(supported).then_some(*encoding) + }) +} + +/// The [`TextDecoder`][mdn] class represents an encoder for a specific method, that is +/// a specific character encoding, like `utf-8`. /// -/// # Errors +/// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder +#[derive(Debug, Default, Clone, JsData, Trace, Finalize)] +pub struct TextDecoder { + #[unsafe_ignore_trace] + encoding: Encoding, + #[unsafe_ignore_trace] + ignore_bom: bool, +} + +#[boa_class] +impl TextDecoder { + /// The [`TextDecoder()`][mdn] constructor returns a new `TextDecoder` object. + /// + /// # Errors + /// This will return an error if the encoding or options are invalid or unsupported. + /// + /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder/TextDecoder + #[boa(constructor)] + pub fn constructor( + encoding: Option, + options: Option, + ) -> JsResult { + let ignore_bom = options.and_then(|o| o.ignore_bom).unwrap_or(false); + + let encoding = match encoding { + Some(enc) => { + let label = enc.to_std_string_lossy(); + resolve_text_decoder_label(&label).ok_or_else( + || js_error!(RangeError: "The given encoding '{}' is not supported.", label), + )? + } + None => Encoding::default(), + }; + + Ok(Self { + encoding, + ignore_bom, + }) + } + + /// The [`TextDecoder.encoding`][mdn] read-only property returns a string containing + /// the name of the character encoding that this decoder will use. + /// + /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder/encoding + #[boa(getter)] + #[must_use] + pub fn encoding(&self) -> JsString { + match self.encoding { + Encoding::Utf8 => js_string!("utf-8"), + Encoding::Utf16Le => js_string!("utf-16le"), + Encoding::Utf16Be => js_string!("utf-16be"), + } + } + + /// The [`TextDecoder.ignoreBOM`][mdn] read-only property returns a `bool` indicating + /// whether the BOM (byte order mark) is ignored. + /// + /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder/ignoreBOM + #[boa(getter)] + #[boa(rename = "ignoreBOM")] + #[must_use] + pub fn ignore_bom(&self) -> bool { + self.ignore_bom + } + + /// The [`TextDecoder.decode()`][mdn] method returns a string containing text decoded from the + /// buffer passed as a parameter. + /// + /// If `buffer` is omitted or `undefined`, this returns an empty string. + /// + /// `buffer` can be an `ArrayBuffer`, a `TypedArray` or a `DataView`. + /// + /// # Errors + /// Any error that arises during decoding the specific encoding. + /// + /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder/decode + pub fn decode(&self, buffer: JsValue, context: &mut Context) -> JsResult { + if buffer.is_undefined() { + return Ok(js_string!("")); + } + + let mut range = None; + let array_buffer = if let Ok(array_buffer) = JsArrayBuffer::try_from_js(&buffer, context) { + array_buffer + } else if let Ok(typed_array) = JsTypedArray::try_from_js(&buffer, context) { + let Some(obj) = typed_array.buffer(context)?.as_object() else { + return Err(js_error!(TypeError: "Invalid buffer backing TypedArray.")); + }; + + let offset = typed_array.byte_offset(context)?; + let length = typed_array.byte_length(context)?; + + range = Some(offset..offset + length); + + JsArrayBuffer::from_object(obj)? + } else if let Ok(data_view) = JsDataView::try_from_js(&buffer, context) { + let Some(obj) = data_view.buffer(context)?.as_object() else { + return Err(js_error!(TypeError: "Invalid buffer backing DataView.")); + }; + + let offset = usize::try_from(data_view.byte_offset(context)?) + .map_err(|_| js_error!(RangeError: "DataView offset exceeds addressable size."))?; + let length = usize::try_from(data_view.byte_length(context)?) + .map_err(|_| js_error!(RangeError: "DataView length exceeds addressable size."))?; + + range = Some(offset..offset + length); + + JsArrayBuffer::from_object(obj)? + } else { + return Err(js_error!( + TypeError: "Argument 1 must be an ArrayBuffer, TypedArray or DataView." + )); + }; + + let strip_bom = !self.ignore_bom; + + let Some(full_data) = array_buffer.data() else { + return Err(js_error!(TypeError: "cannot decode a detached ArrayBuffer")); + }; + + let data: &[u8] = if let Some(range) = range { + full_data.get(range).ok_or_else( + // We do not say invalid range here, as both subarray(10, 5) and subarray("a", "b") + // are valid JS, it would just an empty array. If this error occurs, it most likely means something else + // is wrong + || js_error!(RangeError: "The range for the underlying ArrayBuffer can not be accessed."), + )? + } else { + &full_data + }; + + Ok(match self.encoding { + Encoding::Utf8 => encodings::utf8::decode(data, strip_bom), + Encoding::Utf16Le => encodings::utf16le::decode(data, strip_bom), + Encoding::Utf16Be => { + let owned = data.to_vec(); + encodings::utf16be::decode(owned, strip_bom) + } + }) + } +} + +/// The `TextEncoder`[mdn] class represents a UTF-8 encoder. /// -/// Returns a [`boa_engine::JsError`] if registration fails. -pub fn register( - _realm: Option, - _ctx: &mut boa_engine::Context, -) -> boa_engine::JsResult<()> { - Ok(()) +/// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder +#[derive(Debug, Default, Clone, JsData, Trace, Finalize)] +pub struct TextEncoder; + +#[boa_class] +impl TextEncoder { + /// The [`TextEncoder()`][mdn] constructor returns a newly created `TextEncoder` object. + /// + /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder/TextEncoder + #[boa(constructor)] + #[must_use] + pub fn constructor() -> Self { + Self + } + + /// The [`TextEncoder.encoding`][mdn] read-only property returns a string containing + /// the name of the encoding algorithm used by the specific encoder. + /// + /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder/encoding + #[boa(getter)] + #[must_use] + fn encoding() -> JsString { + js_string!("utf-8") + } + + /// The [`TextEncoder.encode()`][mdn] method takes a string as input, and returns + /// a `Uint8Array` containing the string encoded using UTF-8. + /// + /// # Errors + /// This will error if there is an issue creating the `Uint8Array` or encoding + /// the string itself. + /// + /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder/encode + pub fn encode(&self, text: Option, context: &mut Context) -> JsResult { + let Some(text) = text else { + return JsUint8Array::from_iter([], context); + }; + + JsUint8Array::from_iter(encodings::utf8::encode(&text), context) + } +} + +/// JavaScript module containing the text encoding/decoding classes. +#[boa_module] +pub mod js_module { + type TextDecoder = super::TextDecoder; + type TextEncoder = super::TextEncoder; +} + +/// Register both `TextDecoder` and `TextEncoder` classes into the realm/context. +/// +/// # Errors +/// This will error if the context or realm cannot register the class. +pub fn register(realm: Option, context: &mut Context) -> JsResult<()> { + js_module::boa_register(realm, context) } diff --git a/core/runtime/src/text/tests.rs b/core/wintertc/src/encoding/tests.rs similarity index 95% rename from core/runtime/src/text/tests.rs rename to core/wintertc/src/encoding/tests.rs index 43511525a0a..ab9d85bde96 100644 --- a/core/runtime/src/text/tests.rs +++ b/core/wintertc/src/encoding/tests.rs @@ -1,5 +1,5 @@ +use crate::encoding; use crate::test::{TestAction, run_test_actions_with}; -use crate::text; use boa_engine::object::builtins::JsUint8Array; use boa_engine::property::Attribute; use boa_engine::{Context, JsString, js_str, js_string}; @@ -9,7 +9,7 @@ use test_case::test_case; #[test] fn encoder_js() { let context = &mut Context::default(); - text::register(None, context).unwrap(); + encoding::register(None, context).unwrap(); run_test_actions_with( [ @@ -39,7 +39,7 @@ fn encoder_js_unpaired() { use indoc::indoc; let context = &mut Context::default(); - text::register(None, context).unwrap(); + encoding::register(None, context).unwrap(); let unpaired_surrogates: [u16; 3] = [0xDC58, 0xD83C, 0x0015]; let text = JsString::from(&unpaired_surrogates); @@ -72,7 +72,7 @@ fn encoder_js_unpaired() { #[test] fn decoder_js() { let context = &mut Context::default(); - text::register(None, context).unwrap(); + encoding::register(None, context).unwrap(); run_test_actions_with( [ @@ -97,7 +97,7 @@ fn decoder_js() { #[test] fn decoder_js_without_input() { let context = &mut Context::default(); - text::register(None, context).unwrap(); + encoding::register(None, context).unwrap(); run_test_actions_with( [ @@ -129,7 +129,7 @@ fn decoder_js_invalid() { use indoc::indoc; let context = &mut Context::default(); - text::register(None, context).unwrap(); + encoding::register(None, context).unwrap(); run_test_actions_with( [ @@ -157,7 +157,7 @@ fn decoder_js_invalid() { #[test] fn roundtrip_utf8() { let context = &mut Context::default(); - text::register(None, context).unwrap(); + encoding::register(None, context).unwrap(); run_test_actions_with( [ @@ -186,7 +186,7 @@ fn roundtrip_utf8() { #[test_case("utf-16be")] fn encoder_ignores_non_utf_encoding_arguments(encoding: &'static str) { let context = &mut Context::default(); - text::register(None, context).unwrap(); + encoding::register(None, context).unwrap(); run_test_actions_with( [ @@ -224,7 +224,7 @@ fn encoder_ignores_non_utf_encoding_arguments(encoding: &'static str) { #[test_case("utf-16be", &[0xFE, 0xFF, 0, 72, 0, 105])] fn decoder_bom_default_stripped(encoding: &'static str, bytes: &'static [u8]) { let context = &mut Context::default(); - text::register(None, context).unwrap(); + encoding::register(None, context).unwrap(); let input = JsUint8Array::from_iter(bytes.iter().copied(), context).unwrap(); context @@ -257,7 +257,7 @@ fn decoder_bom_default_stripped(encoding: &'static str, bytes: &'static [u8]) { #[test_case("utf-16be", &[0xFE, 0xFF, 0, 72, 0, 105])] fn decoder_bom_ignore_bom_true(encoding: &'static str, bytes: &'static [u8]) { let context = &mut Context::default(); - text::register(None, context).unwrap(); + encoding::register(None, context).unwrap(); let input = JsUint8Array::from_iter(bytes.iter().copied(), context).unwrap(); context @@ -290,7 +290,7 @@ fn decoder_bom_ignore_bom_true(encoding: &'static str, bytes: &'static [u8]) { #[test_case("utf-16be", &[0xFE, 0xFF, 0, 72, 0, 105])] fn decoder_bom_ignore_bom_false(encoding: &'static str, bytes: &'static [u8]) { let context = &mut Context::default(); - text::register(None, context).unwrap(); + encoding::register(None, context).unwrap(); let input = JsUint8Array::from_iter(bytes.iter().copied(), context).unwrap(); context @@ -328,7 +328,7 @@ fn decoder_bom_ignore_bom_false(encoding: &'static str, bytes: &'static [u8]) { #[test_case("UnicodeFFFE", "utf-16be"; "unicodefffe alias")] fn decoder_normalizes_supported_labels(label: &'static str, expected: &'static str) { let context = &mut Context::default(); - text::register(None, context).unwrap(); + encoding::register(None, context).unwrap(); run_test_actions_with( [ @@ -353,7 +353,7 @@ fn decoder_normalizes_supported_labels(label: &'static str, expected: &'static s #[test] fn decoder_rejects_unsupported_label_after_normalization() { let context = &mut Context::default(); - text::register(None, context).unwrap(); + encoding::register(None, context).unwrap(); run_test_actions_with( [TestAction::run(indoc! {r#" @@ -373,7 +373,7 @@ fn decoder_rejects_unsupported_label_after_normalization() { #[test] fn decoder_ignore_bom_getter() { let context = &mut Context::default(); - text::register(None, context).unwrap(); + encoding::register(None, context).unwrap(); run_test_actions_with( [ @@ -410,7 +410,7 @@ fn decoder_ignore_bom_getter() { #[test] fn decoder_handle_data_view() { let context = &mut Context::default(); - text::register(None, context).unwrap(); + encoding::register(None, context).unwrap(); run_test_actions_with( [ @@ -434,7 +434,7 @@ fn decoder_handle_data_view() { #[test] fn decoder_handle_typed_array_offset_and_length() { let context = &mut Context::default(); - text::register(None, context).unwrap(); + encoding::register(None, context).unwrap(); run_test_actions_with( [ @@ -456,7 +456,7 @@ fn decoder_handle_typed_array_offset_and_length() { #[test] fn decoder_handle_data_view_offset_and_length() { let context = &mut Context::default(); - text::register(None, context).unwrap(); + encoding::register(None, context).unwrap(); run_test_actions_with( [ diff --git a/core/wintertc/src/url/mod.rs b/core/wintertc/src/url/mod.rs index f4bb51fed1f..8014e85a9ac 100644 --- a/core/wintertc/src/url/mod.rs +++ b/core/wintertc/src/url/mod.rs @@ -1,25 +1,933 @@ -//! TC55 URL APIs: `URL`, `URLSearchParams`. +//! Boa's implementation of JavaScript's `URL` Web API class. //! -//! Spec: +//! The `URL` class can be instantiated from any global object. +//! This relies on the `url` feature. //! -//! # TC55 Status +//! More information: +//! - [MDN documentation][mdn] +//! - [WHATWG `URL` specification][spec] +//! +//! Implemented sections in this file: +//! - `URL.searchParams`: +//! - `URLSearchParams` constructor and methods: //! -//! `URL` and `URLSearchParams` are required in the `WinterTC` TC55 Minimum Common Web API. +//! [spec]: https://url.spec.whatwg.org/ +//! [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/URL //! -//! # TODO +//! # TC55 Status //! -//! - Migrate `URL` from `boa_runtime::url`. -//! - Implement `URLSearchParams`. +//! `URL` and `URLSearchParams` are required by the `WinterTC` TC55 Minimum Common Web API and are +//! implemented in this module. +#![allow(clippy::needless_pass_by_value)] + +#[cfg(test)] +mod tests; + +use boa_engine::builtins::iterable::{IteratorRecord, create_iter_result_object}; +use boa_engine::builtins::object::OrdinaryObject; +use boa_engine::class::Class; +use boa_engine::interop::{JsClass, TryFromJsArgument}; +use boa_engine::object::{ + ObjectInitializer, WeakJsObject, + builtins::{JsArray, TypedJsFunction}, +}; +use boa_engine::property::Attribute; +use boa_engine::realm::Realm; +use boa_engine::value::Convert; +use boa_engine::{ + Context, Finalize, JsData, JsError, JsObject, JsResult, JsString, JsSymbol, JsValue, Trace, + boa_class, boa_module, js_error, js_string, native_function::NativeFunction, +}; +use std::collections::HashMap; +use std::fmt::Display; +use std::sync::{Arc, PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard}; + +/// A callback function for the `URLSearchParams.prototype.forEach` method. +pub type SearchParamsForEachCallback = TypedJsFunction<(JsString, JsString, JsObject), ()>; + +#[derive(Debug, Clone, Copy)] +enum UrlSearchParamsIteratorKind { + Key, + Value, + KeyAndValue, +} + +fn to_usv_string(string: &JsString) -> JsString { + JsString::from(string.to_std_string_lossy()) +} + +fn to_usv_string_value(value: &JsValue, context: &mut Context) -> JsResult { + value + .to_string(context) + .map(|string| to_usv_string(&string)) +} + +fn parse_search_params(input: &JsString) -> Vec<(JsString, JsString)> { + let input = input.to_std_string_lossy(); + let input = input.strip_prefix('?').unwrap_or(&input); + + url::form_urlencoded::parse(input.as_bytes()) + .map(|(name, value)| { + ( + JsString::from(name.as_ref()), + JsString::from(value.as_ref()), + ) + }) + .collect() +} + +fn serialize_search_params(params: &[(JsString, JsString)]) -> String { + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + + for (name, value) in params { + let name = name.to_std_string_lossy(); + let value = value.to_std_string_lossy(); + serializer.append_pair(&name, &value); + } + + serializer.finish() +} + +#[derive(Debug, Clone)] +struct SharedUrl(Arc>); -/// Register `URL` and `URLSearchParams` into the given context. +impl SharedUrl { + fn new(url: url::Url) -> Self { + Self(Arc::new(RwLock::new(url))) + } + + fn read(&self) -> RwLockReadGuard<'_, url::Url> { + self.0.read().unwrap_or_else(PoisonError::into_inner) + } + + fn write(&self) -> RwLockWriteGuard<'_, url::Url> { + self.0.write().unwrap_or_else(PoisonError::into_inner) + } + + fn key(&self) -> usize { + Arc::as_ptr(&self.0) as usize + } + + fn into_url(self) -> url::Url { + match Arc::try_unwrap(self.0) { + Ok(url) => url.into_inner().unwrap_or_else(PoisonError::into_inner), + Err(url) => url.read().unwrap_or_else(PoisonError::into_inner).clone(), + } + } +} + +#[derive(Debug, Trace, Finalize, JsData)] +struct UrlSearchParamsCacheEntry { + owner: WeakJsObject, + search_params: JsObject, +} + +#[derive(Debug, Trace, Finalize, JsData)] +struct UrlSearchParamsCache { + entries: HashMap, + next_sweep: usize, +} + +impl Default for UrlSearchParamsCache { + fn default() -> Self { + Self { + entries: HashMap::new(), + next_sweep: 64, + } + } +} + +impl UrlSearchParamsCache { + fn get(&self, key: usize) -> Option> { + self.entries + .get(&key) + .map(|entry| entry.search_params.clone()) + } + + fn insert( + &mut self, + key: usize, + owner: &JsObject, + search_params: JsObject, + ) { + if self.entries.len() >= self.next_sweep { + self.entries.retain(|_, entry| entry.owner.is_upgradable()); + self.next_sweep = self.entries.len().saturating_mul(2).max(64); + } + + self.entries.insert( + key, + UrlSearchParamsCacheEntry { + owner: WeakJsObject::new(owner), + search_params, + }, + ); + } +} + +/// Captures whether an optional argument was actually supplied. /// -/// # Errors +/// This is used for overloads where an explicit `undefined` must not be treated +/// the same as an omitted argument. +#[derive(Debug, Clone)] +struct OptionalArg(Option); + +impl<'a> TryFromJsArgument<'a> for OptionalArg { + fn try_from_js_argument( + _: &'a JsValue, + rest: &'a [JsValue], + _: &mut Context, + ) -> JsResult<(Self, &'a [JsValue])> { + match rest.split_first() { + Some((first, rest)) => Ok((Self(Some(first.clone())), rest)), + None => Ok((Self(None), rest)), + } + } +} + +fn close_iterator_with_error( + iterator: &IteratorRecord, + error: JsError, + context: &mut Context, +) -> JsResult { + match iterator.close(Err(error), context) { + Ok(_) => unreachable!("iterator close with error completion should not succeed"), + Err(err) => Err(err), + } +} + +fn collect_sequence_item_pair( + item: &JsObject, + context: &mut Context, +) -> JsResult<(JsString, JsString)> { + let Some(iterator_method) = item.get_method(JsSymbol::iterator(), context)? else { + return Err(js_error!( + TypeError: "URLSearchParams constructor expects each sequence item to be an iterable pair" + )); + }; + + let item_value = JsValue::from(item.clone()); + let mut pair_iterator = item_value.get_iterator_from_method(&iterator_method, context)?; + + let Some(name) = pair_iterator.step_value(context)? else { + return Err(js_error!( + TypeError: "URLSearchParams constructor expects each sequence item to contain exactly two values" + )); + }; + let name = match to_usv_string_value(&name, context) { + Ok(name) => name, + Err(err) => return close_iterator_with_error(&pair_iterator, err, context), + }; + + let Some(value) = pair_iterator.step_value(context)? else { + return Err(js_error!( + TypeError: "URLSearchParams constructor expects each sequence item to contain exactly two values" + )); + }; + let value = match to_usv_string_value(&value, context) { + Ok(value) => value, + Err(err) => return close_iterator_with_error(&pair_iterator, err, context), + }; + + if pair_iterator.step_value(context)?.is_some() { + return close_iterator_with_error( + &pair_iterator, + js_error!( + TypeError: "URLSearchParams constructor expects each sequence item to contain exactly two values" + ), + context, + ); + } + + Ok((name, value)) +} + +fn collect_sequence_pairs( + init: &JsValue, + iterator_method: &JsObject, + context: &mut Context, +) -> JsResult> { + let mut items = init.get_iterator_from_method(iterator_method, context)?; + let mut pairs = Vec::new(); + + while let Some(item) = items.step_value(context)? { + let Some(item_object) = item.as_object() else { + return close_iterator_with_error( + &items, + js_error!( + TypeError: "URLSearchParams constructor expects each sequence item to be an iterable pair" + ), + context, + ); + }; + + let pair = match collect_sequence_item_pair(&item_object, context) { + Ok(pair) => pair, + Err(err) => return close_iterator_with_error(&items, err, context), + }; + pairs.push(pair); + } + + Ok(pairs) +} + +fn collect_record_pairs( + object: &JsObject, + context: &mut Context, +) -> JsResult> { + let keys = object.own_property_keys(context)?; + let mut pairs = Vec::new(); + + for key in keys { + let enumerable = OrdinaryObject::property_is_enumerable( + &object.clone().into(), + &[key.clone().into()], + context, + )? + .to_boolean(); + + if !enumerable { + continue; + } + + let name = to_usv_string_value(&JsValue::from(key.clone()), context)?; + let value = to_usv_string_value(&object.get(key, context)?, context)?; + pairs.push((name, value)); + } + + Ok(pairs) +} + +/// The `URLSearchParams` class represents the query portion of a URL. +#[derive(Debug, JsData, Trace, Finalize)] +pub struct UrlSearchParams { + list: Vec<(JsString, JsString)>, + #[unsafe_ignore_trace] + url: Option, +} + +impl UrlSearchParams { + fn from_url(url: SharedUrl, context: &mut Context) -> JsResult> { + Self::from_data( + Self { + list: Vec::new(), + url: Some(url), + }, + context, + )? + .downcast::() + .map_err(|_| js_error!(Error: "URLSearchParams class should be registered")) + } + + fn pairs(&self) -> Vec<(JsString, JsString)> { + if let Some(url) = &self.url { + let url = url.read(); + return url + .query_pairs() + .map(|(name, value)| { + ( + JsString::from(name.as_ref()), + JsString::from(value.as_ref()), + ) + }) + .collect(); + } + + self.list.clone() + } + + fn update(&mut self, pairs: Vec<(JsString, JsString)>) { + if let Some(url) = &self.url { + let mut url = url.write(); + + if pairs.is_empty() { + url.set_query(None); + } else { + let query = serialize_search_params(&pairs); + url.set_query(Some(&query)); + } + return; + } + + self.list = pairs; + } +} + +#[derive(Debug, JsData, Trace, Finalize)] +struct UrlSearchParamsIterator { + search_params: JsObject, + next_index: usize, + #[unsafe_ignore_trace] + kind: UrlSearchParamsIteratorKind, + done: bool, +} + +impl UrlSearchParamsIterator { + fn create( + search_params: JsObject, + kind: UrlSearchParamsIteratorKind, + context: &mut Context, + ) -> JsValue { + ObjectInitializer::with_native_data_and_proto( + Self { + search_params, + next_index: 0, + kind, + done: false, + }, + context.intrinsics().constructors().iterator().prototype(), + context, + ) + .function( + NativeFunction::from_fn_ptr(Self::next), + js_string!("next"), + 0, + ) + .function( + NativeFunction::from_fn_ptr(Self::iterator), + JsSymbol::iterator(), + 0, + ) + .property( + JsSymbol::to_string_tag(), + js_string!("URLSearchParams Iterator"), + Attribute::CONFIGURABLE, + ) + .build() + .into() + } + + #[allow(clippy::unnecessary_wraps)] + fn iterator(this: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + Ok(this.clone()) + } + + fn next(this: &JsValue, _: &[JsValue], context: &mut Context) -> JsResult { + let object = this + .as_object() + .ok_or_else(|| js_error!(TypeError: "`this` is not a URLSearchParams iterator"))?; + let mut iterator = object + .downcast_mut::() + .ok_or_else(|| js_error!(TypeError: "`this` is not a URLSearchParams iterator"))?; + + if iterator.done { + return Ok(create_iter_result_object( + JsValue::undefined(), + true, + context, + )); + } + + let pair = iterator + .search_params + .borrow() + .data() + .pairs() + .get(iterator.next_index) + .cloned(); + + let Some((name, value)) = pair else { + iterator.done = true; + return Ok(create_iter_result_object( + JsValue::undefined(), + true, + context, + )); + }; + + iterator.next_index += 1; + + let result: JsValue = match iterator.kind { + UrlSearchParamsIteratorKind::Key => name.into(), + UrlSearchParamsIteratorKind::Value => value.into(), + UrlSearchParamsIteratorKind::KeyAndValue => { + JsArray::from_iter([name.into(), value.into()], context).into() + } + }; + + Ok(create_iter_result_object(result, false, context)) + } +} + +#[boa_class(rename = "URLSearchParams")] +#[boa(rename_all = "camelCase")] +impl UrlSearchParams { + /// WHATWG URL: + /// + /// This implements the constructor branches for: + /// - empty / null init + /// - sequence input via @@iterator + /// - record input via enumerable own properties + /// - string input parsed as application/x-www-form-urlencoded + #[boa(constructor)] + fn constructor(init: JsValue, context: &mut Context) -> JsResult { + let list = if init.is_undefined() || init.is_null() { + Vec::new() + } else if let Some(object) = init.as_object() { + if let Some(iterator_method) = object.get_method(JsSymbol::iterator(), context)? { + collect_sequence_pairs(&init, &iterator_method, context)? + } else { + collect_record_pairs(&object, context)? + } + } else { + parse_search_params(&to_usv_string_value(&init, context)?) + }; + + Ok(Self { list, url: None }) + } + + #[boa(getter)] + fn size(&self) -> usize { + self.pairs().len() + } + + fn append(&mut self, name: Convert, value: Convert) { + let mut pairs = self.pairs(); + pairs.push((to_usv_string(&name.0), to_usv_string(&value.0))); + self.update(pairs); + } + + fn delete( + &mut self, + name: Convert, + value: OptionalArg, + context: &mut Context, + ) -> JsResult<()> { + // WHATWG URL: + // The second argument only participates when it was actually supplied. + let name = to_usv_string(&name.0); + let value = value + .0 + .as_ref() + .map(|value| value.try_js_into::>(context)) + .transpose()? + .map(|value| to_usv_string(&value.0)); + let mut pairs = self.pairs(); + + match value { + Some(value) => { + pairs.retain(|(existing_name, existing_value)| { + existing_name != &name || existing_value != &value + }); + } + None => { + pairs.retain(|(existing_name, _)| existing_name != &name); + } + } + + self.update(pairs); + Ok(()) + } + + fn entries(this: JsClass, context: &mut Context) -> JsValue { + UrlSearchParamsIterator::create( + this.inner(), + UrlSearchParamsIteratorKind::KeyAndValue, + context, + ) + } + + #[boa(method)] + fn for_each( + this: JsClass, + callback: SearchParamsForEachCallback, + this_arg: Option, + context: &mut Context, + ) -> JsResult<()> { + let object = this.inner().upcast(); + let this_arg = this_arg.unwrap_or_default(); + let mut index = 0usize; + + loop { + let pair = { + let params = this.borrow(); + params.pairs().get(index).cloned() + }; + let Some((name, value)) = pair else { + break; + }; + + callback.call_with_this(&this_arg, context, (value, name, object.clone()))?; + index += 1; + } + + Ok(()) + } + + fn get(&self, name: Convert) -> JsValue { + let name = to_usv_string(&name.0); + self.pairs() + .into_iter() + .find_map(|(existing_name, value)| (existing_name == name).then_some(value.into())) + .unwrap_or_else(JsValue::null) + } + + fn get_all(&self, name: Convert) -> Vec { + let name = to_usv_string(&name.0); + self.pairs() + .into_iter() + .filter_map(|(existing_name, value)| (existing_name == name).then_some(value)) + .collect() + } + + fn has( + &self, + name: Convert, + value: OptionalArg, + context: &mut Context, + ) -> JsResult { + // WHATWG URL: + // The second argument only participates when it was actually supplied. + let name = to_usv_string(&name.0); + let value = value + .0 + .as_ref() + .map(|value| value.try_js_into::>(context)) + .transpose()? + .map(|value| to_usv_string(&value.0)); + + Ok(match value { + Some(value) => self + .pairs() + .into_iter() + .any(|(existing_name, existing_value)| { + existing_name == name && existing_value == value + }), + None => self + .pairs() + .into_iter() + .any(|(existing_name, _)| existing_name == name), + }) + } + + #[boa(symbol = "iterator")] + fn iterator(this: JsClass, context: &mut Context) -> JsValue { + Self::entries(this, context) + } + + fn keys(this: JsClass, context: &mut Context) -> JsValue { + UrlSearchParamsIterator::create(this.inner(), UrlSearchParamsIteratorKind::Key, context) + } + + fn set(&mut self, name: Convert, value: Convert) { + let name = to_usv_string(&name.0); + let value = to_usv_string(&value.0); + let mut found = false; + let mut result = Vec::with_capacity(self.pairs().len() + 1); + + for (existing_name, existing_value) in self.pairs() { + if existing_name == name { + if !found { + result.push((existing_name, value.clone())); + found = true; + } + } else { + result.push((existing_name, existing_value)); + } + } + + if !found { + result.push((name, value)); + } + + self.update(result); + } + + fn sort(&mut self) { + let mut pairs = self.pairs(); + pairs.sort_by(|(left, _), (right, _)| left.cmp(right)); + self.update(pairs); + } + + fn to_string(&self) -> JsString { + JsString::from(serialize_search_params(&self.pairs())) + } + + fn values(this: JsClass, context: &mut Context) -> JsValue { + UrlSearchParamsIterator::create(this.inner(), UrlSearchParamsIteratorKind::Value, context) + } +} + +/// The `URL` class represents a (properly parsed) Uniform Resource Locator. +#[derive(Debug, JsData, Trace, Finalize)] +#[boa_gc(unsafe_no_drop)] +pub struct Url(#[unsafe_ignore_trace] SharedUrl); + +impl Clone for Url { + fn clone(&self) -> Self { + Self(SharedUrl::new(self.0.read().clone())) + } +} + +impl Url { + /// Register the `URL` class into the realm. Pass `None` for the realm to + /// register globally. + /// + /// # Errors + /// This will error if the context or realm cannot register the class. + pub fn register(realm: Option, context: &mut Context) -> JsResult<()> { + js_module::boa_register(realm, context) + } + + /// Create a native `Url` value from Rust code. + /// + /// # Errors + /// Returns an error if `url` cannot be parsed against the optional `base`. + pub fn new(Convert(ref url): Convert, base: Option>) -> JsResult { + Self::parse_url(url, base.as_ref().map(|base| base.0.as_str())).map(Self::from) + } + + /// Create a JavaScript `URL` object from native `Url` data. + /// + /// # Errors + /// Returns an error if the object cannot be allocated. + pub fn from_data(data: Self, context: &mut Context) -> JsResult { + ::from_data(data, context) + } + + fn parse_url(url: &str, base: Option<&str>) -> JsResult { + if let Some(base) = base { + let base_url = url::Url::parse(base) + .map_err(|e| js_error!(TypeError: "Failed to parse base URL: {}", e))?; + + base_url + .join(url) + .map_err(|e| js_error!(TypeError: "Failed to parse URL: {}", e)) + } else { + url::Url::parse(url).map_err(|e| js_error!(TypeError: "Failed to parse URL: {}", e)) + } + } +} + +impl Display for Url { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", *self.0.read()) + } +} + +impl From for Url { + fn from(url: url::Url) -> Self { + Self(SharedUrl::new(url)) + } +} + +impl From for url::Url { + fn from(url: Url) -> Self { + url.0.into_url() + } +} + +#[boa_class(rename = "URL")] +#[boa(rename_all = "camelCase")] +impl Url { + /// Create a new `URL` object. Meant to be called from the JavaScript constructor. + /// + /// # Errors + /// Any errors that might occur during URL parsing. + #[boa(constructor)] + pub fn constructor( + Convert(ref url): Convert, + base: Option>, + ) -> JsResult { + Self::parse_url(url, base.as_ref().map(|base| base.0.as_str())).map(Self::from) + } + + #[boa(getter)] + fn hash(&self) -> JsString { + JsString::from(url::quirks::hash(&self.0.read())) + } + + #[boa(setter)] + #[boa(rename = "hash")] + fn set_hash(&mut self, value: Convert) { + url::quirks::set_hash(&mut self.0.write(), &value.0); + } + + #[boa(getter)] + fn hostname(&self) -> JsString { + JsString::from(url::quirks::hostname(&self.0.read())) + } + + #[boa(setter)] + #[boa(rename = "hostname")] + fn set_hostname(&mut self, value: Convert) { + let _ = url::quirks::set_hostname(&mut self.0.write(), &value.0); + } + + #[boa(getter)] + fn host(&self) -> JsString { + JsString::from(url::quirks::host(&self.0.read())) + } + + #[boa(setter)] + #[boa(rename = "host")] + fn set_host(&mut self, value: Convert) { + let _ = url::quirks::set_host(&mut self.0.write(), &value.0); + } + + #[boa(getter)] + fn href(&self) -> JsString { + JsString::from(url::quirks::href(&self.0.read())) + } + + #[boa(setter)] + #[boa(rename = "href")] + fn set_href(&mut self, value: Convert) -> JsResult<()> { + url::quirks::set_href(&mut self.0.write(), &value.0) + .map_err(|e| js_error!(TypeError: "Failed to set href: {}", e)) + } + + #[boa(getter)] + fn origin(&self) -> JsString { + JsString::from(url::quirks::origin(&self.0.read())) + } + + #[boa(getter)] + fn password(&self) -> JsString { + JsString::from(url::quirks::password(&self.0.read())) + } + + #[boa(setter)] + #[boa(rename = "password")] + fn set_password(&mut self, value: Convert) { + let _ = url::quirks::set_password(&mut self.0.write(), &value.0); + } + + #[boa(getter)] + fn pathname(&self) -> JsString { + JsString::from(url::quirks::pathname(&self.0.read())) + } + + #[boa(setter)] + #[boa(rename = "pathname")] + fn set_pathname(&mut self, value: Convert) { + let () = url::quirks::set_pathname(&mut self.0.write(), &value.0); + } + + #[boa(getter)] + fn port(&self) -> JsString { + JsString::from(url::quirks::port(&self.0.read())) + } + + #[boa(setter)] + #[boa(rename = "port")] + fn set_port(&mut self, value: Convert) { + let _ = url::quirks::set_port(&mut self.0.write(), &value.0.to_std_string_lossy()); + } + + #[boa(getter)] + fn protocol(&self) -> JsString { + JsString::from(url::quirks::protocol(&self.0.read())) + } + + #[boa(setter)] + #[boa(rename = "protocol")] + fn set_protocol(&mut self, value: Convert) { + let _ = url::quirks::set_protocol(&mut self.0.write(), &value.0); + } + + #[boa(getter)] + fn search(&self) -> JsString { + JsString::from(url::quirks::search(&self.0.read())) + } + + #[boa(setter)] + #[boa(rename = "search")] + fn set_search(&mut self, value: Convert) { + url::quirks::set_search(&mut self.0.write(), &value.0); + } + + #[boa(getter)] + fn search_params(this: JsClass, context: &mut Context) -> JsResult { + let (key, url) = { + let url = this.borrow(); + (url.0.key(), url.0.clone()) + }; + + if let Some(search_params) = context + .get_data::() + .and_then(|cache| cache.get(key)) + { + return Ok(search_params.upcast()); + } + + let search_params = UrlSearchParams::from_url(url, context)?; + let owner = this.inner(); + + if let Some(cache) = context.host_defined_mut().get_mut::() { + cache.insert(key, &owner, search_params.clone()); + } else { + let mut cache = UrlSearchParamsCache::default(); + cache.insert(key, &owner, search_params.clone()); + context.insert_data(cache); + } + + Ok(search_params.upcast()) + } + + #[boa(getter)] + fn username(&self) -> JsString { + JsString::from(self.0.read().username()) + } + + #[boa(setter)] + #[boa(rename = "username")] + fn set_username(&mut self, value: Convert) { + let _ = self.0.write().set_username(&value.0); + } + + fn to_string(&self) -> JsString { + JsString::from(self.0.read().as_str()) + } + + #[boa(rename = "toJSON")] + fn to_json(&self) -> JsString { + JsString::from(self.0.read().as_str()) + } + + #[boa(static)] + fn create_object_url() -> JsResult<()> { + Err(js_error!(Error: "URL.createObjectURL is not implemented")) + } + + #[boa(static)] + fn can_parse(url: Convert, base: Option>) -> bool { + Self::parse_url(&url.0, base.as_ref().map(|base| base.0.as_str())).is_ok() + } + + #[boa(static)] + fn parse( + url: Convert, + base: Option>, + context: &mut Context, + ) -> JsResult { + Self::parse_url(&url.0, base.as_ref().map(|base| base.0.as_str())) + .map_or(Ok(JsValue::null()), |url| { + Self::from_data(Self::from(url), context).map(JsValue::from) + }) + } + + #[boa(static)] + fn revoke_object_url() -> JsResult<()> { + Err(js_error!(Error: "URL.revokeObjectURL is not implemented")) + } +} + +/// JavaScript module containing the Url class. +#[boa_module] +pub mod js_module { + type Url = super::Url; + type UrlSearchParams = super::UrlSearchParams; +} + +/// Register the `URL` and `URLSearchParams` classes into the realm. Pass `None` for the realm to +/// register globally. /// -/// Returns a [`boa_engine::JsError`] if registration fails. -#[cfg(feature = "url")] -pub fn register( - _realm: Option, - _ctx: &mut boa_engine::Context, -) -> boa_engine::JsResult<()> { - Ok(()) +/// This is the entry point used by [`crate::register`] to install the URL APIs as part of the full +/// TC55 API surface; it forwards to [`Url::register`]. +/// +/// # Errors +/// This will error if the context or realm cannot register the classes. +pub fn register(realm: Option, context: &mut Context) -> JsResult<()> { + Url::register(realm, context) } diff --git a/core/runtime/src/url/tests.rs b/core/wintertc/src/url/tests.rs similarity index 100% rename from core/runtime/src/url/tests.rs rename to core/wintertc/src/url/tests.rs