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 ...
This commit is contained in:
1 parent
1fac14fcc7
commit
dd02a2bb32
10 files changed
+316
-47
No files matched your search
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
] }
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user