diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0288989..bbb7dfd 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -43,6 +43,7 @@ jobs: rust: stable env: RUST_BACKTRACE: full + PROJECT_ID: "dummy" steps: - uses: actions/checkout@v3 @@ -80,3 +81,15 @@ jobs: with: github_token: ${{ secrets.github_token }} locale: "US" + + wasm: + env: + PROJECT_ID: "dummy" + name: "wasm" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Install wasm-pack + run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh + - name: "wasm-pack" + run: cd wasm_websocket_demo && wasm-pack build --target web diff --git a/.gitignore b/.gitignore index 088ba6b..d8a0ee9 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ Cargo.lock # These are backup files generated by rustfmt **/*.rs.bk + +.idea/ + +pkg/ diff --git a/Cargo.toml b/Cargo.toml index 7857ebc..3801b41 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ authors = ["WalletConnect Team"] license = "Apache-2.0" [workspace] -members = ["blockchain_api", "relay_client", "relay_rpc"] +members = ["blockchain_api", "relay_client", "relay_rpc", "wasm_websocket_demo"] [features] default = ["full"] diff --git a/relay_client/Cargo.toml b/relay_client/Cargo.toml index 503cb2e..6400ac0 100644 --- a/relay_client/Cargo.toml +++ b/relay_client/Cargo.toml @@ -5,8 +5,8 @@ edition = "2021" license = "Apache-2.0" [features] -default = ["tokio-tungstenite/native-tls"] -rustls = ["tokio-tungstenite/rustls-tls-native-roots"] +default = ["tokio-tungstenite/native-tls", "tokio-tungstenite/url"] +rustls = ["tokio-tungstenite/rustls-tls-native-roots", "tokio-tungstenite/url"] [dependencies] relay_rpc = { path = "../relay_rpc" } @@ -16,7 +16,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_qs = "0.10" pin-project = "1.0" -chrono = { version = "0.4", default-features = false, features = ["alloc", "std"] } +chrono = { version = "0.4", default-features = false, features = ["alloc", "std", "wasmbind", "wasm-bindgen"] } url = "2.3" http = "1.0.0" @@ -24,11 +24,17 @@ http = "1.0.0" reqwest = { version = "0.12.2", features = ["json"] } # WebSocket client dependencies. -tokio = { version = "1.22", features = ["rt", "time", "sync", "macros", "rt-multi-thread"] } -tokio-tungstenite = "0.21.0" +tokio = { version = "1", features = ["rt", "time", "sync", "macros"] } futures-channel = "0.3" -tokio-stream = "0.1" -tokio-util = "0.7" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tokio-tungstenite = { version = "0.24" } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +tokio-tungstenite-wasm = { version = "0.3" } +wasm-bindgen-futures = { version = "0.4" } +web-sys = { version = "0.3" , features = ["ConsoleEvent"] } +getrandom = { version = "0.2" , features = ["wasm-bindgen", "js"]} [lints.clippy] indexing_slicing = "deny" diff --git a/relay_client/src/lib.rs b/relay_client/src/lib.rs index 7408975..ce0937d 100644 --- a/relay_client/src/lib.rs +++ b/relay_client/src/lib.rs @@ -104,6 +104,7 @@ impl ConnectionOptions { Ok(url) } + #[cfg(not(target_arch = "wasm32"))] fn as_ws_request(&self) -> Result, RequestBuildError> { use { crate::websocket::WebsocketClientError, @@ -121,6 +122,20 @@ impl ConnectionOptions { Ok(request) } + #[cfg(target_arch = "wasm32")] + fn as_ws_request(&self) -> Result, RequestBuildError> { + use crate::websocket::WebsocketClientError; + + let url = self.as_url()?; + let mut request = HttpRequest::builder() + .uri(format!("{}", url)) + .body(()) + .map_err(WebsocketClientError::HttpErr)?; + + self.update_request_headers(request.headers_mut())?; + Ok(request) + } + fn update_request_headers(&self, headers: &mut HeaderMap) -> Result<(), RequestBuildError> { if let Authorization::Header(token) = &self.auth { let value = format!("Bearer {token}") diff --git a/relay_client/src/websocket.rs b/relay_client/src/websocket.rs index 29bb2f5..0688d48 100644 --- a/relay_client/src/websocket.rs +++ b/relay_client/src/websocket.rs @@ -28,15 +28,15 @@ use { oneshot, }, }; -pub use { - fetch::*, - inbound::*, - outbound::*, - stream::*, - tokio_tungstenite::tungstenite::protocol::CloseFrame, -}; - +pub use {fetch::*, inbound::*, outbound::*, stream::*}; +#[cfg(not(target_arch = "wasm32"))] pub type TransportError = tokio_tungstenite::tungstenite::Error; +#[cfg(not(target_arch = "wasm32"))] +pub use tokio_tungstenite::tungstenite::protocol::CloseFrame; +#[cfg(target_arch = "wasm32")] +pub type TransportError = tokio_tungstenite_wasm::Error; +#[cfg(target_arch = "wasm32")] +pub use tokio_tungstenite_wasm::CloseFrame; #[derive(Debug, thiserror::Error)] pub enum WebsocketClientError { @@ -52,6 +52,9 @@ pub enum WebsocketClientError { #[error("Websocket transport error: {0}")] Transport(TransportError), + #[error("Url error: {0}")] + HttpErr(http::Error), + #[error("Not connected")] NotConnected, } @@ -146,7 +149,12 @@ impl Client { { let (control_tx, control_rx) = mpsc::unbounded_channel(); - tokio::spawn(connection_event_loop(control_rx, handler)); + let fut = connection_event_loop(control_rx, handler); + #[cfg(target_arch = "wasm32")] + wasm_bindgen_futures::spawn_local(fut); + + #[cfg(not(target_arch = "wasm32"))] + tokio::spawn(fut); Self { control_tx } } diff --git a/relay_client/src/websocket/inbound.rs b/relay_client/src/websocket/inbound.rs index 2581c7d..c005046 100644 --- a/relay_client/src/websocket/inbound.rs +++ b/relay_client/src/websocket/inbound.rs @@ -1,3 +1,7 @@ +#[cfg(not(target_arch = "wasm32"))] +use tokio_tungstenite::tungstenite::Message; +#[cfg(target_arch = "wasm32")] +use tokio_tungstenite_wasm::Message; use { crate::ClientError, relay_rpc::{ @@ -5,9 +9,7 @@ use { rpc::{self, ErrorResponse, Payload, Response, ServiceRequest, SuccessfulResponse}, }, tokio::sync::mpsc::UnboundedSender, - tokio_tungstenite::tungstenite::Message, }; - /// The lower-level inbound RPC request. /// /// Provides access to the request payload (via [`InboundRequest::data()`]) and diff --git a/relay_client/src/websocket/stream.rs b/relay_client/src/websocket/stream.rs index 28a5e1e..a706810 100644 --- a/relay_client/src/websocket/stream.rs +++ b/relay_client/src/websocket/stream.rs @@ -1,3 +1,12 @@ +#[cfg(not(target_arch = "wasm32"))] +use tokio_tungstenite::{ + connect_async, + tungstenite::{protocol::CloseFrame, Message}, + MaybeTlsStream, + WebSocketStream, +}; +#[cfg(target_arch = "wasm32")] +use tokio_tungstenite_wasm::{connect as connect_async, CloseFrame, Message, WebSocketStream}; use { super::{ inbound::InboundRequest, @@ -17,26 +26,22 @@ use { pin::Pin, task::{Context, Poll}, }, - tokio::{ - net::TcpStream, - sync::{ - mpsc, - mpsc::{UnboundedReceiver, UnboundedSender}, - oneshot, - }, - }, - tokio_tungstenite::{ - connect_async, - tungstenite::{protocol::CloseFrame, Message}, - MaybeTlsStream, - WebSocketStream, + tokio::sync::{ + mpsc, + mpsc::{UnboundedReceiver, UnboundedSender}, + oneshot, }, }; - +#[cfg(not(target_arch = "wasm32"))] pub type SocketStream = WebSocketStream>; +#[cfg(not(target_arch = "wasm32"))] +use tokio::net::TcpStream; +#[cfg(target_arch = "wasm32")] +pub type SocketStream = WebSocketStream; /// Opens a connection to the Relay and returns [`ClientStream`] for the /// connection. +#[cfg(not(target_arch = "wasm32"))] pub async fn create_stream(request: HttpRequest<()>) -> Result { let (socket, _) = connect_async(request) .await @@ -45,6 +50,16 @@ pub async fn create_stream(request: HttpRequest<()>) -> Result) -> Result { + let url = format!("{}", request.uri()); + let socket = connect_async(url) + .await + .map_err(WebsocketClientError::ConnectionFailed)?; + + Ok(ClientStream::new(socket)) +} + /// Possible events produced by the [`ClientStream`]. /// /// The events are produced by polling [`ClientStream`] in a loop. @@ -140,6 +155,7 @@ impl ClientStream { } /// Closes the connection. + #[cfg(not(target_arch = "wasm32"))] pub async fn close(&mut self, frame: Option>) -> Result<(), ClientError> { self.close_frame = frame.clone(); self.socket @@ -148,6 +164,15 @@ impl ClientStream { .map_err(|err| WebsocketClientError::ClosingFailed(err).into()) } + #[cfg(target_arch = "wasm32")] + pub async fn close(&mut self, frame: Option>) -> Result<(), ClientError> { + self.close_frame = frame.clone(); + self.socket + .close() + .await + .map_err(|err| WebsocketClientError::ClosingFailed(err).into()) + } + fn parse_inbound(&mut self, result: Result) -> Option { match result { Ok(message) => match &message { @@ -223,7 +248,7 @@ impl ClientStream { self.close_frame = frame.clone(); Some(StreamEvent::ConnectionClosed(frame.clone())) } - + #[cfg(not(target_arch = "wasm32"))] _ => None, }, @@ -269,6 +294,7 @@ impl Stream for ClientStream { type Item = StreamEvent; fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + #[cfg(not(target_arch = "wasm32"))] if self.socket.is_terminated() { return Poll::Ready(None); } @@ -300,9 +326,15 @@ impl Stream for ClientStream { } impl FusedStream for ClientStream { + #[cfg(not(target_arch = "wasm32"))] fn is_terminated(&self) -> bool { self.socket.is_terminated() } + + #[cfg(target_arch = "wasm32")] + fn is_terminated(&self) -> bool { + false + } } impl Drop for ClientStream { diff --git a/relay_rpc/Cargo.toml b/relay_rpc/Cargo.toml index c3206d1..a31737c 100644 --- a/relay_rpc/Cargo.toml +++ b/relay_rpc/Cargo.toml @@ -29,6 +29,8 @@ rand = "0.8" chrono = { version = "0.4", default-features = false, features = [ "std", "clock", + "wasmbind", + "wasm-bindgen", ] } regex = "1.7" once_cell = "1.16" diff --git a/relay_rpc/src/jwt.rs b/relay_rpc/src/jwt.rs index 120a99d..d37599f 100644 --- a/relay_rpc/src/jwt.rs +++ b/relay_rpc/src/jwt.rs @@ -67,7 +67,7 @@ impl Default for JwtHeader<'_> { } } -impl<'a> JwtHeader<'a> { +impl JwtHeader<'_> { pub fn is_valid(&self) -> bool { self.typ == JWT_HEADER_TYP && self.alg == JWT_HEADER_ALG } diff --git a/wasm_websocket_demo/Cargo.toml b/wasm_websocket_demo/Cargo.toml new file mode 100644 index 0000000..28641d8 --- /dev/null +++ b/wasm_websocket_demo/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "wasm_websocket_demo" +version = "0.1.0" +edition = "2021" +authors = ["WalletConnect Team"] +license = "Apache-2.0" + +[build] +target = "wasm32-unknown-uknown" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +anyhow = "1" +data-encoding = "2" +futures = "0.3" +rand = "0.8.5" +getrandom = { version = "0.2", features = ["js"] } +wasm-bindgen = { version = "0.2", features = ["serde-serialize"] } +wasm-bindgen-futures = "0.4" +web-sys = { version = "0.3", features = [ + "Element", "HtmlElement", "Window", + "WebSocket", "console", "Event", + "Document", "Crypto", "CryptoKey", + "DateTimeValue", "SubtleCrypto", "Performance", + "TimeEvent" +] } +walletconnect_sdk = { path = "../" } +console_error_panic_hook = { version = "0.1" } +web-time = { version = "1", features = ["serde"] } +gloo-timers = { version = "0.3", features = ["futures"] } + +[package.metadata.wasm-pack.profile.dev] +wasm-bindgen = { debug-js-glue = true, demangle-name-section = true } +wasm-opt = false + + diff --git a/wasm_websocket_demo/README.md b/wasm_websocket_demo/README.md new file mode 100644 index 0000000..d4fd5cc --- /dev/null +++ b/wasm_websocket_demo/README.md @@ -0,0 +1,29 @@ +# Wasm Websocket Client Example + +## Quickstart + +1. Set the PROJECT_ID env: + +```shell +export PROJECT_ID="my-project-id" +``` + +2. Install wasm-pack + +```shell +cargo install wasm-pack +``` + +3. Install basic-http-server + +```shell +cargo install basic-http-server +``` + +4. Build + +```shell + wasm-pack build --target web --dev +``` + +Visit [http://localhost:4000](http://localhost:4000) diff --git a/wasm_websocket_demo/favicon.ico b/wasm_websocket_demo/favicon.ico new file mode 100644 index 0000000..cd84284 Binary files /dev/null and b/wasm_websocket_demo/favicon.ico differ diff --git a/wasm_websocket_demo/index.html b/wasm_websocket_demo/index.html new file mode 100644 index 0000000..f3221a8 --- /dev/null +++ b/wasm_websocket_demo/index.html @@ -0,0 +1,43 @@ + + + + + WASM WebSocket Client Example + + + +

