gpui_web: Implement fetch based HTTP client (#50463)

Lukas Wirth created

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 ...

Change summary

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(-)

Detailed changes

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",

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 }

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"))]

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());

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<dyn Platform> {
     #[cfg(target_family = "wasm")]
     {
         let _ = headless;
-        Rc::new(gpui_web::WebPlatform::new())
+        Rc::new(gpui_web::WebPlatform::new(true))
     }
 }
 

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",
 ] }

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<RunnableVariant>,
     main_thread_mailbox: Arc<MainThreadMailbox>,
     supports_threads: bool,
+    #[cfg(feature = "multithreaded")]

     _background_threads: Vec<wasm_thread::JoinHandle<()>>,
 }
 
@@ -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,
         }
     }

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;

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<js_sys::Promise, JsValue>;
+}
+
+pub struct FetchHttpClient {
+    user_agent: Option<http_client::http::header::HeaderValue>,
+}
+
+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<Self> {
+        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<Self> {
+        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>(F);
+
+unsafe impl<F> Send for AssertSend<F> {}
+
+impl<F: Future> Future for AssertSend<F> {
+    type Output = F::Output;
+
+    fn poll(self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll<Self::Output> {
+        // 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<AsyncBody>,
+    ) -> futures::future::BoxFuture<'static, anyhow::Result<http_client::http::Response<AsyncBody>>>
+    {
+        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::<RedirectPolicy>() {
+                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::<js_sys::Array>() {
+                    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<Option<Vec<u8>>> {
+    let mut buffer = Vec::new();
+    body.read_to_end(&mut buffer).await?;
+    if buffer.is_empty() {
+        Ok(None)
+    } else {
+        Ok(Some(buffer))
+    }
+}

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(