Skip to main content

nodejs/stdlib/
url_legacy.rs

1//! The legacy `url.parse` / `url.format` API — a faithful port of Node's
2//! `lib/url.js` `Url.prototype.parse`, `Url.prototype.format` and `urlFormat`.
3//!
4//! This API predates (and disagrees with) the WHATWG `URL` parser in `url.rs`:
5//! it is a hand-rolled scanner with its own whitespace trimming, backslash
6//! rewriting, "simple path" fast path, auto-escaping table and slashed/hostless
7//! protocol sets. Reimplementing it from the documentation produces a parser
8//! that agrees on `http://host/path` and disagrees on everything else, so this
9//! is a line-for-line port of the JS instead, sharing the same field names and
10//! insertion order (`protocol, slashes, auth, host, port, hostname, hash,
11//! search, query, pathname, path, href`).
12//!
13//! Node scans by UTF-16 code unit; this port scans by `char`. Every branch keys
14//! off ASCII delimiters, so the emitted strings match.
15
16use crate::host::{with_host, JsObj};
17use fusevm::Value;
18use indexmap::IndexMap;
19
20/// Protocols whose `rest` is NOT auto-escaped (`javascript:`).
21fn is_unsafe_protocol(lower_proto: &str) -> bool {
22    matches!(lower_proto, "javascript" | "javascript:")
23}
24
25/// Protocols that never take a host, even after `//` (`javascript:`).
26fn is_hostless_protocol(lower_proto: &str) -> bool {
27    matches!(lower_proto, "javascript" | "javascript:")
28}
29
30/// Protocols that imply `//` in `format` and a `/` pathname in `parse`.
31fn is_slashed_protocol(p: &str) -> bool {
32    matches!(
33        p,
34        "http"
35            | "http:"
36            | "https"
37            | "https:"
38            | "ftp"
39            | "ftp:"
40            | "gopher"
41            | "gopher:"
42            | "file"
43            | "file:"
44            | "ws"
45            | "ws:"
46            | "wss"
47            | "wss:"
48    )
49}
50
51/// Node's `escapedCodes` table: the RFC 2396 delimiters + unwise characters
52/// (plus `'`) that `autoEscapeStr` percent-encodes.
53fn escaped_code(c: char) -> Option<&'static str> {
54    Some(match c {
55        '\t' => "%09",
56        '\n' => "%0A",
57        '\r' => "%0D",
58        ' ' => "%20",
59        '"' => "%22",
60        '\'' => "%27",
61        '<' => "%3C",
62        '>' => "%3E",
63        '\\' => "%5C",
64        '^' => "%5E",
65        '`' => "%60",
66        '{' => "%7B",
67        '|' => "%7C",
68        '}' => "%7D",
69        _ => return None,
70    })
71}
72
73fn auto_escape_str(rest: &str) -> String {
74    let mut out = String::with_capacity(rest.len());
75    let mut escaped_any = false;
76    for c in rest.chars() {
77        match escaped_code(c) {
78            Some(e) => {
79                out.push_str(e);
80                escaped_any = true;
81            }
82            None => out.push(c),
83        }
84    }
85    if escaped_any {
86        out
87    } else {
88        rest.to_string()
89    }
90}
91
92/// JS `\s` for the purposes of `simplePathPattern`.
93fn is_js_space(c: char) -> bool {
94    c.is_whitespace() || c == '\u{feff}'
95}
96
97/// The whitespace class Node's trimming loop uses (`code < 33` plus NBSP/BOM).
98fn is_trim_ws(c: char) -> bool {
99    (c as u32) < 33 || c == '\u{a0}' || c == '\u{feff}'
100}
101
102/// The parsed legacy URL. Every field is `Option`, mirroring the `null`-initialized
103/// `Url` instance; `slashes` is a tri-state (`None` = `null`).
104#[derive(Default)]
105pub struct Url {
106    pub protocol: Option<String>,
107    pub slashes: Option<bool>,
108    pub auth: Option<String>,
109    pub host: Option<String>,
110    pub port: Option<String>,
111    pub hostname: Option<String>,
112    pub hash: Option<String>,
113    pub search: Option<String>,
114    /// `Some(Ok(raw))` when `parseQueryString` is off, `Some(Err(qs))` when it is
115    /// on (the caller turns `qs` into an object via `querystring.parse`).
116    pub query: Option<Result<String, String>>,
117    pub pathname: Option<String>,
118    pub path: Option<String>,
119    pub href: Option<String>,
120}
121
122/// Port of `Url.prototype.parseHost`: split a trailing `:port` off `host`.
123fn parse_host(u: &mut Url) {
124    let Some(host) = u.host.clone() else { return };
125    let hc: Vec<char> = host.chars().collect();
126    // portPattern = /:[0-9]*$/
127    let mut i = hc.len();
128    while i > 0 && hc[i - 1].is_ascii_digit() {
129        i -= 1;
130    }
131    let mut host = host;
132    if i > 0 && hc[i - 1] == ':' {
133        let port: String = hc[i..].iter().collect();
134        if !port.is_empty() {
135            u.port = Some(port);
136        }
137        host = hc[..i - 1].iter().collect();
138    }
139    if !host.is_empty() {
140        u.hostname = Some(host);
141    }
142}
143
144/// `/^[a-z0-9.+-]+:/i` — the matched protocol including the colon.
145fn match_protocol(rest: &[char]) -> Option<String> {
146    let mut i = 0;
147    while i < rest.len() && (rest[i].is_ascii_alphanumeric() || matches!(rest[i], '.' | '+' | '-'))
148    {
149        i += 1;
150    }
151    if i > 0 && rest.get(i) == Some(&':') {
152        Some(rest[..=i].iter().collect())
153    } else {
154        None
155    }
156}
157
158/// `/^\/\/[^@/]+@[^@/]+/` — does `rest` look like `//user@host…`?
159fn matches_host_pattern(rest: &[char]) -> bool {
160    if rest.len() < 2 || rest[0] != '/' || rest[1] != '/' {
161        return false;
162    }
163    let mut i = 2;
164    let start = i;
165    while i < rest.len() && rest[i] != '@' && rest[i] != '/' {
166        i += 1;
167    }
168    if i == start || rest.get(i) != Some(&'@') {
169        return false;
170    }
171    i += 1;
172    let start = i;
173    while i < rest.len() && rest[i] != '@' && rest[i] != '/' {
174        i += 1;
175    }
176    i > start
177}
178
179/// `/^(\/\/?(?!\/)[^?\s]*)(\?[^\s]*)?$/` — returns `(group1, group2)` on a match.
180fn match_simple_path(rest: &[char]) -> Option<(String, Option<String>)> {
181    if rest.first() != Some(&'/') {
182        return None;
183    }
184    // Greedy `\/?` then `(?!\/)`; backtracking to one slash also fails when the
185    // next character is a slash, so `///…` never matches.
186    let mut i = 1;
187    if rest.get(1) == Some(&'/') {
188        i = 2;
189    }
190    if rest.get(i) == Some(&'/') {
191        return None;
192    }
193    let mut j = i;
194    while j < rest.len() && rest[j] != '?' && !is_js_space(rest[j]) {
195        j += 1;
196    }
197    let group1: String = rest[..j].iter().collect();
198    if j == rest.len() {
199        return Some((group1, None));
200    }
201    if rest[j] != '?' {
202        // Trailing whitespace can't be consumed before the `$` anchor.
203        return None;
204    }
205    if rest[j + 1..].iter().any(|&c| is_js_space(c)) {
206        return None;
207    }
208    let group2: String = rest[j..].iter().collect();
209    Some((group1, Some(group2)))
210}
211
212/// Node's `getHostname`: stop the hostname at the first character that can't
213/// appear in one, moving the remainder into the path. `Err` mirrors the
214/// `ERR_INVALID_ARG_VALUE` thrown for a leftover leading `:` (an invalid port).
215fn get_hostname(u: &mut Url, rest: &str, hostname: &str, url: &str) -> Result<String, String> {
216    for (i, c) in hostname.chars().enumerate() {
217        if matches!(c, '/' | '\\' | '#' | '?' | ':') {
218            if c == ':' {
219                // Node's call site passes ERR_INVALID_ARG_VALUE's arguments in
220                // an unusual order, so the rendered message interleaves the URL
221                // and the reason exactly like this.
222                return Err(std::format!(
223                    "TypeError [ERR_INVALID_ARG_VALUE]: The argument 'url' {url}. \
224                     Received 'Invalid port in url'"
225                ));
226            }
227            let head: String = hostname.chars().take(i).collect();
228            let tail: String = hostname.chars().skip(i).collect();
229            u.hostname = Some(head);
230            return Ok(std::format!("/{tail}{rest}"));
231        }
232    }
233    Ok(rest.to_string())
234}
235
236fn is_ipv6_hostname(h: &str) -> bool {
237    h.starts_with('[') && h.ends_with(']') && h.len() >= 2
238}
239
240fn has_forbidden_host_char(h: &str, ipv6: bool) -> bool {
241    h.chars().any(|c| {
242        matches!(
243            c,
244            '\0' | '\t'
245                | '\n'
246                | '\r'
247                | ' '
248                | '#'
249                | '%'
250                | '/'
251                | '<'
252                | '>'
253                | '?'
254                | '@'
255                | '\\'
256                | '^'
257                | '|'
258        ) || (!ipv6 && matches!(c, ':' | '[' | ']'))
259    })
260}
261
262/// Port of `Url.prototype.parse`. `slashes_denote_host` is the third `url.parse`
263/// argument. Errors correspond to the JS `throw` sites.
264pub fn parse(
265    url: &str,
266    parse_query_string: bool,
267    slashes_denote_host: bool,
268) -> Result<Url, String> {
269    let mut u = Url::default();
270    let uc: Vec<char> = url.chars().collect();
271
272    // Trim outer whitespace and rewrite backslashes before the first `?`/`#`,
273    // matching Chrome/IE/Opera (https://crbug.com/25916).
274    let mut has_hash = false;
275    let mut has_at = false;
276    let mut start: isize = -1;
277    let mut end: isize = -1;
278    let mut rest = String::new();
279    let mut last_pos: usize = 0;
280    let mut in_ws = false;
281    let mut split = false;
282    for (i, &code) in uc.iter().enumerate() {
283        let is_ws = is_trim_ws(code);
284        if start == -1 {
285            if is_ws {
286                continue;
287            }
288            last_pos = i;
289            start = i as isize;
290        } else if in_ws {
291            if !is_ws {
292                end = -1;
293                in_ws = false;
294            }
295        } else if is_ws {
296            end = i as isize;
297            in_ws = true;
298        }
299
300        if !split {
301            match code {
302                '@' => has_at = true,
303                '#' => {
304                    has_hash = true;
305                    split = true;
306                }
307                '?' => split = true,
308                '\\' => {
309                    if i > last_pos {
310                        rest.extend(&uc[last_pos..i]);
311                    }
312                    rest.push('/');
313                    last_pos = i + 1;
314                }
315                _ => {}
316            }
317        } else if !has_hash && code == '#' {
318            has_hash = true;
319        }
320    }
321
322    if start != -1 {
323        let s = start as usize;
324        if last_pos == s {
325            rest = if end == -1 {
326                uc[s..].iter().collect()
327            } else {
328                uc[s..end as usize].iter().collect()
329            };
330        } else if end == -1 && last_pos < uc.len() {
331            rest.extend(&uc[last_pos..]);
332        } else if end != -1 && (last_pos as isize) < end {
333            rest.extend(&uc[last_pos..end as usize]);
334        }
335    }
336
337    let set_query = |u: &mut Url, raw: String| {
338        u.query = Some(if parse_query_string {
339            Err(raw)
340        } else {
341            Ok(raw)
342        });
343    };
344
345    if !slashes_denote_host && !has_hash && !has_at {
346        let rc: Vec<char> = rest.chars().collect();
347        if let Some((g1, g2)) = match_simple_path(&rc) {
348            u.path = Some(rest.clone());
349            u.href = Some(rest.clone());
350            u.pathname = Some(g1);
351            match g2 {
352                Some(q) => {
353                    let raw: String = q.chars().skip(1).collect();
354                    u.search = Some(q);
355                    set_query(&mut u, raw);
356                }
357                None if parse_query_string => {
358                    u.search = None;
359                    u.query = Some(Err(String::new()));
360                }
361                None => {}
362            }
363            return Ok(u);
364        }
365    }
366
367    let mut rc: Vec<char> = rest.chars().collect();
368    let proto = match_protocol(&rc);
369    let mut lower_proto = String::new();
370    if let Some(p) = &proto {
371        lower_proto = p.to_lowercase();
372        u.protocol = Some(lower_proto.clone());
373        rc = rc[p.chars().count()..].to_vec();
374    }
375
376    // `user@server` is always a host, and `//foo/bar` resolves as host=foo the
377    // way a browser resolves a protocol-relative reference.
378    let mut slashes = false;
379    if slashes_denote_host || proto.is_some() || matches_host_pattern(&rc) {
380        slashes = rc.first() == Some(&'/') && rc.get(1) == Some(&'/');
381        if slashes && !(proto.is_some() && is_hostless_protocol(&lower_proto)) {
382            rc = rc[2..].to_vec();
383            u.slashes = Some(true);
384        }
385    }
386
387    if !is_hostless_protocol(&lower_proto)
388        && (slashes || (proto.is_some() && !is_slashed_protocol(proto.as_deref().unwrap_or(""))))
389    {
390        // The first `/ ? #` ends the host, but characters left of the LAST `@`
391        // are auth even when they'd otherwise be illegal in a hostname:
392        //   http://a@b@c/  => auth a@b, host c
393        //   http://a@b?@c  => auth a,   host b, path /?@c
394        let mut host_end: isize = -1;
395        let mut at_sign: isize = -1;
396        let mut non_host: isize = -1;
397        let mut i = 0usize;
398        while i < rc.len() {
399            match rc[i] {
400                '\t' | '\n' | '\r' => {
401                    // WHATWG URL strips tab/LF/CR; so does this parser.
402                    rc.remove(i);
403                    continue;
404                }
405                ' ' | '"' | '%' | '\'' | ';' | '<' | '>' | '\\' | '^' | '`' | '{' | '|' | '}' => {
406                    if non_host == -1 {
407                        non_host = i as isize;
408                    }
409                }
410                '#' | '/' | '?' => {
411                    if non_host == -1 {
412                        non_host = i as isize;
413                    }
414                    host_end = i as isize;
415                }
416                '@' => {
417                    at_sign = i as isize;
418                    non_host = -1;
419                }
420                _ => {}
421            }
422            if host_end != -1 {
423                break;
424            }
425            i += 1;
426        }
427        let mut start = 0usize;
428        if at_sign != -1 {
429            u.auth = Some(super::url::percent_decode(
430                &rc[..at_sign as usize].iter().collect::<String>(),
431            ));
432            start = at_sign as usize + 1;
433        }
434        if non_host == -1 {
435            u.host = Some(rc[start..].iter().collect());
436            rc = Vec::new();
437        } else {
438            u.host = Some(rc[start..non_host as usize].iter().collect());
439            rc = rc[non_host as usize..].to_vec();
440        }
441
442        parse_host(&mut u);
443
444        // The host was declared present, so `hostname` must be a string even
445        // when empty.
446        if u.hostname.is_none() {
447            u.hostname = Some(String::new());
448        }
449        let hostname = u.hostname.clone().unwrap_or_default();
450        let ipv6 = is_ipv6_hostname(&hostname);
451        if !ipv6 {
452            let rest_s: String = rc.iter().collect();
453            rc = get_hostname(&mut u, &rest_s, &hostname, url)?
454                .chars()
455                .collect();
456        }
457
458        let hn = u.hostname.clone().unwrap_or_default();
459        u.hostname = Some(if hn.chars().count() > 255 {
460            String::new()
461        } else {
462            hn.to_lowercase()
463        });
464
465        let hn = u.hostname.clone().unwrap_or_default();
466        if !hn.is_empty() {
467            if ipv6 {
468                if has_forbidden_host_char(&hn, true) {
469                    return Err(invalid_url(url));
470                }
471            } else {
472                // IDNA: punycode only the labels carrying non-ASCII.
473                let ascii = super::punycode::to_ascii(&hn);
474                u.hostname = Some(ascii.clone());
475                // An empty or newly-forbidden hostname can only have come from
476                // toASCII (getHostname would have split it out otherwise), so
477                // this is a spoofing attempt rather than a recoverable path.
478                if ascii.is_empty() || has_forbidden_host_char(&ascii, false) {
479                    return Err(invalid_url(url));
480                }
481            }
482        }
483
484        let p = match &u.port {
485            Some(p) => std::format!(":{p}"),
486            None => String::new(),
487        };
488        let h = u.hostname.clone().unwrap_or_default();
489        u.host = Some(std::format!("{h}{p}"));
490
491        // `hostname` drops the IPv6 brackets; `host` keeps them.
492        if ipv6 {
493            let hn = u.hostname.clone().unwrap_or_default();
494            let inner: String = {
495                let c: Vec<char> = hn.chars().collect();
496                if c.len() >= 2 {
497                    c[1..c.len() - 1].iter().collect()
498                } else {
499                    String::new()
500                }
501            };
502            u.hostname = Some(inner);
503            if rc.first() != Some(&'/') {
504                rc.insert(0, '/');
505            }
506        }
507    }
508
509    if !is_unsafe_protocol(&lower_proto) {
510        rc = auto_escape_str(&rc.iter().collect::<String>())
511            .chars()
512            .collect();
513    }
514
515    let mut question_idx: isize = -1;
516    let mut hash_idx: isize = -1;
517    for (i, &c) in rc.iter().enumerate() {
518        if c == '#' {
519            u.hash = Some(rc[i..].iter().collect());
520            hash_idx = i as isize;
521            break;
522        } else if c == '?' && question_idx == -1 {
523            question_idx = i as isize;
524        }
525    }
526
527    if question_idx != -1 {
528        let q = question_idx as usize;
529        if hash_idx == -1 {
530            u.search = Some(rc[q..].iter().collect());
531            set_query(&mut u, rc[q + 1..].iter().collect());
532        } else {
533            let h = hash_idx as usize;
534            u.search = Some(rc[q..h].iter().collect());
535            set_query(&mut u, rc[q + 1..h].iter().collect());
536        }
537    } else if parse_query_string {
538        u.search = None;
539        u.query = Some(Err(String::new()));
540    }
541
542    let use_question = question_idx != -1 && (hash_idx == -1 || question_idx < hash_idx);
543    let first_idx = if use_question { question_idx } else { hash_idx };
544    if first_idx == -1 {
545        if !rc.is_empty() {
546            u.pathname = Some(rc.iter().collect());
547        }
548    } else if first_idx > 0 {
549        u.pathname = Some(rc[..first_idx as usize].iter().collect());
550    }
551    // `this.hostname` is JS-truthy here, so an EMPTY hostname (`http://?a`)
552    // must NOT get the synthesized `/` pathname.
553    if is_slashed_protocol(&lower_proto)
554        && !u.hostname.as_deref().unwrap_or("").is_empty()
555        && u.pathname.as_deref().unwrap_or("").is_empty()
556    {
557        u.pathname = Some("/".into());
558    }
559
560    // http.request needs `path` = pathname + search.
561    if u.pathname.is_some() || u.search.is_some() {
562        let p = u.pathname.clone().unwrap_or_default();
563        let s = u.search.clone().unwrap_or_default();
564        u.path = Some(std::format!("{p}{s}"));
565    }
566
567    u.href = Some(format_url(&u, None));
568    Ok(u)
569}
570
571fn invalid_url(_url: &str) -> String {
572    "TypeError [ERR_INVALID_URL]: Invalid URL".into()
573}
574
575/// The `noEscapeAuth` table: characters `Url.prototype.format` leaves as-is in
576/// `auth`. Everything else is percent-encoded UTF-8.
577fn auth_needs_escape(c: char) -> bool {
578    !(c.is_ascii_alphanumeric()
579        || matches!(
580            c,
581            '!' | '-' | '.' | '_' | '~' | '\'' | '(' | ')' | '*' | ':'
582        ))
583}
584
585fn encode_auth(auth: &str) -> String {
586    let mut out = String::with_capacity(auth.len());
587    for c in auth.chars() {
588        if auth_needs_escape(c) {
589            let mut buf = [0u8; 4];
590            for b in c.encode_utf8(&mut buf).as_bytes() {
591                out.push_str(&std::format!("%{b:02X}"));
592            }
593        } else {
594            out.push(c);
595        }
596    }
597    out
598}
599
600/// Port of `Url.prototype.format`. `query_string` is the already-stringified
601/// `query` object (Node runs `querystring.stringify` when `query` is an object);
602/// `None` means `query` was not an object.
603pub fn format_url(u: &Url, query_string: Option<&str>) -> String {
604    let mut auth = u.auth.clone().unwrap_or_default();
605    if !auth.is_empty() {
606        auth = std::format!("{}@", encode_auth(&auth));
607    }
608
609    let mut protocol = u.protocol.clone().unwrap_or_default();
610    if !protocol.is_empty() && !protocol.ends_with(':') {
611        protocol.push(':');
612    }
613
614    let mut pathname = u.pathname.clone().unwrap_or_default();
615    let mut hash = u.hash.clone().unwrap_or_default();
616    let mut host = String::new();
617
618    if let Some(h) = u.host.as_ref().filter(|h| !h.is_empty()) {
619        host = std::format!("{auth}{h}");
620    } else if let Some(hn) = u.hostname.as_ref().filter(|h| !h.is_empty()) {
621        let bracketed = if hn.contains(':') && !is_ipv6_hostname(hn) {
622            std::format!("[{hn}]")
623        } else {
624            hn.clone()
625        };
626        host = std::format!("{auth}{bracketed}");
627        if let Some(p) = u.port.as_ref().filter(|p| !p.is_empty()) {
628            host.push(':');
629            host.push_str(p);
630        }
631    }
632
633    let query = query_string.unwrap_or("");
634    let mut search = u.search.clone().unwrap_or_default();
635    if search.is_empty() && !query.is_empty() {
636        search = std::format!("?{query}");
637    }
638
639    if pathname.contains('#') || pathname.contains('?') {
640        pathname = pathname
641            .chars()
642            .map(|c| match c {
643                '#' => "%23".to_string(),
644                '?' => "%3F".to_string(),
645                c => c.to_string(),
646            })
647            .collect();
648    }
649
650    // Only the slashed protocols get `//`; `mailto:`/`xmpp:` keep theirs only
651    // when the source had them.
652    if u.slashes == Some(true) || is_slashed_protocol(&protocol) {
653        if u.slashes == Some(true) || !host.is_empty() {
654            if !pathname.is_empty() && !pathname.starts_with('/') {
655                pathname = std::format!("/{pathname}");
656            }
657            host = std::format!("//{host}");
658        } else if protocol.starts_with("file") {
659            host = "//".into();
660        }
661    }
662
663    if search.contains('#') {
664        search = search.replace('#', "%23");
665    }
666    if !hash.is_empty() && !hash.starts_with('#') {
667        hash = std::format!("#{hash}");
668    }
669    if !search.is_empty() && !search.starts_with('?') {
670        search = std::format!("?{search}");
671    }
672
673    std::format!("{protocol}{host}{pathname}{search}{hash}")
674}
675
676// ── JS-object bridging ───────────────────────────────────────────────────────
677
678/// Build the JS `Url`-shaped object, preserving Node's field insertion order.
679pub fn to_js(u: &Url) -> Value {
680    // `query` may need `querystring.parse`, which itself allocates on the host,
681    // so resolve it before the `with_host` that builds the object.
682    let query_val: Option<Value> = u.query.as_ref().map(|q| match q {
683        Ok(raw) => with_host(|h| h.new_str(raw.clone())),
684        Err(raw) => {
685            let arg = with_host(|h| h.new_str(raw.clone()));
686            super::querystring::call("parse", &[arg])
687                .and_then(|r| r.ok())
688                .unwrap_or(Value::Undef)
689        }
690    });
691    with_host(|h| {
692        let mut m = IndexMap::new();
693        let opt = |h: &mut crate::host::JsHost, v: &Option<String>| match v {
694            Some(s) => h.new_str(s.clone()),
695            None => h.null(),
696        };
697        m.insert("protocol".into(), opt(h, &u.protocol));
698        m.insert(
699            "slashes".into(),
700            match u.slashes {
701                Some(b) => Value::Bool(b),
702                None => h.null(),
703            },
704        );
705        m.insert("auth".into(), opt(h, &u.auth));
706        m.insert("host".into(), opt(h, &u.host));
707        m.insert("port".into(), opt(h, &u.port));
708        m.insert("hostname".into(), opt(h, &u.hostname));
709        m.insert("hash".into(), opt(h, &u.hash));
710        m.insert("search".into(), opt(h, &u.search));
711        m.insert(
712            "query".into(),
713            query_val.clone().unwrap_or_else(|| h.null()),
714        );
715        m.insert("pathname".into(), opt(h, &u.pathname));
716        m.insert("path".into(), opt(h, &u.path));
717        m.insert("href".into(), opt(h, &u.href));
718        h.new_object(m)
719    })
720}
721
722/// Read a JS object back into a `Url` for `url.format(obj)`. A missing,
723/// `undefined` or `null` property stays `None` (JS falsy), matching the
724/// `this.x || ''` reads in `Url.prototype.format`.
725fn from_js(v: &Value) -> (Url, Option<String>) {
726    // `query` may be an object, which `format` stringifies via querystring.
727    let query_obj = with_host(|h| match h.get(v) {
728        Some(JsObj::Object(p)) => p.get("query").cloned(),
729        _ => None,
730    });
731    let query_string = match &query_obj {
732        Some(q) if with_host(|h| matches!(h.get(q), Some(JsObj::Object(_)))) => {
733            super::querystring::call("stringify", std::slice::from_ref(q))
734                .and_then(|r| r.ok())
735                .map(|s| with_host(|h| h.str_of(&s)))
736        }
737        _ => None,
738    };
739    let get = |k: &str| {
740        with_host(|h| match h.get(v) {
741            Some(JsObj::Object(p)) => match p.get(k) {
742                None | Some(Value::Undef) => None,
743                Some(x) if h.is_null(x) => None,
744                Some(x) => Some(h.str_of(x)),
745            },
746            _ => None,
747        })
748    };
749    let slashes = with_host(|h| match h.get(v) {
750        Some(JsObj::Object(p)) => p.get("slashes").map(|x| h.truthy(x)),
751        _ => None,
752    });
753    let u = Url {
754        protocol: get("protocol"),
755        slashes,
756        auth: get("auth"),
757        host: get("host"),
758        port: get("port"),
759        hostname: get("hostname"),
760        hash: get("hash"),
761        search: get("search"),
762        query: None,
763        pathname: get("pathname"),
764        path: get("path"),
765        href: get("href"),
766    };
767    (u, query_string)
768}
769
770/// `url.format(urlObject)` — a string is re-parsed first, a `URL` instance uses
771/// its `href`, and anything else goes through `Url.prototype.format`.
772pub fn format_value(v: &Value) -> Result<Value, String> {
773    if let Some(s) = with_host(|h| h.as_str(v)) {
774        let u = parse(&s, false, false)?;
775        let out = format_url(&u, None);
776        return Ok(with_host(|h| h.new_str(out)));
777    }
778    // A WHATWG `URL` instance formats to its `href`.
779    let href = with_host(|h| match h.get(v) {
780        Some(JsObj::Object(p)) if p.get("@@native").is_some() => p.get("href").map(|x| h.str_of(x)),
781        _ => None,
782    });
783    if let Some(href) = href {
784        return Ok(with_host(|h| h.new_str(href)));
785    }
786    let is_obj = with_host(|h| matches!(h.get(v), Some(JsObj::Object(_))));
787    if !is_obj {
788        let received = super::received_desc(v);
789        return Err(std::format!(
790            "TypeError [ERR_INVALID_ARG_TYPE]: The \"urlObject\" argument must be \
791             one of type object or string. Received {received}"
792        ));
793    }
794    let (u, qs) = from_js(v);
795    let out = format_url(&u, qs.as_deref());
796    Ok(with_host(|h| h.new_str(out)))
797}