diff --git a/Cargo.lock b/Cargo.lock index 65e908d0627..ca5f7284758 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2345,9 +2345,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.61" +version = "0.3.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "445dde2150c55e483f3d8416706b97ec8e8237c307e5b7b4b8dd15e6af2a0730" +checksum = "c5f195fe497f702db0f318b07fdd68edb16955aed830df8363d837542f8f935a" dependencies = [ "wasm-bindgen", ] @@ -5430,7 +5430,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.28", + "syn 2.0.27", "wasm-bindgen-shared", ] @@ -5459,9 +5459,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.34" +version = "0.4.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f219e0d211ba40266969f6dbdd90636da12f75bee4fc9d6c23d1260dadb51454" +checksum = "c02dbc21516f9f1f04f187958890d7e6026df8d16540b7ad9492bc34a67cea03" dependencies = [ "cfg-if", "js-sys", @@ -5487,7 +5487,7 @@ checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.28", + "syn 2.0.27", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -6283,6 +6283,7 @@ dependencies = [ "hex", "http", "hyper", + "js-sys", "lazy_static", "libc", "linked_hash_set", @@ -6319,6 +6320,7 @@ dependencies = [ "wai-bindgen-wasmer", "waker-fn", "wasm-bindgen", + "wasm-bindgen-futures", "wasm-bindgen-test", "wasmer", "wasmer-emscripten", @@ -6326,6 +6328,7 @@ dependencies = [ "wasmer-wasix-types", "wcgi", "wcgi-host", + "web-sys", "webc", "weezl", "winapi", @@ -6357,7 +6360,7 @@ dependencies = [ "num_enum", "pretty_assertions", "serde", - "time 0.2.27", + "time 0.3.23", "tracing", "wai-bindgen-gen-core", "wai-bindgen-gen-rust", @@ -6625,9 +6628,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.61" +version = "0.3.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e33b99f4b23ba3eec1a53ac264e35a755f00e966e0065077d6027c0f575b0b97" +checksum = "9b85cbef8c220a6abc02aefd892dfc0fc23afb1c6a426316ec33253a3877249b" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/lib/wasi-types/Cargo.toml b/lib/wasi-types/Cargo.toml index 8d1453c6039..59751bb8c34 100644 --- a/lib/wasi-types/Cargo.toml +++ b/lib/wasi-types/Cargo.toml @@ -29,7 +29,7 @@ bitflags = "1.3.0" cfg-if = "1.0.0" anyhow = "1.0.66" byteorder = "1.3" -time = "0.2" +time = "0.3" tracing = { version = "0.1.37" } [dev-dependencies.pretty_assertions] diff --git a/lib/wasix/Cargo.toml b/lib/wasix/Cargo.toml index 6cf03f39c0a..6418e96e1c0 100644 --- a/lib/wasix/Cargo.toml +++ b/lib/wasix/Cargo.toml @@ -68,6 +68,9 @@ url = "2.3.1" petgraph = "0.6.3" rayon = { version = "1.7.0", optional = true } wasm-bindgen = { version = "0.2.87", optional = true } +js-sys = { version = "0.3.64", optional = true } +wasm-bindgen-futures = { version = "0.4.37", optional = true } +web-sys = { version = "0.3.64", features = ["Request", "RequestInit", "Window", "WorkerGlobalScope", "RequestMode", "Response", "Headers"], optional = true } [target.'cfg(not(target_arch = "riscv64"))'.dependencies.reqwest] version = "0.11" @@ -94,6 +97,7 @@ winapi = "0.3" wasmer = { path = "../api", version = "=4.1.0", default-features = false, features = ["wat", "js-serializable-module"] } tokio = { version = "1", features = [ "sync", "macros", "rt" ], default_features = false } pretty_assertions = "1.3.0" +wasm-bindgen-test = "0.3.0" [target.'cfg(target_arch = "wasm32")'.dev-dependencies] wasm-bindgen-test = "0.3.0" @@ -111,7 +115,7 @@ time = ["tokio/time"] webc_runner_rt_wcgi = ["hyper", "wcgi", "wcgi-host", "tower", "tower-http"] webc_runner_rt_emscripten = ["wasmer-emscripten"] -sys = ["webc/mmap", "time"] +sys = ["webc/mmap", "time", "virtual-mio/sys"] sys-default = ["sys", "logging", "host-fs", "sys-poll", "sys-thread", "host-vnet", "host-threads", "host-reqwest"] sys-poll = [] sys-thread = ["tokio/rt", "tokio/time", "tokio/rt-multi-thread", "rayon"] @@ -119,7 +123,7 @@ sys-thread = ["tokio/rt", "tokio/time", "tokio/rt-multi-thread", "rayon"] # Deprecated. Kept it for compatibility compiler = [] -js = ["virtual-fs/no-time", "getrandom/js", "chrono"] +js = ["virtual-fs/no-time", "getrandom/js", "chrono", "js-sys", "wasm-bindgen", "wasm-bindgen-futures", "web-sys"] js-default = ["js"] test-js = ["js", "wasmer/wat"] diff --git a/lib/wasix/src/http/client.rs b/lib/wasix/src/http/client.rs index df3ff78b0f3..824834d5272 100644 --- a/lib/wasix/src/http/client.rs +++ b/lib/wasix/src/http/client.rs @@ -138,7 +138,6 @@ impl From> for HttpRequest { } } - // TODO: use types from http crate? pub struct HttpResponse { pub body: Option>, diff --git a/lib/wasix/src/http/mod.rs b/lib/wasix/src/http/mod.rs index 5d6c02dba31..1afb9ca9cd3 100644 --- a/lib/wasix/src/http/mod.rs +++ b/lib/wasix/src/http/mod.rs @@ -4,7 +4,10 @@ pub mod client_impl; #[cfg(feature = "host-reqwest")] pub mod reqwest; -pub use self::client::*; +#[cfg(feature = "js")] +mod web_http_client; + +pub use self::{client::*, web_http_client::WebHttpClient}; pub(crate) const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "-", env!("CARGO_PKG_VERSION")); @@ -13,6 +16,8 @@ pub fn default_http_client() -> Option cfg_if::cfg_if! { if #[cfg(feature = "host-reqwest")] { Some(self::reqwest::ReqwestHttpClient::default()) + } else if #[cfg(feature = "js")] { + Some(web_http_client::WebHttpClient::default()) } else { // Note: We need something to use with turbofish otherwise returning // a plain None will complain about not being able to infer the "T" diff --git a/lib/wasix/src/http/web_http_client.rs b/lib/wasix/src/http/web_http_client.rs new file mode 100644 index 00000000000..8d95bf4057e --- /dev/null +++ b/lib/wasix/src/http/web_http_client.rs @@ -0,0 +1,272 @@ +use anyhow::{Context, Error}; +use futures::future::BoxFuture; +use http::header::{HeaderMap, HeaderValue, IntoHeaderName}; +use wasm_bindgen::{JsCast, JsValue}; +use wasm_bindgen_futures::JsFuture; +use web_sys::{RequestInit, RequestMode, Window, WorkerGlobalScope}; + +use crate::{ + http::{HttpClient, HttpRequest, HttpRequestOptions, HttpResponse}, + utils::web::js_error, +}; + +#[derive(Debug, Default, Clone, PartialEq)] +#[non_exhaustive] +pub struct WebHttpClient { + default_headers: HeaderMap, +} + +impl WebHttpClient { + pub fn new() -> Self { + WebHttpClient { + default_headers: HeaderMap::new(), + } + } + + pub fn with_default_header( + &mut self, + name: impl IntoHeaderName, + value: HeaderValue, + ) -> &mut Self { + self.default_headers.insert(name, value); + self + } + + pub fn extend_default_headers(&mut self, map: HeaderMap) -> &mut Self { + self.default_headers.extend(map); + self + } +} + +impl HttpClient for WebHttpClient { + fn request(&self, mut request: HttpRequest) -> BoxFuture<'_, Result> { + let (sender, receiver) = futures::channel::oneshot::channel(); + + for (name, value) in &self.default_headers { + if !request.headers.contains_key(name) { + request.headers.insert(name, value.clone()); + } + } + + // Note: We can't spawn this on our normal thread-pool because + // JavaScript promises are !Send, so we run it on the browser's event + // loop directly. + wasm_bindgen_futures::spawn_local(async move { + let result = fetch(request).await; + let _ = sender.send(result); + }); + + Box::pin(async move { + match receiver.await { + Ok(result) => result, + Err(e) => Err(Error::new(e)), + } + }) + } +} + +/// Send a `fetch()` request using the browser APIs. +async fn fetch(request: HttpRequest) -> Result { + let HttpRequest { + url, + method, + headers, + body, + options: HttpRequestOptions { + gzip: _, + cors_proxy, + }, + } = request; + + let mut opts = RequestInit::new(); + opts.method(method.as_str()); + opts.mode(RequestMode::Cors); + + if let Some(data) = body { + let data_len = data.len(); + let array = js_sys::Uint8Array::new_with_length(data_len as u32); + array.copy_from(&data[..]); + + opts.body(Some(&array)); + } + + let request = { + let request = web_sys::Request::new_with_str_and_init(url.as_str(), &opts) + .map_err(js_error) + .context("Could not construct request object")?; + + let set_headers = request.headers(); + + for (name, val) in headers.iter() { + let val = String::from_utf8_lossy(val.as_bytes()); + set_headers + .set(name.as_str(), &val) + .map_err(js_error) + .with_context(|| format!("could not apply request header: '{name}': '{val}'"))?; + } + request + }; + + let resp_value = match call_fetch(&request).await { + Ok(a) => a, + Err(e) => { + // If the request failed it may be because of CORS so if a cors proxy + // is configured then try again with the cors proxy + let url = if let Some(cors_proxy) = cors_proxy { + format!("https://{}/{}", cors_proxy, url) + } else { + return Err(js_error(e).context(format!("Could not fetch '{url}'"))); + }; + + let request = web_sys::Request::new_with_str_and_init(&url, &opts) + .map_err(js_error) + .with_context(|| format!("Could not construct request for url '{url}'"))?; + + let set_headers = request.headers(); + for (name, val) in headers.iter() { + let value = String::from_utf8_lossy(val.as_bytes()); + set_headers + .set(name.as_str(), &value) + .map_err(js_error) + .with_context(|| { + anyhow::anyhow!("Could not apply request header: '{name}': '{value}'") + })?; + } + + call_fetch(&request) + .await + .map_err(js_error) + .with_context(|| format!("Could not fetch '{url}'"))? + } + }; + + let response = resp_value.dyn_ref().unwrap(); + read_response(response).await +} + +async fn read_response(response: &web_sys::Response) -> Result { + let status = http::StatusCode::from_u16(response.status())?; + let headers = headers(response.headers()).context("Unable to read the headers")?; + let body = get_response_data(response).await?; + + Ok(HttpResponse { + body: Some(body), + redirected: response.redirected(), + status, + headers, + }) +} + +fn headers(headers: web_sys::Headers) -> Result { + let iter = js_sys::try_iter(&headers) + .map_err(js_error)? + .context("Not an iterator")?; + let mut header_map = http::HeaderMap::new(); + + for pair in iter { + let pair = pair.map_err(js_error)?; + let [key, value]: [js_sys::JsString; 2] = + js_array(&pair).context("Unable to unpack the header's key-value pairs")?; + + let key = String::from(key); + let key: http::HeaderName = key.parse()?; + let value = String::from(value); + let value = http::HeaderValue::from_str(&value) + .with_context(|| format!("Invalid header value: {value}"))?; + + header_map.insert(key, value); + } + + Ok(header_map) +} + +fn js_array(value: &JsValue) -> Result<[T; N], anyhow::Error> +where + T: JsCast, +{ + let array: &js_sys::Array = value.dyn_ref().context("Not an array")?; + + let mut items = Vec::new(); + + for value in array.iter() { + let item = value + .dyn_into() + .map_err(|_| anyhow::anyhow!("Unable to cast to a {}", std::any::type_name::()))?; + items.push(item); + } + + <[T; N]>::try_from(items).map_err(|original| { + anyhow::anyhow!( + "Unable to turn a list of {} items into an array of {N} items", + original.len() + ) + }) +} + +pub async fn get_response_data(resp: &web_sys::Response) -> Result, anyhow::Error> { + let buffer = JsFuture::from(resp.array_buffer().unwrap()) + .await + .map_err(js_error) + .with_context(|| "Could not retrieve response body".to_string())?; + + let buffer = js_sys::Uint8Array::new(&buffer); + + Ok(buffer.to_vec()) +} + +fn call_fetch(request: &web_sys::Request) -> JsFuture { + let global = js_sys::global(); + if JsValue::from_str("WorkerGlobalScope").js_in(&global) + && global.is_instance_of::() + { + JsFuture::from( + global + .unchecked_into::() + .fetch_with_request(request), + ) + } else { + JsFuture::from( + global + .unchecked_into::() + .fetch_with_request(request), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::runtime::resolver::WapmSource; + + #[wasm_bindgen_test::wasm_bindgen_test] + async fn query_the_wasmer_registry_graphql_endpoint() { + let http_client = WebHttpClient::default(); + let query = r#"{ + "query": "{ info { defaultFrontend } }" + }"#; + let request = http::Request::post(WapmSource::WASMER_PROD_ENDPOINT) + .header(http::header::CONTENT_TYPE, "application/json") + .body(query) + .unwrap(); + + let response = http_client.request(request.into()).await.unwrap(); + + assert_eq!( + response + .headers + .get(http::header::CONTENT_TYPE) + .unwrap() + .to_str() + .unwrap(), + "application/json", + ); + let body: serde_json::Value = + serde_json::from_slice(response.body.as_deref().unwrap()).unwrap(); + let frontend_url = body + .pointer("/data/info/defaultFrontend") + .unwrap() + .as_str() + .unwrap(); + assert_eq!(frontend_url, "https://wasmer.io"); + } +} diff --git a/lib/wasix/src/lib.rs b/lib/wasix/src/lib.rs index f3db61da7b3..b273a4f5e89 100644 --- a/lib/wasix/src/lib.rs +++ b/lib/wasix/src/lib.rs @@ -30,6 +30,9 @@ compile_error!( "The `js` feature must be enabled only for the `wasm32` target (either `wasm32-unknown-unknown` or `wasm32-wasi`)." ); +#[cfg(all(test, target_arch = "wasm32"))] +wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); + #[cfg(test)] #[macro_use] extern crate pretty_assertions; diff --git a/lib/wasix/src/runtime/resolver/utils.rs b/lib/wasix/src/runtime/resolver/utils.rs index 34cc5b5d72b..11f83505971 100644 --- a/lib/wasix/src/runtime/resolver/utils.rs +++ b/lib/wasix/src/runtime/resolver/utils.rs @@ -86,6 +86,7 @@ pub(crate) fn file_path_from_url(url: &Url) -> Result { #[cfg(test)] mod tests { + #[allow(unused_imports)] use super::*; #[test] diff --git a/lib/wasix/src/utils/mod.rs b/lib/wasix/src/utils/mod.rs index 572e9e0fa22..6cf471da1d8 100644 --- a/lib/wasix/src/utils/mod.rs +++ b/lib/wasix/src/utils/mod.rs @@ -1,20 +1,22 @@ +mod dummy_waker; mod owned_mutex_guard; pub mod store; mod thread_parker; -mod dummy_waker; -pub use self::dummy_waker::WasiDummyWaker; - -use std::collections::BTreeSet; +#[cfg(feature = "js")] +pub(crate) mod web; -use wasmer::Module; -use wasmer_wasix_types::wasi::Errno; +pub use self::{dummy_waker::WasiDummyWaker, thread_parker::WasiParkingLot}; -pub use self::thread_parker::WasiParkingLot; pub(crate) use owned_mutex_guard::{ read_owned, write_owned, OwnedRwLockReadGuard, OwnedRwLockWriteGuard, }; +use std::collections::BTreeSet; + +use wasmer::Module; +use wasmer_wasix_types::wasi::Errno; + /// Check if a provided module is compiled for some version of WASI. /// Use [`get_wasi_version`] to find out which version of WASI the module is. pub fn is_wasi_module(module: &Module) -> bool { diff --git a/lib/wasix/src/utils/web.rs b/lib/wasix/src/utils/web.rs new file mode 100644 index 00000000000..ee340c1f87f --- /dev/null +++ b/lib/wasix/src/utils/web.rs @@ -0,0 +1,15 @@ +use wasm_bindgen::{JsCast, JsValue}; + +/// Try to extract the most appropriate error message from a [`JsValue`], +/// falling back to a generic error message. +pub fn js_error(value: JsValue) -> anyhow::Error { + if let Some(e) = value.dyn_ref::() { + anyhow::Error::msg(String::from(e.message())) + } else if let Some(obj) = value.dyn_ref::() { + return anyhow::Error::msg(String::from(obj.to_string())); + } else if let Some(s) = value.dyn_ref::() { + return anyhow::Error::msg(String::from(s)); + } else { + anyhow::anyhow!("An unknown error occurred: {value:?}") + } +}