From e30720a781ad5e4bee9ab6e5c9f228baffef466c Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Mon, 2 Mar 2026 16:18:01 +0100 Subject: [PATCH] gpui_web: Implement fetch based HTTP client (#50463) Can only be used in single threaded environments for now due to js futures being non-send. Release Notes: - N/A *or* Added/Fixed/Improved ... --- Cargo.lock | 2 + crates/gpui/Cargo.toml | 1 + crates/gpui/examples/image/image.rs | 90 ++++++---- crates/gpui/examples/image_gallery.rs | 26 ++- crates/gpui_platform/src/gpui_platform.rs | 8 +- crates/gpui_web/Cargo.toml | 12 +- crates/gpui_web/src/dispatcher.rs | 16 +- crates/gpui_web/src/gpui_web.rs | 2 + crates/gpui_web/src/http_client.rs | 199 ++++++++++++++++++++++ crates/gpui_web/src/platform.rs | 7 +- 10 files changed, 316 insertions(+), 47 deletions(-) create mode 100644 crates/gpui_web/src/http_client.rs diff --git a/Cargo.lock b/Cargo.lock index 030777000144d01de7653fbce314c14223e158d7..97cc166c14e099b57b74585277869052de0cff87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7572,6 +7572,7 @@ dependencies = [ "gpui_macros", "gpui_platform", "gpui_util", + "gpui_web", "http_client", "image", "inventory", @@ -7763,6 +7764,7 @@ dependencies = [ "futures 0.3.31", "gpui", "gpui_wgpu", + "http_client", "js-sys", "log", "parking_lot", diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 4bd9510eac1710554f8eec52f22609db31c531ad..c80f97efb6dc8bf1450c08bfe85290096b44815b 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -156,6 +156,7 @@ reqwest_client = { workspace = true, features = ["test-support"] } [target.'cfg(target_family = "wasm")'.dev-dependencies] wasm-bindgen = { workspace = true } +gpui_web.workspace = true [build-dependencies] embed-resource = { version = "3.0", optional = true } diff --git a/crates/gpui/examples/image/image.rs b/crates/gpui/examples/image/image.rs index cf879ba281e18521883222fba54451bb143fae29..832cdf896a80e84c3ca8b591e0a0956af2cedcac 100644 --- a/crates/gpui/examples/image/image.rs +++ b/crates/gpui/examples/image/image.rs @@ -10,7 +10,7 @@ use gpui::{ SharedString, SharedUri, TitlebarOptions, Window, WindowBounds, WindowOptions, actions, div, img, prelude::*, px, rgb, size, }; -use gpui_platform::application; +#[cfg(not(target_family = "wasm"))] use reqwest_client::ReqwestClient; struct Assets { @@ -151,47 +151,63 @@ actions!(image, [Quit]); fn run_example() { let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - application() - .with_assets(Assets { - base: manifest_dir.join("examples"), - }) - .run(move |cx: &mut App| { + #[cfg(not(target_family = "wasm"))] + let app = gpui_platform::application(); + #[cfg(target_family = "wasm")] + let app = gpui_platform::application(); + app.with_assets(Assets { + base: manifest_dir.join("examples"), + }) + .run(move |cx: &mut App| { + #[cfg(not(target_family = "wasm"))] + { let http_client = ReqwestClient::user_agent("gpui example").unwrap(); cx.set_http_client(Arc::new(http_client)); - - cx.activate(true); - cx.on_action(|_: &Quit, cx| cx.quit()); - cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]); - cx.set_menus(vec![Menu { - name: "Image".into(), - items: vec![MenuItem::action("Quit", Quit)], - }]); - - let window_options = WindowOptions { - titlebar: Some(TitlebarOptions { - title: Some(SharedString::from("Image Example")), - appears_transparent: false, - ..Default::default() - }), - - window_bounds: Some(WindowBounds::Windowed(Bounds { - size: size(px(1100.), px(600.)), - origin: Point::new(px(200.), px(200.)), - })), - - ..Default::default() + } + #[cfg(target_family = "wasm")] + { + // Safety: the web examples run single-threaded; the client is + // created and used exclusively on the main thread. + let http_client = unsafe { + gpui_web::FetchHttpClient::with_user_agent("gpui example") + .expect("failed to create FetchHttpClient") }; + cx.set_http_client(Arc::new(http_client)); + } - cx.open_window(window_options, |_, cx| { - cx.new(|_| ImageShowcase { - // Relative path to your root project path - local_resource: manifest_dir.join("examples/image/app-icon.png").into(), - remote_resource: "https://picsum.photos/800/400".into(), - asset_resource: "image/color.svg".into(), - }) + cx.activate(true); + cx.on_action(|_: &Quit, cx| cx.quit()); + cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]); + cx.set_menus(vec![Menu { + name: "Image".into(), + items: vec![MenuItem::action("Quit", Quit)], + }]); + + let window_options = WindowOptions { + titlebar: Some(TitlebarOptions { + title: Some(SharedString::from("Image Example")), + appears_transparent: false, + ..Default::default() + }), + + window_bounds: Some(WindowBounds::Windowed(Bounds { + size: size(px(1100.), px(600.)), + origin: Point::new(px(200.), px(200.)), + })), + + ..Default::default() + }; + + cx.open_window(window_options, |_, cx| { + cx.new(|_| ImageShowcase { + // Relative path to your root project path + local_resource: manifest_dir.join("examples/image/app-icon.png").into(), + remote_resource: "https://picsum.photos/800/400".into(), + asset_resource: "image/color.svg".into(), }) - .unwrap(); - }); + }) + .unwrap(); + }); } #[cfg(not(target_family = "wasm"))] diff --git a/crates/gpui/examples/image_gallery.rs b/crates/gpui/examples/image_gallery.rs index eba3fc0b6444c1b02ed8d6d2437505f1d341e605..9d8ac29ff8c9762417ff59acbfc83db6ad9c8346 100644 --- a/crates/gpui/examples/image_gallery.rs +++ b/crates/gpui/examples/image_gallery.rs @@ -7,7 +7,7 @@ use gpui::{ RetainAllImageCache, SharedString, TitlebarOptions, Window, WindowBounds, WindowOptions, actions, div, hash, image_cache, img, prelude::*, px, rgb, size, }; -use gpui_platform::application; +#[cfg(not(target_family = "wasm"))] use reqwest_client::ReqwestClient; use std::{collections::HashMap, sync::Arc}; @@ -248,9 +248,27 @@ impl ImageCache for SimpleLruCache { actions!(image, [Quit]); fn run_example() { - application().run(move |cx: &mut App| { - let http_client = ReqwestClient::user_agent("gpui example").unwrap(); - cx.set_http_client(Arc::new(http_client)); + #[cfg(not(target_family = "wasm"))] + let app = gpui_platform::application(); + #[cfg(target_family = "wasm")] + let app = gpui_platform::single_threaded_web(); + + app.run(move |cx: &mut App| { + #[cfg(not(target_family = "wasm"))] + { + let http_client = ReqwestClient::user_agent("gpui example").unwrap(); + cx.set_http_client(Arc::new(http_client)); + } + #[cfg(target_family = "wasm")] + { + // Safety: the web examples run single-threaded; the client is + // created and used exclusively on the main thread. + let http_client = unsafe { + gpui_web::FetchHttpClient::with_user_agent("gpui example") + .expect("failed to create FetchHttpClient") + }; + cx.set_http_client(Arc::new(http_client)); + } cx.activate(true); cx.on_action(|_: &Quit, cx| cx.quit()); diff --git a/crates/gpui_platform/src/gpui_platform.rs b/crates/gpui_platform/src/gpui_platform.rs index 86c0577f75ff4ac61ab7a4d956b7e34718fb26e5..7dac5498a652f7a7fe68b9f6d7ea23dffabdfb22 100644 --- a/crates/gpui_platform/src/gpui_platform.rs +++ b/crates/gpui_platform/src/gpui_platform.rs @@ -18,6 +18,12 @@ pub fn headless() -> gpui::Application { gpui::Application::with_platform(current_platform(true)) } +/// Unlike `application`, this function returns a single-threaded web application. +#[cfg(target_family = "wasm")] +pub fn single_threaded_web() -> gpui::Application { + gpui::Application::with_platform(Rc::new(gpui_web::WebPlatform::new(false))) +} + /// Initializes panic hooks and logging for the web platform. /// Call this before running the application in a wasm_bindgen entrypoint. #[cfg(target_family = "wasm")] @@ -49,7 +55,7 @@ pub fn current_platform(headless: bool) -> Rc { #[cfg(target_family = "wasm")] { let _ = headless; - Rc::new(gpui_web::WebPlatform::new()) + Rc::new(gpui_web::WebPlatform::new(true)) } } diff --git a/crates/gpui_web/Cargo.toml b/crates/gpui_web/Cargo.toml index a2bb95a9f4bb3007a2a2feb9f7483d38dff3cf1d..dbb110597c7b850c28cde99ed573eab8264a18f7 100644 --- a/crates/gpui_web/Cargo.toml +++ b/crates/gpui_web/Cargo.toml @@ -9,6 +9,10 @@ autoexamples = false [lints] workspace = true +[features] +default = ["multithreaded"] +multithreaded = ["dep:wasm_thread"] + [lib] path = "src/gpui_web.rs" @@ -16,6 +20,7 @@ path = "src/gpui_web.rs" gpui.workspace = true parking_lot = { workspace = true, features = ["nightly"] } gpui_wgpu.workspace = true +http_client.workspace = true anyhow.workspace = true futures.workspace = true log.workspace = true @@ -27,7 +32,7 @@ web-time.workspace = true console_error_panic_hook = "0.1.7" js-sys = "0.3" raw-window-handle = "0.6" -wasm_thread = { version = "0.3", features = ["es_modules"] } +wasm_thread = { version = "0.3", features = ["es_modules"], optional = true } web-sys = { version = "0.3", features = [ "console", "CssStyleDeclaration", @@ -56,6 +61,11 @@ web-sys = { version = "0.3", features = [ "Screen", "Storage", "VisualViewport", + "Headers", + "Request", + "RequestInit", + "RequestRedirect", + "Response", "WheelEvent", "Window", ] } diff --git a/crates/gpui_web/src/dispatcher.rs b/crates/gpui_web/src/dispatcher.rs index ca0b700a1bf0bc75e1dafd859b59a04540524f63..d9419fb35353cfadd809b0bbc1cb9e7dbf124cda 100644 --- a/crates/gpui_web/src/dispatcher.rs +++ b/crates/gpui_web/src/dispatcher.rs @@ -8,8 +8,10 @@ use std::time::Duration; use wasm_bindgen::prelude::*; use web_time::Instant; +#[cfg(feature = "multithreaded")] const MIN_BACKGROUND_THREADS: usize = 2; +#[cfg(feature = "multithreaded")] fn shared_memory_supported() -> bool { let global = js_sys::global(); let has_shared_array_buffer = @@ -126,6 +128,7 @@ pub struct WebDispatcher { background_sender: PriorityQueueSender, main_thread_mailbox: Arc, supports_threads: bool, + #[cfg(feature = "multithreaded")] _background_threads: Vec>, } @@ -135,11 +138,18 @@ unsafe impl Send for WebDispatcher {} unsafe impl Sync for WebDispatcher {} impl WebDispatcher { - pub fn new(browser_window: web_sys::Window) -> Self { + pub fn new(browser_window: web_sys::Window, allow_threads: bool) -> Self { + #[cfg(feature = "multithreaded")] let (background_sender, background_receiver) = PriorityQueueReceiver::new(); + #[cfg(not(feature = "multithreaded"))] + let (background_sender, _) = PriorityQueueReceiver::new(); let main_thread_mailbox = Arc::new(MainThreadMailbox::new()); - let supports_threads = shared_memory_supported(); + + #[cfg(feature = "multithreaded")] + let supports_threads = allow_threads && shared_memory_supported(); + #[cfg(not(feature = "multithreaded"))] + let supports_threads = false; if supports_threads { main_thread_mailbox.run_waker_loop(browser_window.clone()); @@ -149,6 +159,7 @@ impl WebDispatcher { ); } + #[cfg(feature = "multithreaded")] let background_threads = if supports_threads { let thread_count = browser_window .navigator() @@ -193,6 +204,7 @@ impl WebDispatcher { background_sender, main_thread_mailbox, supports_threads, + #[cfg(feature = "multithreaded")] _background_threads: background_threads, } } diff --git a/crates/gpui_web/src/gpui_web.rs b/crates/gpui_web/src/gpui_web.rs index 966ff3b0d7d90219e8cf702a16fce598f813c835..9cd773823bd9b65ef99cb89c12184919a4c45dc2 100644 --- a/crates/gpui_web/src/gpui_web.rs +++ b/crates/gpui_web/src/gpui_web.rs @@ -3,6 +3,7 @@ mod dispatcher; mod display; mod events; +mod http_client; mod keyboard; mod logging; mod platform; @@ -10,6 +11,7 @@ mod window; pub use dispatcher::WebDispatcher; pub use display::WebDisplay; +pub use http_client::FetchHttpClient; pub use keyboard::WebKeyboardLayout; pub use logging::init_logging; pub use platform::WebPlatform; diff --git a/crates/gpui_web/src/http_client.rs b/crates/gpui_web/src/http_client.rs new file mode 100644 index 0000000000000000000000000000000000000000..14d58cf45766885af76f49892589f70b89fb8116 --- /dev/null +++ b/crates/gpui_web/src/http_client.rs @@ -0,0 +1,199 @@ +use anyhow::anyhow; +use futures::AsyncReadExt as _; +use http_client::{AsyncBody, HttpClient, RedirectPolicy}; +use std::future::Future; +use std::pin::Pin; +use std::task::Poll; +use wasm_bindgen::JsCast as _; +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(catch, js_name = "fetch")] + fn global_fetch(input: &web_sys::Request) -> Result; +} + +pub struct FetchHttpClient { + user_agent: Option, +} + +impl Default for FetchHttpClient { + fn default() -> Self { + Self { user_agent: None } + } +} + +#[cfg(feature = "multithreaded")] +impl FetchHttpClient { + /// # Safety + /// + /// The caller must ensure that the created `FetchHttpClient` is only used in a single thread environment. + pub unsafe fn new() -> Self { + Self::default() + } + + /// # Safety + /// + /// The caller must ensure that the created `FetchHttpClient` is only used in a single thread environment. + pub unsafe fn with_user_agent(user_agent: &str) -> anyhow::Result { + Ok(Self { + user_agent: Some(http_client::http::header::HeaderValue::from_str( + user_agent, + )?), + }) + } +} + +#[cfg(not(feature = "multithreaded"))] +impl FetchHttpClient { + pub fn new() -> Self { + Self::default() + } + + pub fn with_user_agent(user_agent: &str) -> anyhow::Result { + Ok(Self { + user_agent: Some(http_client::http::header::HeaderValue::from_str( + user_agent, + )?), + }) + } +} + +/// Wraps a `!Send` future to satisfy the `Send` bound on `BoxFuture`. +/// +/// Safety: only valid in WASM contexts where the `FetchHttpClient` is +/// confined to a single thread (guaranteed by the caller via unsafe +/// constructors when `multithreaded` is enabled, or by the absence of +/// threads when it is not). +struct AssertSend(F); + +unsafe impl Send for AssertSend {} + +impl Future for AssertSend { + type Output = F::Output; + + fn poll(self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll { + // Safety: pin projection for a single-field newtype wrapper. + let inner = unsafe { self.map_unchecked_mut(|this| &mut this.0) }; + inner.poll(cx) + } +} + +impl HttpClient for FetchHttpClient { + fn user_agent(&self) -> Option<&http_client::http::header::HeaderValue> { + self.user_agent.as_ref() + } + + fn proxy(&self) -> Option<&http_client::Url> { + None + } + + fn send( + &self, + req: http_client::http::Request, + ) -> futures::future::BoxFuture<'static, anyhow::Result>> + { + let (parts, body) = req.into_parts(); + + Box::pin(AssertSend(async move { + let body_bytes = read_body_to_bytes(body).await?; + + let init = web_sys::RequestInit::new(); + init.set_method(parts.method.as_str()); + + if let Some(redirect_policy) = parts.extensions.get::() { + match redirect_policy { + RedirectPolicy::NoFollow => { + init.set_redirect(web_sys::RequestRedirect::Manual); + } + RedirectPolicy::FollowLimit(_) | RedirectPolicy::FollowAll => { + init.set_redirect(web_sys::RequestRedirect::Follow); + } + } + } + + if let Some(ref bytes) = body_bytes { + let uint8array = js_sys::Uint8Array::from(bytes.as_slice()); + init.set_body(uint8array.as_ref()); + } + + let url = parts.uri.to_string(); + let request = web_sys::Request::new_with_str_and_init(&url, &init) + .map_err(|error| anyhow!("failed to create fetch Request: {error:?}"))?; + + let request_headers = request.headers(); + for (name, value) in &parts.headers { + let value_str = value + .to_str() + .map_err(|_| anyhow!("non-ASCII header value for {name}"))?; + request_headers + .set(name.as_str(), value_str) + .map_err(|error| anyhow!("failed to set header {name}: {error:?}"))?; + } + + let promise = global_fetch(&request) + .map_err(|error| anyhow!("fetch threw an error: {error:?}"))?; + let response_value = wasm_bindgen_futures::JsFuture::from(promise) + .await + .map_err(|error| anyhow!("fetch failed: {error:?}"))?; + + let web_response: web_sys::Response = response_value + .dyn_into() + .map_err(|error| anyhow!("fetch result is not a Response: {error:?}"))?; + + let status = web_response.status(); + let mut builder = http_client::http::Response::builder().status(status); + + // `Headers` is a JS iterable yielding `[name, value]` pairs. + // `js_sys::Array::from` calls `Array.from()` which accepts any iterable. + let header_pairs = js_sys::Array::from(&web_response.headers()); + for index in 0..header_pairs.length() { + match header_pairs.get(index).dyn_into::() { + Ok(pair) => match (pair.get(0).as_string(), pair.get(1).as_string()) { + (Some(name), Some(value)) => { + builder = builder.header(name, value); + } + (name, value) => { + log::warn!( + "skipping response header at index {index}: \ + name={name:?}, value={value:?}" + ); + } + }, + Err(entry) => { + log::warn!("skipping non-array header entry at index {index}: {entry:?}"); + } + } + } + + // The entire response body is eagerly buffered into memory via + // `arrayBuffer()`. The Fetch API does not expose a synchronous + // streaming interface; streaming would require `ReadableStream` + // interop which is significantly more complex. + let body_promise = web_response + .array_buffer() + .map_err(|error| anyhow!("failed to initiate response body read: {error:?}"))?; + let body_value = wasm_bindgen_futures::JsFuture::from(body_promise) + .await + .map_err(|error| anyhow!("failed to read response body: {error:?}"))?; + let array_buffer: js_sys::ArrayBuffer = body_value + .dyn_into() + .map_err(|error| anyhow!("response body is not an ArrayBuffer: {error:?}"))?; + let response_bytes = js_sys::Uint8Array::new(&array_buffer).to_vec(); + + builder + .body(AsyncBody::from(response_bytes)) + .map_err(|error| anyhow!(error)) + })) + } +} + +async fn read_body_to_bytes(mut body: AsyncBody) -> anyhow::Result>> { + let mut buffer = Vec::new(); + body.read_to_end(&mut buffer).await?; + if buffer.is_empty() { + Ok(None) + } else { + Ok(Some(buffer)) + } +} diff --git a/crates/gpui_web/src/platform.rs b/crates/gpui_web/src/platform.rs index 420b7cb3f470c683888aa76bd61236c1f1ff181e..4d78b71aa05b743f779d0e8a1e7ed8a5eac136f9 100644 --- a/crates/gpui_web/src/platform.rs +++ b/crates/gpui_web/src/platform.rs @@ -54,10 +54,13 @@ struct WebPlatformCallbacks { } impl WebPlatform { - pub fn new() -> Self { + pub fn new(allow_multi_threading: bool) -> Self { let browser_window = web_sys::window().expect("must be running in a browser window context"); - let dispatcher = Arc::new(WebDispatcher::new(browser_window.clone())); + let dispatcher = Arc::new(WebDispatcher::new( + browser_window.clone(), + allow_multi_threading, + )); let background_executor = BackgroundExecutor::new(dispatcher.clone()); let foreground_executor = ForegroundExecutor::new(dispatcher); let text_system = Arc::new(gpui_wgpu::CosmicTextSystem::new_without_system_fonts(