Skip to content
Open
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 7 additions & 5 deletions src/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,11 +172,13 @@ url.host() // bun_core::String — the hostname WITHOUT the port (opposite
url.port() // u32 (u32::MAX = unset; otherwise u16 range)
```

`URL::href_from_js`, `URL::file_url_from_string`, `URL::path_from_file_url`
do whole-string conversions. The JSC-free shim `bun_url::whatwg::URL` exposes
`hostname()`, which returns the host WITH the port (also the opposite of JS
`hostname`) — so `bun_jsc::URL::host` and `bun_url::whatwg::URL::hostname`
are effectively swapped relative to their JS namesakes.
`bun_jsc::URL` is a re-export of `bun_url::whatwg::URL`; the JS-value entry
points (`href_from_js`, `from_js`) come from the `bun_jsc::UrlJsc` extension
trait. Whole-string conversions are free functions in `bun_url`
(`href_from_string`, `join`, `file_url_from_string`; the file-URL pair also
has by-value `URL::` associated forms). `whatwg::URL::hostname()` returns the
host WITH the port, so `host` and `hostname` are swapped relative to their JS
namesakes.

## MIME Types (`bun_http_types::MimeType`)

Expand Down
94 changes: 10 additions & 84 deletions src/jsc/URL.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,102 +3,28 @@ use core::ptr::NonNull;
use bun_core::String;
use bun_jsc::{JSGlobalObject, JSValue, JsResult};

bun_opaque::opaque_ffi! {
/// Opaque handle to a WebKit `WTF::URL` allocated on the C++ side.
pub struct URL;
}
pub use bun_url::whatwg::URL;
Comment thread
robobun marked this conversation as resolved.

// Getters take `&URL` (non-null `*const URL` at the C ABI; BunString.cpp never
// mutates the WTF::URL on read). `&mut String` for the in/out params is
// ABI-identical to non-null `*mut String`. `URL__deinit` consumes the C++
// allocation, so it keeps a raw pointer and stays `unsafe fn`.
unsafe extern "C" {
safe fn URL__fromJS(value: JSValue, global: &JSGlobalObject) -> *mut URL;
safe fn URL__fromString(input: &mut String) -> *mut URL;
safe fn URL__protocol(url: &URL) -> String;
safe fn URL__username(url: &URL) -> String;
safe fn URL__password(url: &URL) -> String;
safe fn URL__host(url: &URL) -> String;
safe fn URL__port(url: &URL) -> u32;
fn URL__deinit(url: *mut URL);
safe fn URL__pathname(url: &URL) -> String;
safe fn URL__getHrefFromJS(value: JSValue, global: &JSGlobalObject) -> String;
safe fn URL__getFileURLString(input: &mut String) -> String;
safe fn URL__pathFromFileURL(input: &mut String) -> String;
}

impl URL {
pub fn file_url_from_string(str: String) -> String {
let mut input = str;
URL__getFileURLString(&mut input)
}

pub fn path_from_file_url(str: String) -> String {
let mut input = str;
URL__pathFromFileURL(&mut input)
}
pub trait UrlJsc: Sized {
/// Percent-encoded href; invalid input yields a Dead-tagged string without throwing.
fn href_from_js(value: JSValue, global: &JSGlobalObject) -> JsResult<String>;
/// Returns an owned C++ heap pointer that the caller must `destroy()`.
fn from_js(value: JSValue, global: &JSGlobalObject) -> JsResult<Option<NonNull<Self>>>;
}

/// This percent-encodes the URL, punycode-encodes the hostname, and returns the result
/// If it fails, the tag is marked Dead
impl UrlJsc for URL {
#[track_caller]
pub fn href_from_js(value: JSValue, global: &JSGlobalObject) -> JsResult<String> {
fn href_from_js(value: JSValue, global: &JSGlobalObject) -> JsResult<String> {
crate::call_check_slow(global, || URL__getHrefFromJS(value, global))
}

#[track_caller]
pub fn from_js(value: JSValue, global: &JSGlobalObject) -> JsResult<Option<NonNull<URL>>> {
fn from_js(value: JSValue, global: &JSGlobalObject) -> JsResult<Option<NonNull<URL>>> {
crate::call_check_slow(global, || URL__fromJS(value, global)).map(NonNull::new)
}

pub fn from_utf8(input: &[u8]) -> Option<NonNull<URL>> {
Self::from_string(String::borrow_utf8(input))
}

pub fn from_string(str: String) -> Option<NonNull<URL>> {
let mut input = str;
NonNull::new(URL__fromString(&mut input))
}
// from_js/from_string/from_utf8 return an owned C++ heap pointer that the
// caller must destroy().

pub fn protocol(&self) -> String {
URL__protocol(self)
}

pub fn username(&self) -> String {
URL__username(self)
}

pub fn password(&self) -> String {
URL__password(self)
}

/// Returns the host WITHOUT the port.
///
/// Note that this does NOT match JS behavior, which returns the host with the port. The
/// with-port form lives on the JSC-free shim as `bun_url::whatwg::URL::hostname`.
///
/// ```text
/// URL("http://example.com:8080").host() => "example.com"
/// ```
pub fn host(&self) -> String {
URL__host(self)
}

