diff --git a/Cargo.lock b/Cargo.lock index 2743eede14e..bd480010cc0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -576,7 +576,6 @@ dependencies = [ "boa_gc", "boa_wintertc", "bus", - "bytemuck", "either", "futures", "futures-lite", @@ -587,7 +586,6 @@ dependencies = [ "rustc-hash 2.1.3", "serde_json", "temp-env", - "test-case", "textwrap", "url", ] @@ -643,11 +641,14 @@ dependencies = [ "base64 0.23.0", "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 1c4c4568da3..58a38dfa3cb 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 @@ -23,14 +22,13 @@ http = { workspace = true, optional = true } reqwest = { workspace = true, optional = true } rustc-hash = { workspace = true, features = ["std"] } serde_json = { workspace = true, optional = true } -url = { workspace = true, optional = true } [dev-dependencies] indoc.workspace = true rstest.workspace = true -test-case.workspace = true textwrap.workspace = true temp-env.workspace = true +url.workspace = true [lints] workspace = true @@ -49,7 +47,7 @@ default = [ ] all = ["default", "reqwest-blocking"] -url = ["dep:url"] +url = ["boa_wintertc/url"] fetch = [ "dep:either", "dep:http", 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..fe46ce67798 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` — `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` — 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 e34b555987e..00000000000 --- a/core/runtime/src/url.rs +++ /dev/null @@ -1,247 +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] -//! -//! [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::class::Class; -use boa_engine::realm::Realm; -use boa_engine::value::Convert; -use boa_engine::{ - Context, Finalize, JsData, JsResult, JsString, JsValue, Trace, boa_class, boa_module, js_error, -}; -use std::fmt::Display; - -/// The `URL` class represents a (properly parsed) Uniform Resource Locator. -#[derive(Debug, Clone, JsData, Trace, Finalize)] -#[boa_gc(unsafe_no_drop)] -pub struct Url(#[unsafe_ignore_trace] url::Url); - -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) - } -} - -impl Display for Url { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -impl From for Url { - fn from(url: url::Url) -> Self { - Self(url) - } -} - -impl From for url::Url { - fn from(url: Url) -> url::Url { - url.0 - } -} - -#[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 new(Convert(ref url): Convert, base: Option>) -> JsResult { - if let Some(Convert(ref base)) = base { - let base_url = url::Url::parse(base) - .map_err(|e| js_error!(TypeError: "Failed to parse base URL: {}", e))?; - if base_url.cannot_be_a_base() { - return Err(js_error!(TypeError: "Base URL {} cannot be a base", base)); - } - - let url = base_url - .join(url) - .map_err(|e| js_error!(TypeError: "Failed to parse URL: {}", e))?; - Ok(Self(url)) - } else { - let url = url::Url::parse(url) - .map_err(|e| js_error!(TypeError: "Failed to parse URL: {}", e))?; - Ok(Self(url)) - } - } - - #[boa(getter)] - fn hash(&self) -> JsString { - JsString::from(url::quirks::hash(&self.0)) - } - - #[boa(setter)] - #[boa(rename = "hash")] - fn set_hash(&mut self, value: Convert) { - url::quirks::set_hash(&mut self.0, &value.0); - } - - #[boa(getter)] - fn hostname(&self) -> JsString { - JsString::from(url::quirks::hostname(&self.0)) - } - - #[boa(setter)] - #[boa(rename = "hostname")] - fn set_hostname(&mut self, value: Convert) { - let _ = url::quirks::set_hostname(&mut self.0, &value.0); - } - - #[boa(getter)] - fn host(&self) -> JsString { - JsString::from(url::quirks::host(&self.0)) - } - - #[boa(setter)] - #[boa(rename = "host")] - fn set_host(&mut self, value: Convert) { - let _ = url::quirks::set_host(&mut self.0, &value.0); - } - - #[boa(getter)] - fn href(&self) -> JsString { - JsString::from(url::quirks::href(&self.0)) - } - - #[boa(setter)] - #[boa(rename = "href")] - fn set_href(&mut self, value: Convert) -> JsResult<()> { - url::quirks::set_href(&mut self.0, &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)) - } - - #[boa(getter)] - fn password(&self) -> JsString { - JsString::from(url::quirks::password(&self.0)) - } - - #[boa(setter)] - #[boa(rename = "password")] - fn set_password(&mut self, value: Convert) { - let _ = url::quirks::set_password(&mut self.0, &value.0); - } - - #[boa(getter)] - fn pathname(&self) -> JsString { - JsString::from(url::quirks::pathname(&self.0)) - } - - #[boa(setter)] - #[boa(rename = "pathname")] - fn set_pathname(&mut self, value: Convert) { - let () = url::quirks::set_pathname(&mut self.0, &value.0); - } - - #[boa(getter)] - fn port(&self) -> JsString { - JsString::from(url::quirks::port(&self.0)) - } - - #[boa(setter)] - #[boa(rename = "port")] - fn set_port(&mut self, value: Convert) { - let _ = url::quirks::set_port(&mut self.0, &value.0.to_std_string_lossy()); - } - - #[boa(getter)] - fn protocol(&self) -> JsString { - JsString::from(url::quirks::protocol(&self.0)) - } - - #[boa(setter)] - #[boa(rename = "protocol")] - fn set_protocol(&mut self, value: Convert) { - let _ = url::quirks::set_protocol(&mut self.0, &value.0); - } - - #[boa(getter)] - fn search(&self) -> JsString { - JsString::from(url::quirks::search(&self.0)) - } - - #[boa(setter)] - #[boa(rename = "search")] - fn set_search(&mut self, value: Convert) { - url::quirks::set_search(&mut self.0, &value.0); - } - - #[boa(getter)] - fn search_params() -> JsResult<()> { - Err(js_error!(Error: "URL.searchParams is not implemented")) - } - - #[boa(getter)] - fn username(&self) -> JsString { - JsString::from(self.0.username()) - } - - #[boa(setter)] - #[boa(rename = "username")] - fn set_username(&mut self, value: Convert) { - let _ = self.0.set_username(&value.0); - } - - fn to_string(&self) -> JsString { - JsString::from(format!("{}", self.0)) - } - - #[boa(rename = "toJSON")] - fn to_json(&self) -> JsString { - JsString::from(format!("{}", self.0)) - } - - #[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 { - Url::new(url, base).is_ok() - } - - #[boa(static)] - fn parse( - url: Convert, - base: Option>, - context: &mut Context, - ) -> JsResult { - Url::new(url, base).map_or(Ok(JsValue::null()), |u| { - Url::from_data(u, 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; -} 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..532c9f2f9ab 100644 --- a/core/wintertc/src/url/mod.rs +++ b/core/wintertc/src/url/mod.rs @@ -1,25 +1,268 @@ -//! 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. +//! +//! More information: +//! - [MDN documentation][mdn] +//! - [WHATWG `URL` specification][spec] +//! +//! [spec]: https://url.spec.whatwg.org/ +//! [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/URL //! //! # TC55 Status //! //! `URL` and `URLSearchParams` are required in the `WinterTC` TC55 Minimum Common Web API. +//! `URL` is implemented here. //! //! # TODO //! -//! - Migrate `URL` from `boa_runtime::url`. //! - Implement `URLSearchParams`. +#![allow(clippy::needless_pass_by_value)] + +#[cfg(test)] +mod tests; + +use boa_engine::class::Class; +use boa_engine::realm::Realm; +use boa_engine::value::Convert; +use boa_engine::{ + Context, Finalize, JsData, JsResult, JsString, JsValue, Trace, boa_class, boa_module, js_error, +}; +use std::fmt::Display; + +/// The `URL` class represents a (properly parsed) Uniform Resource Locator. +#[derive(Debug, Clone, JsData, Trace, Finalize)] +#[boa_gc(unsafe_no_drop)] +pub struct Url(#[unsafe_ignore_trace] url::Url); + +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) + } +} + +impl Display for Url { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for Url { + fn from(url: url::Url) -> Self { + Self(url) + } +} + +impl From for url::Url { + fn from(url: Url) -> url::Url { + url.0 + } +} + +#[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 new(Convert(ref url): Convert, base: Option>) -> JsResult { + if let Some(Convert(ref base)) = base { + let base_url = url::Url::parse(base) + .map_err(|e| js_error!(TypeError: "Failed to parse base URL: {}", e))?; + if base_url.cannot_be_a_base() { + return Err(js_error!(TypeError: "Base URL {} cannot be a base", base)); + } + + let url = base_url + .join(url) + .map_err(|e| js_error!(TypeError: "Failed to parse URL: {}", e))?; + Ok(Self(url)) + } else { + let url = url::Url::parse(url) + .map_err(|e| js_error!(TypeError: "Failed to parse URL: {}", e))?; + Ok(Self(url)) + } + } + + #[boa(getter)] + fn hash(&self) -> JsString { + JsString::from(url::quirks::hash(&self.0)) + } + + #[boa(setter)] + #[boa(rename = "hash")] + fn set_hash(&mut self, value: Convert) { + url::quirks::set_hash(&mut self.0, &value.0); + } + + #[boa(getter)] + fn hostname(&self) -> JsString { + JsString::from(url::quirks::hostname(&self.0)) + } + + #[boa(setter)] + #[boa(rename = "hostname")] + fn set_hostname(&mut self, value: Convert) { + let _ = url::quirks::set_hostname(&mut self.0, &value.0); + } + + #[boa(getter)] + fn host(&self) -> JsString { + JsString::from(url::quirks::host(&self.0)) + } + + #[boa(setter)] + #[boa(rename = "host")] + fn set_host(&mut self, value: Convert) { + let _ = url::quirks::set_host(&mut self.0, &value.0); + } + + #[boa(getter)] + fn href(&self) -> JsString { + JsString::from(url::quirks::href(&self.0)) + } + + #[boa(setter)] + #[boa(rename = "href")] + fn set_href(&mut self, value: Convert) -> JsResult<()> { + url::quirks::set_href(&mut self.0, &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)) + } -/// Register `URL` and `URLSearchParams` into the given context. + #[boa(getter)] + fn password(&self) -> JsString { + JsString::from(url::quirks::password(&self.0)) + } + + #[boa(setter)] + #[boa(rename = "password")] + fn set_password(&mut self, value: Convert) { + let _ = url::quirks::set_password(&mut self.0, &value.0); + } + + #[boa(getter)] + fn pathname(&self) -> JsString { + JsString::from(url::quirks::pathname(&self.0)) + } + + #[boa(setter)] + #[boa(rename = "pathname")] + fn set_pathname(&mut self, value: Convert) { + let () = url::quirks::set_pathname(&mut self.0, &value.0); + } + + #[boa(getter)] + fn port(&self) -> JsString { + JsString::from(url::quirks::port(&self.0)) + } + + #[boa(setter)] + #[boa(rename = "port")] + fn set_port(&mut self, value: Convert) { + let _ = url::quirks::set_port(&mut self.0, &value.0.to_std_string_lossy()); + } + + #[boa(getter)] + fn protocol(&self) -> JsString { + JsString::from(url::quirks::protocol(&self.0)) + } + + #[boa(setter)] + #[boa(rename = "protocol")] + fn set_protocol(&mut self, value: Convert) { + let _ = url::quirks::set_protocol(&mut self.0, &value.0); + } + + #[boa(getter)] + fn search(&self) -> JsString { + JsString::from(url::quirks::search(&self.0)) + } + + #[boa(setter)] + #[boa(rename = "search")] + fn set_search(&mut self, value: Convert) { + url::quirks::set_search(&mut self.0, &value.0); + } + + #[boa(getter)] + fn search_params() -> JsResult<()> { + Err(js_error!(Error: "URL.searchParams is not implemented")) + } + + #[boa(getter)] + fn username(&self) -> JsString { + JsString::from(self.0.username()) + } + + #[boa(setter)] + #[boa(rename = "username")] + fn set_username(&mut self, value: Convert) { + let _ = self.0.set_username(&value.0); + } + + fn to_string(&self) -> JsString { + JsString::from(format!("{}", self.0)) + } + + #[boa(rename = "toJSON")] + fn to_json(&self) -> JsString { + JsString::from(format!("{}", self.0)) + } + + #[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 { + Url::new(url, base).is_ok() + } + + #[boa(static)] + fn parse( + url: Convert, + base: Option>, + context: &mut Context, + ) -> JsResult { + Url::new(url, base).map_or(Ok(JsValue::null()), |u| { + Url::from_data(u, 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; +} + +/// Register the `URL` class into the realm. Pass `None` for the realm to +/// register globally. /// -/// # Errors +/// This is the entry point used by [`crate::register`] to install `URL` as part of the full +/// TC55 API surface; it forwards to [`Url::register`]. /// -/// 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(()) +/// # Errors +/// This will error if the context or realm cannot register the class. +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