diff --git a/src/js/node/url.ts b/src/js/node/url.ts index e39f66566d63..eb9c4c201149 100644 --- a/src/js/node/url.ts +++ b/src/js/node/url.ts @@ -28,7 +28,7 @@ const { URL, URLSearchParams } = globalThis; const [domainToASCII, domainToUnicode] = $cpp("NodeURL.cpp", "Bun::createNodeURLBinding"); const { urlToHttpOptions } = require("internal/url"); -const { validateString } = require("internal/validators"); +const { validateString, validateObject } = require("internal/validators"); const ObjectSetPrototypeOf = Object.setPrototypeOf; function Url() { @@ -465,8 +465,8 @@ function getHostname(self, rest, hostname: string, url) { } // format a parsed object into a url string -declare function urlFormat(urlObject: string | URL | Url): string; -function urlFormat(urlObject: unknown) { +declare function urlFormat(urlObject: string | URL | Url, options?: object): string; +function urlFormat(urlObject: unknown, options?: unknown) { /* * ensure it's an object, and not a string url. * If it's an obj, this is a no-op. @@ -478,6 +478,31 @@ function urlFormat(urlObject: unknown) { // NOTE: $isObject returns true for functions } else if (typeof urlObject !== "object" || urlObject === null) { throw $ERR_INVALID_ARG_TYPE("urlObject", ["Object", "string"], urlObject); + } else if (urlObject instanceof URL) { + let fragment = true; + let unicode = false; + let search = true; + let auth = true; + + if (options) { + validateObject(options, "options"); + + const { fragment: fragmentOption, unicode: unicodeOption, search: searchOption, auth: authOption } = options; + if (fragmentOption != null) { + fragment = Boolean(fragmentOption); + } + if (unicodeOption != null) { + unicode = Boolean(unicodeOption); + } + if (searchOption != null) { + search = Boolean(searchOption); + } + if (authOption != null) { + auth = Boolean(authOption); + } + } + + return formatWHATWG(urlObject, auth, fragment, search, unicode); } if (!(urlObject instanceof Url)) { @@ -486,6 +511,93 @@ function urlFormat(urlObject: unknown) { return urlObject.format(); } +function formatWHATWG(urlObject: URL, auth: boolean, fragment: boolean, search: boolean, unicode: boolean) { + const href = urlObject.href; + const protocol = urlObject.protocol; + const pathname = urlObject.pathname; + + let ret = protocol; + + // A URL has an authority component if its serialization has "//" directly + // after the scheme. Special-scheme URLs (http, https, ws, wss, ftp, file) + // always do; non-special URLs may or may not. + if ( + href.length > protocol.length + 1 && + href.$charCodeAt(protocol.length) === Char.FORWARD_SLASH && + href.$charCodeAt(protocol.length + 1) === Char.FORWARD_SLASH + ) { + ret += "//"; + + const username = urlObject.username; + const password = urlObject.password; + if (auth && (username || password)) { + ret += username; + if (password) ret += ":" + password; + ret += "@"; + } + + ret += unicode ? hostnameToUnicode(urlObject.hostname) : urlObject.hostname; + + const port = urlObject.port; + if (port) ret += ":" + port; + } else if ( + pathname.length > 1 && + pathname.$charCodeAt(0) === Char.FORWARD_SLASH && + pathname.$charCodeAt(1) === Char.FORWARD_SLASH + ) { + // https://url.spec.whatwg.org/#url-serializing step 3: when the host is + // null and the first path segment is empty, emit "/." so the result does + // not re-parse as having an authority. + ret += "/."; + } + + ret += pathname; + + // The .search and .hash getters collapse "absent" and "empty" to the same + // value (""). "?" and "#" only appear in the serialized href as the query + // and fragment delimiters, so scan the href to recover the empty case. + const hashIdx = href.indexOf("#"); + if (search) { + let s = urlObject.search; + if (!s) { + const qIdx = href.indexOf("?"); + if (qIdx !== -1 && (hashIdx === -1 || qIdx < hashIdx)) s = "?"; + } + ret += s; + } + if (fragment) { + ret += urlObject.hash || (hashIdx !== -1 ? "#" : ""); + } + + return ret; +} + +function hostnameToUnicode(hostname: string) { + // Only labels that literally start with "xn--" are decoded; others keep + // their original bytes and case (opaque hosts for non-special schemes are + // not lowercased by the parser). domainToUnicode is UTS#46 and case-folds, + // so a mixed-case "xn--" label in an opaque host can differ from Node's + // serializer (raw per-label punycode); it matches Node's own + // url.domainToUnicode instead. + if (!hostname || hostname.$charCodeAt(0) === Char.LEFT_SQUARE_BRACKET || hostname.indexOf("xn--") === -1) { + return hostname; + } + const labels = hostname.split("."); + for (let i = 0; i < labels.length; i++) { + const label = labels[i]; + if ( + label.length >= 4 && + label.$charCodeAt(0) === 120 /* x */ && + label.$charCodeAt(1) === 110 /* n */ && + label.$charCodeAt(2) === 45 /* - */ && + label.$charCodeAt(3) === 45 /* - */ + ) { + labels[i] = domainToUnicode(label); + } + } + return labels.join("."); +} + Url.prototype.format = function format() { var auth: string = this.auth || ""; if (auth) { diff --git a/test/js/node/url/url-format-whatwg.test.js b/test/js/node/url/url-format-whatwg.test.js index 91c6535ffb51..d6a724607f33 100644 --- a/test/js/node/url/url-format-whatwg.test.js +++ b/test/js/node/url/url-format-whatwg.test.js @@ -6,22 +6,34 @@ describe("url.format", () => { test("WHATWG", () => { const myURL = new URL("http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c"); - // TODO: Support these. - // - // assert.strictEqual(url.format(myURL), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c"); - - // assert.strictEqual(url.format(myURL, {}), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c"); - - // TODO: Support this kind of assert.throws. - // { - // [true, 1, "test", Infinity].forEach(value => { - // assert.throws(() => url.format(myURL, value), { - // code: "ERR_INVALID_ARG_TYPE", - // name: "TypeError", - // message: 'The "options" argument must be of type object.', - // }); - // }); - // } + assert.strictEqual(url.format(myURL), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c"); + + assert.strictEqual(url.format(myURL, {}), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c"); + + { + [true, 1, "test", Infinity].forEach(value => { + assert.throws(() => url.format(myURL, value), { + code: "ERR_INVALID_ARG_TYPE", + name: "TypeError", + }); + }); + } + + // Node only validates a truthy `options` value; falsy values other than + // undefined are ignored and the defaults apply (auth is kept). + { + [false, 0, "", null].forEach(value => { + assert.strictEqual(url.format(myURL, value), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c"); + }); + } + + // An explicit `null` option value is treated the same as undefined: the + // default applies (Node checks each option with `!= null`). + { + ["auth", "fragment", "search", "unicode"].forEach(name => { + assert.strictEqual(url.format(myURL, { [name]: null }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c"); + }); + } // Any falsy value other than undefined will be treated as false. // Any truthy value will be treated as true. @@ -32,47 +44,121 @@ describe("url.format", () => { assert.strictEqual(url.format(myURL, { auth: 0 }), "http://xn--lck1c3crb1723bpq4a.com/a?a=b#c"); - // TODO: Support these. - // - // assert.strictEqual(url.format(myURL, { auth: 1 }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c"); + assert.strictEqual(url.format(myURL, { auth: 1 }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c"); - // assert.strictEqual(url.format(myURL, { auth: {} }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c"); + assert.strictEqual(url.format(myURL, { auth: {} }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c"); - // assert.strictEqual(url.format(myURL, { fragment: false }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b"); + assert.strictEqual(url.format(myURL, { fragment: false }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b"); - // assert.strictEqual(url.format(myURL, { fragment: "" }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b"); + assert.strictEqual(url.format(myURL, { fragment: "" }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b"); - // assert.strictEqual(url.format(myURL, { fragment: 0 }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b"); + assert.strictEqual(url.format(myURL, { fragment: 0 }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b"); - // assert.strictEqual(url.format(myURL, { fragment: 1 }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c"); + assert.strictEqual(url.format(myURL, { fragment: 1 }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c"); - // assert.strictEqual(url.format(myURL, { fragment: {} }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c"); + assert.strictEqual(url.format(myURL, { fragment: {} }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c"); - // assert.strictEqual(url.format(myURL, { search: false }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a#c"); + assert.strictEqual(url.format(myURL, { search: false }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a#c"); - // assert.strictEqual(url.format(myURL, { search: "" }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a#c"); + assert.strictEqual(url.format(myURL, { search: "" }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a#c"); - // assert.strictEqual(url.format(myURL, { search: 0 }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a#c"); + assert.strictEqual(url.format(myURL, { search: 0 }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a#c"); - // assert.strictEqual(url.format(myURL, { search: 1 }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c"); + assert.strictEqual(url.format(myURL, { search: 1 }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c"); - // assert.strictEqual(url.format(myURL, { search: {} }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c"); + assert.strictEqual(url.format(myURL, { search: {} }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c"); - // assert.strictEqual(url.format(myURL, { unicode: true }), "http://user:pass@理容ナカムラ.com/a?a=b#c"); + assert.strictEqual(url.format(myURL, { unicode: true }), "http://user:pass@理容ナカムラ.com/a?a=b#c"); - // assert.strictEqual(url.format(myURL, { unicode: 1 }), "http://user:pass@理容ナカムラ.com/a?a=b#c"); + assert.strictEqual(url.format(myURL, { unicode: 1 }), "http://user:pass@理容ナカムラ.com/a?a=b#c"); - // assert.strictEqual(url.format(myURL, { unicode: {} }), "http://user:pass@理容ナカムラ.com/a?a=b#c"); + assert.strictEqual(url.format(myURL, { unicode: {} }), "http://user:pass@理容ナカムラ.com/a?a=b#c"); - // assert.strictEqual(url.format(myURL, { unicode: false }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c"); + assert.strictEqual(url.format(myURL, { unicode: false }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c"); - // assert.strictEqual(url.format(myURL, { unicode: 0 }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c"); + assert.strictEqual(url.format(myURL, { unicode: 0 }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c"); - // assert.strictEqual( - // url.format(new URL("http://user:pass@xn--0zwm56d.com:8080/path"), { unicode: true }), - // "http://user:pass@测试.com:8080/path", - // ); + assert.strictEqual( + url.format(new URL("http://user:pass@xn--0zwm56d.com:8080/path"), { unicode: true }), + "http://user:pass@测试.com:8080/path", + ); assert.strictEqual(url.format(new URL("tel:123")), url.format(new URL("tel:123"), { unicode: true })); }); + + // https://github.com/oven-sh/bun/issues/24233 + test("WHATWG fragment: false strips the hash", () => { + const myURL = new URL("https://example.org?abc#foo"); + assert.strictEqual(url.format(myURL, { fragment: false }), "https://example.org/?abc"); + }); + + // Previously a WHATWG URL fell into the legacy formatter, which only emits + // "//" for the slashedProtocol table (http/https/ftp/gopher/file) and reads + // `.slashes`/`.auth` (both undefined on URL). The result dropped the + // authority marker and userinfo for every other scheme. + test("WHATWG non-special schemes keep their authority", () => { + for (const href of [ + "wss://h:99/x?q", + "ws://h/x", + "git+ssh://git@github.com/x.git", + "myapp://open/x", + "custom://a.b:8/c", + "file:///a/b", + "file://host/a/b", + ]) { + const u = new URL(href); + assert.strictEqual(url.format(u), u.href, href); + assert.strictEqual(url.format(u, {}), u.href, href); + } + }); + + test("WHATWG edge cases", () => { + assert.strictEqual(url.format(new URL("tel:123")), "tel:123"); + assert.strictEqual(url.format(new URL("tel:123"), { unicode: true }), "tel:123"); + assert.strictEqual(url.format(new URL("file:///path"), { unicode: true }), "file:///path"); + assert.strictEqual(url.format(new URL("http://[::1]:8080/path"), { unicode: true }), "http://[::1]:8080/path"); + assert.strictEqual(url.format(new URL("foo://bar/path"), { unicode: true }), "foo://bar/path"); + assert.strictEqual( + url.format(new URL("http://user@example.com/path"), { auth: true }), + "http://user@example.com/path", + ); + assert.strictEqual( + url.format(new URL("http://user:pass@example.com/path?q#h"), { auth: false, search: false, fragment: false }), + "http://example.com/path", + ); + assert.strictEqual(url.format(new URL("blob:http://a/b")), "blob:http://a/b"); + + // No authority, path starting with "//": must emit "/." so the result + // round-trips instead of re-parsing with a host. + assert.strictEqual(url.format(new URL("web+foo:/.//p")), "web+foo:/.//p"); + assert.strictEqual(new URL(url.format(new URL("web+foo:/.//p"))).pathname, "//p"); + + // .search and .hash return "" for both absent and empty; the serializer + // must keep a bare "?" or "#" that is present in the href. + assert.strictEqual(url.format(new URL("http://a/?#")), "http://a/?#"); + assert.strictEqual(url.format(new URL("http://a/?#"), { search: false }), "http://a/#"); + assert.strictEqual(url.format(new URL("http://a/?#"), { fragment: false }), "http://a/?"); + assert.strictEqual(url.format(new URL("http://a/?")), "http://a/?"); + assert.strictEqual(url.format(new URL("http://a/#")), "http://a/#"); + assert.strictEqual(url.format(new URL("http://a/#?")), "http://a/#?"); + assert.strictEqual(url.format(new URL("http://a/#?"), { search: false }), "http://a/#?"); + assert.strictEqual(url.format(new URL("http://a/p?q#"), { fragment: false }), "http://a/p?q"); + + // Opaque hosts (non-special schemes) keep their case with unicode: true; + // only labels that literally start with "xn--" are decoded. + assert.strictEqual(url.format(new URL("foo://EXAMPLE.com/p"), { unicode: true }), "foo://EXAMPLE.com/p"); + assert.strictEqual(url.format(new URL("foo://xn--0zwm56d.example/p"), { unicode: true }), "foo://测试.example/p"); + assert.strictEqual( + url.format(new URL("foo://XN--0ZWM56D.EXAMPLE/p"), { unicode: true }), + "foo://XN--0ZWM56D.EXAMPLE/p", + ); + assert.strictEqual( + url.format(new URL("foo://Sub.xn--0zwm56d.Example/p"), { unicode: true }), + "foo://Sub.测试.Example/p", + ); + + // A bare or undecodable "xn--" label becomes an empty label, matching Node. + assert.strictEqual(url.format(new URL("foo://xn--.a/"), { unicode: true }), "foo://.a/"); + assert.strictEqual(url.format(new URL("foo://xn--a.b/"), { unicode: true }), "foo://.b/"); + }); });