From 28bf9096a7cfab6a5baa89da4975aaffa492feee Mon Sep 17 00:00:00 2001 From: juno-bot Date: Tue, 2 Jun 2026 05:03:08 +0000 Subject: [PATCH] feat(sputnik): Polyfills updated --- .../src/js/apis/node/llrt/llrt_buffer/blob.rs | 146 +++++++--- .../src/js/apis/node/llrt/llrt_buffer/file.rs | 34 ++- .../js/apis/node/llrt/llrt_url/url_class.rs | 238 +++++++++-------- .../node/llrt/llrt_url/url_search_params.rs | 92 +++++-- .../src/js/apis/node/llrt/llrt_utils/bytes.rs | 249 +++++++++++++++++- .../src/js/apis/node/llrt/llrt_utils/class.rs | 46 +++- .../js/apis/node/llrt/llrt_utils/object.rs | 4 +- .../apis/node/llrt/llrt_utils/primordials.rs | 22 +- 8 files changed, 632 insertions(+), 199 deletions(-) diff --git a/src/sputnik/src/js/apis/node/llrt/llrt_buffer/blob.rs b/src/sputnik/src/js/apis/node/llrt/llrt_buffer/blob.rs index 596c3fcfda..abc15e451c 100644 --- a/src/sputnik/src/js/apis/node/llrt/llrt_buffer/blob.rs +++ b/src/sputnik/src/js/apis/node/llrt/llrt_buffer/blob.rs @@ -2,8 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 use std::ops::RangeInclusive; +use llrt_stream_web::{ + readable_byte_stream_controller_close_stream, + readable_byte_stream_controller_enqueue_bytes_borrowed, utils::promise::PromisePrimordials, + CancelAlgorithm, PullAlgorithm, ReadableStream, ReadableStreamControllerClass, +}; use crate::js::apis::node::llrt::llrt_utils::{ - bytes::ObjectBytes, + array_buffer::shared_array_buffer_view, + bytes::{get_lossy_string, ObjectBytes}, primordials::{BasePrimordials, Primordial}, result::ResultExt, }; @@ -29,9 +35,10 @@ const LINE_ENDING: &[u8] = b"\n"; #[rquickjs::class] #[derive(Trace, Clone, rquickjs::JsLifetime)] -pub struct Blob { - #[qjs(skip_trace)] - data: Vec, +pub struct Blob<'js> { + /// Bytes live in a JS-owned `ArrayBuffer` so `.arrayBuffer()` / `.bytes()` + /// / `.stream()` can hand out refcount-bumped views without copying. + data: ArrayBuffer<'js>, mime_type: String, } @@ -49,13 +56,9 @@ fn normalize_type(mut mime_type: String) -> String { } #[rquickjs::methods] -impl Blob { +impl<'js> Blob<'js> { #[qjs(constructor)] - pub fn new<'js>( - ctx: Ctx<'js>, - parts: Opt>, - options: Opt>, - ) -> Result { + pub fn new(ctx: Ctx<'js>, parts: Opt>, options: Opt>) -> Result { let mut endings = EndingType::Transparent; let mut mime_type = String::new(); @@ -79,11 +82,14 @@ impl Blob { } } - let data = if let Some(parts) = parts.0 { + let bytes = if let Some(parts) = parts.0 { bytes_from_parts(&ctx, parts, endings)? } else { Vec::new() }; + // Transfer Vec ownership to JS — QuickJS calls the drop callback when + // the ArrayBuffer is GC'd, so no extra Rust-side copy. + let data = ArrayBuffer::new(ctx, bytes)?; Ok(Self { data, mime_type }) } @@ -99,62 +105,117 @@ impl Blob { } pub async fn text(&self) -> String { - String::from_utf8_lossy(&self.data).to_string() + String::from_utf8_lossy(self.as_bytes()).to_string() } #[qjs(rename = "arrayBuffer")] - pub async fn array_buffer<'js>(&self, ctx: Ctx<'js>) -> Result> { - ArrayBuffer::new(ctx, self.data.to_vec()) + pub async fn array_buffer(&self, ctx: Ctx<'js>) -> Result> { + //should be mutable according to spec, thus copy is required + ArrayBuffer::new_copy(ctx, self.as_bytes()) } - pub async fn bytes<'js>(&self, ctx: Ctx<'js>) -> Result> { - TypedArray::new(ctx, self.data.to_vec()).map(|m| m.into_value()) + pub async fn bytes(&self, ctx: Ctx<'js>) -> Result> { + //should be mutable according to spec, thus copy is required + let ab = ArrayBuffer::new_copy(ctx, self.as_bytes())?; + TypedArray::::from_arraybuffer(ab).map(|t| t.into_value()) } - pub fn slice(&self, start: Opt, end: Opt, content_type: Opt) -> Blob { + pub fn slice( + &self, + ctx: Ctx<'js>, + start: Opt, + end: Opt, + content_type: Opt, + ) -> Result> { + let bytes = self.as_bytes(); + let len = bytes.len(); let start = start.0.unwrap_or_default(); let start = if start < 0 { - (self.data.len() as isize + start).max(0) as usize + (len as isize + start).max(0) as usize } else { - self.data.len().min(start as usize) + len.min(start as usize) }; let end = end.0.unwrap_or_default(); let end = if end < 0 { - (self.data.len() as isize + end).max(0) as usize + (len as isize + end).max(0) as usize } else { - self.data.len().min(end as usize) + len.min(end as usize) }; - let data = &self.data[start..end]; + let data = shared_array_buffer_view(&ctx, &self.data, start, end.saturating_sub(start))?; let mime_type = content_type.0.map(normalize_type).unwrap_or_default(); + Ok(Blob { mime_type, data }) + } - Blob { - mime_type, - data: data.to_vec(), - } + pub fn stream(&self, ctx: Ctx<'js>) -> Result> { + let data = self.data.clone(); + let pull = PullAlgorithm::from_fn_once( + move |ctx: Ctx<'js>, controller: ReadableStreamControllerClass<'js>| { + let ctrl = match controller { + ReadableStreamControllerClass::ReadableStreamByteController(c) => c, + _ => return Err(Exception::throw_type(&ctx, "Expected byte controller")), + }; + let len = data.len(); + if len != 0 { + let view = shared_array_buffer_view(&ctx, &data, 0, len)?; + readable_byte_stream_controller_enqueue_bytes_borrowed( + ctx.clone(), + ctrl.clone(), + view, + )?; + } + readable_byte_stream_controller_close_stream(ctx.clone(), ctrl)?; + Ok(PromisePrimordials::get(&ctx)? + .promise_resolved_with_undefined + .clone()) + }, + ); + // Byte-source stream so callers can use `getReader({ mode: 'byob' })`. + // Matches spec: Blob.stream() returns a `type: "bytes"` ReadableStream. + let stream = ReadableStream::from_byte_pull_algorithm( + ctx, + pull, + CancelAlgorithm::ReturnPromiseUndefined, + )?; + Ok(stream.into_value()) } - #[qjs(get, rename = PredefinedAtom::SymbolToStringTag)] - pub fn to_string_tag(&self) -> &'static str { + #[qjs(prop, rename = PredefinedAtom::SymbolToStringTag, configurable)] + pub fn to_string_tag() -> &'static str { stringify!(Blob) } + + #[qjs(static, rename = PredefinedAtom::SymbolHasInstance)] + pub fn has_instance(value: Value<'js>) -> bool { + if let Some(obj) = value.as_object() { + return obj.instance_of::() || obj.instance_of::(); + } + false + } } -impl Blob { - pub fn from_bytes(data: Vec, content_type: Option) -> Self { +impl<'js> Blob<'js> { + pub fn from_bytes(ctx: &Ctx<'js>, data: Vec, content_type: Option) -> Result { let mime_type = content_type.map(normalize_type).unwrap_or_default(); - Self { mime_type, data } + let data = ArrayBuffer::new(ctx.clone(), data)?; + Ok(Self { mime_type, data }) } pub fn get_bytes(&self) -> Vec { + self.as_bytes().to_vec() + } + + /// Zero-copy access to the underlying `ArrayBuffer`. Cloning the handle is + /// cheap (it's a JS-refcount bump); no bytes are copied. Useful for + /// consumers that want to pass the Blob body on to hyper via + /// `ObjectBytes::DataView` without the `get_bytes()` allocation. + pub fn array_buffer_ref(&self) -> ArrayBuffer<'js> { self.data.clone() } - //FIXME: cant use procedural macro for Symbol rename + static, see https://github.com/DelSkayn/rquickjs/issues/315 - pub fn has_instance(value: Value<'_>) -> bool { - if let Some(obj) = value.as_object() { - return obj.instance_of::() || obj.instance_of::(); - } - false + /// Borrow the underlying bytes directly. Returns `&[]` if the ArrayBuffer + /// has been detached (shouldn't happen in normal blob flow). + pub fn as_bytes(&self) -> &[u8] { + self.data.as_bytes().unwrap_or(&[]) } } @@ -187,14 +248,15 @@ fn bytes_from_parts<'js>( } if let Some(object) = elem.as_object() { if let Some(x) = Class::::from_object(object) { - data.extend_from_slice(&x.borrow().data); + data.extend_from_slice(x.borrow().as_bytes()); continue; } if let Some(x) = Class::::from_object(object) { let file = x.borrow(); let end = Some(file.size().try_into().or_throw(ctx)?); let mime_type = Some(file.mime_type()); - data.extend_from_slice(&file.slice(Opt(Some(0)), Opt(end), Opt(mime_type)).data); + let sub = file.slice(ctx.clone(), Opt(Some(0)), Opt(end), Opt(mime_type))?; + data.extend_from_slice(sub.as_bytes()); continue; } if let Ok(x) = ObjectBytes::from(ctx, object) { @@ -211,7 +273,11 @@ fn bytes_from_parts<'js>( } } - let string = Coerced::::from_js(ctx, elem)?.0; + let string = if elem.is_string() { + get_lossy_string(elem)? + } else { + Coerced::::from_js(ctx, elem)?.0 + }; if let EndingType::Transparent = endings { data.extend_from_slice(string.as_bytes()); } else { diff --git a/src/sputnik/src/js/apis/node/llrt/llrt_buffer/file.rs b/src/sputnik/src/js/apis/node/llrt/llrt_buffer/file.rs index 045996da6f..10bc54aa3b 100644 --- a/src/sputnik/src/js/apis/node/llrt/llrt_buffer/file.rs +++ b/src/sputnik/src/js/apis/node/llrt/llrt_buffer/file.rs @@ -10,17 +10,16 @@ use super::blob::Blob; #[rquickjs::class] #[derive(Trace, Clone, rquickjs::JsLifetime)] -pub struct File { - #[qjs(skip_trace)] - blob: Blob, +pub struct File<'js> { + blob: Blob<'js>, filename: String, last_modified: i64, } #[rquickjs::methods] -impl File { +impl<'js> File<'js> { #[qjs(constructor)] - fn new<'js>( + fn new( ctx: Ctx<'js>, data: Value<'js>, filename: Coerced, @@ -69,8 +68,14 @@ impl File { self.last_modified } - pub fn slice(&self, start: Opt, end: Opt, content_type: Opt) -> Blob { - self.blob.slice(start, end, content_type) + pub fn slice( + &self, + ctx: Ctx<'js>, + start: Opt, + end: Opt, + content_type: Opt, + ) -> Result> { + self.blob.slice(ctx, start, end, content_type) } pub async fn text(&mut self) -> String { @@ -78,21 +83,22 @@ impl File { } #[qjs(rename = "arrayBuffer")] - pub async fn array_buffer<'js>(&self, ctx: Ctx<'js>) -> Result> { + pub async fn array_buffer(&self, ctx: Ctx<'js>) -> Result> { self.blob.array_buffer(ctx).await } - pub async fn bytes<'js>(&self, ctx: Ctx<'js>) -> Result> { + pub async fn bytes(&self, ctx: Ctx<'js>) -> Result> { self.blob.bytes(ctx).await } - #[qjs(get, rename = PredefinedAtom::SymbolToStringTag)] - pub fn to_string_tag(&self) -> &'static str { + #[qjs(prop, rename = PredefinedAtom::SymbolToStringTag, configurable)] + pub fn to_string_tag() -> &'static str { stringify!(File) } } -impl File { - pub fn from_bytes<'js>( + +impl<'js> File<'js> { + pub fn from_bytes( ctx: &Ctx<'js>, data: Vec, filename: String, @@ -118,7 +124,7 @@ impl File { }) } - pub fn get_blob(&self) -> Blob { + pub fn get_blob(&self) -> Blob<'js> { self.blob.clone() } } \ No newline at end of file diff --git a/src/sputnik/src/js/apis/node/llrt/llrt_url/url_class.rs b/src/sputnik/src/js/apis/node/llrt/llrt_url/url_class.rs index ccc61af970..5c8ad71a29 100644 --- a/src/sputnik/src/js/apis/node/llrt/llrt_url/url_class.rs +++ b/src/sputnik/src/js/apis/node/llrt/llrt_url/url_class.rs @@ -2,6 +2,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 #![allow(clippy::uninlined_format_args)] + use std::{cell::RefCell, rc::Rc}; use rquickjs::{ @@ -10,46 +11,14 @@ use rquickjs::{ }; use url::{quirks, Url}; -use super::{convert_trailing_space, url_search_params::URLSearchParams}; - -/// Naively checks for hostname delimiter, a colon ":", that's *probably* not -/// part of an IPv6 address -/// -/// # Arguments -/// -/// * `hostname` - The hostname. -/// -/// # Returns -/// -/// Returns whether the hostname contains a colon that's not followed by a -/// closing square bracket. -fn has_colon_delimiter(hostname: &str) -> bool { - if let Some(last_colon_index) = hostname.rfind(':') { - // Check if there's any closing bracket after the last colon - !hostname[last_colon_index..].contains(']') - } else { - false - } -} +use super::url_search_params::URLSearchParams; /// Represents a JavaScript /// [`URL`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) as defined -/// by the [WHATWG URL standard](https://url.spec.whatwg.org/) in the JavaScript -/// context. -/// -/// # Examples -/// -/// ```rust,ignore -/// // This is JavaScript -/// const url = new URL("https://url.spec.whatwg.org/"); -/// console.log(url.href); -/// ``` +/// by the [WHATWG URL standard](https://url.spec.whatwg.org/). #[derive(Clone, Trace, rquickjs::JsLifetime)] #[rquickjs::class] pub struct URL<'js> { - // URL and URLSearchParams work together to manipulate URLs, so using a - // reference counter (Rc) allows them to have shared ownership of the - // undering Url, and a RefCell allows interior mutability. #[qjs(skip_trace)] url: Rc>, search_params: Class<'js, URLSearchParams>, @@ -59,25 +28,38 @@ pub struct URL<'js> { impl<'js> URL<'js> { #[qjs(constructor)] pub fn new(ctx: Ctx<'js>, input: Value<'js>, base: Opt>) -> Result { - let input: Result> = Coerced::from_js(&ctx, input); + // USVString conversion per WHATWG URL spec: lone UTF-16 surrogates + // must be replaced with U+FFFD (not rejected) before the basic URL + // parser runs (WPT `url-origin.any.js` passes URLs containing lone + // surrogates and expects them to parse). + let input: Result = if input.is_string() { + llrt_utils::bytes::get_lossy_string(input.clone()) + } else { + Coerced::::from_js(&ctx, input.clone()).map(|c| c.0) + }; if let Some(base) = base.into_inner() { if let Some(base) = base.as_string() { if let Ok(base) = base.to_string() { - let mut url: Url = base.parse().map_err(|err| { - Exception::throw_type(&ctx, format!("Invalid base URL: {}", err).as_str()) - })?; - + let base_url: Url = base + .parse() + .map_err(|_| Exception::throw_type(&ctx, "Invalid base URL"))?; + // Work around a url-crate normalization that loses the + // host when a file:// URL's path starts with a Windows + // drive letter (WPT url-constructor.any.js file-URL- + // with-host base cases). Extract the host manually + // from the original source string and preserve it. + let base_url = super::preserve_file_url_host(&base, base_url); if let Ok(input) = input { - url = url.join(input.as_str()).map_err(|err| { - Exception::throw_type(&ctx, format!("Invalid URL: {}", err).as_str()) - })?; + let mut joined = base_url + .join(input.as_str()) + .map_err(|_| Exception::throw_type(&ctx, "Invalid URL"))?; + super::restore_file_url_host(&base_url, &mut joined); + return Self::from_url(ctx, joined); } - - return Self::from_url(ctx, url); + return Self::from_str(ctx, &base); } } } - if let Ok(input) = input { Self::from_str(ctx, input.as_str()) } else { @@ -85,10 +67,6 @@ impl<'js> URL<'js> { } } - // - // Properties - // - #[qjs(get)] pub fn hash(&self) -> String { quirks::hash(&self.url.borrow()).to_string() @@ -96,9 +74,8 @@ impl<'js> URL<'js> { #[qjs(set, rename = "hash")] pub fn set_hash(&mut self, hash: String) -> String { - convert_trailing_space(&mut self.url.borrow_mut()); - - quirks::set_hash(&mut self.url.borrow_mut(), hash.as_str()); + self.before_mutation(); + quirks::set_hash(&mut self.url.borrow_mut(), &hash); hash } @@ -109,9 +86,8 @@ impl<'js> URL<'js> { #[qjs(set, rename = "host")] pub fn set_host(&mut self, host: Coerced) -> String { - convert_trailing_space(&mut self.url.borrow_mut()); - - let _ = quirks::set_host(&mut self.url.borrow_mut(), host.as_str()); + self.before_mutation(); + let _ = quirks::set_host(&mut self.url.borrow_mut(), &host); host.0 } @@ -122,12 +98,9 @@ impl<'js> URL<'js> { #[qjs(set, rename = "hostname")] pub fn set_hostname(&mut self, hostname: Coerced) -> String { - convert_trailing_space(&mut self.url.borrow_mut()); - - // TODO: This should be fixed in Url - if !has_colon_delimiter(hostname.as_str()) { - let _ = quirks::set_hostname(&mut self.url.borrow_mut(), hostname.as_str()); - } + self.before_mutation(); + let _ = quirks::set_hostname(&mut self.url.borrow_mut(), hostname.as_str()); + super::strip_path_sentinel(&mut self.url.borrow_mut()); hostname.0 } @@ -138,15 +111,27 @@ impl<'js> URL<'js> { #[qjs(set, rename = "href")] pub fn set_href(&mut self, href: String) -> String { - convert_trailing_space(&mut self.url.borrow_mut()); - - let _ = quirks::set_href(&mut self.url.borrow_mut(), href.as_str()); + self.before_mutation(); + let _ = quirks::set_href(&mut self.url.borrow_mut(), &href); href } #[qjs(get)] pub fn origin(&self) -> String { - quirks::origin(&self.url.borrow()).to_string() + let url = self.url.borrow(); + // Per WHATWG URL spec §6.2, origin of a blob URL is computed by parsing + // the path as a URL. If the result's scheme is HTTP(S), return that + // URL's origin; otherwise, return an opaque (null) origin. The `url` + // crate returns the nested URL's origin even for non-HTTP schemes, + // breaking WPT `url-origin.any.js` on cases like `blob:ftp://...` and + // `blob:blob:https://...`. + if url.scheme() == "blob" { + return match url::Url::parse(url.path()) { + Ok(inner) if matches!(inner.scheme(), "http" | "https") => quirks::origin(&inner), + _ => "null".into(), + }; + } + quirks::origin(&url) } #[qjs(get)] @@ -156,9 +141,8 @@ impl<'js> URL<'js> { #[qjs(set, rename = "password")] pub fn set_password(&mut self, password: Coerced) -> String { - convert_trailing_space(&mut self.url.borrow_mut()); - - let _ = quirks::set_password(&mut self.url.borrow_mut(), password.as_str()); + self.before_mutation(); + let _ = quirks::set_password(&mut self.url.borrow_mut(), &password); password.0 } @@ -169,9 +153,16 @@ impl<'js> URL<'js> { #[qjs(set, rename = "pathname")] pub fn set_pathname(&mut self, pathname: Coerced) -> String { - convert_trailing_space(&mut self.url.borrow_mut()); - + self.before_mutation(); quirks::set_pathname(&mut self.url.borrow_mut(), pathname.as_str()); + // Per WHATWG URL spec, a non-special URL with an empty host can have + // its path erased (WPT `url-setters.any.js` "Non-special URLs with + // an empty host can have their paths erased"). The `url` crate + // forces a single `/` after the authority; strip it when the caller + // set an empty pathname on such a URL. + if pathname.0.is_empty() { + super::erase_empty_host_path(&mut self.url.borrow_mut()); + } pathname.0 } @@ -182,19 +173,31 @@ impl<'js> URL<'js> { #[qjs(set, rename = "port")] pub fn set_port(&mut self, ctx: Ctx<'js>, port: Value<'js>) -> Value<'js> { - convert_trailing_space(&mut self.url.borrow_mut()); - - // TODO: negative ports should be handled in Url if port.is_null() || port.is_undefined() || (port.is_int() && unsafe { port.as_int().unwrap_unchecked() } < 0) { return port; } - - let port_string: Result> = Coerced::from_js(&ctx, port.clone()); - if let Ok(port_string) = port_string { - let _ = quirks::set_port(&mut self.url.borrow_mut(), port_string.as_str()); + if let Ok(port_string) = Coerced::::from_js(&ctx, port.clone()) { + self.before_mutation(); + // Per WHATWG URL spec, the port-state parser strips tab/LF/CR + // before reading. An empty STRIPPED value (but non-empty original) + // makes port parsing fail, which per spec means no-op (keep + // existing port). An empty ORIGINAL value, however, clears the + // port. + if port_string.is_empty() { + let _ = quirks::set_port(&mut self.url.borrow_mut(), ""); + } else { + let stripped: String = port_string + .chars() + .filter(|c| !matches!(c, '\t' | '\n' | '\r')) + .collect(); + if !stripped.is_empty() { + let _ = quirks::set_port(&mut self.url.borrow_mut(), &stripped); + } + // stripped is empty → parse failure per spec → no-op + } } port } @@ -206,9 +209,8 @@ impl<'js> URL<'js> { #[qjs(set, rename = "protocol")] pub fn set_protocol(&mut self, protocol: Coerced) -> String { - convert_trailing_space(&mut self.url.borrow_mut()); - - let _ = quirks::set_protocol(&mut self.url.borrow_mut(), protocol.as_str()); + self.before_mutation(); + let _ = quirks::set_protocol(&mut self.url.borrow_mut(), &protocol); protocol.0 } @@ -219,9 +221,8 @@ impl<'js> URL<'js> { #[qjs(set, rename = "search")] pub fn set_search(&mut self, search: Coerced) -> String { - convert_trailing_space(&mut self.url.borrow_mut()); - - quirks::set_search(&mut self.url.borrow_mut(), search.as_str()); + self.before_mutation(); + quirks::set_search(&mut self.url.borrow_mut(), &search); search.0 } @@ -230,8 +231,8 @@ impl<'js> URL<'js> { self.search_params.as_value() } - #[qjs(get, rename = PredefinedAtom::SymbolToStringTag)] - pub fn to_string_tag(&self) -> &'static str { + #[qjs(prop, rename = PredefinedAtom::SymbolToStringTag, configurable)] + pub fn to_string_tag() -> &'static str { stringify!(URL) } @@ -242,16 +243,11 @@ impl<'js> URL<'js> { #[qjs(set, rename = "username")] pub fn set_username(&mut self, username: Coerced) -> String { - convert_trailing_space(&mut self.url.borrow_mut()); - - let _ = quirks::set_username(&mut self.url.borrow_mut(), username.as_str()); + self.before_mutation(); + let _ = quirks::set_username(&mut self.url.borrow_mut(), &username); username.0 } - // - // Static methods - // - #[qjs(static)] pub fn can_parse(ctx: Ctx<'js>, input: Value<'js>, base: Opt>) -> bool { Self::new(ctx, input, base).is_ok() @@ -263,48 +259,66 @@ impl<'js> URL<'js> { .map_or_else(|_| Null.into_js(&ctx), |instance| instance.into_js(&ctx)) } - // - // Instance methods - // - #[qjs(rename = PredefinedAtom::ToJSON)] pub fn to_json(&self) -> String { - // https://developer.mozilla.org/en-US/docs/Web/API/URL/toJSON self.to_string() } pub fn to_string(&self) -> String { - self.url.borrow().to_string() + self.href() } } impl<'js> URL<'js> { pub fn from_str(ctx: Ctx<'js>, input: &str) -> Result { - let url: Url = input + let mut url: Url = input .parse() .map_err(|_| Exception::throw_type(&ctx, "Invalid URL"))?; - Self::from_url(ctx, url) + super::normalize_windows_drive_letter(&mut url); + super::convert_trailing_space(&mut url); + Self::build(ctx, url) + } + + pub fn from_url(ctx: Ctx<'js>, mut url: Url) -> Result { + super::normalize_windows_drive_letter(&mut url); + super::convert_trailing_space(&mut url); + Self::build(ctx, url) } - pub fn from_url(ctx: Ctx<'js>, url: Url) -> Result { - let url = Rc::new(RefCell::new(url)); - let search_params = URLSearchParams::from_url(&url); - let search_params = Class::instance(ctx, search_params)?; + /// Validate that a string parses as a URL without constructing a JS + /// instance. Used by callers (e.g. `llrt_fetch`) that just need to know + /// whether a user-supplied string is a valid URL. + pub fn is_valid(input: &str) -> bool { + input.parse::().is_ok() + } - Ok(Self { url, search_params }) + fn build(ctx: Ctx<'js>, url: Url) -> Result { + let shared = Rc::new(RefCell::new(url)); + let search_params = Class::instance(ctx, URLSearchParams::from_url(&shared))?; + Ok(Self { + url: shared, + search_params, + }) + } + + fn before_mutation(&mut self) { + super::convert_trailing_space(&mut self.url.borrow_mut()); + } + + pub(crate) fn inner_url(&self) -> std::cell::Ref<'_, Url> { + self.url.borrow() } } #[allow(dead_code)] pub fn url_to_http_options<'js>(ctx: Ctx<'js>, url: Class<'js, URL<'js>>) -> Result> { let obj = Object::new(ctx)?; - let url = url.borrow(); let port = url.port(); let username = url.username(); let search = url.search(); - let hash = url.url.borrow().fragment().unwrap_or("").to_string(); + let hash = url.inner_url().fragment().unwrap_or("").to_string(); obj.set("protocol", url.protocol())?; obj.set("hostname", url.hostname())?; @@ -312,16 +326,18 @@ pub fn url_to_http_options<'js>(ctx: Ctx<'js>, url: Class<'js, URL<'js>>) -> Res if !hash.is_empty() { obj.set("hash", hash)?; } + + let pathname = url.pathname(); + let path = [pathname.as_str(), search.as_str()].concat(); if !search.is_empty() { obj.set("search", search)?; } - - obj.set("pathname", url.pathname())?; - obj.set("path", [url.pathname(), url.search()].join(""))?; + obj.set("pathname", pathname)?; + obj.set("path", path)?; obj.set("href", url.href())?; if !username.is_empty() { - obj.set("auth", [username, ":".to_string(), url.password()].join(""))?; + obj.set("auth", [username, url.password()].join(":"))?; } if !port.is_empty() { diff --git a/src/sputnik/src/js/apis/node/llrt/llrt_url/url_search_params.rs b/src/sputnik/src/js/apis/node/llrt/llrt_url/url_search_params.rs index ae31c0dd62..261b0af572 100644 --- a/src/sputnik/src/js/apis/node/llrt/llrt_url/url_search_params.rs +++ b/src/sputnik/src/js/apis/node/llrt/llrt_url/url_search_params.rs @@ -1,9 +1,14 @@ #![allow(clippy::inherent_to_string)] // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -use std::{cell::RefCell, collections::HashSet, rc::Rc}; +use std::{ + cell::RefCell, + collections::{HashMap, HashSet}, + rc::Rc, +}; use crate::js::apis::node::llrt::llrt_utils::{ + bytes::get_lossy_string, class::IteratorDef, primordials::{BasePrimordials, Primordial}, }; @@ -53,7 +58,7 @@ impl<'js> URLSearchParams { pub fn new(ctx: Ctx<'js>, init: Opt>) -> Result { if let Some(init) = init.into_inner() { if init.is_string() { - let string: String = Coerced::from_js(&ctx, init)?.0; + let string = get_lossy_string(init)?; return Ok(Self::from_str(string)); } else if init.is_array() { return Self::from_array(&ctx, unsafe { init.into_array().unwrap_unchecked() }); @@ -77,8 +82,8 @@ impl<'js> URLSearchParams { self.url.borrow().query_pairs().count() } - #[qjs(get, rename = PredefinedAtom::SymbolToStringTag)] - pub fn to_string_tag(&self) -> &'static str { + #[qjs(prop, rename = PredefinedAtom::SymbolToStringTag, configurable)] + pub fn to_string_tag() -> &'static str { stringify!(URLSearchParams) } @@ -93,6 +98,7 @@ impl<'js> URLSearchParams { .borrow_mut() .query_pairs_mut() .append_pair(key.as_str(), value.as_str()); + self.sync_query(); } pub fn delete(&mut self, ctx: Ctx<'js>, key: Coerced, value: Opt>) { @@ -124,6 +130,7 @@ impl<'js> URLSearchParams { } else { self.url.borrow_mut().set_query(None); } + self.sync_query(); } pub fn entries(&self, ctx: Ctx<'js>) -> Result> { @@ -214,12 +221,18 @@ impl<'js> URLSearchParams { .query_pairs_mut() .clear() .extend_pairs(new_query_pairs); + self.sync_query(); } pub fn sort(&mut self) { let mut new_pairs: Vec<(String, String)> = self.url.borrow().query_pairs().into_owned().collect(); - new_pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + new_pairs.sort_by(|(a, _), (b, _)| { + // Spec requires sorting by UTF-16 code units + let a_utf16 = a.encode_utf16(); + let b_utf16 = b.encode_utf16(); + a_utf16.cmp(b_utf16) + }); if new_pairs.is_empty() { self.url.borrow_mut().set_query(None); @@ -230,6 +243,7 @@ impl<'js> URLSearchParams { .clear() .extend_pairs(new_pairs); } + self.sync_query(); } pub fn to_string(&self) -> String { @@ -274,6 +288,19 @@ impl<'js> URLSearchParams { } impl<'js> URLSearchParams { + /// Re-serialize the query string with proper percent-encoding. + /// The url crate doesn't encode commas, so we rebuild the query + /// using form_urlencoded::byte_serialize after each mutation. + fn sync_query(&self) { + let query = self.to_string(); + let mut url = self.url.borrow_mut(); + if query.is_empty() { + url.set_query(None); + } else { + url.set_query(Some(&query)); + } + } + #[allow(clippy::should_implement_trait)] pub fn from_str(query: String) -> Self { let query = if !query.starts_with('?') { @@ -299,7 +326,7 @@ impl<'js> URLSearchParams { } } - pub fn from_array(ctx: &Ctx<'js>, array: Array) -> Result { + pub fn from_array(ctx: &Ctx<'js>, array: Array<'js>) -> Result { let mut url: Url = "http://example.com".parse().unwrap(); let query_pairs: Vec<(String, String)> = array .into_iter() @@ -307,11 +334,19 @@ impl<'js> URLSearchParams { if let Ok(value) = value { if let Some(pair) = value.as_array() { if pair.len() == 2 { - if let Ok(key) = pair.get::>(0) { - if let Ok(value) = pair.get::>(1) { - return Ok((key.to_string(), value.to_string())); - } - } + let key_val: Value = pair.get(0)?; + let val_val: Value = pair.get(1)?; + let key = if key_val.is_string() { + get_lossy_string(key_val)? + } else { + Coerced::::from_js(ctx, key_val)?.0 + }; + let value = if val_val.is_string() { + get_lossy_string(val_val)? + } else { + Coerced::::from_js(ctx, val_val)?.0 + }; + return Ok((key, value)); } } }; @@ -341,16 +376,43 @@ impl<'js> URLSearchParams { } let mut url: Url = "http://example.com".parse().unwrap(); - let query_pairs: Vec<(String, String)> = object + let raw_pairs: Vec<(String, String)> = object .keys::>() .map(|key| { let key = key?; - let key_string: String = Coerced::from_js(ctx, key.clone())?.0; - let value: String = object.get::<_, Coerced>(key)?.0; + let key_string = if key.is_string() { + get_lossy_string(key.clone())? + } else { + Coerced::::from_js(ctx, key.clone())?.0 + }; + let value_val: Value = object.get(key)?; + let value = if value_val.is_string() { + get_lossy_string(value_val)? + } else { + Coerced::::from_js(ctx, value_val)?.0 + }; Ok((key_string, value)) }) - .collect::>>()? + .collect::>>()?; + + // WebIDL record conversion: when multiple input keys normalise to the + // same string (e.g. two different lone surrogates both map to U+FFFD), + // the *last* value wins. Preserve original iteration order for keys + // that were only seen once. + let mut order: Vec = Vec::with_capacity(raw_pairs.len()); + let mut map: HashMap = HashMap::with_capacity(raw_pairs.len()); + for (k, v) in raw_pairs { + if !map.contains_key(&k) { + order.push(k.clone()); + } + map.insert(k, v); + } + let query_pairs: Vec<(String, String)> = order .into_iter() + .map(|k| { + let v = map.remove(&k).unwrap_or_default(); + (k, v) + }) .collect(); url.query_pairs_mut().extend_pairs(query_pairs); diff --git a/src/sputnik/src/js/apis/node/llrt/llrt_utils/bytes.rs b/src/sputnik/src/js/apis/node/llrt/llrt_utils/bytes.rs index 718f63e18e..1fccf1bbdc 100644 --- a/src/sputnik/src/js/apis/node/llrt/llrt_utils/bytes.rs +++ b/src/sputnik/src/js/apis/node/llrt/llrt_utils/bytes.rs @@ -2,16 +2,222 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -use std::rc::Rc; +use std::{rc::Rc, slice}; use rquickjs::{ atom::PredefinedAtom, class::{Trace, Tracer}, function::Constructor, - ArrayBuffer, Coerced, Ctx, Exception, FromJs, IntoJs, JsLifetime, Object, Result, TypedArray, - Value, + ArrayBuffer, Coerced, Ctx, Error, Exception, FromJs, IntoJs, JsLifetime, Object, Result, + TypedArray, Value, }; +/// Convert a JS string to a `String`, replacing lone UTF-16 surrogates +/// with U+FFFD per WHATWG USVString. Use when ill-formed strings must +/// not fail. +// +// SAFETY (module-wide): QuickJS only emits valid WTF-8, so any run +// without 0xED is valid strict UTF-8. +pub fn get_lossy_string(string_value: Value) -> Result { + let js_str = string_value.into_string().ok_or_else(|| Error::FromJs { + from: "Value", + to: "JSString", + message: Some("Value is not a string".into()), + })?; + let cstr = js_str.to_cstring()?; + let bytes = unsafe { slice::from_raw_parts(cstr.as_ptr() as *const u8, cstr.len()) }; + + let first = match memchr::memchr(0xED, bytes) { + None => return Ok(unsafe { String::from_utf8_unchecked(bytes.to_vec()) }), + Some(idx) => idx, + }; + let mut result = String::with_capacity(bytes.len()); + result.push_str(unsafe { std::str::from_utf8_unchecked(&bytes[..first]) }); + qjs_substitute_into(&bytes[first..], &mut result); + Ok(result) +} + +fn qjs_substitute_into(bytes: &[u8], result: &mut String) { + let mut start = 0; + while start < bytes.len() { + let next_ed = match memchr::memchr(0xED, &bytes[start..]) { + None => { + result.push_str(unsafe { std::str::from_utf8_unchecked(&bytes[start..]) }); + return; + }, + Some(rel) => start + rel, + }; + if next_ed > start { + result.push_str(unsafe { std::str::from_utf8_unchecked(&bytes[start..next_ed]) }); + } + if next_ed + 3 > bytes.len() { + replace_invalid_utf8_and_utf16_into(&bytes[next_ed..], result); + return; + } + let b1 = bytes[next_ed + 1]; + let b2 = bytes[next_ed + 2]; + if (b1 & 0xC0) != 0x80 || (b2 & 0xC0) != 0x80 { + replace_invalid_utf8_and_utf16_into(&bytes[next_ed..], result); + return; + } + if (b1 & 0xE0) == 0xA0 { + result.push('\u{FFFD}'); + } else { + result.push_str(unsafe { std::str::from_utf8_unchecked(&bytes[next_ed..next_ed + 3]) }); + } + start = next_ed + 3; + } +} + +#[doc(hidden)] +pub fn replace_invalid_utf8_and_utf16(bytes: &[u8]) -> String { + let err = match simdutf8::compat::from_utf8(bytes) { + Ok(s) => return s.to_owned(), + Err(e) => e, + }; + let valid_up_to = err.valid_up_to(); + let mut result = String::with_capacity(bytes.len()); + result.push_str(unsafe { std::str::from_utf8_unchecked(&bytes[..valid_up_to]) }); + replace_invalid_utf8_and_utf16_into(&bytes[valid_up_to..], &mut result); + result +} + +fn replace_invalid_utf8_and_utf16_into(bytes: &[u8], result: &mut String) { + let mut i = 0; + + while i < bytes.len() { + let current = bytes[i]; + match current { + 0x00..=0x7F => { + result.push(current as char); + i += 1; + }, + 0xC0..=0xDF if i + 1 < bytes.len() => { + let next = bytes[i + 1]; + if (next & 0xC0) == 0x80 { + let code_point = ((current as u32 & 0x1F) << 6) | (next as u32 & 0x3F); + result.push(char::from_u32(code_point).unwrap_or('\u{FFFD}')); + i += 2; + } else { + result.push('\u{FFFD}'); + i += 1; + } + }, + 0xE0..=0xEF if i + 2 < bytes.len() => { + let next1 = bytes[i + 1]; + let next2 = bytes[i + 2]; + if (next1 & 0xC0) == 0x80 && (next2 & 0xC0) == 0x80 { + let code_point = ((current as u32 & 0x0F) << 12) + | ((next1 as u32 & 0x3F) << 6) + | (next2 as u32 & 0x3F); + result.push(char::from_u32(code_point).unwrap_or('\u{FFFD}')); + i += 3; + } else { + result.push('\u{FFFD}'); + i += 1; + } + }, + 0xF0..=0xF7 if i + 3 < bytes.len() => { + let next1 = bytes[i + 1]; + let next2 = bytes[i + 2]; + let next3 = bytes[i + 3]; + if (next1 & 0xC0) == 0x80 && (next2 & 0xC0) == 0x80 && (next3 & 0xC0) == 0x80 { + let code_point = ((current as u32 & 0x07) << 18) + | ((next1 as u32 & 0x3F) << 12) + | ((next2 as u32 & 0x3F) << 6) + | (next3 as u32 & 0x3F); + result.push(char::from_u32(code_point).unwrap_or('\u{FFFD}')); + i += 4; + } else { + result.push('\u{FFFD}'); + i += 1; + } + }, + _ => { + result.push('\u{FFFD}'); + i += 1; + }, + } + } +} + +#[cfg(test)] +mod replace_invalid_utf8_tests { + use super::replace_invalid_utf8_and_utf16; + + fn cases() -> Vec<(&'static str, Vec, &'static str)> { + vec![ + ("empty", vec![], ""), + ("ascii", b"hello world".to_vec(), "hello world"), + ( + "ascii_with_control", + vec![b'a', 0x00, b'b', 0x7f, b'c'], + "a\u{0}b\u{7f}c", + ), + ("two_byte_latin1", vec![0xC3, 0xA9], "\u{00E9}"), + ("three_byte_cjk", vec![0xE4, 0xB8, 0x96], "\u{4e16}"), + ("four_byte_emoji", vec![0xF0, 0x9F, 0xA6, 0x80], "\u{1f980}"), + ("lone_high_surrogate", vec![0xED, 0xA0, 0xBD], "\u{FFFD}"), + ("lone_low_surrogate", vec![0xED, 0xB0, 0x80], "\u{FFFD}"), + ( + "surrogate_pair_in_wtf8", + vec![0xED, 0xA0, 0xBD, 0xED, 0xB2, 0xA9], + "\u{FFFD}\u{FFFD}", + ), + ("stray_continuation", vec![0x80], "\u{FFFD}"), + ("truncated_two_byte", vec![0xC3], "\u{FFFD}"), + ("truncated_three_byte", vec![0xE0, 0xA0], "\u{FFFD}\u{FFFD}"), + ( + "truncated_four_byte", + vec![0xF0, 0x9F, 0xA6], + "\u{FFFD}\u{FFFD}\u{FFFD}", + ), + ( + "two_byte_bad_continuation", + vec![0xC3, 0x20, b'a'], + "\u{FFFD} a", + ), + ( + "three_byte_bad_continuation", + vec![0xE4, 0xB8, 0x20, b'a'], + "\u{FFFD}\u{FFFD} a", + ), + ("high_byte_above_f7", vec![0xF8, b'a'], "\u{FFFD}a"), + ( + "mixed_valid_and_invalid", + { + let mut v = b"hello ".to_vec(); + v.extend_from_slice(&[0xED, 0xA0, 0xBD]); + v.extend_from_slice(" world".as_bytes()); + v + }, + "hello \u{FFFD} world", + ), + ( + "long_ascii", + b"the quick brown fox jumps over the lazy dog".repeat(20), + &*Box::leak( + "the quick brown fox jumps over the lazy dog" + .repeat(20) + .into_boxed_str(), + ), + ), + ] + } + + #[test] + fn matches_contract() { + for (name, input, expected) in cases() { + let got = replace_invalid_utf8_and_utf16(&input); + assert_eq!( + got, expected, + "case `{}`: got {:?}, expected {:?}", + name, got, expected + ); + } + } +} + use super::{error_messages::ERROR_MSG_ARRAY_BUFFER_DETACHED, result::ResultExt}; #[derive(Clone, PartialEq)] @@ -26,7 +232,7 @@ pub enum ObjectBytes<'js> { I64Array(TypedArray<'js, i64>), F32Array(TypedArray<'js, f32>), F64Array(TypedArray<'js, f64>), - DataView(ArrayBuffer<'js>), + DataView(ArrayBuffer<'js>, usize, usize), // buffer, offset, length Vec(Vec), } @@ -48,7 +254,7 @@ impl<'js> Trace<'js> for ObjectBytes<'js> { ObjectBytes::I64Array(a) => a.trace(tracer), ObjectBytes::F32Array(a) => a.trace(tracer), ObjectBytes::F64Array(a) => a.trace(tracer), - ObjectBytes::DataView(d) => d.trace(tracer), + ObjectBytes::DataView(d, _, _) => d.trace(tracer), ObjectBytes::Vec(v) => v.trace(tracer), } } @@ -67,7 +273,7 @@ impl<'js> IntoJs<'js> for ObjectBytes<'js> { ObjectBytes::I64Array(a) => a.into_js(ctx), ObjectBytes::F32Array(a) => a.into_js(ctx), ObjectBytes::F64Array(a) => a.into_js(ctx), - ObjectBytes::DataView(d) => { + ObjectBytes::DataView(d, _, _) => { let ctor: Constructor = ctx.globals().get(PredefinedAtom::DataView)?; ctor.construct((d,)) }, @@ -137,6 +343,14 @@ impl<'js> ObjectBytes<'js> { self.as_bytes_inner().or_throw(ctx) } + /// Returns the underlying bytes, or `None` if the buffer is detached. + /// Unlike [`as_bytes`], does not raise a JS exception — useful when the + /// caller needs to distinguish detachment from other errors (e.g. + /// WebIDL-style "treat detached BufferSource as empty"). + pub fn as_bytes_opt(&self) -> Option<&[u8]> { + self.as_bytes_inner().ok() + } + fn as_bytes_inner(&self) -> std::result::Result<&[u8], Rc> { match self { ObjectBytes::U8Array(array) => array.as_bytes(), @@ -149,7 +363,9 @@ impl<'js> ObjectBytes<'js> { ObjectBytes::I64Array(array) => array.as_bytes(), ObjectBytes::F32Array(array) => array.as_bytes(), ObjectBytes::F64Array(array) => array.as_bytes(), - ObjectBytes::DataView(array_buffer) => array_buffer.as_bytes(), + ObjectBytes::DataView(array_buffer, offset, length) => array_buffer + .as_bytes() + .map(|b| &b[*offset..*offset + *length]), ObjectBytes::Vec(bytes) => Some(bytes.as_ref()), } .ok_or(ERROR_MSG_ARRAY_BUFFER_DETACHED.into()) @@ -173,7 +389,8 @@ impl<'js> ObjectBytes<'js> { } //second most common if let Some(array_buffer) = ArrayBuffer::from_object(obj.clone()) { - return Ok(Some(ObjectBytes::DataView(array_buffer))); + let len = array_buffer.len(); + return Ok(Some(ObjectBytes::DataView(array_buffer, 0, len))); } if let Ok(typed_array) = TypedArray::::from_object(obj.clone()) { @@ -213,7 +430,13 @@ impl<'js> ObjectBytes<'js> { } if let Ok(array_buffer) = obj.get::<_, ArrayBuffer>("buffer") { - return Ok(Some(ObjectBytes::DataView(array_buffer))); + let byte_offset: usize = obj.get("byteOffset").unwrap_or(0); + let byte_length: usize = obj.get("byteLength").unwrap_or_else(|_| array_buffer.len()); + return Ok(Some(ObjectBytes::DataView( + array_buffer, + byte_offset, + byte_length, + ))); } Ok(None) @@ -221,7 +444,9 @@ impl<'js> ObjectBytes<'js> { pub fn get_array_buffer(&self) -> Result, usize, usize)>> { let buffer = match self { - ObjectBytes::DataView(array_buffer) => (array_buffer.clone(), array_buffer.len(), 0), + ObjectBytes::DataView(array_buffer, offset, length) => { + (array_buffer.clone(), *length, *offset) + }, ObjectBytes::U8Array(typed_array) => { let byte_length = typed_array.len(); ( @@ -370,8 +595,8 @@ pub fn get_string_bytes( offset: usize, length: Option, ) -> Result>> { - if let Some(val) = value.as_string() { - let string = val.to_string()?; + if value.is_string() { + let string = get_lossy_string(value.clone())?; return Ok(Some(bytes_from_js_string(string, offset, length))); } Ok(None) diff --git a/src/sputnik/src/js/apis/node/llrt/llrt_utils/class.rs b/src/sputnik/src/js/apis/node/llrt/llrt_utils/class.rs index 7d69281450..0c733a57df 100644 --- a/src/sputnik/src/js/apis/node/llrt/llrt_utils/class.rs +++ b/src/sputnik/src/js/apis/node/llrt/llrt_utils/class.rs @@ -3,11 +3,15 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 use rquickjs::{ - atom::PredefinedAtom, class::JsClass, object::Accessor, prelude::This, Array, Class, Ctx, - Function, Object, Result, Symbol, Value, + atom::PredefinedAtom, class::JsClass, object::Accessor, object::Property, prelude::This, Array, + Class, Ctx, Function, Object, Result, Symbol, Value, }; -use super::{object::ObjectExt, result::OptionExt}; +use super::{ + object::ObjectExt, + primordials::{BasePrimordials, Primordial}, + result::OptionExt, +}; pub static CUSTOM_INSPECT_SYMBOL_DESCRIPTION: &str = "llrt.inspect.custom"; @@ -73,4 +77,40 @@ where } Ok(()) } +} + +/// Register a class as a WebIDL pair iterator: registers it on the globals, +/// removes the constructor (it's not exposed to JS), wires its prototype to +/// inherit from `%IteratorPrototype%` (so it's iterable and stringifies as +/// `[object Iterator]`), and re-defines `next` as enumerable per WebIDL. +/// +/// The class must declare a `next(&mut self, ctx) -> Result` method +/// via `#[rquickjs::methods]`. +pub trait WebIdlIteratorExtension<'js> { + fn define_as_webidl_iterator(globals: &Object<'js>, name: &str) -> Result<()>; +} + +impl<'js, C> WebIdlIteratorExtension<'js> for Class<'js, C> +where + C: JsClass<'js> + 'js, +{ + fn define_as_webidl_iterator(globals: &Object<'js>, name: &str) -> Result<()> { + let ctx = globals.ctx(); + Self::define(globals)?; + // Iterator class is not exposed to JS. + globals.remove(name)?; + if let Some(proto) = Class::::prototype(ctx)? { + let iterator_proto = BasePrimordials::get(ctx)?.prototype_iterator.clone(); + proto.set_prototype(Some(&iterator_proto))?; + let next_fn: Function = proto.get("next")?; + proto.prop( + "next", + Property::from(next_fn) + .writable() + .enumerable() + .configurable(), + )?; + } + Ok(()) + } } \ No newline at end of file diff --git a/src/sputnik/src/js/apis/node/llrt/llrt_utils/object.rs b/src/sputnik/src/js/apis/node/llrt/llrt_utils/object.rs index 42c5ffa17d..490d4d68f1 100644 --- a/src/sputnik/src/js/apis/node/llrt/llrt_utils/object.rs +++ b/src/sputnik/src/js/apis/node/llrt/llrt_utils/object.rs @@ -108,7 +108,7 @@ impl<'js> Proxy<'js> { Ok(Self { target, options }) } - pub fn setter(&self, setter: Func) -> Result<()> + pub fn setter(&self, setter: Func) -> Result<()> where T: IntoJsFunc<'js, P> + 'js, { @@ -116,7 +116,7 @@ impl<'js> Proxy<'js> { Ok(()) } - pub fn getter(&self, getter: Func) -> Result<()> + pub fn getter(&self, getter: Func) -> Result<()> where T: IntoJsFunc<'js, P> + 'js, { diff --git a/src/sputnik/src/js/apis/node/llrt/llrt_utils/primordials.rs b/src/sputnik/src/js/apis/node/llrt/llrt_utils/primordials.rs index ae87342e58..49c71e2f67 100644 --- a/src/sputnik/src/js/apis/node/llrt/llrt_utils/primordials.rs +++ b/src/sputnik/src/js/apis/node/llrt/llrt_utils/primordials.rs @@ -3,8 +3,8 @@ use std::any::type_name; use rquickjs::{ - atom::PredefinedAtom, function::Constructor, runtime::UserDataGuard, Ctx, Function, JsLifetime, - Object, Result, + atom::PredefinedAtom, function::Constructor, runtime::UserDataGuard, Ctx, Exception, Function, + JsLifetime, Object, Result, }; use super::result::ResultExt; @@ -39,8 +39,10 @@ pub struct BasePrimordials<'js> { pub function_array_from: Function<'js>, pub function_array_buffer_is_view: Function<'js>, pub function_get_own_property_descriptor: Function<'js>, + pub function_reflect_own_keys: Function<'js>, pub function_parse_int: Function<'js>, pub function_parse_float: Function<'js>, + pub prototype_iterator: Object<'js>, } pub trait Primordial<'js> @@ -118,6 +120,20 @@ impl<'js> Primordial<'js> for BasePrimordials<'js> { let constructor_string: Constructor = globals.get(PredefinedAtom::String)?; + let reflect: Object = globals.get("Reflect")?; + let function_reflect_own_keys: Function = reflect.get("ownKeys")?; + + // Walk to %IteratorPrototype% via an array iterator. + let array = rquickjs::Array::new(ctx.clone())?; + let iter_fn: Function = array + .as_object() + .get(rquickjs::atom::PredefinedAtom::SymbolIterator)?; + let array_iter: Object = iter_fn.call((rquickjs::function::This(array),))?; + let prototype_iterator = array_iter + .get_prototype() + .and_then(|p| p.get_prototype()) + .ok_or_else(|| Exception::throw_internal(ctx, "missing %IteratorPrototype%"))?; + Ok(Self { constructor_map, constructor_set, @@ -142,8 +158,10 @@ impl<'js> Primordial<'js> for BasePrimordials<'js> { function_array_from, function_array_buffer_is_view, function_get_own_property_descriptor, + function_reflect_own_keys, function_parse_float, function_parse_int, + prototype_iterator, }) } } \ No newline at end of file