Skip to content

node:url: route url.format(URL[,opts]) to the WHATWG serializer - #32424

Closed
robobun wants to merge 1 commit into
mainfrom
farm/e319bb0b/url-format-whatwg-options
Closed

node:url: route url.format(URL[,opts]) to the WHATWG serializer#32424
robobun wants to merge 1 commit into
mainfrom
farm/e319bb0b/url-format-whatwg-options

Conversation

@robobun

@robobun robobun commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

Closes #24233
Fixes #24343
Fixes #18695

What does this PR do?

url.format(url, options) from node:url now serializes WHATWG URL instances through a WHATWG serializer instead of the legacy Url.prototype.format path. This fixes two user-visible bugs:

  1. The // authority marker and userinfo were dropped for every scheme outside the legacy slashedProtocol table (http/https/ftp/gopher/file). That includes ws:, wss:, git+ssh:, file: with an empty host, and any custom scheme.
  2. The fragment, search, auth, and unicode options were silently ignored.

Repro

import url from "node:url";

url.format(new URL("wss://h:99/x?q"))
// Before: "wss:h:99/x?q"
// After:  "wss://h:99/x?q"

url.format(new URL("git+ssh://git@github.com/x.git"))
// Before: "git+ssh:github.com/x.git"   (userinfo gone, reparses to a different host)
// After:  "git+ssh://git@github.com/x.git"

url.format(new URL("file:///a/b"))
// Before: "file:/a/b"
// After:  "file:///a/b"

url.format(new URL("https://a:b@example.org/"))
// Before: "https://example.org/"       (auth dropped)
// After:  "https://a:b@example.org/"

url.format(new URL("https://example.org?abc#foo"), { fragment: false })
// Before: "https://example.org/?abc#foo"   (option ignored)
// After:  "https://example.org/?abc"

Node returns u.href unchanged for url.format(u) with no options; Bun now matches.

Cause

urlFormat in src/js/node/url.ts had no branch for WHATWG URL instances and never accepted a second options parameter. A URL instance fell through to Url.prototype.format.$call(urlObject), which reads legacy Url fields (.auth, .slashes, .query) that do not exist on WHATWG URL. With .slashes undefined, only the hardcoded slashedProtocol table got // back, and with .auth undefined the userinfo was always stripped.

Fix

Add an urlObject instanceof URL branch mirroring Node's lib/url.js: read auth, fragment, search, unicode from options (defaults true, true, true, false), validate with validateObject, and re-serialize from the URL's components. The authority check reads // directly out of href so it works for any scheme. unicode: true runs the hostname through domainToUnicode per label.

Verification

  • USE_SYSTEM_BUN=1 bun test test/js/node/url/url-format-whatwg.test.js fails (4/4)
  • bun bd test test/js/node/url/url-format-whatwg.test.js passes (4/4)

The test file previously had the full set of Node-ported assertions commented out behind // TODO: Support these.; they are now enabled and a round-trip test for ws:/wss:/git+ssh:/file:/custom schemes has been added.


[review] gate passed · iteration 11 · 2 files touched

fails on main (without fix)
ASAN without fix: 4 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/url/url-format-whatwg.test.js
bun test v1.4.0 (aa13b3ac9)

