Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 115 additions & 3 deletions src/js/node/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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.
Expand All @@ -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);
Comment thread
robobun marked this conversation as resolved.
}
Comment thread
robobun marked this conversation as resolved.
}

return formatWHATWG(urlObject, auth, fragment, search, unicode);
}

if (!(urlObject instanceof Url)) {
Expand All @@ -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);
}
Comment thread
robobun marked this conversation as resolved.
}
return labels.join(".");
}

Url.prototype.format = function format() {
var auth: string = this.auth || "";
if (auth) {
Expand Down
164 changes: 125 additions & 39 deletions test/js/node/url/url-format-whatwg.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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/");
});
});
Loading