Skip to main content

ferrijs_std/url/
url_class.rs

1// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2// SPDX-License-Identifier: Apache-2.0
3#![allow(clippy::uninlined_format_args)]
4
5use std::{cell::RefCell, rc::Rc};
6
7use rquickjs::{
8    atom::PredefinedAtom, class::Trace, function::Opt, Class, Coerced, Ctx, Exception, FromJs,
9    IntoJs, Null, Object, Result, Value,
10};
11use url::{quirks, Url};
12
13use super::url_search_params::URLSearchParams;
14
15/// Represents a JavaScript
16/// [`URL`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) as defined
17/// by the [WHATWG URL standard](https://url.spec.whatwg.org/).
18#[derive(Clone, Trace, rquickjs::JsLifetime)]
19#[rquickjs::class]
20pub struct URL<'js> {
21    #[qjs(skip_trace)]
22    url: Rc<RefCell<Url>>,
23    search_params: Class<'js, URLSearchParams>,
24}
25
26#[rquickjs::methods(rename_all = "camelCase")]
27impl<'js> URL<'js> {
28    #[qjs(constructor)]
29    pub fn new(ctx: Ctx<'js>, input: Value<'js>, base: Opt<Value<'js>>) -> Result<Self> {
30        // USVString conversion per WHATWG URL spec: lone UTF-16 surrogates
31        // must be replaced with U+FFFD (not rejected) before the basic URL
32        // parser runs (WPT `url-origin.any.js` passes URLs containing lone
33        // surrogates and expects them to parse).
34        let input: Result<String> = if input.is_string() {
35            crate::utils::bytes::get_lossy_string(input.clone())
36        } else {
37            Coerced::<String>::from_js(&ctx, input.clone()).map(|c| c.0)
38        };
39        if let Some(base) = base.into_inner() {
40            if let Some(base) = base.as_string() {
41                if let Ok(base) = base.to_string() {
42                    let base_url: Url = base
43                        .parse()
44                        .map_err(|_| Exception::throw_type(&ctx, "Invalid base URL"))?;
45                    // Work around a url-crate normalization that loses the
46                    // host when a file:// URL's path starts with a Windows
47                    // drive letter (WPT url-constructor.any.js file-URL-
48                    // with-host base cases). Extract the host manually
49                    // from the original source string and preserve it.
50                    let base_url = super::preserve_file_url_host(&base, base_url);
51                    if let Ok(input) = input {
52                        let mut joined = base_url
53                            .join(input.as_str())
54                            .map_err(|_| Exception::throw_type(&ctx, "Invalid URL"))?;
55                        super::restore_file_url_host(&base_url, &mut joined);
56                        return Self::from_url(ctx, joined);
57                    }
58                    return Self::from_str(ctx, &base);
59                }
60            }
61        }
62        if let Ok(input) = input {
63            Self::from_str(ctx, input.as_str())
64        } else {
65            Err(Exception::throw_message(&ctx, "Invalid URL"))
66        }
67    }
68
69    #[qjs(get)]
70    pub fn hash(&self) -> String {
71        quirks::hash(&self.url.borrow()).to_string()
72    }
73
74    #[qjs(set, rename = "hash")]
75    pub fn set_hash(&mut self, hash: String) -> String {
76        self.before_mutation();
77        quirks::set_hash(&mut self.url.borrow_mut(), &hash);
78        hash
79    }
80
81    #[qjs(get)]
82    pub fn host(&self) -> String {
83        quirks::host(&self.url.borrow()).to_string()
84    }
85
86    #[qjs(set, rename = "host")]
87    pub fn set_host(&mut self, host: Coerced<String>) -> String {
88        self.before_mutation();
89        let _ = quirks::set_host(&mut self.url.borrow_mut(), &host);
90        host.0
91    }
92
93    #[qjs(get)]
94    pub fn hostname(&self) -> String {
95        quirks::hostname(&self.url.borrow()).to_string()
96    }
97
98    #[qjs(set, rename = "hostname")]
99    pub fn set_hostname(&mut self, hostname: Coerced<String>) -> String {
100        self.before_mutation();
101        let _ = quirks::set_hostname(&mut self.url.borrow_mut(), hostname.as_str());
102        super::strip_path_sentinel(&mut self.url.borrow_mut());
103        hostname.0
104    }
105
106    #[qjs(get)]
107    pub fn href(&self) -> String {
108        quirks::href(&self.url.borrow()).to_string()
109    }
110
111    #[qjs(set, rename = "href")]
112    pub fn set_href(&mut self, href: String) -> String {
113        self.before_mutation();
114        let _ = quirks::set_href(&mut self.url.borrow_mut(), &href);
115        href
116    }
117
118    #[qjs(get)]
119    pub fn origin(&self) -> String {
120        let url = self.url.borrow();
121        // Per WHATWG URL spec §6.2, origin of a blob URL is computed by parsing
122        // the path as a URL. If the result's scheme is HTTP(S), return that
123        // URL's origin; otherwise, return an opaque (null) origin. The `url`
124        // crate returns the nested URL's origin even for non-HTTP schemes,
125        // breaking WPT `url-origin.any.js` on cases like `blob:ftp://...` and
126        // `blob:blob:https://...`.
127        if url.scheme() == "blob" {
128            return match url::Url::parse(url.path()) {
129                Ok(inner) if matches!(inner.scheme(), "http" | "https") => quirks::origin(&inner),
130                _ => "null".into(),
131            };
132        }
133        quirks::origin(&url)
134    }
135
136    #[qjs(get)]
137    pub fn password(&self) -> String {
138        quirks::password(&self.url.borrow()).to_string()
139    }
140
141    #[qjs(set, rename = "password")]
142    pub fn set_password(&mut self, password: Coerced<String>) -> String {
143        self.before_mutation();
144        let _ = quirks::set_password(&mut self.url.borrow_mut(), &password);
145        password.0
146    }
147
148    #[qjs(get)]
149    pub fn pathname(&self) -> String {
150        quirks::pathname(&self.url.borrow()).to_string()
151    }
152
153    #[qjs(set, rename = "pathname")]
154    pub fn set_pathname(&mut self, pathname: Coerced<String>) -> String {
155        self.before_mutation();
156        quirks::set_pathname(&mut self.url.borrow_mut(), pathname.as_str());
157        // Per WHATWG URL spec, a non-special URL with an empty host can have
158        // its path erased (WPT `url-setters.any.js` "Non-special URLs with
159        // an empty host can have their paths erased"). The `url` crate
160        // forces a single `/` after the authority; strip it when the caller
161        // set an empty pathname on such a URL.
162        if pathname.0.is_empty() {
163            super::erase_empty_host_path(&mut self.url.borrow_mut());
164        }
165        pathname.0
166    }
167
168    #[qjs(get)]
169    pub fn port(&self) -> String {
170        quirks::port(&self.url.borrow()).to_string()
171    }
172
173    #[qjs(set, rename = "port")]
174    pub fn set_port(&mut self, ctx: Ctx<'js>, port: Value<'js>) -> Value<'js> {
175        if port.is_null()
176            || port.is_undefined()
177            || (port.is_int() && unsafe { port.as_int().unwrap_unchecked() } < 0)
178        {
179            return port;
180        }
181        if let Ok(port_string) = Coerced::<String>::from_js(&ctx, port.clone()) {
182            self.before_mutation();
183            // Per WHATWG URL spec, the port-state parser strips tab/LF/CR
184            // before reading. An empty STRIPPED value (but non-empty original)
185            // makes port parsing fail, which per spec means no-op (keep
186            // existing port). An empty ORIGINAL value, however, clears the
187            // port.
188            if port_string.is_empty() {
189                let _ = quirks::set_port(&mut self.url.borrow_mut(), "");
190            } else {
191                let stripped: String = port_string
192                    .chars()
193                    .filter(|c| !matches!(c, '\t' | '\n' | '\r'))
194                    .collect();
195                if !stripped.is_empty() {
196                    let _ = quirks::set_port(&mut self.url.borrow_mut(), &stripped);
197                }
198                // stripped is empty → parse failure per spec → no-op
199            }
200        }
201        port
202    }
203
204    #[qjs(get)]
205    pub fn protocol(&self) -> String {
206        quirks::protocol(&self.url.borrow()).to_string()
207    }
208
209    #[qjs(set, rename = "protocol")]
210    pub fn set_protocol(&mut self, protocol: Coerced<String>) -> String {
211        self.before_mutation();
212        let _ = quirks::set_protocol(&mut self.url.borrow_mut(), &protocol);
213        protocol.0
214    }
215
216    #[qjs(get)]
217    pub fn search(&self) -> String {
218        quirks::search(&self.url.borrow()).to_string()
219    }
220
221    #[qjs(set, rename = "search")]
222    pub fn set_search(&mut self, search: Coerced<String>) -> String {
223        self.before_mutation();
224        quirks::set_search(&mut self.url.borrow_mut(), &search);
225        search.0
226    }
227
228    #[qjs(get)]
229    pub fn search_params(&self) -> &Value<'js> {
230        self.search_params.as_value()
231    }
232
233    #[qjs(prop, rename = PredefinedAtom::SymbolToStringTag, configurable)]
234    pub fn to_string_tag() -> &'static str {
235        stringify!(URL)
236    }
237
238    #[qjs(get)]
239    pub fn username(&self) -> String {
240        quirks::username(&self.url.borrow()).to_string()
241    }
242
243    #[qjs(set, rename = "username")]
244    pub fn set_username(&mut self, username: Coerced<String>) -> String {
245        self.before_mutation();
246        let _ = quirks::set_username(&mut self.url.borrow_mut(), &username);
247        username.0
248    }
249
250    #[qjs(static)]
251    pub fn can_parse(ctx: Ctx<'js>, input: Value<'js>, base: Opt<Value<'js>>) -> bool {
252        Self::new(ctx, input, base).is_ok()
253    }
254
255    #[qjs(static)]
256    pub fn parse(ctx: Ctx<'js>, input: Value<'js>, base: Opt<Value<'js>>) -> Result<Value<'js>> {
257        Self::new(ctx.clone(), input, base)
258            .map_or_else(|_| Null.into_js(&ctx), |instance| instance.into_js(&ctx))
259    }
260
261    #[qjs(rename = PredefinedAtom::ToJSON)]
262    pub fn to_json(&self) -> String {
263        self.to_string()
264    }
265
266    pub fn to_string(&self) -> String {
267        self.href()
268    }
269}
270
271impl<'js> URL<'js> {
272    pub fn from_str(ctx: Ctx<'js>, input: &str) -> Result<Self> {
273        let mut url: Url = input
274            .parse()
275            .map_err(|_| Exception::throw_type(&ctx, "Invalid URL"))?;
276        super::normalize_windows_drive_letter(&mut url);
277        super::convert_trailing_space(&mut url);
278        Self::build(ctx, url)
279    }
280
281    pub fn from_url(ctx: Ctx<'js>, mut url: Url) -> Result<Self> {
282        super::normalize_windows_drive_letter(&mut url);
283        super::convert_trailing_space(&mut url);
284        Self::build(ctx, url)
285    }
286
287    /// Validate that a string parses as a URL without constructing a JS
288    /// instance. Used by callers (e.g. `crate::fetch`) that just need to know
289    /// whether a user-supplied string is a valid URL.
290    pub fn is_valid(input: &str) -> bool {
291        input.parse::<Url>().is_ok()
292    }
293
294    fn build(ctx: Ctx<'js>, url: Url) -> Result<Self> {
295        let shared = Rc::new(RefCell::new(url));
296        let search_params = Class::instance(ctx, URLSearchParams::from_url(&shared))?;
297        Ok(Self {
298            url: shared,
299            search_params,
300        })
301    }
302
303    fn before_mutation(&mut self) {
304        super::convert_trailing_space(&mut self.url.borrow_mut());
305    }
306}
307
308/// `decodeURIComponent` over one URL component, which is what Node applies
309/// to the credentials before joining them into `auth`. Invalid UTF-8 in the
310/// decoded bytes is replaced rather than thrown, since the value came out of
311/// an already-parsed URL.
312fn percent_decode(component: &str) -> String {
313    percent_encoding::percent_decode_str(component)
314        .decode_utf8_lossy()
315        .into_owned()
316}
317
318pub fn url_to_http_options<'js>(ctx: Ctx<'js>, url: Class<'js, URL<'js>>) -> Result<Object<'js>> {
319    let obj = Object::new(ctx)?;
320    let url = url.borrow();
321
322    let port = url.port();
323    let username = url.username();
324    let password = url.password();
325    let search = url.search();
326    let hostname = url.hostname();
327
328    obj.set("protocol", url.protocol())?;
329    // Node hands `http.request` a bare IPv6 host, not the URL's bracketed
330    // form (`[::1]` -> `::1`), because that is what a socket connect takes.
331    obj.set(
332        "hostname",
333        hostname
334            .strip_prefix('[')
335            .and_then(|rest| rest.strip_suffix(']'))
336            .unwrap_or(&hostname)
337            .to_string(),
338    )?;
339    obj.set("hash", url.hash())?;
340
341    let pathname = url.pathname();
342    let path = [pathname.as_str(), search.as_str()].concat();
343    obj.set("search", search)?;
344    obj.set("pathname", pathname)?;
345    obj.set("path", path)?;
346    obj.set("href", url.href())?;
347
348    if !username.is_empty() || !password.is_empty() {
349        obj.set(
350            "auth",
351            [percent_decode(&username), percent_decode(&password)].join(":"),
352        )?;
353    }
354
355    if !port.is_empty() {
356        // Node reports the port as a NUMBER; `http.request` compares it
357        // numerically when it decides whether to append `:port` to Host.
358        obj.set("port", port.parse::<u32>().unwrap_or_default())?;
359    }
360
361    Ok(obj)
362}