test/js/node/url/url-format-whatwg.test.js:
4 | 
5 | describe("url.format", () => {
6 |   test("WHATWG", () => {
7 |     const myURL = new URL("http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c");
8 | 
9 |     assert.strictEqual(url.format(myURL), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c");
               ^
AssertionError: Expected values to be strictly equal:
+ actual - expected

+ 'http://xn--lck1c3crb1723bpq4a.com/a?a=b#c'
- 'http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c'

      at innerFail (node:assert:62:32)
      at strictEqual (node:assert:205:14)
      at <anonymous> (/workspace/bun/test/js/node/url/url-format-whatwg.test.js:9:12)
(fail) url.format > WHATWG [97.16ms]
87 |   });
88 | 
89 |   // https://github.com/oven-sh/bun/issues/24233
90 |   test("WHATWG fragment: false strips the hash", () => {
91 |     const myURL = new URL("https://example.org?abc#foo");
92 |     assert.strictEqual(url.format(myURL, { fragment: false }), "https://e
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (2c3c5876e)

test/js/node/url/url-format-whatwg.test.js:
(pass) url.format > WHATWG [1.74ms]
(pass) url.format > WHATWG fragment: false strips the hash [0.04ms]
(pass) url.format > WHATWG non-special schemes keep their authority [0.13ms]
(pass) url.format > WHATWG edge cases [0.28ms]

 4 pass
 0 fail
Ran 4 tests across 1 file. [261.00ms]
__F:0:S:0
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/url/url-format-whatwg.test.js
bun test v1.4.0 (aa13b3ac9)

test/js/node/url/url-format-whatwg.test.js:
(pass) url.format > WHATWG [81.13ms]
(pass) url.format > WHATWG fragment: false strips the hash [2.37ms]
(pass) url.format > WHATWG non-special schemes keep their authority [11.67ms]
(pass) url.format > WHATWG edge cases [22.24ms]

 4 pass
 0 fail
Ran 4 tests across 1 file. [2.93s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 794ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/21] gen JS modules (bundle-modules)
Preprocess modules (11720ms)
Bundle modules (41ms)
Postprocesss modules (166ms)
Bundle Functions (999ms)
Generate Code (30ms)

[12.97s] Bundled "src/js" for production
  2078 kb
  167 internal modules
  13 native modules
  90 internal functions across 19 files
[1/6] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[
... (truncated)
diff hotspot
src/js/node/url.ts                         | 118 ++++++++++++++++++++-
 test/js/node/url/url-format-whatwg.test.js | 164 ++++++++++++++++++++++-------
 2 files changed, 240 insertions(+), 42 deletions(-)

gate history · 2 passed · 1 rejected · iteration 11

evidence per changed file
file                                        reads  edits  tests
src/js/node/url.ts                              6      6      8
test/js/node/url/url-format-whatwg.test.js      6      8      8

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

url.format() now accepts an optional options argument for URL instances. The implementation validates that argument, routes URL values through a new WHATWG serializer, and adds helper logic for hostname unicode decoding and query/fragment handling. The test suite now exercises these options and edge cases with active assertions.

Changes

WHATWG url.format() options support

Layer / File(s) Summary
urlFormat signature update and options validation
src/js/node/url.ts
Imports validateObject, updates the declared and runtime urlFormat signatures to accept options, and adds a URL-instance branch that validates options and derives fragment, unicode, search, and auth flags before calling formatWHATWG.
formatWHATWG and hostnameToUnicode helpers
src/js/node/url.ts
Adds formatWHATWG to serialize WHATWG URL values with conditional authority, userinfo, unicode hostname decoding, port, and delimiter handling based on href, plus hostnameToUnicode for xn-- labels.
WHATWG format test suite activation
test/js/node/url/url-format-whatwg.test.js
Replaces commented-out cases with active assertions for basic formatting, invalid options errors, option-flag behavior, fragment stripping, and WHATWG-specific edge cases.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately summarizes the main change: routing url.format(URL[,opts]) through the WHATWG serializer.
Description check ✅ Passed The description covers the required purpose and verification details, with a clear implementation summary and test evidence.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 6:21 AM PT - Jul 23rd, 2026

@robobun, your commit aa13b3ac96dcfba4c4bfae7c7c5055b6e4a03150 passed in Build #78595! 🎉


🧪   To try this PR locally:

bunx bun-pr 32424

That installs a local version of the PR into your bun-32424 executable, so you can run:

bun-32424 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. url.format removes username and password by default #24343 - url.format() strips username and password when passed a WHATWG URL instance because it falls through to legacy Url.prototype.format which lacks .auth on WHATWG URLs
  2. Bun has different URL behaviour than node #18695 - url.format(urlObject) drops auth credentials for WHATWG URL instances while url.format(urlString) preserves them — same root cause

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #24343
Fixes #18695

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fix(node:url): Respect options parameter in url.format #24402 - Also fixes url.format from node:url does not strip fragment #24233 and url.format removes username and password by default #24343 by implementing the full auth/fragment/search/unicode options for WHATWG URLs in urlFormat
  2. fix: url.format() should support options parameter #27885 - Also fixes url.format from node:url does not strip fragment #24233 by adding fragment option support to url.format()

🤖 Generated with Claude Code

@robobun

robobun commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator Author

The diff passes locally (fail-before/pass-after verified with bun bd test test/js/node/url/url-format-whatwg.test.js). CI builds 62915, 62927, and 62948 all failed on BuildKite agent provisioning ("Image not found: linux-*-v37") before any build or test job ran, so no test results are available yet. Will need a re-run once the images are back.

The latest push (08d64d4) addresses the two review findings from the bot (path /. disambiguator, empty-but-present ?/#, opaque-host case under unicode: true) and adds tests for each.

Comment thread src/js/node/url.ts Outdated
Comment thread src/js/node/url.ts Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — both edge cases from the earlier review are addressed in 08d64d4 with tests; CI failures are BuildKite agent provisioning, not test failures.

Extended reasoning...

Overview

This PR adds a formatWHATWG serializer to src/js/node/url.ts so that url.format(whatwgURL, options) honors the auth/fragment/search/unicode options and stops dropping userinfo by default. It enables the previously commented-out Node-ported assertions in test/js/node/url/url-format-whatwg.test.js and adds an edge-case suite. Net change is ~115 new lines in url.ts plus ~90 lines of tests.

What changed since my last review

I previously flagged two Node-compat gaps (opaque-host case-folding under unicode: true, and loss of the /. disambiguator / empty ?# markers when reconstructing from public getters). Commit 08d64d4 addresses both:

  • hostnameToUnicode now decodes only labels that literally start with lowercase xn--, leaving opaque-host bytes/case intact — matching ada's behavior. Tests cover foo://EXAMPLE.com, foo://XN--0ZWM56D.EXAMPLE, foo://xn--0zwm56d.example, and a mixed-case multi-label host.
  • The no-authority branch now emits /. when pathname starts with //, and the search/fragment branches consult href to distinguish empty-but-present from absent. Tests cover web+foo:/.//p, http://a/?#, http://a/#? (with and without search/fragment options), etc.

Both inline threads are resolved and the bug-hunting pass on this revision found nothing.

Security risks

None. This is pure string re-serialization of an already-parsed WHATWG URL for Node.js API compatibility; no auth, crypto, FS, or network paths are touched. The new code only reads URL component getters and href.

Level of scrutiny

Medium. It is a hand-rolled serializer (Node uses ada's native one), so edge cases matter — but it has now been through a targeted review round, the fixes landed with regression tests, and the result is a strict improvement over the prior behavior where options were silently ignored and auth was always dropped.

Other factors

  • No CODEOWNERS entry for src/js/node/url.ts.
  • The CI failures on builds 62915/62927/62948 are all "Failed to create agent" (BuildKite image provisioning), not build or test failures; the author confirmed local fail-before/pass-after with bun bd test.
  • The github-actions bot notes potential duplicate PRs (#24402, #27885); that is a merge-coordination question, not a correctness concern for this diff.

@robobun

robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 04567dc to get this unstuck. Two things were keeping CI red:

  1. Builds 62915, 62927, and 62948 all died at BuildKite agent provisioning ("Image not found: linux-*-v37") in under a minute, before any build or test job ran. The queue is healthy again, so this push should produce a real result.
  2. lint(src/js): flag duplicate property reads between an if condition and its body #32522 added the bun/no-duplicate-conditional-property-access oxlint rule for src/js after this branch was cut. The four if (options.X != null) { ... Boolean(options.X) } blocks trip it once merged with main, so the Lint JavaScript job would have gone red on a bare retrigger. 04567dc reads the option properties into locals first, the same pattern lint(src/js): flag duplicate property reads between an if condition and its body #32522 applied to the existing sites.

Re-verified against today's main (eba370b) since the branch is two weeks old and src/js/node/url.ts changed on main in the meantime (only the legacy Url.prototype.* hoisting from #32522, which does not overlap with this diff; the merge is clean):

  • bun bd test test/js/node/url/url-format-whatwg.test.js: 3 pass, 0 fail. The wider bun bd test test/js/node/url/ run is 187 pass, 2 fail, and both failures are pre-existing 5 second timeouts under the debug build (pathToFileURL doesn't leak memory, URL.canParse > repeatedly called produces same result) that never call format and pass in under 4 seconds on a release build.
  • Differential against Node v26.3.0: url.format(new URL(input), options) on this build is byte for byte identical to Node across 52 URL inputs x 16 boolean option combinations (832/832). The corpus covers the cases from the last review round (empty but present ?/#, the file:// empty host, the /. path disambiguator, opaque host casing under unicode: true) plus percent-encoded @ in the userinfo, IPv6 literals, non-special schemes with and without an authority, and opaque-path schemes (mailto:, blob:, javascript:, data:, tel:).
Differential URL corpus
http://user:pw@h/p?q=1#f
http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c
http://user:pass@xn--0zwm56d.com:8080/path
https://a:b@xn--g6w251d/?abc#foo
http://u@h/
http://:p@h/
http://a%40b:c@xn--0zwm56d.com/a@b?x@y#z@w
http://xn--0zwm56d.com/a@b
http://u@xn--0zwm56d.com/a@b
http://h/p?#
http://h/p?
http://h/p#
http://h
http://h:8080
http://[::1]:8080/x#y
http://[::1]/
http://127.0.0.1:3000/a?b#c
file:///tmp/a#x
file:///c:/Windows?q#f
file://host/share/x#y
foo://
foo:////x
foo://h:1/p?q#f
foo:///p?q#f
web+demo:/.//not-a-host/
tel:123
mailto:a@b.com?subject=x
data:text/plain,hi#frag
javascript:alert(1)#x
ws://u:p@xn--0zwm56d.com:81/s?a#b
http://xn--ntidn-jua.example/x
http://user:pass@xn--e1afmkfd.xn--p1ai:1/%D0%BF?%D0%BF#%D0%BF
non-special://u:p@h:1/p?q#f
http://h/p?q=%20&r=%23#%20%23
foo://EXaMPLE/x
foo://xn--0zwm56d/x
HTTP://XN--0ZWM56D.COM/A
http://a.XN--0zwm56d.com/
http://0x7f.1/
http://h/#a?b
http://h/#a#b
http://h/?a%23b#x
file://xn--0zwm56d/share
blob:http://h/x
http://xn--0zwm56d.xn--0zwm56d/
http://h./
http://h/?
http://h/#
foo:/x?q#f
foo:bar?q#f
http://xn--pokmon-bta.com/
http://u:p@xn--0zwm56d.com/?#

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js/node/url.ts`:
- Around line 483-497: The options handling in url.format only calls
validateObject(options, "options") inside the truthy branch, so falsy
non-undefined inputs like false, 0, "" and null bypass validation. Update the
options guard in url.format to validate any provided value except undefined, so
these invalid arguments still flow through validateObject and throw the typed
error. Use the existing url.format options destructuring and validateObject
helper to keep the fix localized.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 733a60b2-e506-45dd-844a-f029482f8f05

📥 Commits

Reviewing files that changed from the base of the PR and between 08d64d4 and 04567dc.

📒 Files selected for processing (1)
  • src/js/node/url.ts

Comment thread src/js/node/url.ts
Comment thread src/js/node/url.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/js/node/url/url-format-whatwg.test.js (1)

86-86: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Strengthen weak self-referential assertion.

This asserts two dynamically-computed values are equal to each other rather than against a known literal. If unicode: true were silently ignored (or mishandled) for opaque-path schemes in a way that affected both calls identically, this assertion would still pass without detecting the regression.

🧪 Proposed fix
-    assert.strictEqual(url.format(new URL("tel:123")), url.format(new URL("tel:123"), { unicode: true }));
+    assert.strictEqual(url.format(new URL("tel:123"), { unicode: true }), "tel:123");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/js/node/url/url-format-whatwg.test.js` at line 86, The assertion in the
WHATWG URL format test is self-referential because it compares two computed
url.format(new URL("tel:123")) results instead of validating against an expected
literal. Update the test around the tel:123 case to assert the exact formatted
output for the default call and the unicode: true option separately, using the
url.format behavior as the source of truth. Keep the check in
url-format-whatwg.test.js near the existing tel:123 coverage so regressions in
opaque-path handling are caught even if both paths fail the same way.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@test/js/node/url/url-format-whatwg.test.js`:
- Line 86: The assertion in the WHATWG URL format test is self-referential
because it compares two computed url.format(new URL("tel:123")) results instead
of validating against an expected literal. Update the test around the tel:123
case to assert the exact formatted output for the default call and the unicode:
true option separately, using the url.format behavior as the source of truth.
Keep the check in url-format-whatwg.test.js near the existing tel:123 coverage
so regressions in opaque-path handling are caught even if both paths fail the
same way.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6c9c5fbe-c9cc-4842-9049-929f341318c1

📥 Commits

Reviewing files that changed from the base of the PR and between bc558e9 and 4aa4d1b.

📒 Files selected for processing (1)
  • test/js/node/url/url-format-whatwg.test.js

@robobun

robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the self-referential tel:123 assertion in d95b586: added

assert.strictEqual(url.format(new URL("tel:123"), { unicode: true }), "tel:123");

next to the existing tel:123 literal in the edge-cases test, so both the default and the unicode: true outputs are now checked against an absolute expected string. The relational assertion on line 86 is kept rather than replaced, since it is ported verbatim from Node's test-url-format-whatwg.js and the new literal makes it strictly redundant rather than load-bearing.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/js/node/url/url-format-whatwg.test.js (1)

86-97: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Redundant vacuous assertion still present alongside the fix.

Line 97 correctly replaces the weak "compare against itself" check with an absolute expected string, matching the commit's own rationale that computed-vs-computed comparisons "could miss a regression." However, the original vacuous assertion at Line 86 (url.format(new URL("tel:123")) compared to url.format(new URL("tel:123"), { unicode: true })) is still present and now redundant — it adds no coverage beyond what Line 97 already asserts.

Consider removing Line 86 (or converting it to an absolute-value assertion) to avoid keeping a known-weak pattern in the suite.

🧹 Suggested cleanup
-    assert.strictEqual(url.format(new URL("tel:123")), url.format(new URL("tel:123"), { unicode: true }));
+    assert.strictEqual(url.format(new URL("tel:123"), { unicode: true }), "tel:123");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/js/node/url/url-format-whatwg.test.js` around lines 86 - 97, The WHATWG
url.format tests still contain a redundant self-comparison for the tel URL case,
which adds no real coverage now that the edge-case assertions use absolute
expectations. Update the url-format-whatwg test around the WHATWG edge cases to
remove the vacuous assertion comparing url.format(new URL("tel:123")) against
the unicode variant, or replace it with an explicit expected string like the
other assertions, so the suite only keeps meaningful checks in url.format and
new URL("tel:123").
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@test/js/node/url/url-format-whatwg.test.js`:
- Around line 86-97: The WHATWG url.format tests still contain a redundant
self-comparison for the tel URL case, which adds no real coverage now that the
edge-case assertions use absolute expectations. Update the url-format-whatwg
test around the WHATWG edge cases to remove the vacuous assertion comparing
url.format(new URL("tel:123")) against the unicode variant, or replace it with
an explicit expected string like the other assertions, so the suite only keeps
meaningful checks in url.format and new URL("tel:123").

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 709d4705-d950-4d69-b7d8-9230036a9701

📥 Commits

Reviewing files that changed from the base of the PR and between 4aa4d1b and d95b586.

📒 Files selected for processing (1)
  • test/js/node/url/url-format-whatwg.test.js

@robobun

robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author

Same line as the previous round, so keeping it is deliberate rather than missed. Line 86 is a verbatim port of the assertion in Node's own test-url-format-whatwg.js, and this file is Bun's port of that test, so I'd rather not drop an upstream assertion over a redundancy. The absolute-value coverage it was missing is what d95b586 added (url.format(new URL("tel:123"), { unicode: true }) is now asserted to equal "tel:123", next to the existing tel:123 literal), and that addition is exactly what makes line 86 redundant rather than load-bearing.

Happy to remove it if a maintainer prefers the suite trimmed.

@robobun

robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author

This is ready; the remaining failure is the automated check's environment, not the diff.

In the latest automated check, the ASAN build fails with BUILD FAILED (no junit output) both with and without this PR's src/ change (the "without fix" leg strips it and fails the same way), so the build failure cannot be caused by this diff. The cause is that the check's workspace has ~145 unrelated files staged on top of the branch. One of them, src/parsers/build.rs, is not in any commit here (git cat-file -e HEAD:src/parsers/build.rs fails) but is present on disk, and its build script aborts looking for a generated file this branch's configure never produces.

On a clean checkout of the PR head (d95b586), the check's exact command passes in 40s and writes the junit report:

bun scripts/build.ts --profile=debug --quiet test --reporter=junit \
  --reporter-outfile=/tmp/gate.xml test/js/node/url/url-format-whatwg.test.js
 3 pass, 0 fail   (junit: tests="3" failures="0")

Fail-before still holds: USE_SYSTEM_BUN=1 bun test test/js/node/url/url-format-whatwg.test.js is 0 pass / 3 fail. I'll stop pushing rather than keep re-triggering the same environment; happy to re-run or rebase on request.

Comment thread src/js/node/url.ts Outdated
@robobun
robobun force-pushed the farm/e319bb0b/url-format-whatwg-options branch from d95b586 to 874e038 Compare July 1, 2026 21:28
@robobun

robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author

Correction to my previous comment: the extra files in the check's workspace are not corruption, they are current main. This branch was based on a main from several weeks ago, so the provisioned workspace (built from current main) always carried a large staged delta relative to the branch, and that delta is what broke the check's builds. That is fixable from this side, so I was wrong that it needed a maintainer.

I have rebased onto current main (clean, no conflicts; the only upstream change to src/js/node/url.ts since the old base was the bun/no-duplicate-conditional-property-access lint sweep in #32522, which this branch already complied with) and force-pushed as 874e038. That removes the workspace mismatch, so the automated checks should run normally now. The same push also picks up the review fix above: undecodable xn-- host labels now serialize to an empty label, matching Node.

Comment thread src/js/node/url.ts
@robobun

robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author

CI on the latest push is red only on failures that are unrelated to this change.

test/js/node/url/url-format-whatwg.test.js passed on every lane that ran it (265 passing jobs across darwin, debian, ubuntu, alpine, and windows, on x64, aarch64, baseline, and asan), and no failure annotation in the build mentions url at all. These are also the first release-build runs of this test (local verification is against the debug build), so both build profiles are now covered.

The failing lanes break down as:

  • test/bake/dev/production.test.ts ("works with sourcemaps - error thrown in React component"), a 90s timeout on 14 lanes. This is not from this PR: the 8 most recent builds of other branches (for example 67739, 67744, 67750) all fail the same test, so it is a main-side failure that every current PR build is hitting.
  • test/js/node/zlib/zlib.test.js ("zlib.brotli streaming encode doesn't wait for entire input"), a 15s timeout on 7 lanes.
  • test/js/node/test/parallel/test-net-connect-memleak.js, a GC-timing assertion on 2 alpine lanes.
  • A group CI itself tagged flaky and auto-retried (spawn-pipe-leak, napi, hot, webview-chrome, in-process-cron, node-http-connect, load-same-js-file-a-lot).
  • Two darwin 26 aarch64 jobs that died on buildkite-agent artifact download timed out after 120s before running any test.

For completeness, the only consumer of node:url inside src/js/ other than url.ts itself is pathToFileURL in internal/util/inspect.js, so there is no path from this change to any of the above. I'm not pushing a retrigger, since a re-run cannot change a deterministic main-side failure. This is ready to merge.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All of my earlier findings have been addressed and no new issues turned up this round, but this is a hand-written ~90-line WHATWG URL serializer with a documented deliberate divergence from Node's ada — worth a maintainer sign-off on the approach rather than an auto-approve.

Extended reasoning...

Overview

Adds a URL-instance branch to urlFormat in src/js/node/url.ts with two new helpers, formatWHATWG (~60 lines) and hostnameToUnicode (~25 lines), and enables/extends the previously commented-out Node-ported assertions in test/js/node/url/url-format-whatwg.test.js. Fixes three linked issues where url.format(new URL(...), options) dropped auth and ignored fragment/search/auth/unicode.

Security risks

None identified. Pure string serialization of an already-parsed URL; no I/O, no auth/crypto, no path resolution against the filesystem. The ?/# scan of href is safe because the WHATWG parser percent-encodes those bytes everywhere except the actual delimiters.

Level of scrutiny

Medium-high. This is Node-compat surface (node:url) and the implementation is a from-scratch serializer that reconstructs the URL from public getters plus href scanning, rather than calling a native serializer the way Node does via ada. It has been through five rounds of inline review here (opaque-host case preservation, the /. path disambiguator, empty-but-present ?/#, != null option semantics, bare/undecodable xn-- labels) and each was fixed with a test. The author also ran an 832-case differential against Node v26.3.0. That's a lot of confidence, but the shape of the solution is non-trivial enough that a human should ratify it.

Other factors

  • One deliberate, documented divergence remains: mixed-case xn--… labels in opaque (non-special-scheme) hosts under unicode: true go through UTS#46 domainToUnicode rather than raw RFC 3492, so output matches Node's own url.domainToUnicode but not Node's url.format. I flagged this as non-blocking and the author added a source comment; a maintainer should confirm that trade-off is acceptable.
  • Two other open PRs (#24402, #27885) reportedly overlap with this fix — a human should decide which one lands.
  • All inline threads on this PR are resolved; the bug-hunting system found nothing new this run.

@robobun
robobun force-pushed the farm/e319bb0b/url-format-whatwg-options branch from 8ab251f to 2c3c587 Compare July 23, 2026 09:25
@robobun robobun changed the title node:url: honor options when formatting WHATWG URL instances node:url: route url.format(URL[,opts]) to the WHATWG serializer Jul 23, 2026
@robobun

robobun commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main (was 439 commits behind with conflicts) and added a round-trip test for ws:/wss:/git+ssh:/file:/custom-scheme URLs, where the authority marker was being dropped entirely (url.format(new URL("wss://h:99/x?q")) returned "wss:h:99/x?q").

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — all prior review rounds have been addressed and the bug hunter found nothing new on the current head.

What was reviewed:

  • formatWHATWG reconstruction: authority detection via href[protocol.length..+1] === '//', the /. path disambiguator, and empty-vs-absent ?/# recovery via href.indexOf (verified ?/# cannot appear earlier in a WHATWG serialization; the qIdx < hashIdx guard handles ? inside a fragment).
  • Options handling: if (options) truthy-gate and per-option != null both match Node v26.3.0's lib/url.js and are pinned by tests.
  • hostnameToUnicode: per-label xn-- gate preserves opaque-host case; the one documented divergence (mixed-case content after lowercase xn--) is narrow, commented, and matches Node's own domainToUnicode.
Extended reasoning...

Overview

Adds a WHATWG URL branch to urlFormat in src/js/node/url.ts (~115 new lines: option parsing, formatWHATWG, hostnameToUnicode) and un-comments/extends the Node-ported assertions in test/js/node/url/url-format-whatwg.test.js. Fixes three filed issues where url.format(new URL(...), opts) dropped auth and ignored fragment/search/auth/unicode.

Security risks

None identified. This is pure string re-assembly of components the WHATWG parser has already canonicalized/percent-encoded. No new parsing of untrusted bytes; domainToUnicode is the existing native binding. No filesystem, network, or credential handling.

Level of scrutiny

Medium. It's a Node-compat serializer with observable output that other packages rely on, so correctness matters, but the blast radius is limited to url.format(URL, ...) — the legacy Url path is untouched. The change has been through four review rounds here (opaque-host casing, /. disambiguator, empty ?/#, undecodable xn-- labels, != null semantics), each addressed with code + a targeted test, plus an 832-case differential against Node v26.3.0 recorded on the PR.

Other factors

  • Every prior inline finding on this PR is marked resolved with a corresponding fix commit or a Node-verified rebuttal (the != null behavior I flagged turned out to match Node exactly; tests now pin it).
  • The remaining known divergence (UTS#46 vs raw punycode for mixed-case xn-- labels in non-special-scheme opaque hosts under unicode: true) is deliberately documented in a code comment, requires hand-crafted input to observe, and I explicitly said it was non-blocking in the earlier round.
  • Test file passed on all 265 CI lanes; the red lanes were unrelated main-side flakes (bake/production, zlib brotli, net-connect-memleak) that don't touch node:url.
  • The bug-hunting system found nothing on the current head.

url.format() had no branch for WHATWG URL instances; they fell through
to Url.prototype.format, which reads legacy .slashes/.auth fields and
only emits "//" for the slashedProtocol table. Any scheme outside that
table (ws, wss, git+ssh, file, custom) lost its authority marker and
userinfo:

  url.format(new URL("wss://h:99/x?q"))          => "wss:h:99/x?q"
  url.format(new URL("git+ssh://git@h/x.git"))   => "git+ssh:h/x.git"
  url.format(new URL("file:///a/b"))             => "file:/a/b"

Add the instanceof URL branch, read the auth/fragment/search/unicode
options the same way Node does, and serialize from the URL's components
so format(u) === u.href modulo options.

Fixes #24233
Fixes #24343
Fixes #18695
@robobun
robobun force-pushed the farm/e319bb0b/url-format-whatwg-options branch from 2c3c587 to aa13b3a Compare July 23, 2026 09:39

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — all four rounds of prior findings on this PR are addressed and the current diff has no open issues.

What was reviewed:

  • formatWHATWG re-serialization: authority // detection via href, the /. path disambiguator, and empty-but-present ?/# recovery — all match the WHATWG serializer and Node.
  • hostnameToUnicode: per-label xn-- decoding preserves opaque-host case; the one deliberate divergence (mixed-case punycode under non-special schemes) is documented.
  • Option handling (!= null, truthy-options guard) verified against Node v26.3.0; the 832-case differential in the thread is byte-for-byte.
  • Ruled out: ?/# cannot appear literally in href before their delimiter positions, so indexOf scans are safe; cross-realm instanceof URL was considered and is a non-issue in practice.
Extended reasoning...

Overview

This PR adds a WHATWG-URL branch to urlFormat in src/js/node/url.ts so that url.format(new URL(...), options) no longer falls through to the legacy Url.prototype.format path (which dropped // and userinfo for any scheme outside the hardcoded slashedProtocol table and ignored the auth/fragment/search/unicode options). Two new helpers, formatWHATWG and hostnameToUnicode, implement the serializer; the test file un-comments the ported Node assertions and adds ~40 edge-case assertions covering everything raised in review.

Security risks

None. This is pure string re-serialization of an already-parsed WHATWG URL object — no filesystem, network, or eval surface. The output is derived from URL getters plus indexOf scans of .href; ? and # are percent-encoded by the WHATWG parser everywhere except their delimiter positions, so the scans cannot be confused by user-controlled path/userinfo bytes.

Level of scrutiny

Medium: it's a user-facing Node-compat API in the built-in JS layer, but no native code, no memory management, and the change is additive (a new branch guarded by instanceof URL; the legacy path is untouched). This PR has already been through four rounds of my own inline review — every finding (opaque-host case-folding, /. disambiguator, empty ?/#, != null semantics, bare/undecodable xn-- labels, mixed-case punycode) was either fixed with a test or, in the one case where matching Node exactly would require reimplementing raw RFC 3492 punycode for a hand-crafted input class, documented as a deliberate divergence that agrees with Node's own url.domainToUnicode. The author ran an 832-case differential (52 URLs × 16 option combos) against Node v26.3.0 with byte-for-byte parity.

Other factors

All prior inline threads are resolved. The CodeRabbit suggestion (validate falsy options) was correctly declined — Node itself gates on truthy options, and the test now pins that behavior for false/0/''/null. The one candidate the finder raised this run (cross-realm instanceof URL) was verified as a non-issue: a cross-realm URL falling through lands in Url.prototype.format.$call, which is exactly the pre-PR behavior for that input, so nothing regresses. CI on the previous head passed the target test on 265 lanes; the current head is a clean rebase plus one added round-trip test.

@alii

alii commented Aug 12, 2026

Copy link
Copy Markdown
Member

landed in #34660, url.format(URL, opts) matches node on main now

@alii alii closed this Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

url.format removes username and password by default url.format from node:url does not strip fragment Bun has different URL behaviour than node

3 participants