/// Returns `u32::MAX` if the port is not set. Otherwise, `port`
/// is guaranteed to be within the `u16` range.
pub fn port(&self) -> u32 {
URL__port(self)
}

// Kept as explicit destroy (not Drop) — URL is an opaque #[repr(C)] FFI
// handle constructed/destroyed across the C++ boundary.
pub unsafe fn destroy(this: *mut Self) {
// SAFETY: `this` is a valid *URL from C++; freed exactly once
unsafe { URL__deinit(this) }
}

pub fn pathname(&self) -> String {
URL__pathname(self)
}
}
5 changes: 1 addition & 4 deletions src/jsc/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -833,7 +833,7 @@ mod __macro_smoke {
// newtypes; the real opaque-FFI structs now live in their own files and are
// surfaced here at the crate root.
pub use self::dom_form_data::DOMFormData;
pub use self::url::URL;
pub use self::url::{URL, UrlJsc};
pub use self::zig_stack_frame::ZigStackFrame;
pub use self::zig_stack_trace::ZigStackTrace;
pub use abort_signal::{AbortSignal, AbortSignalRef};
Expand Down Expand Up @@ -1306,9 +1306,6 @@ impl FromJsEnum for bun_http_types::FetchCacheMode::FetchCacheMode {
}
}

// `URL::path_from_file_url` / `URL::href_from_js` live in `URL.rs` (the
// dedicated port file); the lib.rs copies were duplicate definitions.