WASM WebSocket Client Example

+ +
+
client
+
state
+
topic
+
message
+
error
+ +
wc1
+
disconnected
+
+
+
+ +
wc2
+
disconnected
+
+
+
+
+
+
+ + diff --git a/wasm_websocket_demo/src/lib.rs b/wasm_websocket_demo/src/lib.rs new file mode 100644 index 0000000..cfd5d51 --- /dev/null +++ b/wasm_websocket_demo/src/lib.rs @@ -0,0 +1,152 @@ +mod utils; + +use { + rand::rngs::OsRng, + std::{ + fmt::{Display, Formatter}, + time::Duration, + }, + walletconnect_sdk::{ + client::{ + error::ClientError, + websocket::{Client, CloseFrame, ConnectionHandler, PublishedMessage}, + ConnectionOptions, + }, + rpc::{ + auth::{ed25519_dalek::SigningKey, AuthToken}, + domain::Topic, + }, + }, + wasm_bindgen::prelude::*, + wasm_bindgen_futures::spawn_local, + web_sys::console, +}; + +enum ClientId { + WC1, + WC2, +} + +impl ClientId { + fn div(&self, d: &str) -> String { + format!("{}{d}", self) + } +} +impl Display for ClientId { + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> { + match self { + ClientId::WC1 => write!(f, "wc1"), + ClientId::WC2 => write!(f, "wc2"), + } + } +} + +struct Handler { + client_id: ClientId, +} + +impl Handler { + fn new(name: ClientId) -> Self { + Self { client_id: name } + } + + fn error_div(&self) -> String { + self.client_id.div("error") + } + + fn connect_div(&self) -> String { + self.client_id.div("connect") + } + + fn message_div(&self) -> String { + self.client_id.div("message") + } +} + +impl ConnectionHandler for Handler { + fn connected(&mut self) { + let _ = utils::set_result_text(&self.connect_div(), "connected"); + } + + fn disconnected(&mut self, _frame: Option>) { + let _ = utils::set_result_text(&self.connect_div(), "disconnected"); + } + + fn message_received(&mut self, message: PublishedMessage) { + let div = self.message_div(); + let from = match self.client_id { + ClientId::WC1 => "wc2", + ClientId::WC2 => "wc1", + }; + let msg = format!("message from {from}: '{}'", message.message.as_ref()); + let _ = utils::set_result_text(&div, &msg); + } + + fn inbound_error(&mut self, error: ClientError) { + let e = format!("inbound error: {error}"); + let _ = utils::set_result_text(&self.error_div(), &e); + } + + fn outbound_error(&mut self, error: ClientError) { + let e = format!("outbound error: {error}"); + let _ = utils::set_result_text(&self.error_div(), &e); + } +} + +fn create_opts_result(address: &str, project_id: &str) -> anyhow::Result { + let mut csprng = OsRng; + let key = SigningKey::generate(&mut csprng); + console::log_1(&"loaded key".into()); + let auth = AuthToken::new("http://example.com") + .aud(address) + .ttl(Duration::from_secs(60 * 60)); + console::log_1(&"AuthToken Init".into()); + let auth = auth.as_jwt(&key)?; + console::log_1(&"AuthToken JWT".into()); + Ok(ConnectionOptions::new(project_id, auth).with_address(address)) +} + +fn create_conn_opts(address: &str, project_id: &str) -> Option { + match create_opts_result(address, project_id) { + Ok(o) => Some(o), + Err(e) => { + let error_msg = format!("Failed to create connection options: {:?}", e); + let _ = utils::set_result_text(&ClientId::WC1.div("error"), &error_msg); + let _ = utils::set_result_text(&ClientId::WC2.div("error"), &error_msg); + None + } + } +} + +#[wasm_bindgen(start)] +pub fn run() { + console_error_panic_hook::set_once(); + let project_id = env!("PROJECT_ID"); + spawn_local(async { + let client1 = Client::new(Handler::new(ClientId::WC1)); + let client2 = Client::new(Handler::new(ClientId::WC2)); + let opts = create_conn_opts("wss://relay.walletconnect.org", project_id); + if opts.is_none() { + return; + } + utils::connect("wc1", &client1, &opts.unwrap()).await; + let opts = create_conn_opts("wss://relay.walletconnect.org", project_id); + if opts.is_none() { + return; + } + utils::connect("wc2", &client2, &opts.unwrap()).await; + + let topic = Topic::generate(); + let sub = utils::subscribe_topic(ClientId::WC1, &client1, topic.clone()).await; + if !sub { + return; + } + let sub = utils::subscribe_topic(ClientId::WC2, &client2, topic.clone()).await; + if !sub { + return; + } + spawn_local(utils::publish(ClientId::WC1, client1, topic.clone())); + spawn_local(utils::publish(ClientId::WC2, client2, topic.clone())); + console::log_1(&"done".into()); + }); +} diff --git a/wasm_websocket_demo/src/utils.rs b/wasm_websocket_demo/src/utils.rs new file mode 100644 index 0000000..377cc82 --- /dev/null +++ b/wasm_websocket_demo/src/utils.rs @@ -0,0 +1,78 @@ +use { + crate::ClientId, + gloo_timers::future::TimeoutFuture, + std::{sync::Arc, time::Duration}, + walletconnect_sdk::{ + client::{websocket::Client, ConnectionOptions}, + rpc::domain::Topic, + }, + wasm_bindgen::JsValue, + web_sys::console, +}; + +// Helper function to set text in the result div +pub fn set_result_text(div: &str, text: &str) -> Result<(), JsValue> { + let window = web_sys::window().expect("no global `window` exists"); + let document = window.document().expect("should have a document on window"); + let result_div = document + .get_element_by_id(div) + .expect("should have result element"); + + result_div.set_inner_html(""); + + let text_element = document.create_element("p")?; + text_element.set_inner_html(text); + result_div.append_child(&text_element)?; + Ok(()) +} + +pub async fn subscribe_topic(id: ClientId, client: &Client, topic: Topic) -> bool { + let msg = format!("{topic}"); + match client.subscribe(topic).await { + Ok(_) => { + let div = format!("{}topic", id); + let _ = set_result_text(&div, &msg); + true + } + Err(e) => { + let div = id.div("error"); + let _ = set_result_text(&div, &format!("failed to subscribe {e}")); + false + } + } +} + +pub async fn connect(id: &str, client: &Client, opts: &ConnectionOptions) { + match client.connect(opts).await { + Ok(_) => { + console::log_1(&"WebSocket connection successful".into()); + } + Err(e) => { + let error_msg = format!("WebSocket connection failed: {:?}", e); + let div = format!("{}error", id); + let _ = set_result_text(&div, error_msg.as_str()); + } + } +} + +pub async fn publish(id: ClientId, client: Client, topic: Topic) { + for i in 1..9 { + let msg = format!("{i}"); + if let Err(e) = client + .publish( + topic.clone(), + Arc::from(msg.as_str()), + None, + 0, + Duration::from_secs(60), + false, + ) + .await + { + let error_msg = format!("Failed message send {e}"); + let _ = set_result_text(&id.div("error"), &error_msg); + return; + } + TimeoutFuture::new(2000).await; + } +} diff --git a/wasm_websocket_demo/styles.css b/wasm_websocket_demo/styles.css new file mode 100644 index 0000000..00a0f85 --- /dev/null +++ b/wasm_websocket_demo/styles.css @@ -0,0 +1,71 @@ +/* Base styles */ +.grid-container { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 1rem; + padding: 1rem; + max-width: 1200px; + margin: 0 auto; +} + +.grid-item { + background-color: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 4px; + padding: 1rem; + min-height: 2.5rem; + display: flex; + align-items: center; +} + +/* Header row */ +.grid-item:nth-child(-n+5) { + background-color: #e9ecef; + font-weight: bold; + text-transform: capitalize; +} + +/* Topic cell truncation */ +#wc1topic, #wc2topic { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 8ch; +} + +/* Status indicators */ +#wc1connect, #wc2connect { + &:contains("connected") { + color: #198754; + background-color: #d1e7dd; + } + + &:contains("disconnected") { + color: #842029; + background-color: #f8d7da; + } +} + +/* Error cells */ +#wc1error, #wc2error { + color: #842029; +} + +/* Responsive breakpoints */ +@media (max-width: 768px) { + .grid-container { + grid-template-columns: repeat(3, 1fr); + } + + .grid-item:nth-child(-n+5) { + display: none; + } +} + +@media (max-width: 480px) { + .grid-container { + grid-template-columns: repeat(2, 1fr); + gap: 0.5rem; + padding: 0.5rem; + } +}