// JSString (real module in JSString.rs).
#[path = "JSString.rs"]
pub mod js_string;
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/webcore/fetch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ use bun_core::{String as BunString, Tag as BunStringTag, ZigStringSlice};
use bun_http::{self as http, FetchRedirect, Headers, HeadersExt as _, MimeType};
use bun_http_jsc::method_jsc;
use bun_http_types::Method::Method;
use bun_jsc::{HTTPHeaderName, StringJsc as _, SysErrorJsc as _};
use bun_jsc::{HTTPHeaderName, StringJsc as _, SysErrorJsc as _, UrlJsc as _};
use bun_paths::{self, PathBuffer};
use bun_sys::FdExt as _;
// `FromJsEnum for FetchRedirect` lives in bun_http_jsc; importing the impl crate
Expand Down
1 change: 1 addition & 0 deletions src/url/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ libc.workspace = true
bitflags.workspace = true
bun_alloc.workspace = true
bun_core.workspace = true
bun_opaque.workspace = true
bun_collections.workspace = true
# TODO(b1): bun_io gated — crate does not compile yet; local Write stub in lib.rs
# bun_io.workspace = true
Expand Down
51 changes: 43 additions & 8 deletions src/url/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,10 @@ pub mod whatwg {
use super::BunString as String;
use super::strings;

/// Opaque handle to a heap-allocated WTF::URL (C++). Always behind `*mut URL`.
/// Construct via `from_string`/`from_utf8`; free via `deinit`.
#[repr(C)]
pub struct URL {
_opaque: [u8; 0],
bun_opaque::opaque_ffi! {
/// Opaque handle to a heap-allocated WTF::URL (C++). Always behind `*mut URL`.
/// Construct via `from_string`/`from_utf8`; free via `deinit`.
Comment thread
robobun marked this conversation as resolved.
pub struct URL;
}

// Getters take `*const URL` — the C++ side (BunString.cpp) never mutates the
Expand All @@ -64,11 +63,16 @@ pub mod whatwg {
safe fn URL__fromString(str: &mut String) -> Option<core::ptr::NonNull<URL>>;
safe fn URL__protocol(url: &URL) -> String;
safe fn URL__href(url: &URL) -> String;
safe fn URL__username(url: &URL) -> String;
safe fn URL__password(url: &URL) -> String;
safe fn URL__host(url: &URL) -> String;
safe fn URL__hostname(url: &URL) -> String;
safe fn URL__port(url: &URL) -> u32;
safe fn URL__deinit(url: &mut URL);
safe fn URL__pathname(url: &URL) -> String;
safe fn URL__getHref(input: &mut String) -> String;
safe fn URL__getFileURLString(input: &mut String) -> String;
safe fn URL__pathFromFileURL(input: &mut String) -> String;
safe fn URL__getHrefJoin(base: &mut String, relative: &mut String) -> String;
safe fn URL__fragmentIdentifier(url: &URL) -> String;
fn URL__originLength(latin1_slice: *const u8, len: usize) -> u32;
Expand Down Expand Up @@ -114,12 +118,21 @@ pub mod whatwg {
}

impl URL {
pub(crate) fn from_string(str: &String) -> Option<core::ptr::NonNull<URL>> {
let mut input = *str;
pub fn from_string(str: String) -> Option<core::ptr::NonNull<URL>> {
let mut input = str;
URL__fromString(&mut input)
}
pub fn from_utf8(input: &[u8]) -> Option<core::ptr::NonNull<URL>> {
Self::from_string(&String::borrow_utf8(input))
Self::from_string(String::borrow_utf8(input))
}
/// By-value whole-string file-URL conversions.
pub fn file_url_from_string(str: String) -> String {
let mut input = str;
URL__getFileURLString(&mut input)
}
pub fn path_from_file_url(str: String) -> String {
let mut input = str;
URL__pathFromFileURL(&mut input)
}
/// The URL fragment (the part after `#`), excluding the leading '#'.
pub fn fragment_identifier(&self) -> String {
Expand All @@ -142,12 +155,34 @@ pub mod whatwg {
pub fn hostname(&self) -> String {
URL__hostname(self)
}
pub fn username(&self) -> String {
URL__username(self)
}
pub fn password(&self) -> String {
URL__password(self)
}
/// The host WITHOUT the port (opposite of JS `host`; with-port form is [`URL::hostname`]).
pub fn host(&self) -> String {
URL__host(self)
}
/// `u32::MAX` when the port is unset; otherwise within the `u16` range.
pub fn port(&self) -> u32 {
URL__port(self)
}
pub fn pathname(&self) -> String {
URL__pathname(self)
}
pub fn deinit(&mut self) {
URL__deinit(self)
}
/// Raw-pointer form of [`URL::deinit`].
///
/// # Safety
/// `this` must be an owned pointer from `from_string`/`from_utf8`/`from_js`, freed once.
Comment thread
robobun marked this conversation as resolved.
pub unsafe fn destroy(this: *mut Self) {
// SAFETY: caller guarantees `this` is valid and owned.
unsafe { URL__deinit(&mut *this) }
}
Comment thread
claude[bot] marked this conversation as resolved.
}
}
// Re-export the free helpers at crate root so lower-tier callers can write
Expand Down
21 changes: 21 additions & 0 deletions test/js/web/fetch/fetch-args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ test("fetch(request subclass with headers)", async () => {
expect(headers.get("hello")).toBe("world");
});

test("fetch(URL object)", async () => {
const response = await fetch(new URL(server!.url));
expect(response.status).toBe(200);
});

test("fetch(RequestInit, headers)", async () => {
const myRequest = {
headers: {
Expand Down Expand Up @@ -208,6 +213,22 @@ describe("does not send a request when", () => {
expect(requestCount).toBe(prevCount);
});

test("Invalid proxy string", async () => {
const prevCount = requestCount;
expect(async () => await fetch(url, { proxy: "://" })).toThrow("fetch() proxy URL is invalid");
// Give it a chance to possibly send the request.
await Bun.sleep(2);
expect(requestCount).toBe(prevCount);
});

test("Invalid proxy object url", async () => {
const prevCount = requestCount;
expect(async () => await fetch(url, { proxy: { url: "://" } })).toThrow("fetch() proxy URL is invalid");
// Give it a chance to possibly send the request.
await Bun.sleep(2);
expect(requestCount).toBe(prevCount);
});
Comment thread
robobun marked this conversation as resolved.

test("proxy and unix", async () => {
const prevCount = requestCount;
expect(async () => await fetch(url, { proxy: url, unix: "/tmp/abc.sock" })).toThrow(
Expand Down
14 changes: 14 additions & 0 deletions test/js/web/fetch/fetch-preconnect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,4 +233,18 @@ describe.concurrent.todoIf(isWindows)("fetch.preconnect", () => {
expect(() => fetch.preconnect("unix:///tmp/foo")).toThrow();
expect(() => fetch.preconnect("http://:0")).toThrow();
});

it("fetch.preconnect stringifies non-string input", () => {
expect(() => fetch.preconnect("notaurl")).toThrow("Invalid URL");
// A URL object goes through the same stringifier as a string, so it
// reaches the later port validation instead of failing URL parsing.
expect(() => fetch.preconnect(new URL("http://localhost:0"))).toThrow("Invalid port");
expect(() =>
fetch.preconnect({
toString() {
throw new Error("boom from toString");
},
}),
).toThrow("boom from toString");
});
});