From 4a57514154f760193d6c79fa3fcc0d6e667ecfc4 Mon Sep 17 00:00:00 2001 From: Thales R Date: Thu, 24 Apr 2025 19:51:13 +0200 Subject: [PATCH 01/32] Replace SDK File struct with StorageEntry for web --- sdks/rust/Cargo.toml | 8 +- sdks/rust/src/credentials.rs | 311 +++++++++++++++++++++-------------- 2 files changed, 194 insertions(+), 125 deletions(-) diff --git a/sdks/rust/Cargo.toml b/sdks/rust/Cargo.toml index 6dc26e25d61..9d0d6844f6c 100644 --- a/sdks/rust/Cargo.toml +++ b/sdks/rust/Cargo.toml @@ -23,15 +23,21 @@ bytes.workspace = true flate2.workspace = true futures.workspace = true futures-channel.workspace = true -home.workspace = true http.workspace = true log.workspace = true once_cell.workspace = true prometheus.workspace = true rand.workspace = true + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +home.workspace = true tokio.workspace = true tokio-tungstenite.workspace = true +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen = "0.2.100" +web-sys = { version = "0.3.77", features = [ "Window", "Storage" ] } + [dev-dependencies] # for quickstart-chat and cursive-chat examples hex.workspace = true diff --git a/sdks/rust/src/credentials.rs b/sdks/rust/src/credentials.rs index bdef761048c..497cd21c628 100644 --- a/sdks/rust/src/credentials.rs +++ b/sdks/rust/src/credentials.rs @@ -8,144 +8,207 @@ //! } //! ``` -use home::home_dir; -use spacetimedb_lib::{bsatn, de::Deserialize, ser::Serialize}; -use std::path::PathBuf; -use thiserror::Error; - -const CREDENTIALS_DIR: &str = ".spacetimedb_client_credentials"; - -#[derive(Error, Debug)] -pub enum CredentialFileError { - #[error("Failed to determine user home directory as root for credentials storage")] - DetermineHomeDir, - #[error("Error creating credential storage directory {path}")] - CreateDir { - path: PathBuf, - #[source] - source: std::io::Error, - }, - #[error("Error serializing credentials for storage in file")] - Serialize { - #[source] - source: bsatn::EncodeError, - }, - #[error("Error writing BSATN-serialized credentials to file {path}")] - Write { - path: PathBuf, - #[source] - source: std::io::Error, - }, - #[error("Error reading BSATN-serialized credentials from file {path}")] - Read { - path: PathBuf, - #[source] - source: std::io::Error, - }, - #[error("Error deserializing credentials from bytes stored in file {path}")] - Deserialize { - path: PathBuf, - #[source] - source: bsatn::DecodeError, - }, -} +#[cfg(not(target_arch = "wasm32"))] +mod native_mod { + use home::home_dir; + use spacetimedb_lib::{bsatn, de::Deserialize, ser::Serialize}; + use std::path::PathBuf; + use thiserror::Error; -/// A file on disk which stores, or can store, a JWT for authenticating with SpacetimeDB. -/// -/// The file does not necessarily exist or store credentials. -/// If the credentials have been stored previously, they can be accessed with [`File::load`]. -/// New credentials can be saved to disk with [`File::save`]. -pub struct File { - filename: String, -} + const CREDENTIALS_DIR: &str = ".spacetimedb_client_credentials"; -#[derive(Serialize, Deserialize)] -struct Credentials { - token: String, -} + #[derive(Error, Debug)] + pub enum CredentialFileError { + #[error("Failed to determine user home directory as root for credentials storage")] + DetermineHomeDir, + #[error("Error creating credential storage directory {path}")] + CreateDir { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("Error serializing credentials for storage in file")] + Serialize { + #[source] + source: bsatn::EncodeError, + }, + #[error("Error writing BSATN-serialized credentials to file {path}")] + Write { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("Error reading BSATN-serialized credentials from file {path}")] + Read { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("Error deserializing credentials from bytes stored in file {path}")] + Deserialize { + path: PathBuf, + #[source] + source: bsatn::DecodeError, + }, + } -impl File { - /// Get a handle on a file which stores a SpacetimeDB [`Identity`] and its private access token. + /// A file on disk which stores, or can store, a JWT for authenticating with SpacetimeDB. /// - /// This method does not create the file or check that it exists. - /// - /// Distinct applications running as the same user on the same machine - /// may share [`Identity`]/token pairs by supplying the same `key`. - /// Users who desire distinct credentials for their application - /// should supply a unique `key` per application. - /// - /// No additional namespacing is provided to tie the stored token - /// to a particular SpacetimeDB instance or cluster. - /// Users who intend to connect to multiple instances or clusters - /// should use a distinct `key` per cluster. - pub fn new(key: impl Into) -> Self { - Self { filename: key.into() } + /// The file does not necessarily exist or store credentials. + /// If the credentials have been stored previously, they can be accessed with [`File::load`]. + /// New credentials can be saved to disk with [`File::save`]. + pub struct File { + filename: String, } - fn determine_home_dir() -> Result { - home_dir().ok_or(CredentialFileError::DetermineHomeDir) + #[derive(Serialize, Deserialize)] + struct Credentials { + token: String, } - fn ensure_credentials_dir() -> Result<(), CredentialFileError> { - let mut path = Self::determine_home_dir()?; - path.push(CREDENTIALS_DIR); + impl File { + /// Get a handle on a file which stores a SpacetimeDB [`Identity`] and its private access token. + /// + /// This method does not create the file or check that it exists. + /// + /// Distinct applications running as the same user on the same machine + /// may share [`Identity`]/token pairs by supplying the same `key`. + /// Users who desire distinct credentials for their application + /// should supply a unique `key` per application. + /// + /// No additional namespacing is provided to tie the stored token + /// to a particular SpacetimeDB instance or cluster. + /// Users who intend to connect to multiple instances or clusters + /// should use a distinct `key` per cluster. + pub fn new(key: impl Into) -> Self { + Self { filename: key.into() } + } + + fn determine_home_dir() -> Result { + home_dir().ok_or(CredentialFileError::DetermineHomeDir) + } + + fn ensure_credentials_dir() -> Result<(), CredentialFileError> { + let mut path = Self::determine_home_dir()?; + path.push(CREDENTIALS_DIR); + + std::fs::create_dir_all(&path).map_err(|source| CredentialFileError::CreateDir { path, source }) + } + + fn path(&self) -> Result { + let mut path = Self::determine_home_dir()?; + path.push(CREDENTIALS_DIR); + path.push(&self.filename); + Ok(path) + } + + /// Store the provided `token` to disk in the file referred to by `self`. + /// + /// Future calls to [`Self::load`] on a `File` with the same key can retrieve the token. + /// + /// Expected usage is to call this from a [`super::DbConnectionBuilder::on_connect`] callback. + /// + /// ```ignore + /// DbConnection::builder() + /// .on_connect(|_ctx, _identity, token| { + /// credentials::File::new("my_app").save(token).unwrap(); + /// }) + /// ``` + pub fn save(self, token: impl Into) -> Result<(), CredentialFileError> { + Self::ensure_credentials_dir()?; + + let creds = bsatn::to_vec(&Credentials { token: token.into() }) + .map_err(|source| CredentialFileError::Serialize { source })?; + let path = self.path()?; + std::fs::write(&path, creds).map_err(|source| CredentialFileError::Write { path, source })?; + Ok(()) + } + + /// Load a saved token from disk in the file referred to by `self`, + /// if they have previously been stored by [`Self::save`]. + /// + /// Returns `Err` if I/O fails, + /// `None` if credentials have not previously been stored, + /// or `Some` if credentials are successfully loaded from disk. + /// After unwrapping the `Result`, the returned `Option` can be passed to + /// [`super::DbConnectionBuilder::with_token`]. + /// + /// ```ignore + /// DbConnection::builder() + /// .with_token(credentials::File::new("my_app").load().unwrap()) + /// ``` + pub fn load(self) -> Result, CredentialFileError> { + let path = self.path()?; + + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(e) if matches!(e.kind(), std::io::ErrorKind::NotFound) => return Ok(None), + Err(source) => return Err(CredentialFileError::Read { path, source }), + }; - std::fs::create_dir_all(&path).map_err(|source| CredentialFileError::CreateDir { path, source }) + let creds = bsatn::from_slice::(&bytes) + .map_err(|source| CredentialFileError::Deserialize { path, source })?; + Ok(Some(creds.token)) + } } +} + +#[cfg(target_arch = "wasm32")] +mod web_mod { + use thiserror::Error; + + #[derive(Error, Debug)] + pub enum CredentialStorageError { + #[error("Could not access localStorage")] + LocalStorageAccess, + + #[error("Exception while interacting with localStorage: {0:?}")] + LocalStorageJsError(wasm_bindgen::JsValue), - fn path(&self) -> Result { - let mut path = Self::determine_home_dir()?; - path.push(CREDENTIALS_DIR); - path.push(&self.filename); - Ok(path) + #[error("Window object is not available in this context")] + WindowObjectAccess, } - /// Store the provided `token` to disk in the file referred to by `self`. - /// - /// Future calls to [`Self::load`] on a `File` with the same key can retrieve the token. - /// - /// Expected usage is to call this from a [`super::DbConnectionBuilder::on_connect`] callback. - /// - /// ```ignore - /// DbConnection::builder() - /// .on_connect(|_ctx, _identity, token| { - /// credentials::File::new("my_app").save(token).unwrap(); - /// }) - /// ``` - pub fn save(self, token: impl Into) -> Result<(), CredentialFileError> { - Self::ensure_credentials_dir()?; - - let creds = bsatn::to_vec(&Credentials { token: token.into() }) - .map_err(|source| CredentialFileError::Serialize { source })?; - let path = self.path()?; - std::fs::write(&path, creds).map_err(|source| CredentialFileError::Write { path, source })?; - Ok(()) + /// TODO: Give it an option for 'Local', 'Session', 'Cookie'? + pub struct StorageEntry { + key: String, } - /// Load a saved token from disk in the file referred to by `self`, - /// if they have previously been stored by [`Self::save`]. - /// - /// Returns `Err` if I/O fails, - /// `None` if credentials have not previously been stored, - /// or `Some` if credentials are successfully loaded from disk. - /// After unwrapping the `Result`, the returned `Option` can be passed to - /// [`super::DbConnectionBuilder::with_token`]. - /// - /// ```ignore - /// DbConnection::builder() - /// .with_token(credentials::File::new("my_app").load().unwrap()) - /// ``` - pub fn load(self) -> Result, CredentialFileError> { - let path = self.path()?; - - let bytes = match std::fs::read(&path) { - Ok(bytes) => bytes, - Err(e) if matches!(e.kind(), std::io::ErrorKind::NotFound) => return Ok(None), - Err(source) => return Err(CredentialFileError::Read { path, source }), - }; - - let creds = bsatn::from_slice::(&bytes) - .map_err(|source| CredentialFileError::Deserialize { path, source })?; - Ok(Some(creds.token)) + impl StorageEntry { + pub fn new(key: impl Into) -> Self { + Self { key: key.into() } + } + + pub fn save(&self, token: impl Into) -> Result<(), CredentialStorageError> { + let local_storage = web_sys::window() + .ok_or(CredentialStorageError::WindowObjectAccess)? + .local_storage() + .map_err(CredentialStorageError::LocalStorageJsError)? + .ok_or(CredentialStorageError::LocalStorageAccess)?; + local_storage + .set_item(&self.key, &token.into()) + .map_err(CredentialStorageError::LocalStorageJsError)?; + Ok(()) + } + + pub fn load(&self) -> Result, CredentialStorageError> { + let local_storage = web_sys::window() + .ok_or(CredentialStorageError::WindowObjectAccess)? + .local_storage() + .map_err(CredentialStorageError::LocalStorageJsError)? + .ok_or(CredentialStorageError::LocalStorageAccess)?; + + match local_storage.get_item(&self.key) { + Ok(Some(token)) => Ok(Some(token)), + Ok(None) => Ok(None), + Err(err) => Err(CredentialStorageError::LocalStorageJsError(err)), + } + } } } + +#[cfg(not(target_arch = "wasm32"))] +pub use native_mod::*; + +#[cfg(target_arch = "wasm32")] +pub use web_mod::*; From 505496c48e52a36e0b4feaa9d347871590e8d4f6 Mon Sep 17 00:00:00 2001 From: Thales R Date: Thu, 24 Apr 2025 20:13:16 +0200 Subject: [PATCH 02/32] Enable web build for the SDK crate - `DbConnectionBuilder::build` becomes async without tokio's block_in_place. Still need to add `web` feature flag. --- Cargo.lock | 52 ++++++++++++ sdks/rust/Cargo.toml | 9 +++ sdks/rust/src/db_connection.rs | 139 ++++++++++++++++++++++++++++++++- sdks/rust/src/websocket.rs | 126 +++++++++++++++++++++++++++++- 4 files changed, 318 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a8b001bd7a2..9ca83ed5543 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2553,6 +2553,32 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "gloo-console" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a17868f56b4a24f677b17c8cb69958385102fa879418052d60b50bc1727e261" +dependencies = [ + "gloo-utils", + "js-sys", + "serde", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-utils" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "gzip-header" version = "1.0.0" @@ -6684,6 +6710,7 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" dependencies = [ + "web-time", "zeroize", ] @@ -8197,6 +8224,8 @@ dependencies = [ "flate2", "futures", "futures-channel", + "getrandom 0.3.2", + "gloo-console", "hex", "home", "http 1.3.1", @@ -8213,6 +8242,11 @@ dependencies = [ "thiserror 1.0.69", "tokio", "tokio-tungstenite", + "tokio-tungstenite-wasm", + "tungstenite", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[package]] @@ -9199,6 +9233,24 @@ dependencies = [ "tungstenite", ] +[[package]] +name = "tokio-tungstenite-wasm" +version = "0.5.0" +source = "git+https://github.com/thlsrms/tokio-tungstenite-wasm?rev=c788b7cfc30f576c#c788b7cfc30f576c207344c2907932b5317ca5e0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.3.1", + "httparse", + "js-sys", + "thiserror 2.0.12", + "tokio", + "tokio-tungstenite", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "tokio-util" version = "0.7.16" diff --git a/sdks/rust/Cargo.toml b/sdks/rust/Cargo.toml index 9d0d6844f6c..91dd7b43000 100644 --- a/sdks/rust/Cargo.toml +++ b/sdks/rust/Cargo.toml @@ -35,7 +35,16 @@ tokio.workspace = true tokio-tungstenite.workspace = true [target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom = { version = "0.3.2", features = ["wasm_js"]} +gloo-console = "0.3.0" +rustls-pki-types = { version = "1.11.0", features = ["web"] } +tokio-tungstenite-wasm = { git = "https://github.com/thlsrms/tokio-tungstenite-wasm", rev = "c788b7cfc30f576c" } +tokio = { version = "1.37", default-features = false, features = [ + "rt", "macros", "sync" +] } +tungstenite = { version = "0.26.2", features = ["rustls"] } wasm-bindgen = "0.2.100" +wasm-bindgen-futures = "0.4.45" web-sys = { version = "0.3.77", features = [ "Window", "Storage" ] } [dev-dependencies] diff --git a/sdks/rust/src/db_connection.rs b/sdks/rust/src/db_connection.rs index 6ad86a45ba3..2d78ae00fe1 100644 --- a/sdks/rust/src/db_connection.rs +++ b/sdks/rust/src/db_connection.rs @@ -44,10 +44,9 @@ use std::{ collections::HashMap, sync::{atomic::AtomicU32, Arc, Mutex as StdMutex, OnceLock}, }; -use tokio::{ - runtime::{self, Runtime}, - sync::Mutex as TokioMutex, -}; +use tokio::runtime::{self, Runtime}; +#[cfg(not(target_arch = "wasm32"))] +use tokio::sync::Mutex as TokioMutex; pub(crate) type SharedCell = Arc>; @@ -70,8 +69,12 @@ pub struct DbContextImpl { /// Receiver channel for WebSocket messages, /// which are pre-parsed in the background by [`parse_loop`]. + #[cfg(not(target_arch = "wasm32"))] recv: Arc>>>, + #[cfg(target_arch = "wasm32")] + recv: SharedCell>>, + /// Channel into which operations which apparently mutate SDK state, /// e.g. registering callbacks, push [`PendingMutation`] messages, /// rather than immediately locking the connection and applying their change, @@ -80,8 +83,12 @@ pub struct DbContextImpl { /// Receive end of `pending_mutations_send`, /// from which [Self::apply_pending_mutations] and friends read mutations. + #[cfg(not(target_arch = "wasm32"))] pending_mutations_recv: Arc>>>, + #[cfg(target_arch = "wasm32")] + pending_mutations_recv: SharedCell>>, + /// This connection's `Identity`. /// /// May be `None` if we connected anonymously @@ -288,9 +295,16 @@ impl DbContextImpl { /// Apply all queued [`PendingMutation`]s. fn apply_pending_mutations(&self) -> crate::Result<()> { + #[cfg(not(target_arch = "wasm32"))] while let Ok(Some(pending_mutation)) = self.pending_mutations_recv.blocking_lock().try_next() { self.apply_mutation(pending_mutation)?; } + + #[cfg(target_arch = "wasm32")] + while let Ok(Some(pending_mutation)) = self.pending_mutations_recv.lock().unwrap().try_next() { + self.apply_mutation(pending_mutation)?; + } + Ok(()) } @@ -519,6 +533,7 @@ impl DbContextImpl { /// If no WebSocket messages are in the queue, immediately return `false`. /// /// Called by the autogenerated `DbConnection` method of the same name. + #[cfg(not(target_arch = "wasm32"))] pub fn advance_one_message(&self) -> crate::Result { // Apply any pending mutations before processing a WS message, // so that pending callbacks don't get skipped. @@ -552,14 +567,45 @@ impl DbContextImpl { res } + #[cfg(target_arch = "wasm32")] + pub fn advance_one_message(&self) -> crate::Result { + self.apply_pending_mutations()?; + // Synchronously try to pull one server message + let res = { + let mut chan = self.recv.lock().unwrap(); + match chan.try_next() { + Ok(None) => { + // Shouldn’t happen on unbounded, treat as disconnect + let ctx = self.make_event_ctx(None); + self.invoke_disconnected(&ctx); + Err(crate::Error::Disconnected) + } + Err(_) => Ok(false), + Ok(Some(msg)) => self.process_message(msg).map(|_| true), + } + }; + + // send any pending outgoing mutations now that we've done a read + self.apply_pending_mutations()?; + + res + } + async fn get_message(&self) -> Message { // Holding these locks across the below await can only cause a deadlock if // there are multiple parallel callers of `advance_one_message` or its siblings. // We call this out as an incorrect and unsupported thing to do. #![allow(clippy::await_holding_lock)] + #[cfg(not(target_arch = "wasm32"))] let mut pending_mutations = self.pending_mutations_recv.lock().await; + #[cfg(target_arch = "wasm32")] + let mut pending_mutations = self.pending_mutations_recv.lock().unwrap(); + + #[cfg(not(target_arch = "wasm32"))] let mut recv = self.recv.lock().await; + #[cfg(target_arch = "wasm32")] + let mut recv = self.recv.lock().unwrap(); // Always process pending mutations before WS messages, if they're available, // so that newly registered callbacks run on messages. @@ -917,13 +963,21 @@ You must explicitly advance the connection by calling any one of: Which of these methods you should call depends on the specific needs of your application, but you must call one of them, or else the connection will never progress. "] + #[cfg(not(target_arch = "wasm32"))] pub fn build(self) -> crate::Result { let imp = self.build_impl()?; Ok(::new(imp)) } + #[cfg(target_arch = "wasm32")] + pub async fn build(self) -> crate::Result { + let imp = self.build_impl().await?; + Ok(::new(imp)) + } + /// Open a WebSocket connection, build an empty client cache, &c, /// to construct a [`DbContextImpl`]. + #[cfg(not(target_arch = "wasm32"))] fn build_impl(self) -> crate::Result> { let (runtime, handle) = enter_or_create_runtime()?; let db_callbacks = DbCallbacks::default(); @@ -982,6 +1036,64 @@ but you must call one of them, or else the connection will never progress. Ok(ctx_imp) } + #[cfg(target_arch = "wasm32")] + pub async fn build_impl(self) -> crate::Result> { + let (runtime, handle) = enter_or_create_runtime()?; + let db_callbacks = DbCallbacks::default(); + let reducer_callbacks = ReducerCallbacks::default(); + + let connection_id_override = get_connection_id_override(); + let ws_connection = WsConnection::connect( + self.uri.unwrap(), + self.module_name.as_ref().unwrap(), + self.token.as_deref(), + connection_id_override, + self.params, + ) + .await + .map_err(|source| crate::Error::FailedToConnect { + source: InternalError::new("Failed to initiate WebSocket connection").with_cause(source), + })?; + + let (raw_msg_recv, raw_msg_send) = ws_connection.spawn_message_loop(); + let parsed_recv_chan = spawn_parse_loop::(raw_msg_recv, &handle); + + let inner = Arc::new(StdMutex::new(DbContextImplInner { + runtime, + + db_callbacks, + reducer_callbacks, + subscriptions: SubscriptionManager::default(), + + on_connect: self.on_connect, + on_connect_error: self.on_connect_error, + on_disconnect: self.on_disconnect, + call_reducer_flags: <_>::default(), + })); + + let mut cache = ClientCache::default(); + M::register_tables(&mut cache); + let cache = Arc::new(StdMutex::new(cache)); + let send_chan = Arc::new(StdMutex::new(Some(raw_msg_send))); + + let (pending_mutations_send, pending_mutations_recv) = mpsc::unbounded(); + let ctx_imp = DbContextImpl { + runtime: handle, + inner, + send_chan, + cache, + #[cfg(target_arch = "wasm32")] + recv: Arc::new(StdMutex::new(parsed_recv_chan)), + pending_mutations_send, + #[cfg(target_arch = "wasm32")] + pending_mutations_recv: Arc::new(StdMutex::new(pending_mutations_recv)), + identity: Arc::new(StdMutex::new(None)), + connection_id: Arc::new(StdMutex::new(connection_id_override)), + }; + + Ok(ctx_imp) + } + /// Set the URI of the SpacetimeDB host which is running the remote module. /// /// The URI must have either no scheme or one of the schemes `http`, `https`, `ws` or `wss`. @@ -1007,6 +1119,7 @@ but you must call one of them, or else the connection will never progress. /// If the passed token is invalid or rejected by the host, /// the connection will fail asynchrnonously. // FIXME: currently this causes `disconnect` to be called rather than `on_connect_error`. + #[cfg(not(target_arch = "wasm32"))] pub fn with_token(mut self, token: Option>) -> Self { self.token = token.map(|token| token.into()); self @@ -1117,12 +1230,19 @@ Instead of registering multiple `on_disconnect` callbacks, register a single cal fn enter_or_create_runtime() -> crate::Result<(Option, runtime::Handle)> { match runtime::Handle::try_current() { Err(e) if e.is_missing_context() => { + #[cfg(not(target_arch = "wasm32"))] let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .worker_threads(1) .thread_name("spacetimedb-background-connection") .build() .map_err(|source| InternalError::new("Failed to create Tokio runtime").with_cause(source))?; + #[cfg(target_arch = "wasm32")] + let rt = tokio::runtime::Builder::new_current_thread() + .worker_threads(1) + .thread_name("spacetimedb-background-connection") + .build() + .map_err(|source| InternalError::new("Failed to create Tokio runtime").with_cause(source))?; let handle = rt.handle().clone(); Ok((Some(rt), handle)) @@ -1162,6 +1282,7 @@ enum ParsedMessage { }, } +#[cfg(not(target_arch = "wasm32"))] fn spawn_parse_loop( raw_message_recv: mpsc::UnboundedReceiver>, handle: &runtime::Handle, @@ -1171,6 +1292,16 @@ fn spawn_parse_loop( (handle, parsed_message_recv) } +#[cfg(target_arch = "wasm32")] +fn spawn_parse_loop( + raw_message_recv: mpsc::UnboundedReceiver>, + _handle: &runtime::Handle, +) -> mpsc::UnboundedReceiver> { + let (parsed_message_send, parsed_message_recv) = mpsc::unbounded(); + wasm_bindgen_futures::spawn_local(parse_loop(raw_message_recv, parsed_message_send)); + parsed_message_recv +} + /// A loop which reads raw WS messages from `recv`, parses them into domain types, /// and pushes the [`ParsedMessage`]s into `send`. async fn parse_loop( diff --git a/sdks/rust/src/websocket.rs b/sdks/rust/src/websocket.rs index e0372a53dbb..c5d8f4a0cbe 100644 --- a/sdks/rust/src/websocket.rs +++ b/sdks/rust/src/websocket.rs @@ -2,27 +2,33 @@ //! //! This module is internal, and may incompatibly change without warning. +#[cfg(not(target_arch = "wasm32"))] use std::mem; use std::sync::Arc; +#[cfg(not(target_arch = "wasm32"))] use std::time::Duration; use bytes::Bytes; -use futures::{SinkExt, StreamExt as _, TryStreamExt}; +#[cfg(not(target_arch = "wasm32"))] +use futures::TryStreamExt; +use futures::{SinkExt, StreamExt as _}; use futures_channel::mpsc; use http::uri::{InvalidUri, Scheme, Uri}; use spacetimedb_client_api_messages::websocket::{BsatnFormat, Compression, BIN_PROTOCOL}; use spacetimedb_client_api_messages::websocket::{ClientMessage, ServerMessage}; use spacetimedb_lib::{bsatn, ConnectionId}; use thiserror::Error; -use tokio::task::JoinHandle; -use tokio::time::Instant; -use tokio::{net::TcpStream, runtime}; +#[cfg(not(target_arch = "wasm32"))] +use tokio::{net::TcpStream, runtime, task::JoinHandle, time::Instant}; +#[cfg(not(target_arch = "wasm32"))] use tokio_tungstenite::{ connect_async_with_config, tungstenite::client::IntoClientRequest, tungstenite::protocol::{Message as WebSocketMessage, WebSocketConfig}, MaybeTlsStream, WebSocketStream, }; +#[cfg(target_arch = "wasm32")] +use tokio_tungstenite_wasm::{Message as WebSocketMessage, WebSocketStream}; use crate::compression::decompress_server_message; use crate::metrics::CLIENT_METRICS; @@ -53,6 +59,7 @@ pub enum WsError { #[error(transparent)] UriError(#[from] UriError), + #[cfg(not(target_arch = "wasm32"))] #[error("Error in WebSocket connection with {uri}: {source}")] Tungstenite { uri: Uri, @@ -61,6 +68,15 @@ pub enum WsError { source: Arc, }, + #[cfg(target_arch = "wasm32")] + #[error("Error in WebSocket connection with {uri}: {source}")] + Tungstenite { + uri: Uri, + #[source] + // `Arc` is required for `Self: Clone`, as `tungstenite::Error: !Clone`. + source: Arc, + }, + #[error("Received empty raw message, but valid messages always start with a one-byte compression flag")] EmptyMessage, @@ -84,7 +100,10 @@ pub enum WsError { pub(crate) struct WsConnection { db_name: Box, + #[cfg(not(target_arch = "wasm32"))] sock: WebSocketStream>, + #[cfg(target_arch = "wasm32")] + sock: WebSocketStream, } fn parse_scheme(scheme: Option) -> Result { @@ -179,6 +198,7 @@ fn make_uri(host: Uri, db_name: &str, connection_id: Option, param // rather than having Tungstenite manage its own connections. Should this library do // the same? +#[cfg(not(target_arch = "wasm32"))] fn make_request( host: Uri, db_name: &str, @@ -196,6 +216,7 @@ fn make_request( Ok(req) } +#[cfg(not(target_arch = "wasm32"))] fn request_insert_protocol_header(req: &mut http::Request<()>) { req.headers_mut().insert( http::header::SEC_WEBSOCKET_PROTOCOL, @@ -203,6 +224,7 @@ fn request_insert_protocol_header(req: &mut http::Request<()>) { ); } +#[cfg(not(target_arch = "wasm32"))] fn request_insert_auth_header(req: &mut http::Request<()>, token: Option<&str>) { if let Some(token) = token { let auth = ["Bearer ", token].concat().try_into().unwrap(); @@ -213,6 +235,7 @@ fn request_insert_auth_header(req: &mut http::Request<()>, token: Option<&str>) /// If `res` evaluates to `Err(e)`, log a warning in the form `"{}: {:?}", $cause, e`. /// /// Could be trivially written as a function, but macro-ifying it preserves the source location of the log. +#[cfg(not(target_arch = "wasm32"))] macro_rules! maybe_log_error { ($cause:expr, $res:expr) => { if let Err(e) = $res { @@ -222,6 +245,7 @@ macro_rules! maybe_log_error { } impl WsConnection { + #[cfg(not(target_arch = "wasm32"))] pub(crate) async fn connect( host: Uri, db_name: &str, @@ -253,6 +277,28 @@ impl WsConnection { }) } + #[cfg(target_arch = "wasm32")] + pub(crate) async fn connect( + host: Uri, + db_name: &str, + _token: Option<&str>, + connection_id: Option, + params: WsParams, + ) -> Result { + let uri = make_uri(host, db_name, connection_id, params)?; + let sock = tokio_tungstenite_wasm::connect_with_protocols(&uri.to_string(), &[BIN_PROTOCOL]) + .await + .map_err(|source| WsError::Tungstenite { + uri, + source: Arc::new(source), + })?; + + Ok(WsConnection { + db_name: db_name.into(), + sock, + }) + } + pub(crate) fn parse_response(bytes: &[u8]) -> Result, WsError> { let bytes = &*decompress_server_message(bytes)?; bsatn::from_slice(bytes).map_err(|source| WsError::DeserializeMessage { source }) @@ -262,6 +308,7 @@ impl WsConnection { WebSocketMessage::Binary(bsatn::to_vec(&msg).unwrap().into()) } + #[cfg(not(target_arch = "wasm32"))] async fn message_loop( mut self, incoming_messages: mpsc::UnboundedSender>, @@ -399,6 +446,7 @@ impl WsConnection { } } + #[cfg(not(target_arch = "wasm32"))] pub(crate) fn spawn_message_loop( self, runtime: &runtime::Handle, @@ -412,4 +460,74 @@ impl WsConnection { let handle = runtime.spawn(self.message_loop(incoming_send, outgoing_recv)); (handle, incoming_recv, outgoing_send) } + + #[cfg(target_arch = "wasm32")] + pub(crate) fn spawn_message_loop( + self, + ) -> ( + mpsc::UnboundedReceiver>, + mpsc::UnboundedSender>, + ) { + + let websocket_received = CLIENT_METRICS.websocket_received.with_label_values(&self.db_name); + let websocket_received_msg_size = CLIENT_METRICS + .websocket_received_msg_size + .with_label_values(&self.db_name); + let record_metrics = move |msg_size: usize| { + websocket_received.inc(); + websocket_received_msg_size.observe(msg_size as f64); + }; + + let (outgoing_tx, outgoing_rx) = mpsc::unbounded::>(); + let (incoming_tx, incoming_rx) = mpsc::unbounded::>(); + + let ws = self.sock; + + wasm_bindgen_futures::spawn_local(async move { + // fuse both streams so `select!` knows when one side is done + let mut ws_stream = ws.fuse(); + let mut outgoing = outgoing_rx.fuse(); + + loop { + futures::select! { + // 1) inbound WS frames + frame = ws_stream.next() => match frame { + Some(Ok(WebSocketMessage::Binary(bytes))) => { + record_metrics(bytes.len()); + // parse + forward into `incoming_tx` + if let Ok(msg) = Self::parse_response(&bytes) { + match incoming_tx.unbounded_send(msg) { + Ok(_) => {}, + Err(_) => {} + } + } + } + Some(Err(e)) => { + gloo_console::warn!("WS Error: ", format!("{:?}",e)); + break; + } + None => { + gloo_console::warn!("WS Closed"); + break; + } + _ => {} + }, + + // 2) outbound messages + client_msg = outgoing.next() => if let Some(client_msg) = client_msg { + let raw = Self::encode_message(client_msg); + if let Err(e) = ws_stream.send(raw).await { + gloo_console::warn!("WS Send error: ", format!("{:?}",e)); + break; + } + } else { + // channel closed, so we're done sending + break; + }, + } + } + }); + + (incoming_rx, outgoing_tx) + } } From d3348bbeadd8dc9ac76a48702f11d018c6ef320d Mon Sep 17 00:00:00 2001 From: Thales R Date: Fri, 25 Apr 2025 18:38:16 +0200 Subject: [PATCH 03/32] Enable run_threaded on the web with spawn_local --- crates/codegen/src/rust.rs | 6 ++++++ .../tests/snapshots/codegen__codegen_rust.snap | 6 ++++++ sdks/rust/src/db_connection.rs | 15 +++++++++++++++ 3 files changed, 27 insertions(+) diff --git a/crates/codegen/src/rust.rs b/crates/codegen/src/rust.rs index 4394db26607..23436328d59 100644 --- a/crates/codegen/src/rust.rs +++ b/crates/codegen/src/rust.rs @@ -1518,10 +1518,16 @@ impl DbConnection {{ }} /// Spawn a thread which processes WebSocket messages as they are received. + #[cfg(not(target_arch = \"wasm32\"))] pub fn run_threaded(&self) -> std::thread::JoinHandle<()> {{ self.imp.run_threaded() }} + #[cfg(target_arch = \"wasm32\")] + pub fn run_threaded(&self) {{ + self.imp.run_threaded() + }} + /// Run an `async` loop which processes WebSocket messages when polled. pub async fn run_async(&self) -> __sdk::Result<()> {{ self.imp.run_async().await diff --git a/crates/codegen/tests/snapshots/codegen__codegen_rust.snap b/crates/codegen/tests/snapshots/codegen__codegen_rust.snap index 3a29743c0d9..f8e01689b90 100644 --- a/crates/codegen/tests/snapshots/codegen__codegen_rust.snap +++ b/crates/codegen/tests/snapshots/codegen__codegen_rust.snap @@ -1958,10 +1958,16 @@ impl DbConnection { } /// Spawn a thread which processes WebSocket messages as they are received. + #[cfg(not(target_arch = "wasm32"))] pub fn run_threaded(&self) -> std::thread::JoinHandle<()> { self.imp.run_threaded() } + #[cfg(target_arch = "wasm32")] + pub fn run_threaded(&self) { + self.imp.run_threaded() + } + /// Run an `async` loop which processes WebSocket messages when polled. pub async fn run_async(&self) -> __sdk::Result<()> { self.imp.run_async().await diff --git a/sdks/rust/src/db_connection.rs b/sdks/rust/src/db_connection.rs index 2d78ae00fe1..5478236d8cb 100644 --- a/sdks/rust/src/db_connection.rs +++ b/sdks/rust/src/db_connection.rs @@ -663,6 +663,7 @@ impl DbContextImpl { /// Spawn a thread which does [`Self::advance_one_message_blocking`] in a loop. /// /// Called by the autogenerated `DbConnection` method of the same name. + #[cfg(not(target_arch = "wasm32"))] pub fn run_threaded(&self) -> std::thread::JoinHandle<()> { let this = self.clone(); std::thread::spawn(move || loop { @@ -674,6 +675,20 @@ impl DbContextImpl { }) } + #[cfg(target_arch = "wasm32")] + pub fn run_threaded(&self) { + let this = self.clone(); + wasm_bindgen_futures::spawn_local(async move { + loop { + match this.advance_one_message_async().await { + Ok(()) => (), + Err(e) if error_is_normal_disconnect(&e) => return, + Err(e) => panic!("{e:?}"), + } + } + }) + } + /// An async task which does [`Self::advance_one_message_async`] in a loop. /// /// Called by the autogenerated `DbConnection` method of the same name. From 5075b55d2725e2f36c612efa5540a97cc661cb48 Mon Sep 17 00:00:00 2001 From: Thales R Date: Thu, 1 May 2025 22:34:00 +0200 Subject: [PATCH 04/32] Improve Rust SDK websocket message handling on wasm32 --- sdks/rust/src/websocket.rs | 65 +++++++++++++++++++++++++++----------- 1 file changed, 47 insertions(+), 18 deletions(-) diff --git a/sdks/rust/src/websocket.rs b/sdks/rust/src/websocket.rs index c5d8f4a0cbe..55d9875eb12 100644 --- a/sdks/rust/src/websocket.rs +++ b/sdks/rust/src/websocket.rs @@ -481,47 +481,76 @@ impl WsConnection { let (outgoing_tx, outgoing_rx) = mpsc::unbounded::>(); let (incoming_tx, incoming_rx) = mpsc::unbounded::>(); - let ws = self.sock; + let (mut ws_writer, ws_reader) = self.sock.split(); wasm_bindgen_futures::spawn_local(async move { - // fuse both streams so `select!` knows when one side is done - let mut ws_stream = ws.fuse(); + let mut incoming = ws_reader.fuse(); let mut outgoing = outgoing_rx.fuse(); loop { futures::select! { // 1) inbound WS frames - frame = ws_stream.next() => match frame { + inbound = incoming.next() => match inbound { + Some(Err(tokio_tungstenite_wasm::Error::ConnectionClosed)) | None => { + gloo_console::log!("Connection closed"); + break; + }, + Some(Ok(WebSocketMessage::Binary(bytes))) => { record_metrics(bytes.len()); // parse + forward into `incoming_tx` - if let Ok(msg) = Self::parse_response(&bytes) { - match incoming_tx.unbounded_send(msg) { - Ok(_) => {}, - Err(_) => {} - } + match Self::parse_response(&bytes) { + Ok(msg) => if let Err(_e) = incoming_tx.unbounded_send(msg) { + gloo_console::warn!("Incoming receiver dropped."); + break; + }, + Err(e) => { + gloo_console::warn!( + "Error decoding WebSocketMessage::Binay payload: ", + format!("{:?}", e) + ); + }, } - } - Some(Err(e)) => { - gloo_console::warn!("WS Error: ", format!("{:?}",e)); + }, + + Some(Ok(WebSocketMessage::Ping(payload))) + | Some(Ok(WebSocketMessage::Pong(payload))) => { + record_metrics(payload.len()); + }, + + Some(Ok(WebSocketMessage::Close(r))) => { + let reason: String = if let Some(r) = r { + format!("{}:{:?}", r, r.code) + } else {String::default()}; + gloo_console::warn!("Connection Closed.", reason); + let _ = ws_writer.close().await; break; - } - None => { - gloo_console::warn!("WS Closed"); + }, + + Some(Err(e)) => { + gloo_console::warn!( + "Error reading message from read WebSocket stream: ", + format!("{:?}",e) + ); break; + }, + + Some(Ok(other)) => { + record_metrics(other.len()); + gloo_console::warn!("Unexpected WebSocket message: ", format!("{:?}",other)); } - _ => {} }, // 2) outbound messages - client_msg = outgoing.next() => if let Some(client_msg) = client_msg { + outbound = outgoing.next() => if let Some(client_msg) = outbound { let raw = Self::encode_message(client_msg); - if let Err(e) = ws_stream.send(raw).await { + if let Err(e) = ws_writer.send(raw).await { gloo_console::warn!("WS Send error: ", format!("{:?}",e)); break; } } else { // channel closed, so we're done sending + let _ = ws_writer.close().await; break; }, } From 97563d8c92db792a2813fb0311a9ca0bab1c823e Mon Sep 17 00:00:00 2001 From: Thales R Date: Fri, 2 May 2025 16:46:54 +0200 Subject: [PATCH 05/32] Add cookie API and re-export gloo storage wrappers for the web SDK --- Cargo.lock | 16 +++ sdks/rust/Cargo.toml | 3 +- sdks/rust/src/credentials.rs | 188 ++++++++++++++++++++++++++++------- 3 files changed, 168 insertions(+), 39 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9ca83ed5543..37a753be994 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2566,6 +2566,21 @@ dependencies = [ "web-sys", ] +[[package]] +name = "gloo-storage" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbc8031e8c92758af912f9bc08fbbadd3c6f3cfcbf6b64cdf3d6a81f0139277a" +dependencies = [ + "gloo-utils", + "js-sys", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "gloo-utils" version = "0.2.0" @@ -8226,6 +8241,7 @@ dependencies = [ "futures-channel", "getrandom 0.3.2", "gloo-console", + "gloo-storage", "hex", "home", "http 1.3.1", diff --git a/sdks/rust/Cargo.toml b/sdks/rust/Cargo.toml index 91dd7b43000..d555fa23e3f 100644 --- a/sdks/rust/Cargo.toml +++ b/sdks/rust/Cargo.toml @@ -37,6 +37,7 @@ tokio-tungstenite.workspace = true [target.'cfg(target_arch = "wasm32")'.dependencies] getrandom = { version = "0.3.2", features = ["wasm_js"]} gloo-console = "0.3.0" +gloo-storage = "0.3.0" rustls-pki-types = { version = "1.11.0", features = ["web"] } tokio-tungstenite-wasm = { git = "https://github.com/thlsrms/tokio-tungstenite-wasm", rev = "c788b7cfc30f576c" } tokio = { version = "1.37", default-features = false, features = [ @@ -45,7 +46,7 @@ tokio = { version = "1.37", default-features = false, features = [ tungstenite = { version = "0.26.2", features = ["rustls"] } wasm-bindgen = "0.2.100" wasm-bindgen-futures = "0.4.45" -web-sys = { version = "0.3.77", features = [ "Window", "Storage" ] } +web-sys = { version = "0.3.77", features = [ "Document", "HtmlDocument", "Window", "Storage" ] } [dev-dependencies] # for quickstart-chat and cursive-chat examples diff --git a/sdks/rust/src/credentials.rs b/sdks/rust/src/credentials.rs index 497cd21c628..d9b322d8507 100644 --- a/sdks/rust/src/credentials.rs +++ b/sdks/rust/src/credentials.rs @@ -155,55 +155,167 @@ mod native_mod { #[cfg(target_arch = "wasm32")] mod web_mod { - use thiserror::Error; + pub use gloo_storage::{LocalStorage, SessionStorage, Storage}; - #[derive(Error, Debug)] - pub enum CredentialStorageError { - #[error("Could not access localStorage")] - LocalStorageAccess, + pub mod cookies { + use thiserror::Error; + use wasm_bindgen::{JsCast, JsValue}; + use web_sys::{window, Document, HtmlDocument}; - #[error("Exception while interacting with localStorage: {0:?}")] - LocalStorageJsError(wasm_bindgen::JsValue), + #[derive(Error, Debug)] + pub enum CookieError { + #[error("Window Object not valid in this context")] + NoWindow, + #[error("No `document` available on `window` object")] + NoDocument, + #[error("`document` is not an HtmlDocument")] + NoHtmlDocument, + #[error("web_sys error: {0:?}")] + WebSys(JsValue), + } - #[error("Window object is not available in this context")] - WindowObjectAccess, - } + impl From for CookieError { + fn from(err: JsValue) -> Self { + CookieError::WebSys(err) + } + } - /// TODO: Give it an option for 'Local', 'Session', 'Cookie'? - pub struct StorageEntry { - key: String, - } + /// A builder for contructing and setting cookies. + pub struct Cookie { + name: String, + value: String, + path: Option, + domain: Option, + max_age: Option, + secure: bool, + same_site: Option, + } - impl StorageEntry { - pub fn new(key: impl Into) -> Self { - Self { key: key.into() } + impl Cookie { + /// Creates a new cookie builder with the specified name and value. + pub fn new(name: impl Into, value: impl Into) -> Self { + Self { + name: name.into(), + value: value.into(), + path: None, + domain: None, + max_age: None, + secure: false, + same_site: None, + } + } + + /// Gets the value of a cookie by name. + pub fn get(name: &str) -> Result, CookieError> { + let doc = get_html_document()?; + let all = doc.cookie().map_err(CookieError::from)?; + for cookie in all.split(';') { + let cookie = cookie.trim(); + if let Some((k, v)) = cookie.split_once('=') { + if k == name { + return Ok(Some(v.to_string())); + } + } + } + + Ok(None) + } + + /// Sets the `Path` attribute (e.g., "/"). + pub fn path(mut self, path: impl Into) -> Self { + self.path = Some(path.into()); + self + } + + /// Sets the `Domain` attribute (e.g., "example.com"). + pub fn domain(mut self, domain: impl Into) -> Self { + self.domain = Some(domain.into()); + self + } + + /// Sets the `Max-Age` attribute in seconds. + pub fn max_age(mut self, seconds: i32) -> Self { + self.max_age = Some(seconds); + self + } + + /// Toggles the `Secure` flag. + /// The default is `false`. + pub fn secure(mut self, enabled: bool) -> Self { + self.secure = enabled; + self + } + + /// Sets the `SameSite` attribute (`Strict`, `Lax`, or `None`). + pub fn same_site(mut self, same_site: SameSite) -> Self { + self.same_site = Some(same_site); + self + } + + pub fn set(self) -> Result<(), CookieError> { + let doc = get_html_document()?; + let mut parts = vec![format!("{}={}", self.name, self.value)]; + + if let Some(path) = self.path { + parts.push(format!("Path={}", path)); + } + if let Some(domain) = self.domain { + parts.push(format!("Domain={}", domain)); + } + if let Some(age) = self.max_age { + parts.push(format!("Max-Age={}", age)); + } + if self.secure { + parts.push("Secure".into()); + } + if let Some(same) = self.same_site { + parts.push(format!("SameSite={}", same.to_string())); + } + + let cookie_str = parts.join("; "); + doc.set_cookie(&cookie_str).map_err(CookieError::from) + } + + /// Deletes the cookie by setting its value to empty and `Max-Age=0`. + pub fn delete(self) -> Result<(), CookieError> { + self.value("").max_age(0).set() + } + + /// Helper to override value for delete + fn value(mut self, value: impl Into) -> Self { + self.value = value.into(); + self + } } - pub fn save(&self, token: impl Into) -> Result<(), CredentialStorageError> { - let local_storage = web_sys::window() - .ok_or(CredentialStorageError::WindowObjectAccess)? - .local_storage() - .map_err(CredentialStorageError::LocalStorageJsError)? - .ok_or(CredentialStorageError::LocalStorageAccess)?; - local_storage - .set_item(&self.key, &token.into()) - .map_err(CredentialStorageError::LocalStorageJsError)?; - Ok(()) + /// Controls the `SameSite` attribute for cookies. + pub enum SameSite { + Strict, + Lax, + None, } - pub fn load(&self) -> Result, CredentialStorageError> { - let local_storage = web_sys::window() - .ok_or(CredentialStorageError::WindowObjectAccess)? - .local_storage() - .map_err(CredentialStorageError::LocalStorageJsError)? - .ok_or(CredentialStorageError::LocalStorageAccess)?; - - match local_storage.get_item(&self.key) { - Ok(Some(token)) => Ok(Some(token)), - Ok(None) => Ok(None), - Err(err) => Err(CredentialStorageError::LocalStorageJsError(err)), + impl ToString for SameSite { + fn to_string(&self) -> String { + match self { + SameSite::Strict => "Strict".into(), + SameSite::Lax => "Lax".into(), + SameSite::None => "None".into(), + } } } + + fn get_document() -> Result { + window() + .ok_or(CookieError::NoWindow)? + .document() + .ok_or(CookieError::NoDocument) + } + + fn get_html_document() -> Result { + let doc = get_document()?; + doc.dyn_into::().map_err(|_| CookieError::NoHtmlDocument) + } } } From 2612d1a09db33b6f5c967030c12381d81f6836cd Mon Sep 17 00:00:00 2001 From: Thales R Date: Mon, 5 May 2025 14:09:51 +0200 Subject: [PATCH 06/32] Remove some redundancies from wasm32 rust sdk --- Cargo.lock | 5 ++-- sdks/rust/Cargo.toml | 6 ++-- sdks/rust/src/db_connection.rs | 55 ++++++++++------------------------ sdks/rust/src/websocket.rs | 11 +++---- 4 files changed, 26 insertions(+), 51 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 37a753be994..82e1f49e5a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9251,8 +9251,9 @@ dependencies = [ [[package]] name = "tokio-tungstenite-wasm" -version = "0.5.0" -source = "git+https://github.com/thlsrms/tokio-tungstenite-wasm?rev=c788b7cfc30f576c#c788b7cfc30f576c207344c2907932b5317ca5e0" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02567f5f341725fb3e452c1f55dd4e5b0f2a685355c3b10babf0fe8e137d176e" dependencies = [ "bytes", "futures-channel", diff --git a/sdks/rust/Cargo.toml b/sdks/rust/Cargo.toml index d555fa23e3f..776791a07aa 100644 --- a/sdks/rust/Cargo.toml +++ b/sdks/rust/Cargo.toml @@ -39,14 +39,14 @@ getrandom = { version = "0.3.2", features = ["wasm_js"]} gloo-console = "0.3.0" gloo-storage = "0.3.0" rustls-pki-types = { version = "1.11.0", features = ["web"] } -tokio-tungstenite-wasm = { git = "https://github.com/thlsrms/tokio-tungstenite-wasm", rev = "c788b7cfc30f576c" } tokio = { version = "1.37", default-features = false, features = [ - "rt", "macros", "sync" + "rt", "macros", "sync", "io-util" ] } +tokio-tungstenite-wasm = "0.6.0" tungstenite = { version = "0.26.2", features = ["rustls"] } wasm-bindgen = "0.2.100" wasm-bindgen-futures = "0.4.45" -web-sys = { version = "0.3.77", features = [ "Document", "HtmlDocument", "Window", "Storage" ] } +web-sys = { version = "0.3.77", features = [ "Document", "HtmlDocument", "Window" ] } [dev-dependencies] # for quickstart-chat and cursive-chat examples diff --git a/sdks/rust/src/db_connection.rs b/sdks/rust/src/db_connection.rs index 5478236d8cb..9d399aeaec0 100644 --- a/sdks/rust/src/db_connection.rs +++ b/sdks/rust/src/db_connection.rs @@ -71,7 +71,6 @@ pub struct DbContextImpl { /// which are pre-parsed in the background by [`parse_loop`]. #[cfg(not(target_arch = "wasm32"))] recv: Arc>>>, - #[cfg(target_arch = "wasm32")] recv: SharedCell>>, @@ -85,7 +84,6 @@ pub struct DbContextImpl { /// from which [Self::apply_pending_mutations] and friends read mutations. #[cfg(not(target_arch = "wasm32"))] pending_mutations_recv: Arc>>>, - #[cfg(target_arch = "wasm32")] pending_mutations_recv: SharedCell>>, @@ -533,7 +531,6 @@ impl DbContextImpl { /// If no WebSocket messages are in the queue, immediately return `false`. /// /// Called by the autogenerated `DbConnection` method of the same name. - #[cfg(not(target_arch = "wasm32"))] pub fn advance_one_message(&self) -> crate::Result { // Apply any pending mutations before processing a WS message, // so that pending callbacks don't get skipped. @@ -550,34 +547,18 @@ impl DbContextImpl { // returns `Err(_)`. Similar behavior as `Iterator::next` and // `Stream::poll_next`. No comment on whether this is a good mental // model or not. - let res = match self.recv.blocking_lock().try_next() { - Ok(None) => { - let disconnect_ctx = self.make_event_ctx(None); - self.invoke_disconnected(&disconnect_ctx); - Err(crate::Error::Disconnected) - } - Err(_) => Ok(false), - Ok(Some(msg)) => self.process_message(msg).map(|_| true), - }; - // Also apply any new pending messages afterwards, - // so that outgoing WS messages get sent as soon as possible. - self.apply_pending_mutations()?; + let res = { + #[cfg(not(target_arch = "wasm32"))] + let mut recv = self.recv.blocking_lock(); - res - } + #[cfg(target_arch = "wasm32")] + let mut recv = self.recv.lock().unwrap(); - #[cfg(target_arch = "wasm32")] - pub fn advance_one_message(&self) -> crate::Result { - self.apply_pending_mutations()?; - // Synchronously try to pull one server message - let res = { - let mut chan = self.recv.lock().unwrap(); - match chan.try_next() { + match recv.try_next() { Ok(None) => { - // Shouldn’t happen on unbounded, treat as disconnect - let ctx = self.make_event_ctx(None); - self.invoke_disconnected(&ctx); + let disconnect_ctx = self.make_event_ctx(None); + self.invoke_disconnected(&disconnect_ctx); Err(crate::Error::Disconnected) } Err(_) => Ok(false), @@ -585,7 +566,8 @@ impl DbContextImpl { } }; - // send any pending outgoing mutations now that we've done a read + // Also apply any new pending messages afterwards, + // so that outgoing WS messages get sent as soon as possible. self.apply_pending_mutations()?; res @@ -1071,7 +1053,7 @@ but you must call one of them, or else the connection will never progress. })?; let (raw_msg_recv, raw_msg_send) = ws_connection.spawn_message_loop(); - let parsed_recv_chan = spawn_parse_loop::(raw_msg_recv, &handle); + let parsed_recv_chan = spawn_parse_loop::(raw_msg_recv); let inner = Arc::new(StdMutex::new(DbContextImplInner { runtime, @@ -1097,10 +1079,8 @@ but you must call one of them, or else the connection will never progress. inner, send_chan, cache, - #[cfg(target_arch = "wasm32")] recv: Arc::new(StdMutex::new(parsed_recv_chan)), pending_mutations_send, - #[cfg(target_arch = "wasm32")] pending_mutations_recv: Arc::new(StdMutex::new(pending_mutations_recv)), identity: Arc::new(StdMutex::new(None)), connection_id: Arc::new(StdMutex::new(connection_id_override)), @@ -1246,14 +1226,12 @@ fn enter_or_create_runtime() -> crate::Result<(Option, runtime::Handle) match runtime::Handle::try_current() { Err(e) if e.is_missing_context() => { #[cfg(not(target_arch = "wasm32"))] - let rt = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .worker_threads(1) - .thread_name("spacetimedb-background-connection") - .build() - .map_err(|source| InternalError::new("Failed to create Tokio runtime").with_cause(source))?; + let mut rt = tokio::runtime::Builder::new_multi_thread(); #[cfg(target_arch = "wasm32")] - let rt = tokio::runtime::Builder::new_current_thread() + let mut rt = tokio::runtime::Builder::new_current_thread(); + + let rt = rt + .enable_all() .worker_threads(1) .thread_name("spacetimedb-background-connection") .build() @@ -1310,7 +1288,6 @@ fn spawn_parse_loop( #[cfg(target_arch = "wasm32")] fn spawn_parse_loop( raw_message_recv: mpsc::UnboundedReceiver>, - _handle: &runtime::Handle, ) -> mpsc::UnboundedReceiver> { let (parsed_message_send, parsed_message_recv) = mpsc::unbounded(); wasm_bindgen_futures::spawn_local(parse_loop(raw_message_recv, parsed_message_send)); diff --git a/sdks/rust/src/websocket.rs b/sdks/rust/src/websocket.rs index 55d9875eb12..a9bfe072306 100644 --- a/sdks/rust/src/websocket.rs +++ b/sdks/rust/src/websocket.rs @@ -513,11 +513,6 @@ impl WsConnection { } }, - Some(Ok(WebSocketMessage::Ping(payload))) - | Some(Ok(WebSocketMessage::Pong(payload))) => { - record_metrics(payload.len()); - }, - Some(Ok(WebSocketMessage::Close(r))) => { let reason: String = if let Some(r) = r { format!("{}:{:?}", r, r.code) @@ -545,12 +540,14 @@ impl WsConnection { outbound = outgoing.next() => if let Some(client_msg) = outbound { let raw = Self::encode_message(client_msg); if let Err(e) = ws_writer.send(raw).await { - gloo_console::warn!("WS Send error: ", format!("{:?}",e)); + gloo_console::warn!("Error sending outgoing message:", format!("{:?}",e)); break; } } else { // channel closed, so we're done sending - let _ = ws_writer.close().await; + if let Err(e) = ws_writer.close().await { + gloo_console::warn!("Error sending close frame:", format!("{:?}", e)); + } break; }, } From f36cc7d2743e1c7b49af41aa4d526116c7a7b91f Mon Sep 17 00:00:00 2001 From: Thales R Date: Sun, 11 May 2025 12:18:57 +0200 Subject: [PATCH 07/32] Add `web` feature flag to the rust sdk --- Cargo.lock | 40 ++++++++++++++++++++++++----- sdks/rust/Cargo.toml | 33 +++++++++++++++++------- sdks/rust/src/credentials.rs | 8 +++--- sdks/rust/src/db_connection.rs | 46 +++++++++++++++++----------------- sdks/rust/src/websocket.rs | 36 +++++++++++++------------- 5 files changed, 103 insertions(+), 60 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 82e1f49e5a7..c0f49a42a08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7554,7 +7554,7 @@ dependencies = [ "tikv-jemalloc-ctl", "tikv-jemallocator", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.27.0", "toml 0.8.23", "toml_edit 0.22.27", "tracing", @@ -7613,7 +7613,7 @@ dependencies = [ "thiserror 1.0.69", "tokio", "tokio-stream", - "tokio-tungstenite", + "tokio-tungstenite 0.27.0", "toml 0.8.23", "tower-http 0.5.2", "tower-layer", @@ -8257,9 +8257,8 @@ dependencies = [ "spacetimedb-testing", "thiserror 1.0.69", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.27.0", "tokio-tungstenite-wasm", - "tungstenite", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -9235,6 +9234,18 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.26.2", +] + [[package]] name = "tokio-tungstenite" version = "0.27.0" @@ -9246,7 +9257,7 @@ dependencies = [ "native-tls", "tokio", "tokio-native-tls", - "tungstenite", + "tungstenite 0.27.0", ] [[package]] @@ -9263,7 +9274,7 @@ dependencies = [ "js-sys", "thiserror 2.0.12", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.26.2", "wasm-bindgen", "web-sys", ] @@ -9639,6 +9650,23 @@ dependencies = [ "termcolor", ] +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http 1.3.1", + "httparse", + "log", + "rand 0.9.1", + "sha1", + "thiserror 2.0.12", + "utf-8", +] + [[package]] name = "tungstenite" version = "0.27.0" diff --git a/sdks/rust/Cargo.toml b/sdks/rust/Cargo.toml index 776791a07aa..40914e8477b 100644 --- a/sdks/rust/Cargo.toml +++ b/sdks/rust/Cargo.toml @@ -8,6 +8,19 @@ rust-version.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[features] +default = [] +web = [ + "dep:getrandom", + "dep:gloo-console", + "dep:gloo-storage", + "dep:rustls-pki-types", + "dep:tokio-tungstenite-wasm", + "dep:wasm-bindgen", + "dep:wasm-bindgen-futures", + "dep:web-sys", +] + [dependencies] spacetimedb-data-structures.workspace = true spacetimedb-sats.workspace = true @@ -29,24 +42,26 @@ once_cell.workspace = true prometheus.workspace = true rand.workspace = true +getrandom = { version = "0.3.2", features = ["wasm_js"], optional = true } +gloo-console = { version = "0.3.0", optional = true } +gloo-storage = { version = "0.3.0", optional = true } +rustls-pki-types = { version = "1.12.0", features = ["web"], optional = true } +tokio-tungstenite-wasm = { version = "0.6.0", optional = true } +wasm-bindgen = { version = "0.2.100", optional = true } +wasm-bindgen-futures = { version = "0.4.45", optional = true } +web-sys = { version = "0.3.77", features = [ + "Document", "HtmlDocument", "Window" +], optional = true} + [target.'cfg(not(target_arch = "wasm32"))'.dependencies] home.workspace = true tokio.workspace = true tokio-tungstenite.workspace = true [target.'cfg(target_arch = "wasm32")'.dependencies] -getrandom = { version = "0.3.2", features = ["wasm_js"]} -gloo-console = "0.3.0" -gloo-storage = "0.3.0" -rustls-pki-types = { version = "1.11.0", features = ["web"] } tokio = { version = "1.37", default-features = false, features = [ "rt", "macros", "sync", "io-util" ] } -tokio-tungstenite-wasm = "0.6.0" -tungstenite = { version = "0.26.2", features = ["rustls"] } -wasm-bindgen = "0.2.100" -wasm-bindgen-futures = "0.4.45" -web-sys = { version = "0.3.77", features = [ "Document", "HtmlDocument", "Window" ] } [dev-dependencies] # for quickstart-chat and cursive-chat examples diff --git a/sdks/rust/src/credentials.rs b/sdks/rust/src/credentials.rs index d9b322d8507..4f5c774b591 100644 --- a/sdks/rust/src/credentials.rs +++ b/sdks/rust/src/credentials.rs @@ -8,7 +8,7 @@ //! } //! ``` -#[cfg(not(target_arch = "wasm32"))] +#[cfg(not(feature = "web"))] mod native_mod { use home::home_dir; use spacetimedb_lib::{bsatn, de::Deserialize, ser::Serialize}; @@ -153,7 +153,7 @@ mod native_mod { } } -#[cfg(target_arch = "wasm32")] +#[cfg(feature = "web")] mod web_mod { pub use gloo_storage::{LocalStorage, SessionStorage, Storage}; @@ -319,8 +319,8 @@ mod web_mod { } } -#[cfg(not(target_arch = "wasm32"))] +#[cfg(not(feature = "web"))] pub use native_mod::*; -#[cfg(target_arch = "wasm32")] +#[cfg(feature = "web")] pub use web_mod::*; diff --git a/sdks/rust/src/db_connection.rs b/sdks/rust/src/db_connection.rs index 9d399aeaec0..4996bec04c1 100644 --- a/sdks/rust/src/db_connection.rs +++ b/sdks/rust/src/db_connection.rs @@ -45,7 +45,7 @@ use std::{ sync::{atomic::AtomicU32, Arc, Mutex as StdMutex, OnceLock}, }; use tokio::runtime::{self, Runtime}; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(not(feature = "web"))] use tokio::sync::Mutex as TokioMutex; pub(crate) type SharedCell = Arc>; @@ -69,9 +69,9 @@ pub struct DbContextImpl { /// Receiver channel for WebSocket messages, /// which are pre-parsed in the background by [`parse_loop`]. - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(feature = "web"))] recv: Arc>>>, - #[cfg(target_arch = "wasm32")] + #[cfg(feature = "web")] recv: SharedCell>>, /// Channel into which operations which apparently mutate SDK state, @@ -82,9 +82,9 @@ pub struct DbContextImpl { /// Receive end of `pending_mutations_send`, /// from which [Self::apply_pending_mutations] and friends read mutations. - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(feature = "web"))] pending_mutations_recv: Arc>>>, - #[cfg(target_arch = "wasm32")] + #[cfg(feature = "web")] pending_mutations_recv: SharedCell>>, /// This connection's `Identity`. @@ -293,12 +293,12 @@ impl DbContextImpl { /// Apply all queued [`PendingMutation`]s. fn apply_pending_mutations(&self) -> crate::Result<()> { - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(feature = "web"))] while let Ok(Some(pending_mutation)) = self.pending_mutations_recv.blocking_lock().try_next() { self.apply_mutation(pending_mutation)?; } - #[cfg(target_arch = "wasm32")] + #[cfg(feature = "web")] while let Ok(Some(pending_mutation)) = self.pending_mutations_recv.lock().unwrap().try_next() { self.apply_mutation(pending_mutation)?; } @@ -549,10 +549,10 @@ impl DbContextImpl { // model or not. let res = { - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(feature = "web"))] let mut recv = self.recv.blocking_lock(); - #[cfg(target_arch = "wasm32")] + #[cfg(feature = "web")] let mut recv = self.recv.lock().unwrap(); match recv.try_next() { @@ -579,14 +579,14 @@ impl DbContextImpl { // We call this out as an incorrect and unsupported thing to do. #![allow(clippy::await_holding_lock)] - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(feature = "web"))] let mut pending_mutations = self.pending_mutations_recv.lock().await; - #[cfg(target_arch = "wasm32")] + #[cfg(feature = "web")] let mut pending_mutations = self.pending_mutations_recv.lock().unwrap(); - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(feature = "web"))] let mut recv = self.recv.lock().await; - #[cfg(target_arch = "wasm32")] + #[cfg(feature = "web")] let mut recv = self.recv.lock().unwrap(); // Always process pending mutations before WS messages, if they're available, @@ -645,7 +645,7 @@ impl DbContextImpl { /// Spawn a thread which does [`Self::advance_one_message_blocking`] in a loop. /// /// Called by the autogenerated `DbConnection` method of the same name. - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(feature = "web"))] pub fn run_threaded(&self) -> std::thread::JoinHandle<()> { let this = self.clone(); std::thread::spawn(move || loop { @@ -657,7 +657,7 @@ impl DbContextImpl { }) } - #[cfg(target_arch = "wasm32")] + #[cfg(feature = "web")] pub fn run_threaded(&self) { let this = self.clone(); wasm_bindgen_futures::spawn_local(async move { @@ -960,13 +960,13 @@ You must explicitly advance the connection by calling any one of: Which of these methods you should call depends on the specific needs of your application, but you must call one of them, or else the connection will never progress. "] - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(feature = "web"))] pub fn build(self) -> crate::Result { let imp = self.build_impl()?; Ok(::new(imp)) } - #[cfg(target_arch = "wasm32")] + #[cfg(feature = "web")] pub async fn build(self) -> crate::Result { let imp = self.build_impl().await?; Ok(::new(imp)) @@ -974,7 +974,7 @@ but you must call one of them, or else the connection will never progress. /// Open a WebSocket connection, build an empty client cache, &c, /// to construct a [`DbContextImpl`]. - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(feature = "web"))] fn build_impl(self) -> crate::Result> { let (runtime, handle) = enter_or_create_runtime()?; let db_callbacks = DbCallbacks::default(); @@ -1033,7 +1033,7 @@ but you must call one of them, or else the connection will never progress. Ok(ctx_imp) } - #[cfg(target_arch = "wasm32")] + #[cfg(feature = "web")] pub async fn build_impl(self) -> crate::Result> { let (runtime, handle) = enter_or_create_runtime()?; let db_callbacks = DbCallbacks::default(); @@ -1225,9 +1225,9 @@ Instead of registering multiple `on_disconnect` callbacks, register a single cal fn enter_or_create_runtime() -> crate::Result<(Option, runtime::Handle)> { match runtime::Handle::try_current() { Err(e) if e.is_missing_context() => { - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(feature = "web"))] let mut rt = tokio::runtime::Builder::new_multi_thread(); - #[cfg(target_arch = "wasm32")] + #[cfg(feature = "web")] let mut rt = tokio::runtime::Builder::new_current_thread(); let rt = rt @@ -1275,7 +1275,7 @@ enum ParsedMessage { }, } -#[cfg(not(target_arch = "wasm32"))] +#[cfg(not(feature = "web"))] fn spawn_parse_loop( raw_message_recv: mpsc::UnboundedReceiver>, handle: &runtime::Handle, @@ -1285,7 +1285,7 @@ fn spawn_parse_loop( (handle, parsed_message_recv) } -#[cfg(target_arch = "wasm32")] +#[cfg(feature = "web")] fn spawn_parse_loop( raw_message_recv: mpsc::UnboundedReceiver>, ) -> mpsc::UnboundedReceiver> { diff --git a/sdks/rust/src/websocket.rs b/sdks/rust/src/websocket.rs index a9bfe072306..db7876304f0 100644 --- a/sdks/rust/src/websocket.rs +++ b/sdks/rust/src/websocket.rs @@ -2,14 +2,14 @@ //! //! This module is internal, and may incompatibly change without warning. -#[cfg(not(target_arch = "wasm32"))] +#[cfg(not(feature = "web"))] use std::mem; use std::sync::Arc; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(not(feature = "web"))] use std::time::Duration; use bytes::Bytes; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(not(feature = "web"))] use futures::TryStreamExt; use futures::{SinkExt, StreamExt as _}; use futures_channel::mpsc; @@ -18,16 +18,16 @@ use spacetimedb_client_api_messages::websocket::{BsatnFormat, Compression, BIN_P use spacetimedb_client_api_messages::websocket::{ClientMessage, ServerMessage}; use spacetimedb_lib::{bsatn, ConnectionId}; use thiserror::Error; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(not(feature = "web"))] use tokio::{net::TcpStream, runtime, task::JoinHandle, time::Instant}; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(not(feature = "web"))] use tokio_tungstenite::{ connect_async_with_config, tungstenite::client::IntoClientRequest, tungstenite::protocol::{Message as WebSocketMessage, WebSocketConfig}, MaybeTlsStream, WebSocketStream, }; -#[cfg(target_arch = "wasm32")] +#[cfg(feature = "web")] use tokio_tungstenite_wasm::{Message as WebSocketMessage, WebSocketStream}; use crate::compression::decompress_server_message; @@ -59,7 +59,7 @@ pub enum WsError { #[error(transparent)] UriError(#[from] UriError), - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(feature = "web"))] #[error("Error in WebSocket connection with {uri}: {source}")] Tungstenite { uri: Uri, @@ -68,7 +68,7 @@ pub enum WsError { source: Arc, }, - #[cfg(target_arch = "wasm32")] + #[cfg(feature = "web")] #[error("Error in WebSocket connection with {uri}: {source}")] Tungstenite { uri: Uri, @@ -100,9 +100,9 @@ pub enum WsError { pub(crate) struct WsConnection { db_name: Box, - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(feature = "web"))] sock: WebSocketStream>, - #[cfg(target_arch = "wasm32")] + #[cfg(feature = "web")] sock: WebSocketStream, } @@ -198,7 +198,7 @@ fn make_uri(host: Uri, db_name: &str, connection_id: Option, param // rather than having Tungstenite manage its own connections. Should this library do // the same? -#[cfg(not(target_arch = "wasm32"))] +#[cfg(not(feature = "web"))] fn make_request( host: Uri, db_name: &str, @@ -216,7 +216,7 @@ fn make_request( Ok(req) } -#[cfg(not(target_arch = "wasm32"))] +#[cfg(not(feature = "web"))] fn request_insert_protocol_header(req: &mut http::Request<()>) { req.headers_mut().insert( http::header::SEC_WEBSOCKET_PROTOCOL, @@ -224,7 +224,7 @@ fn request_insert_protocol_header(req: &mut http::Request<()>) { ); } -#[cfg(not(target_arch = "wasm32"))] +#[cfg(not(feature = "web"))] fn request_insert_auth_header(req: &mut http::Request<()>, token: Option<&str>) { if let Some(token) = token { let auth = ["Bearer ", token].concat().try_into().unwrap(); @@ -245,7 +245,7 @@ macro_rules! maybe_log_error { } impl WsConnection { - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(feature = "web"))] pub(crate) async fn connect( host: Uri, db_name: &str, @@ -277,7 +277,7 @@ impl WsConnection { }) } - #[cfg(target_arch = "wasm32")] + #[cfg(feature = "web")] pub(crate) async fn connect( host: Uri, db_name: &str, @@ -308,7 +308,7 @@ impl WsConnection { WebSocketMessage::Binary(bsatn::to_vec(&msg).unwrap().into()) } - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(feature = "web"))] async fn message_loop( mut self, incoming_messages: mpsc::UnboundedSender>, @@ -446,7 +446,7 @@ impl WsConnection { } } - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(feature = "web"))] pub(crate) fn spawn_message_loop( self, runtime: &runtime::Handle, @@ -461,7 +461,7 @@ impl WsConnection { (handle, incoming_recv, outgoing_send) } - #[cfg(target_arch = "wasm32")] + #[cfg(feature = "web")] pub(crate) fn spawn_message_loop( self, ) -> ( From 38a22f85ab4b402f59e7915315c944b214097cc7 Mon Sep 17 00:00:00 2001 From: Thales R Date: Wed, 7 May 2025 18:33:14 +0200 Subject: [PATCH 08/32] Add token verification for the wasm sdk websocket connection --- Cargo.lock | 23 +++++++++ sdks/rust/Cargo.toml | 4 ++ sdks/rust/src/db_connection.rs | 1 - sdks/rust/src/websocket.rs | 90 ++++++++++++++++++++++++++++++++-- 4 files changed, 114 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c0f49a42a08..70bc9cfa99e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2566,6 +2566,27 @@ dependencies = [ "web-sys", ] +[[package]] +name = "gloo-net" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils", + "http 1.3.1", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "gloo-storage" version = "0.3.0" @@ -8241,10 +8262,12 @@ dependencies = [ "futures-channel", "getrandom 0.3.2", "gloo-console", + "gloo-net", "gloo-storage", "hex", "home", "http 1.3.1", + "js-sys", "log", "once_cell", "prometheus", diff --git a/sdks/rust/Cargo.toml b/sdks/rust/Cargo.toml index 40914e8477b..7ac29c9b621 100644 --- a/sdks/rust/Cargo.toml +++ b/sdks/rust/Cargo.toml @@ -13,7 +13,9 @@ default = [] web = [ "dep:getrandom", "dep:gloo-console", + "dep:gloo-net", "dep:gloo-storage", + "dep:js-sys", "dep:rustls-pki-types", "dep:tokio-tungstenite-wasm", "dep:wasm-bindgen", @@ -44,7 +46,9 @@ rand.workspace = true getrandom = { version = "0.3.2", features = ["wasm_js"], optional = true } gloo-console = { version = "0.3.0", optional = true } +gloo-net = { version = "0.6.0", optional = true } gloo-storage = { version = "0.3.0", optional = true } +js-sys = { version = "0.3", optional = true } rustls-pki-types = { version = "1.12.0", features = ["web"], optional = true } tokio-tungstenite-wasm = { version = "0.6.0", optional = true } wasm-bindgen = { version = "0.2.100", optional = true } diff --git a/sdks/rust/src/db_connection.rs b/sdks/rust/src/db_connection.rs index 4996bec04c1..3d8fa8dd042 100644 --- a/sdks/rust/src/db_connection.rs +++ b/sdks/rust/src/db_connection.rs @@ -1114,7 +1114,6 @@ but you must call one of them, or else the connection will never progress. /// If the passed token is invalid or rejected by the host, /// the connection will fail asynchrnonously. // FIXME: currently this causes `disconnect` to be called rather than `on_connect_error`. - #[cfg(not(target_arch = "wasm32"))] pub fn with_token(mut self, token: Option>) -> Self { self.token = token.map(|token| token.into()); self diff --git a/sdks/rust/src/websocket.rs b/sdks/rust/src/websocket.rs index db7876304f0..1d7ff2518a3 100644 --- a/sdks/rust/src/websocket.rs +++ b/sdks/rust/src/websocket.rs @@ -96,6 +96,10 @@ pub enum WsError { #[error("Unrecognized compression scheme: {scheme:#x}")] UnknownCompressionScheme { scheme: u8 }, + + #[cfg(feature = "web")] + #[error("Token verification error: {0}")] + TokenVerification(String), } pub(crate) struct WsConnection { @@ -132,7 +136,29 @@ pub(crate) struct WsParams { pub confirmed: Option, } +#[cfg(not(feature = "web"))] fn make_uri(host: Uri, db_name: &str, connection_id: Option, params: WsParams) -> Result { + make_uri_impl(host, db_name, connection_id, params, None) +} + +#[cfg(feature = "web")] +fn make_uri( + host: Uri, + db_name: &str, + connection_id: Option, + params: WsParams, + token: Option<&str>, +) -> Result { + make_uri_impl(host, db_name, connection_id, params, token) +} + +fn make_uri_impl( + host: Uri, + db_name: &str, + connection_id: Option, + params: WsParams, + token: Option<&str>, +) -> Result { let mut parts = host.into_parts(); let scheme = parse_scheme(parts.scheme.take())?; parts.scheme = Some(scheme); @@ -181,6 +207,11 @@ fn make_uri(host: Uri, db_name: &str, connection_id: Option, param path.push_str(if confirmed { "true" } else { "false" }); } + // Specify the `token` param if needed + if let Some(token) = token { + path.push_str(&format!("&token={token}")); + } + parts.path_and_query = Some(path.parse().map_err(|source: InvalidUri| UriError::InvalidUri { source: Arc::new(source), })?); @@ -232,10 +263,57 @@ fn request_insert_auth_header(req: &mut http::Request<()>, token: Option<&str>) } } +#[cfg(feature = "web")] +async fn fetch_ws_token(host: &Uri, auth_token: &str) -> Result { + use gloo_net::http::{Method, RequestBuilder}; + use js_sys::{Reflect, JSON}; + use wasm_bindgen::{JsCast, JsValue}; + + let url = format!("{}v1/identity/websocket-token", host); + + // helpers to convert gloo_net::Error or JsValue into WsError::TokenVerification + let gloo_to_ws_err = |e: gloo_net::Error| match e { + gloo_net::Error::JsError(js_err) => WsError::TokenVerification(js_err.message.into()), + gloo_net::Error::SerdeError(e) => WsError::TokenVerification(e.to_string()), + gloo_net::Error::GlooError(msg) => WsError::TokenVerification(msg), + }; + let js_to_ws_err = |e: JsValue| { + if let Some(err) = e.dyn_ref::() { + WsError::TokenVerification(err.message().into()) + } else if let Some(s) = e.as_string() { + WsError::TokenVerification(s) + } else { + WsError::TokenVerification(format!("{:?}", e)) + } + }; + + let res = RequestBuilder::new(&url) + .method(Method::POST) + .header("Authorization", &format!("Bearer {auth_token}")) + .send() + .await + .map_err(gloo_to_ws_err)?; + + if !res.ok() { + return Err(WsError::TokenVerification(format!( + "HTTP error: {} {}", + res.status(), + res.status_text() + ))); + } + + let body = res.text().await.map_err(gloo_to_ws_err)?; + let json = JSON::parse(&body).map_err(js_to_ws_err)?; + let token_js = Reflect::get(&json, &JsValue::from_str("token")).map_err(js_to_ws_err)?; + token_js + .as_string() + .ok_or_else(|| WsError::TokenVerification("`token` parsing failed".into())) +} + /// If `res` evaluates to `Err(e)`, log a warning in the form `"{}: {:?}", $cause, e`. /// /// Could be trivially written as a function, but macro-ifying it preserves the source location of the log. -#[cfg(not(target_arch = "wasm32"))] +#[cfg(not(feature = "web"))] macro_rules! maybe_log_error { ($cause:expr, $res:expr) => { if let Err(e) = $res { @@ -281,11 +359,17 @@ impl WsConnection { pub(crate) async fn connect( host: Uri, db_name: &str, - _token: Option<&str>, + token: Option<&str>, connection_id: Option, params: WsParams, ) -> Result { - let uri = make_uri(host, db_name, connection_id, params)?; + let token = if let Some(auth_token) = token { + Some(fetch_ws_token(&host, auth_token).await?) + } else { + None + }; + + let uri = make_uri(host, db_name, connection_id, params, token.as_deref())?; let sock = tokio_tungstenite_wasm::connect_with_protocols(&uri.to_string(), &[BIN_PROTOCOL]) .await .map_err(|source| WsError::Tungstenite { From a284ebc186ad6ced0f6842c437fbea3396106b4f Mon Sep 17 00:00:00 2001 From: Thales R Date: Wed, 7 May 2025 18:41:00 +0200 Subject: [PATCH 09/32] Rename misleading `run_threaded` to `run_background` on wasm32 Renamed the `run_threaded` method on `wasm32` to better reflect its behavior of spawning a background task. The generated `DbConnection` methods `run_threaded`, `run_background`, and `advance_one_message_blocking` now include runtime panics with a clear error feedback when called on unsupported targets. --- crates/codegen/src/rust.rs | 45 ++++++++++++++++--- .../snapshots/codegen__codegen_rust.snap | 45 ++++++++++++++++--- sdks/rust/src/db_connection.rs | 7 ++- 3 files changed, 83 insertions(+), 14 deletions(-) diff --git a/crates/codegen/src/rust.rs b/crates/codegen/src/rust.rs index 23436328d59..6583c87a624 100644 --- a/crates/codegen/src/rust.rs +++ b/crates/codegen/src/rust.rs @@ -1384,6 +1384,7 @@ impl __sdk::InModule for RemoteTables {{ /// /// - [`DbConnection::frame_tick`]. /// - [`DbConnection::run_threaded`]. +/// - [`DbConnection::run_background`]. /// - [`DbConnection::run_async`]. /// - [`DbConnection::advance_one_message`]. /// - [`DbConnection::advance_one_message_blocking`]. @@ -1493,8 +1494,19 @@ impl DbConnection {{ /// This is a low-level primitive exposed for power users who need significant control over scheduling. /// Most applications should call [`Self::run_threaded`] to spawn a thread /// which advances the connection automatically. + /// + /// # Panics + /// At runtime if called on any `wasm32` target. pub fn advance_one_message_blocking(&self) -> __sdk::Result<()> {{ - self.imp.advance_one_message_blocking() + #[cfg(target_arch = \"wasm32\")] + {{ + panic!(\"`DbConnection::advance_one_message_blocking` is not supported on WebAssembly (wasm32); \\ + prefer using `advance_one_message` or `advance_one_message_async` instead\"); + }} + #[cfg(not(target_arch = \"wasm32\"))] + {{ + self.imp.advance_one_message_blocking() + }} }} /// Process one WebSocket message, `await`ing until one is received. @@ -1518,14 +1530,35 @@ impl DbConnection {{ }} /// Spawn a thread which processes WebSocket messages as they are received. - #[cfg(not(target_arch = \"wasm32\"))] + /// + /// # Panics + /// At runtime if called on any `wasm32` target. pub fn run_threaded(&self) -> std::thread::JoinHandle<()> {{ - self.imp.run_threaded() + #[cfg(target_arch = \"wasm32\")] + {{ + panic!(\"`DbConnection::run_threaded` is not supported on WebAssembly (wasm32); \\ + prefer using `DbConnection::run_background` instead\"); + }} + #[cfg(not(target_arch = \"wasm32\"))] + {{ + self.imp.run_threaded() + }} }} - #[cfg(target_arch = \"wasm32\")] - pub fn run_threaded(&self) {{ - self.imp.run_threaded() + /// Spawn a task which processes WebSocket messages as they are received. + /// + /// # Panics + /// At runtime if called on any non-`wasm32` target. + pub fn run_background(&self) {{ + #[cfg(not(target_arch = \"wasm32\"))] + {{ + panic!(\"`DbConnection::run_background` is only supported on WebAssembly (wasm32); \\ + prefer using `DbConnection::run_threaded` instead\"); + }} + #[cfg(target_arch = \"wasm32\")] + {{ + self.imp.run_background() + }} }} /// Run an `async` loop which processes WebSocket messages when polled. diff --git a/crates/codegen/tests/snapshots/codegen__codegen_rust.snap b/crates/codegen/tests/snapshots/codegen__codegen_rust.snap index f8e01689b90..62c87ba6e3f 100644 --- a/crates/codegen/tests/snapshots/codegen__codegen_rust.snap +++ b/crates/codegen/tests/snapshots/codegen__codegen_rust.snap @@ -1824,6 +1824,7 @@ impl __sdk::InModule for RemoteTables { /// /// - [`DbConnection::frame_tick`]. /// - [`DbConnection::run_threaded`]. +/// - [`DbConnection::run_background`]. /// - [`DbConnection::run_async`]. /// - [`DbConnection::advance_one_message`]. /// - [`DbConnection::advance_one_message_blocking`]. @@ -1933,8 +1934,19 @@ impl DbConnection { /// This is a low-level primitive exposed for power users who need significant control over scheduling. /// Most applications should call [`Self::run_threaded`] to spawn a thread /// which advances the connection automatically. + /// + /// # Panics + /// At runtime if called on any `wasm32` target. pub fn advance_one_message_blocking(&self) -> __sdk::Result<()> { - self.imp.advance_one_message_blocking() + #[cfg(target_arch = "wasm32")] + { + panic!("`DbConnection::advance_one_message_blocking` is not supported on WebAssembly (wasm32); \ + prefer using `advance_one_message` or `advance_one_message_async` instead"); + } + #[cfg(not(target_arch = "wasm32"))] + { + self.imp.advance_one_message_blocking() + } } /// Process one WebSocket message, `await`ing until one is received. @@ -1958,14 +1970,35 @@ impl DbConnection { } /// Spawn a thread which processes WebSocket messages as they are received. - #[cfg(not(target_arch = "wasm32"))] + /// + /// # Panics + /// At runtime if called on any `wasm32` target. pub fn run_threaded(&self) -> std::thread::JoinHandle<()> { - self.imp.run_threaded() + #[cfg(target_arch = "wasm32")] + { + panic!("`DbConnection::run_threaded` is not supported on WebAssembly (wasm32); \ + prefer using `DbConnection::run_background` instead"); + } + #[cfg(not(target_arch = "wasm32"))] + { + self.imp.run_threaded() + } } - #[cfg(target_arch = "wasm32")] - pub fn run_threaded(&self) { - self.imp.run_threaded() + /// Spawn a task which processes WebSocket messages as they are received. + /// + /// # Panics + /// At runtime if called on any non-`wasm32` target. + pub fn run_background(&self) { + #[cfg(not(target_arch = "wasm32"))] + { + panic!("`DbConnection::run_background` is only supported on WebAssembly (wasm32); \ + prefer using `DbConnection::run_threaded` instead"); + } + #[cfg(target_arch = "wasm32")] + { + self.imp.run_background() + } } /// Run an `async` loop which processes WebSocket messages when polled. diff --git a/sdks/rust/src/db_connection.rs b/sdks/rust/src/db_connection.rs index 3d8fa8dd042..20ae9b034e4 100644 --- a/sdks/rust/src/db_connection.rs +++ b/sdks/rust/src/db_connection.rs @@ -645,7 +645,6 @@ impl DbContextImpl { /// Spawn a thread which does [`Self::advance_one_message_blocking`] in a loop. /// /// Called by the autogenerated `DbConnection` method of the same name. - #[cfg(not(feature = "web"))] pub fn run_threaded(&self) -> std::thread::JoinHandle<()> { let this = self.clone(); std::thread::spawn(move || loop { @@ -657,8 +656,11 @@ impl DbContextImpl { }) } + /// Spawn a background task which does [`Self::advance_one_message_async`] in a loop. + /// + /// Called by the autogenerated `DbConnection` method of the same name. #[cfg(feature = "web")] - pub fn run_threaded(&self) { + pub fn run_background(&self) { let this = self.clone(); wasm_bindgen_futures::spawn_local(async move { loop { @@ -952,6 +954,7 @@ You must explicitly advance the connection by calling any one of: - `DbConnection::frame_tick`. - `DbConnection::run_threaded`. +- `DbConnection::run_background`. - `DbConnection::run_async`. - `DbConnection::advance_one_message`. - `DbConnection::advance_one_message_blocking`. From 180bbfe44333c545ab8f5eaaef8ec06eaa53ddef Mon Sep 17 00:00:00 2001 From: Thales R Date: Sun, 11 May 2025 15:03:17 +0200 Subject: [PATCH 10/32] Reduce cfg noise by simplifying mutex handling in the sdk crate Trim down repetitive `cfg` clauses by extracting common lock patterns into `get_lock_[sync|async]`. --- sdks/rust/src/db_connection.rs | 67 ++++++++++++++++++---------------- 1 file changed, 35 insertions(+), 32 deletions(-) diff --git a/sdks/rust/src/db_connection.rs b/sdks/rust/src/db_connection.rs index 20ae9b034e4..228aeb50a5d 100644 --- a/sdks/rust/src/db_connection.rs +++ b/sdks/rust/src/db_connection.rs @@ -293,13 +293,7 @@ impl DbContextImpl { /// Apply all queued [`PendingMutation`]s. fn apply_pending_mutations(&self) -> crate::Result<()> { - #[cfg(not(feature = "web"))] - while let Ok(Some(pending_mutation)) = self.pending_mutations_recv.blocking_lock().try_next() { - self.apply_mutation(pending_mutation)?; - } - - #[cfg(feature = "web")] - while let Ok(Some(pending_mutation)) = self.pending_mutations_recv.lock().unwrap().try_next() { + while let Ok(Some(pending_mutation)) = get_lock_sync(&self.pending_mutations_recv).try_next() { self.apply_mutation(pending_mutation)?; } @@ -547,23 +541,14 @@ impl DbContextImpl { // returns `Err(_)`. Similar behavior as `Iterator::next` and // `Stream::poll_next`. No comment on whether this is a good mental // model or not. - - let res = { - #[cfg(not(feature = "web"))] - let mut recv = self.recv.blocking_lock(); - - #[cfg(feature = "web")] - let mut recv = self.recv.lock().unwrap(); - - match recv.try_next() { - Ok(None) => { - let disconnect_ctx = self.make_event_ctx(None); - self.invoke_disconnected(&disconnect_ctx); - Err(crate::Error::Disconnected) - } - Err(_) => Ok(false), - Ok(Some(msg)) => self.process_message(msg).map(|_| true), + let res = match get_lock_sync(&self.recv).try_next() { + Ok(None) => { + let disconnect_ctx = self.make_event_ctx(None); + self.invoke_disconnected(&disconnect_ctx); + Err(crate::Error::Disconnected) } + Err(_) => Ok(false), + Ok(Some(msg)) => self.process_message(msg).map(|_| true), }; // Also apply any new pending messages afterwards, @@ -579,15 +564,8 @@ impl DbContextImpl { // We call this out as an incorrect and unsupported thing to do. #![allow(clippy::await_holding_lock)] - #[cfg(not(feature = "web"))] - let mut pending_mutations = self.pending_mutations_recv.lock().await; - #[cfg(feature = "web")] - let mut pending_mutations = self.pending_mutations_recv.lock().unwrap(); - - #[cfg(not(feature = "web"))] - let mut recv = self.recv.lock().await; - #[cfg(feature = "web")] - let mut recv = self.recv.lock().unwrap(); + let mut pending_mutations = get_lock_async(&self.pending_mutations_recv).await; + let mut recv = get_lock_async(&self.recv).await; // Always process pending mutations before WS messages, if they're available, // so that newly registered callbacks run on messages. @@ -1251,6 +1229,31 @@ fn enter_or_create_runtime() -> crate::Result<(Option, runtime::Handle) } } +/// Synchronous lock helper: native = blocking_lock, web = lock().unwrap() +#[cfg(not(feature = "web"))] +fn get_lock_sync(mutex: &TokioMutex) -> tokio::sync::MutexGuard<'_, T> { + mutex.blocking_lock() +} + +/// Synchronous lock helper: native = blocking_lock, web = lock().unwrap() +#[cfg(feature = "web")] +fn get_lock_sync(mutex: &StdMutex) -> std::sync::MutexGuard<'_, T> { + mutex.lock().unwrap() +} + +// Async‐lock helper: native = .lock().await, web = lock().unwrap() inside async fn +#[cfg(not(feature = "web"))] +async fn get_lock_async(mutex: &TokioMutex) -> tokio::sync::MutexGuard<'_, T> { + mutex.lock().await +} + +// Async‐lock helper: native = .lock().await, web = lock().unwrap() inside async fn +#[cfg(feature = "web")] +pub async fn get_lock_async(mutex: &StdMutex) -> std::sync::MutexGuard<'_, T> { + // still async, but does the sync lock immediately + mutex.lock().unwrap() +} + enum ParsedMessage { InitialSubscription { db_update: M::DbUpdate, From 9936c9f6d06607167ef6015c29fd145dbe35d003 Mon Sep 17 00:00:00 2001 From: Thales R Date: Sun, 11 May 2025 15:30:26 +0200 Subject: [PATCH 11/32] Remove tokio dependency from the rust web sdk --- Cargo.lock | 1 - sdks/rust/Cargo.toml | 7 ------- sdks/rust/src/db_connection.rs | 37 +++++++++++++++++++++++----------- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 70bc9cfa99e..299bd661920 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6746,7 +6746,6 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" dependencies = [ - "web-time", "zeroize", ] diff --git a/sdks/rust/Cargo.toml b/sdks/rust/Cargo.toml index 7ac29c9b621..af3c1aeb596 100644 --- a/sdks/rust/Cargo.toml +++ b/sdks/rust/Cargo.toml @@ -16,7 +16,6 @@ web = [ "dep:gloo-net", "dep:gloo-storage", "dep:js-sys", - "dep:rustls-pki-types", "dep:tokio-tungstenite-wasm", "dep:wasm-bindgen", "dep:wasm-bindgen-futures", @@ -49,7 +48,6 @@ gloo-console = { version = "0.3.0", optional = true } gloo-net = { version = "0.6.0", optional = true } gloo-storage = { version = "0.3.0", optional = true } js-sys = { version = "0.3", optional = true } -rustls-pki-types = { version = "1.12.0", features = ["web"], optional = true } tokio-tungstenite-wasm = { version = "0.6.0", optional = true } wasm-bindgen = { version = "0.2.100", optional = true } wasm-bindgen-futures = { version = "0.4.45", optional = true } @@ -62,11 +60,6 @@ home.workspace = true tokio.workspace = true tokio-tungstenite.workspace = true -[target.'cfg(target_arch = "wasm32")'.dependencies] -tokio = { version = "1.37", default-features = false, features = [ - "rt", "macros", "sync", "io-util" -] } - [dev-dependencies] # for quickstart-chat and cursive-chat examples hex.workspace = true diff --git a/sdks/rust/src/db_connection.rs b/sdks/rust/src/db_connection.rs index 228aeb50a5d..45bd20e3cac 100644 --- a/sdks/rust/src/db_connection.rs +++ b/sdks/rust/src/db_connection.rs @@ -34,6 +34,8 @@ use crate::{ }; use bytes::Bytes; use futures::StreamExt; +#[cfg(feature = "web")] +use futures::{pin_mut, FutureExt}; use futures_channel::mpsc; use http::Uri; use spacetimedb_client_api_messages::websocket as ws; @@ -44,9 +46,11 @@ use std::{ collections::HashMap, sync::{atomic::AtomicU32, Arc, Mutex as StdMutex, OnceLock}, }; -use tokio::runtime::{self, Runtime}; #[cfg(not(feature = "web"))] -use tokio::sync::Mutex as TokioMutex; +use tokio::{ + runtime::{self, Runtime}, + sync::Mutex as TokioMutex, +}; pub(crate) type SharedCell = Arc>; @@ -56,6 +60,7 @@ pub(crate) type SharedCell = Arc>; /// This must be relatively cheaply `Clone`-able, and have internal sharing, /// as numerous operations will clone it to get new handles on the connection. pub struct DbContextImpl { + #[cfg(not(feature = "web"))] runtime: runtime::Handle, /// All the state which is safe to hold a lock on while running callbacks. @@ -102,6 +107,7 @@ pub struct DbContextImpl { impl Clone for DbContextImpl { fn clone(&self) -> Self { Self { + #[cfg(not(feature = "web"))] runtime: self.runtime.clone(), // Being very explicit with `Arc::clone` here, // since we'll be doing `DbContextImpl::clone` very frequently, @@ -576,15 +582,28 @@ impl DbContextImpl { return Message::Local(pending_mutation.unwrap()); } + #[cfg(not(feature = "web"))] tokio::select! { pending_mutation = pending_mutations.next() => Message::Local(pending_mutation.unwrap()), incoming_message = recv.next() => Message::Ws(incoming_message), } + + #[cfg(feature = "web")] + { + let (pending_fut, recv_fut) = (pending_mutations.next().fuse(), recv.next().fuse()); + pin_mut!(pending_fut, recv_fut); + + futures::select! { + pending_mutation = pending_fut => Message::Local(pending_mutation.unwrap()), + incoming_message = recv_fut => Message::Ws(incoming_message), + } + } } /// Like [`Self::advance_one_message`], but sleeps the thread until a message is available. /// /// Called by the autogenerated `DbConnection` method of the same name. + #[cfg(not(feature = "web"))] pub fn advance_one_message_blocking(&self) -> crate::Result<()> { match self.runtime.block_on(self.get_message()) { Message::Local(pending) => self.apply_mutation(pending), @@ -623,6 +642,7 @@ impl DbContextImpl { /// Spawn a thread which does [`Self::advance_one_message_blocking`] in a loop. /// /// Called by the autogenerated `DbConnection` method of the same name. + #[cfg(not(feature = "web"))] pub fn run_threaded(&self) -> std::thread::JoinHandle<()> { let this = self.clone(); std::thread::spawn(move || loop { @@ -806,6 +826,7 @@ pub(crate) struct DbContextImplInner { /// `Some` if not within the context of an outer runtime. The `Runtime` must /// then live as long as `Self`. #[allow(unused)] + #[cfg(not(feature = "web"))] runtime: Option, db_callbacks: DbCallbacks, @@ -1016,7 +1037,6 @@ but you must call one of them, or else the connection will never progress. #[cfg(feature = "web")] pub async fn build_impl(self) -> crate::Result> { - let (runtime, handle) = enter_or_create_runtime()?; let db_callbacks = DbCallbacks::default(); let reducer_callbacks = ReducerCallbacks::default(); @@ -1037,8 +1057,6 @@ but you must call one of them, or else the connection will never progress. let parsed_recv_chan = spawn_parse_loop::(raw_msg_recv); let inner = Arc::new(StdMutex::new(DbContextImplInner { - runtime, - db_callbacks, reducer_callbacks, subscriptions: SubscriptionManager::default(), @@ -1056,7 +1074,6 @@ but you must call one of them, or else the connection will never progress. let (pending_mutations_send, pending_mutations_recv) = mpsc::unbounded(); let ctx_imp = DbContextImpl { - runtime: handle, inner, send_chan, cache, @@ -1202,15 +1219,11 @@ Instead of registering multiple `on_disconnect` callbacks, register a single cal // When called from within an async context, return a handle to it (and no // `Runtime`), otherwise create a fresh `Runtime` and return it along with a // handle to it. +#[cfg(not(feature = "web"))] fn enter_or_create_runtime() -> crate::Result<(Option, runtime::Handle)> { match runtime::Handle::try_current() { Err(e) if e.is_missing_context() => { - #[cfg(not(feature = "web"))] - let mut rt = tokio::runtime::Builder::new_multi_thread(); - #[cfg(feature = "web")] - let mut rt = tokio::runtime::Builder::new_current_thread(); - - let rt = rt + let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .worker_threads(1) .thread_name("spacetimedb-background-connection") From 0fee245f0e7c8acb2a5fdc720170715815ec8dd6 Mon Sep 17 00:00:00 2001 From: Thales R Date: Fri, 16 May 2025 19:44:02 +0200 Subject: [PATCH 12/32] Improve error handling for the wasm sdk's Cookie builder --- Cargo.lock | 1 + sdks/rust/Cargo.toml | 6 ++--- sdks/rust/src/credentials.rs | 46 +++++++++++++----------------------- 3 files changed, 20 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 299bd661920..bdca5d5c2ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8263,6 +8263,7 @@ dependencies = [ "gloo-console", "gloo-net", "gloo-storage", + "gloo-utils", "hex", "home", "http 1.3.1", diff --git a/sdks/rust/Cargo.toml b/sdks/rust/Cargo.toml index af3c1aeb596..1b0f6566c2f 100644 --- a/sdks/rust/Cargo.toml +++ b/sdks/rust/Cargo.toml @@ -15,6 +15,7 @@ web = [ "dep:gloo-console", "dep:gloo-net", "dep:gloo-storage", + "dep:gloo-utils", "dep:js-sys", "dep:tokio-tungstenite-wasm", "dep:wasm-bindgen", @@ -47,13 +48,12 @@ getrandom = { version = "0.3.2", features = ["wasm_js"], optional = true } gloo-console = { version = "0.3.0", optional = true } gloo-net = { version = "0.6.0", optional = true } gloo-storage = { version = "0.3.0", optional = true } +gloo-utils = { version = "0.2.0", optional = true } js-sys = { version = "0.3", optional = true } tokio-tungstenite-wasm = { version = "0.6.0", optional = true } wasm-bindgen = { version = "0.2.100", optional = true } wasm-bindgen-futures = { version = "0.4.45", optional = true } -web-sys = { version = "0.3.77", features = [ - "Document", "HtmlDocument", "Window" -], optional = true} +web-sys = { version = "0.3.77", features = ["HtmlDocument"], optional = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] home.workspace = true diff --git a/sdks/rust/src/credentials.rs b/sdks/rust/src/credentials.rs index 4f5c774b591..30dff1e30bf 100644 --- a/sdks/rust/src/credentials.rs +++ b/sdks/rust/src/credentials.rs @@ -160,27 +160,18 @@ mod web_mod { pub mod cookies { use thiserror::Error; use wasm_bindgen::{JsCast, JsValue}; - use web_sys::{window, Document, HtmlDocument}; + use web_sys::HtmlDocument; #[derive(Error, Debug)] pub enum CookieError { - #[error("Window Object not valid in this context")] - NoWindow, - #[error("No `document` available on `window` object")] - NoDocument, - #[error("`document` is not an HtmlDocument")] - NoHtmlDocument, - #[error("web_sys error: {0:?}")] - WebSys(JsValue), - } + #[error("Error reading cookies: {0:?}")] + Get(JsValue), - impl From for CookieError { - fn from(err: JsValue) -> Self { - CookieError::WebSys(err) - } + #[error("Error setting cookie `{key}`: {js_value:?}")] + Set { key: String, js_value: JsValue }, } - /// A builder for contructing and setting cookies. + /// A builder for constructing and setting cookies. pub struct Cookie { name: String, value: String, @@ -207,8 +198,8 @@ mod web_mod { /// Gets the value of a cookie by name. pub fn get(name: &str) -> Result, CookieError> { - let doc = get_html_document()?; - let all = doc.cookie().map_err(CookieError::from)?; + let doc = get_html_document(); + let all = doc.cookie().map_err(|e| CookieError::Get(e))?; for cookie in all.split(';') { let cookie = cookie.trim(); if let Some((k, v)) = cookie.split_once('=') { @@ -240,7 +231,7 @@ mod web_mod { } /// Toggles the `Secure` flag. - /// The default is `false`. + /// Defaults to `false`. pub fn secure(mut self, enabled: bool) -> Self { self.secure = enabled; self @@ -253,7 +244,7 @@ mod web_mod { } pub fn set(self) -> Result<(), CookieError> { - let doc = get_html_document()?; + let doc = get_html_document(); let mut parts = vec![format!("{}={}", self.name, self.value)]; if let Some(path) = self.path { @@ -273,7 +264,10 @@ mod web_mod { } let cookie_str = parts.join("; "); - doc.set_cookie(&cookie_str).map_err(CookieError::from) + doc.set_cookie(&cookie_str).map_err(|e| CookieError::Set { + key: self.name.clone(), + js_value: e, + }) } /// Deletes the cookie by setting its value to empty and `Max-Age=0`. @@ -305,16 +299,8 @@ mod web_mod { } } - fn get_document() -> Result { - window() - .ok_or(CookieError::NoWindow)? - .document() - .ok_or(CookieError::NoDocument) - } - - fn get_html_document() -> Result { - let doc = get_document()?; - doc.dyn_into::().map_err(|_| CookieError::NoHtmlDocument) + fn get_html_document() -> HtmlDocument { + gloo_utils::document().unchecked_into::() } } } From 87c64f6bb431a671162abac65718e6a30f24d9ea Mon Sep 17 00:00:00 2001 From: Thales R Date: Tue, 28 Oct 2025 13:51:35 +0100 Subject: [PATCH 13/32] Use conditional type aliases to reduce cfg noise on structs --- sdks/rust/src/db_connection.rs | 15 +++++++-------- sdks/rust/src/websocket.rs | 17 ++++++----------- 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/sdks/rust/src/db_connection.rs b/sdks/rust/src/db_connection.rs index 45bd20e3cac..521498702fe 100644 --- a/sdks/rust/src/db_connection.rs +++ b/sdks/rust/src/db_connection.rs @@ -54,6 +54,11 @@ use tokio::{ pub(crate) type SharedCell = Arc>; +#[cfg(not(feature = "web"))] +type SharedAsyncCell = Arc>; +#[cfg(feature = "web")] +type SharedAsyncCell = SharedCell; + /// Implementation of `DbConnection`, `EventContext`, /// and anything else that provides access to the database connection. /// @@ -74,10 +79,7 @@ pub struct DbContextImpl { /// Receiver channel for WebSocket messages, /// which are pre-parsed in the background by [`parse_loop`]. - #[cfg(not(feature = "web"))] - recv: Arc>>>, - #[cfg(feature = "web")] - recv: SharedCell>>, + recv: SharedAsyncCell>>, /// Channel into which operations which apparently mutate SDK state, /// e.g. registering callbacks, push [`PendingMutation`] messages, @@ -87,10 +89,7 @@ pub struct DbContextImpl { /// Receive end of `pending_mutations_send`, /// from which [Self::apply_pending_mutations] and friends read mutations. - #[cfg(not(feature = "web"))] - pending_mutations_recv: Arc>>>, - #[cfg(feature = "web")] - pending_mutations_recv: SharedCell>>, + pending_mutations_recv: SharedAsyncCell>>, /// This connection's `Identity`. /// diff --git a/sdks/rust/src/websocket.rs b/sdks/rust/src/websocket.rs index 1d7ff2518a3..04d0b81636c 100644 --- a/sdks/rust/src/websocket.rs +++ b/sdks/rust/src/websocket.rs @@ -33,6 +33,11 @@ use tokio_tungstenite_wasm::{Message as WebSocketMessage, WebSocketStream}; use crate::compression::decompress_server_message; use crate::metrics::CLIENT_METRICS; +#[cfg(not(feature = "web"))] +type TokioTungsteniteError = tokio_tungstenite::tungstenite::Error; +#[cfg(feature = "web")] +type TokioTungsteniteError = tokio_tungstenite_wasm::Error; + #[derive(Error, Debug, Clone)] pub enum UriError { #[error("Unknown URI scheme {scheme}, expected http, https, ws or wss")] @@ -59,22 +64,12 @@ pub enum WsError { #[error(transparent)] UriError(#[from] UriError), - #[cfg(not(feature = "web"))] - #[error("Error in WebSocket connection with {uri}: {source}")] - Tungstenite { - uri: Uri, - #[source] - // `Arc` is required for `Self: Clone`, as `tungstenite::Error: !Clone`. - source: Arc, - }, - - #[cfg(feature = "web")] #[error("Error in WebSocket connection with {uri}: {source}")] Tungstenite { uri: Uri, #[source] // `Arc` is required for `Self: Clone`, as `tungstenite::Error: !Clone`. - source: Arc, + source: Arc, }, #[error("Received empty raw message, but valid messages always start with a one-byte compression flag")] From 1dcc3908b20d956e6579c03d199409d94082735c Mon Sep 17 00:00:00 2001 From: Thales R Date: Tue, 28 Oct 2025 14:02:05 +0100 Subject: [PATCH 14/32] Consolidate web/non-web `build_impl` functions Moves the creation of DbContextImplInner and DbContextImpl into private helper functions (`build_db_ctx_inner` and `build_db_ctx`) to reduce duplication between the web and non-web implementations of `build_impl`. --- Cargo.lock | 8 +- sdks/rust/src/db_connection.rs | 147 ++++++++++++++++++--------------- 2 files changed, 85 insertions(+), 70 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bdca5d5c2ea..6adc69c4979 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8259,7 +8259,7 @@ dependencies = [ "flate2", "futures", "futures-channel", - "getrandom 0.3.2", + "getrandom 0.3.4", "gloo-console", "gloo-net", "gloo-storage", @@ -9295,7 +9295,7 @@ dependencies = [ "http 1.3.1", "httparse", "js-sys", - "thiserror 2.0.12", + "thiserror 2.0.17", "tokio", "tokio-tungstenite 0.26.2", "wasm-bindgen", @@ -9684,9 +9684,9 @@ dependencies = [ "http 1.3.1", "httparse", "log", - "rand 0.9.1", + "rand 0.9.2", "sha1", - "thiserror 2.0.12", + "thiserror 2.0.17", "utf-8", ] diff --git a/sdks/rust/src/db_connection.rs b/sdks/rust/src/db_connection.rs index 521498702fe..1f1b20a2a6a 100644 --- a/sdks/rust/src/db_connection.rs +++ b/sdks/rust/src/db_connection.rs @@ -978,9 +978,6 @@ but you must call one of them, or else the connection will never progress. #[cfg(not(feature = "web"))] fn build_impl(self) -> crate::Result> { let (runtime, handle) = enter_or_create_runtime()?; - let db_callbacks = DbCallbacks::default(); - let reducer_callbacks = ReducerCallbacks::default(); - let procedure_callbacks = ProcedureCallbacks::default(); let connection_id_override = get_connection_id_override(); let ws_connection = tokio::task::block_in_place(|| { @@ -998,50 +995,30 @@ but you must call one of them, or else the connection will never progress. let (_websocket_loop_handle, raw_msg_recv, raw_msg_send) = ws_connection.spawn_message_loop(&handle); let (_parse_loop_handle, parsed_recv_chan) = spawn_parse_loop::(raw_msg_recv, &handle); - - let inner = Arc::new(StdMutex::new(DbContextImplInner { - runtime, - - db_callbacks, - reducer_callbacks, - subscriptions: SubscriptionManager::default(), - - on_connect: self.on_connect, - on_connect_error: self.on_connect_error, - on_disconnect: self.on_disconnect, - call_reducer_flags: <_>::default(), - procedure_callbacks, - })); - - let mut cache = ClientCache::default(); - M::register_tables(&mut cache); - let cache = Arc::new(StdMutex::new(cache)); - let send_chan = Arc::new(StdMutex::new(Some(raw_msg_send))); + let parsed_recv_chan = Arc::new(TokioMutex::new(parsed_recv_chan)); let (pending_mutations_send, pending_mutations_recv) = mpsc::unbounded(); - let ctx_imp = DbContextImpl { - runtime: handle, - inner, - send_chan, - cache, - recv: Arc::new(TokioMutex::new(parsed_recv_chan)), + let pending_mutations_recv = Arc::new(TokioMutex::new(pending_mutations_recv)); + + let inner_ctx = build_db_ctx_inner(runtime, self.on_connect, self.on_connect_error, self.on_disconnect); + Ok(build_db_ctx( + handle, + inner_ctx, + raw_msg_send, + parsed_recv_chan, pending_mutations_send, - pending_mutations_recv: Arc::new(TokioMutex::new(pending_mutations_recv)), - identity: Arc::new(StdMutex::new(None)), - connection_id: Arc::new(StdMutex::new(connection_id_override)), - }; - - Ok(ctx_imp) + pending_mutations_recv, + connection_id_override, + )) } + /// Open a WebSocket connection, build an empty client cache, &c, + /// to construct a [`DbContextImpl`]. #[cfg(feature = "web")] - pub async fn build_impl(self) -> crate::Result> { - let db_callbacks = DbCallbacks::default(); - let reducer_callbacks = ReducerCallbacks::default(); - + async fn build_impl(self) -> crate::Result> { let connection_id_override = get_connection_id_override(); let ws_connection = WsConnection::connect( - self.uri.unwrap(), + self.uri.clone().unwrap(), self.module_name.as_ref().unwrap(), self.token.as_deref(), connection_id_override, @@ -1054,36 +1031,20 @@ but you must call one of them, or else the connection will never progress. let (raw_msg_recv, raw_msg_send) = ws_connection.spawn_message_loop(); let parsed_recv_chan = spawn_parse_loop::(raw_msg_recv); - - let inner = Arc::new(StdMutex::new(DbContextImplInner { - db_callbacks, - reducer_callbacks, - subscriptions: SubscriptionManager::default(), - - on_connect: self.on_connect, - on_connect_error: self.on_connect_error, - on_disconnect: self.on_disconnect, - call_reducer_flags: <_>::default(), - })); - - let mut cache = ClientCache::default(); - M::register_tables(&mut cache); - let cache = Arc::new(StdMutex::new(cache)); - let send_chan = Arc::new(StdMutex::new(Some(raw_msg_send))); + let parsed_recv_chan = Arc::new(StdMutex::new(parsed_recv_chan)); let (pending_mutations_send, pending_mutations_recv) = mpsc::unbounded(); - let ctx_imp = DbContextImpl { - inner, - send_chan, - cache, - recv: Arc::new(StdMutex::new(parsed_recv_chan)), - pending_mutations_send, - pending_mutations_recv: Arc::new(StdMutex::new(pending_mutations_recv)), - identity: Arc::new(StdMutex::new(None)), - connection_id: Arc::new(StdMutex::new(connection_id_override)), - }; + let pending_mutations_recv = Arc::new(StdMutex::new(pending_mutations_recv)); - Ok(ctx_imp) + let inner_ctx = build_db_ctx_inner(self.on_connect, self.on_connect_error, self.on_disconnect); + Ok(build_db_ctx( + inner_ctx, + raw_msg_send, + parsed_recv_chan, + pending_mutations_send, + pending_mutations_recv, + connection_id_override, + )) } /// Set the URI of the SpacetimeDB host which is running the remote module. @@ -1215,6 +1176,60 @@ Instead of registering multiple `on_disconnect` callbacks, register a single cal } } +/// Create a [`DbContextImplInner`] wrapped in `Arc>`. +fn build_db_ctx_inner( + #[cfg(not(feature = "web"))] runtime: Option, + + on_connect_cb: Option>, + on_connect_error_cb: Option>, + on_disconnect_cb: Option>, +) -> Arc>> { + Arc::new(StdMutex::new(DbContextImplInner { + #[cfg(not(feature = "web"))] + runtime, + + db_callbacks: DbCallbacks::default(), + reducer_callbacks: ReducerCallbacks::default(), + subscriptions: SubscriptionManager::default(), + + on_connect: on_connect_cb, + on_connect_error: on_connect_error_cb, + on_disconnect: on_disconnect_cb, + call_reducer_flags: <_>::default(), + + procedure_callbacks: ProcedureCallbacks::default(), + })) +} + +/// Assemble and return a [`DbContextImpl`] from the provided [`DbContextImplInner`], and channels. +fn build_db_ctx( + #[cfg(not(feature = "web"))] runtime_handle: runtime::Handle, + + inner_ctx: Arc>>, + raw_msg_send: mpsc::UnboundedSender>, + parsed_msg_recv: SharedAsyncCell>>, + pending_mutations_send: mpsc::UnboundedSender>, + pending_mutations_recv: SharedAsyncCell>>, + connection_id: Option, +) -> DbContextImpl { + let mut cache = ClientCache::default(); + M::register_tables(&mut cache); + let cache = Arc::new(StdMutex::new(cache)); + + DbContextImpl { + #[cfg(not(feature = "web"))] + runtime: runtime_handle, + inner: inner_ctx, + send_chan: Arc::new(StdMutex::new(Some(raw_msg_send))), + cache, + recv: parsed_msg_recv, + pending_mutations_send, + pending_mutations_recv, + identity: Arc::new(StdMutex::new(None)), + connection_id: Arc::new(StdMutex::new(connection_id)), + } +} + // When called from within an async context, return a handle to it (and no // `Runtime`), otherwise create a fresh `Runtime` and return it along with a // handle to it. From d97a6ddbc1335e873be93fe86d9d9452f8eba05d Mon Sep 17 00:00:00 2001 From: Thales R Date: Tue, 28 Oct 2025 14:43:13 +0100 Subject: [PATCH 15/32] Enforce compile error on non-portable `DbConnection` methods --- crates/codegen/src/rust.rs | 44 +++---------------- .../snapshots/codegen__codegen_rust.snap | 44 +++---------------- 2 files changed, 14 insertions(+), 74 deletions(-) diff --git a/crates/codegen/src/rust.rs b/crates/codegen/src/rust.rs index 6583c87a624..38cd9973431 100644 --- a/crates/codegen/src/rust.rs +++ b/crates/codegen/src/rust.rs @@ -1494,19 +1494,9 @@ impl DbConnection {{ /// This is a low-level primitive exposed for power users who need significant control over scheduling. /// Most applications should call [`Self::run_threaded`] to spawn a thread /// which advances the connection automatically. - /// - /// # Panics - /// At runtime if called on any `wasm32` target. + #[cfg(not(target_arch = \"wasm32\"))] pub fn advance_one_message_blocking(&self) -> __sdk::Result<()> {{ - #[cfg(target_arch = \"wasm32\")] - {{ - panic!(\"`DbConnection::advance_one_message_blocking` is not supported on WebAssembly (wasm32); \\ - prefer using `advance_one_message` or `advance_one_message_async` instead\"); - }} - #[cfg(not(target_arch = \"wasm32\"))] - {{ - self.imp.advance_one_message_blocking() - }} + self.imp.advance_one_message_blocking() }} /// Process one WebSocket message, `await`ing until one is received. @@ -1530,35 +1520,15 @@ impl DbConnection {{ }} /// Spawn a thread which processes WebSocket messages as they are received. - /// - /// # Panics - /// At runtime if called on any `wasm32` target. + #[cfg(not(target_arch = \"wasm32\"))] pub fn run_threaded(&self) -> std::thread::JoinHandle<()> {{ - #[cfg(target_arch = \"wasm32\")] - {{ - panic!(\"`DbConnection::run_threaded` is not supported on WebAssembly (wasm32); \\ - prefer using `DbConnection::run_background` instead\"); - }} - #[cfg(not(target_arch = \"wasm32\"))] - {{ - self.imp.run_threaded() - }} + self.imp.run_threaded() }} - /// Spawn a task which processes WebSocket messages as they are received. - /// - /// # Panics - /// At runtime if called on any non-`wasm32` target. + /// Spawn a background task which processes WebSocket messages as they are received. + #[cfg(target_arch = \"wasm32\")] pub fn run_background(&self) {{ - #[cfg(not(target_arch = \"wasm32\"))] - {{ - panic!(\"`DbConnection::run_background` is only supported on WebAssembly (wasm32); \\ - prefer using `DbConnection::run_threaded` instead\"); - }} - #[cfg(target_arch = \"wasm32\")] - {{ - self.imp.run_background() - }} + self.imp.run_background() }} /// Run an `async` loop which processes WebSocket messages when polled. diff --git a/crates/codegen/tests/snapshots/codegen__codegen_rust.snap b/crates/codegen/tests/snapshots/codegen__codegen_rust.snap index 62c87ba6e3f..75109aeb30f 100644 --- a/crates/codegen/tests/snapshots/codegen__codegen_rust.snap +++ b/crates/codegen/tests/snapshots/codegen__codegen_rust.snap @@ -1934,19 +1934,9 @@ impl DbConnection { /// This is a low-level primitive exposed for power users who need significant control over scheduling. /// Most applications should call [`Self::run_threaded`] to spawn a thread /// which advances the connection automatically. - /// - /// # Panics - /// At runtime if called on any `wasm32` target. + #[cfg(not(target_arch = "wasm32"))] pub fn advance_one_message_blocking(&self) -> __sdk::Result<()> { - #[cfg(target_arch = "wasm32")] - { - panic!("`DbConnection::advance_one_message_blocking` is not supported on WebAssembly (wasm32); \ - prefer using `advance_one_message` or `advance_one_message_async` instead"); - } - #[cfg(not(target_arch = "wasm32"))] - { - self.imp.advance_one_message_blocking() - } + self.imp.advance_one_message_blocking() } /// Process one WebSocket message, `await`ing until one is received. @@ -1970,35 +1960,15 @@ impl DbConnection { } /// Spawn a thread which processes WebSocket messages as they are received. - /// - /// # Panics - /// At runtime if called on any `wasm32` target. + #[cfg(not(target_arch = "wasm32"))] pub fn run_threaded(&self) -> std::thread::JoinHandle<()> { - #[cfg(target_arch = "wasm32")] - { - panic!("`DbConnection::run_threaded` is not supported on WebAssembly (wasm32); \ - prefer using `DbConnection::run_background` instead"); - } - #[cfg(not(target_arch = "wasm32"))] - { - self.imp.run_threaded() - } + self.imp.run_threaded() } - /// Spawn a task which processes WebSocket messages as they are received. - /// - /// # Panics - /// At runtime if called on any non-`wasm32` target. + /// Spawn a background task which processes WebSocket messages as they are received. + #[cfg(target_arch = "wasm32")] pub fn run_background(&self) { - #[cfg(not(target_arch = "wasm32"))] - { - panic!("`DbConnection::run_background` is only supported on WebAssembly (wasm32); \ - prefer using `DbConnection::run_threaded` instead"); - } - #[cfg(target_arch = "wasm32")] - { - self.imp.run_background() - } + self.imp.run_background() } /// Run an `async` loop which processes WebSocket messages when polled. From 0c63e0e88cb78cafdb5ac2cb3c65e87ef6214d2c Mon Sep 17 00:00:00 2001 From: Thales R Date: Tue, 28 Oct 2025 14:47:36 +0100 Subject: [PATCH 16/32] Bump `getrandom` to 0.3.4, remove legacy `RUSTFLAGS` requirement --- sdks/rust/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdks/rust/Cargo.toml b/sdks/rust/Cargo.toml index 1b0f6566c2f..755ae80058e 100644 --- a/sdks/rust/Cargo.toml +++ b/sdks/rust/Cargo.toml @@ -44,7 +44,7 @@ once_cell.workspace = true prometheus.workspace = true rand.workspace = true -getrandom = { version = "0.3.2", features = ["wasm_js"], optional = true } +getrandom = { version = "0.3.4", features = ["wasm_js"], optional = true } gloo-console = { version = "0.3.0", optional = true } gloo-net = { version = "0.6.0", optional = true } gloo-storage = { version = "0.3.0", optional = true } From d54722f86c3a658db5d01376c655b03d96141910 Mon Sep 17 00:00:00 2001 From: Thales R Date: Tue, 28 Oct 2025 14:51:00 +0100 Subject: [PATCH 17/32] Minor formatting and code simplification for clippy --- sdks/rust/src/credentials.rs | 20 ++++++++++---------- sdks/rust/src/websocket.rs | 7 +++---- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/sdks/rust/src/credentials.rs b/sdks/rust/src/credentials.rs index 30dff1e30bf..7174e8fea15 100644 --- a/sdks/rust/src/credentials.rs +++ b/sdks/rust/src/credentials.rs @@ -199,7 +199,7 @@ mod web_mod { /// Gets the value of a cookie by name. pub fn get(name: &str) -> Result, CookieError> { let doc = get_html_document(); - let all = doc.cookie().map_err(|e| CookieError::Get(e))?; + let all = doc.cookie().map_err(CookieError::Get)?; for cookie in all.split(';') { let cookie = cookie.trim(); if let Some((k, v)) = cookie.split_once('=') { @@ -248,19 +248,19 @@ mod web_mod { let mut parts = vec![format!("{}={}", self.name, self.value)]; if let Some(path) = self.path { - parts.push(format!("Path={}", path)); + parts.push(format!("Path={path}")); } if let Some(domain) = self.domain { - parts.push(format!("Domain={}", domain)); + parts.push(format!("Domain={domain}")); } if let Some(age) = self.max_age { - parts.push(format!("Max-Age={}", age)); + parts.push(format!("Max-Age={age}")); } if self.secure { parts.push("Secure".into()); } if let Some(same) = self.same_site { - parts.push(format!("SameSite={}", same.to_string())); + parts.push(format!("SameSite={same}")); } let cookie_str = parts.join("; "); @@ -289,12 +289,12 @@ mod web_mod { None, } - impl ToString for SameSite { - fn to_string(&self) -> String { + impl std::fmt::Display for SameSite { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - SameSite::Strict => "Strict".into(), - SameSite::Lax => "Lax".into(), - SameSite::None => "None".into(), + SameSite::Strict => f.write_str("Strict"), + SameSite::Lax => f.write_str("Lax"), + SameSite::None => f.write_str("None"), } } } diff --git a/sdks/rust/src/websocket.rs b/sdks/rust/src/websocket.rs index 04d0b81636c..6474faac9ba 100644 --- a/sdks/rust/src/websocket.rs +++ b/sdks/rust/src/websocket.rs @@ -264,11 +264,11 @@ async fn fetch_ws_token(host: &Uri, auth_token: &str) -> Result use js_sys::{Reflect, JSON}; use wasm_bindgen::{JsCast, JsValue}; - let url = format!("{}v1/identity/websocket-token", host); + let url = format!("{host}v1/identity/websocket-token"); // helpers to convert gloo_net::Error or JsValue into WsError::TokenVerification let gloo_to_ws_err = |e: gloo_net::Error| match e { - gloo_net::Error::JsError(js_err) => WsError::TokenVerification(js_err.message.into()), + gloo_net::Error::JsError(js_err) => WsError::TokenVerification(js_err.message), gloo_net::Error::SerdeError(e) => WsError::TokenVerification(e.to_string()), gloo_net::Error::GlooError(msg) => WsError::TokenVerification(msg), }; @@ -278,7 +278,7 @@ async fn fetch_ws_token(host: &Uri, auth_token: &str) -> Result } else if let Some(s) = e.as_string() { WsError::TokenVerification(s) } else { - WsError::TokenVerification(format!("{:?}", e)) + WsError::TokenVerification(format!("{e:?}")) } }; @@ -547,7 +547,6 @@ impl WsConnection { mpsc::UnboundedReceiver>, mpsc::UnboundedSender>, ) { - let websocket_received = CLIENT_METRICS.websocket_received.with_label_values(&self.db_name); let websocket_received_msg_size = CLIENT_METRICS .websocket_received_msg_size From 76b30354eedfb8c4f68faa7aa15c16fd38f982d0 Mon Sep 17 00:00:00 2001 From: Thales R Date: Sat, 22 Nov 2025 18:05:15 +0100 Subject: [PATCH 18/32] Rename run_background to run_background_task for clarity --- crates/codegen/src/rust.rs | 6 +++--- crates/codegen/tests/snapshots/codegen__codegen_rust.snap | 6 +++--- sdks/rust/src/db_connection.rs | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/codegen/src/rust.rs b/crates/codegen/src/rust.rs index 38cd9973431..619b923ea86 100644 --- a/crates/codegen/src/rust.rs +++ b/crates/codegen/src/rust.rs @@ -1384,7 +1384,7 @@ impl __sdk::InModule for RemoteTables {{ /// /// - [`DbConnection::frame_tick`]. /// - [`DbConnection::run_threaded`]. -/// - [`DbConnection::run_background`]. +/// - [`DbConnection::run_background_task`]. /// - [`DbConnection::run_async`]. /// - [`DbConnection::advance_one_message`]. /// - [`DbConnection::advance_one_message_blocking`]. @@ -1527,8 +1527,8 @@ impl DbConnection {{ /// Spawn a background task which processes WebSocket messages as they are received. #[cfg(target_arch = \"wasm32\")] - pub fn run_background(&self) {{ - self.imp.run_background() + pub fn run_background_task(&self) {{ + self.imp.run_background_task() }} /// Run an `async` loop which processes WebSocket messages when polled. diff --git a/crates/codegen/tests/snapshots/codegen__codegen_rust.snap b/crates/codegen/tests/snapshots/codegen__codegen_rust.snap index 75109aeb30f..d881a7ba0cc 100644 --- a/crates/codegen/tests/snapshots/codegen__codegen_rust.snap +++ b/crates/codegen/tests/snapshots/codegen__codegen_rust.snap @@ -1824,7 +1824,7 @@ impl __sdk::InModule for RemoteTables { /// /// - [`DbConnection::frame_tick`]. /// - [`DbConnection::run_threaded`]. -/// - [`DbConnection::run_background`]. +/// - [`DbConnection::run_background_task`]. /// - [`DbConnection::run_async`]. /// - [`DbConnection::advance_one_message`]. /// - [`DbConnection::advance_one_message_blocking`]. @@ -1967,8 +1967,8 @@ impl DbConnection { /// Spawn a background task which processes WebSocket messages as they are received. #[cfg(target_arch = "wasm32")] - pub fn run_background(&self) { - self.imp.run_background() + pub fn run_background_task(&self) { + self.imp.run_background_task() } /// Run an `async` loop which processes WebSocket messages when polled. diff --git a/sdks/rust/src/db_connection.rs b/sdks/rust/src/db_connection.rs index 1f1b20a2a6a..34fd9a19d1b 100644 --- a/sdks/rust/src/db_connection.rs +++ b/sdks/rust/src/db_connection.rs @@ -657,7 +657,7 @@ impl DbContextImpl { /// /// Called by the autogenerated `DbConnection` method of the same name. #[cfg(feature = "web")] - pub fn run_background(&self) { + pub fn run_background_task(&self) { let this = self.clone(); wasm_bindgen_futures::spawn_local(async move { loop { @@ -952,7 +952,7 @@ You must explicitly advance the connection by calling any one of: - `DbConnection::frame_tick`. - `DbConnection::run_threaded`. -- `DbConnection::run_background`. +- `DbConnection::run_background_task`. - `DbConnection::run_async`. - `DbConnection::advance_one_message`. - `DbConnection::advance_one_message_blocking`. From 0aff3163b60ab07cd2cb2216b983c8dc4a395d47 Mon Sep 17 00:00:00 2001 From: Thales R Date: Sat, 22 Nov 2025 18:17:16 +0100 Subject: [PATCH 19/32] Hide wasm-only DbConnection methods in native docs --- crates/codegen/src/rust.rs | 6 +++--- .../tests/snapshots/codegen__codegen_rust.snap | 12 +++++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/codegen/src/rust.rs b/crates/codegen/src/rust.rs index 619b923ea86..597d0ff0ca8 100644 --- a/crates/codegen/src/rust.rs +++ b/crates/codegen/src/rust.rs @@ -1383,11 +1383,11 @@ impl __sdk::InModule for RemoteTables {{ /// You must explicitly advance the connection by calling any one of: /// /// - [`DbConnection::frame_tick`]. -/// - [`DbConnection::run_threaded`]. -/// - [`DbConnection::run_background_task`]. +#[cfg_attr(not(target_arch = \"wasm32\"), doc = \"- [`DbConnection::run_threaded`].\")] +#[cfg_attr(target_arch = \"wasm32\", doc = \"- [`DbConnection::run_background_task`].\")] /// - [`DbConnection::run_async`]. /// - [`DbConnection::advance_one_message`]. -/// - [`DbConnection::advance_one_message_blocking`]. +#[cfg_attr(not(target_arch = \"wasm32\"), doc = \"- [`DbConnection::advance_one_message_blocking`].\")] /// - [`DbConnection::advance_one_message_async`]. /// /// Which of these methods you should call depends on the specific needs of your application, diff --git a/crates/codegen/tests/snapshots/codegen__codegen_rust.snap b/crates/codegen/tests/snapshots/codegen__codegen_rust.snap index d881a7ba0cc..30f3132235d 100644 --- a/crates/codegen/tests/snapshots/codegen__codegen_rust.snap +++ b/crates/codegen/tests/snapshots/codegen__codegen_rust.snap @@ -1823,11 +1823,17 @@ impl __sdk::InModule for RemoteTables { /// You must explicitly advance the connection by calling any one of: /// /// - [`DbConnection::frame_tick`]. -/// - [`DbConnection::run_threaded`]. -/// - [`DbConnection::run_background_task`]. +#[cfg_attr(not(target_arch = "wasm32"), doc = "- [`DbConnection::run_threaded`].")] +#[cfg_attr( + target_arch = "wasm32", + doc = "- [`DbConnection::run_background_task`]." +)] /// - [`DbConnection::run_async`]. /// - [`DbConnection::advance_one_message`]. -/// - [`DbConnection::advance_one_message_blocking`]. +#[cfg_attr( + not(target_arch = "wasm32"), + doc = "- [`DbConnection::advance_one_message_blocking`]." +)] /// - [`DbConnection::advance_one_message_async`]. /// /// Which of these methods you should call depends on the specific needs of your application, From f078603ae7a2ed91bcdd978d63140c28dc5e326a Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Mon, 2 Feb 2026 14:05:06 -0800 Subject: [PATCH 20/32] [bfops/wasm-test]: WASM testing --- crates/testing/src/sdk.rs | 12 +++++++-- sdks/rust/tests/test-client/Cargo.toml | 37 +++++++++++++++++++++++--- sdks/rust/tests/test.rs | 18 +++++++++++++ 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/crates/testing/src/sdk.rs b/crates/testing/src/sdk.rs index 2cdba252cfb..2b7f9b40d35 100644 --- a/crates/testing/src/sdk.rs +++ b/crates/testing/src/sdk.rs @@ -358,8 +358,9 @@ fn split_command_string(command: &str) -> (String, Vec) { // Note: this function is memoized to ensure we only compile each client once. fn compile_client(compile_command: &str, client_project: &str) { let client_project = client_project.to_owned(); + let compile_command = compile_command.to_owned(); - memoized!(|client_project: String| -> () { + memoized!(|(client_project, compile_command): (String, String)| -> () { let (exe, args) = split_command_string(compile_command); let output = cmd(exe, args) @@ -378,7 +379,14 @@ fn compile_client(compile_command: &str, client_project: &str) { fn run_client(run_command: &str, client_project: &str, db_name: &str) { let (exe, args) = split_command_string(run_command); - let output = cmd(exe, args) + let is_wasm32_unknown_unknown = run_command.contains("--target wasm32-unknown-unknown"); + + let mut command = cmd(exe, args); + if is_wasm32_unknown_unknown { + command = command.env("CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER", "wasm-bindgen-test-runner"); + } + + let output = command .dir(client_project) .env(TEST_CLIENT_PROJECT_ENV_VAR, client_project) .env(TEST_DB_NAME_ENV_VAR, db_name) diff --git a/sdks/rust/tests/test-client/Cargo.toml b/sdks/rust/tests/test-client/Cargo.toml index 7a7167234a5..24e5ccabbdc 100644 --- a/sdks/rust/tests/test-client/Cargo.toml +++ b/sdks/rust/tests/test-client/Cargo.toml @@ -6,13 +6,42 @@ license-file = "LICENSE" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[features] +default = ["native"] + +# Builds the existing CLI test client. +native = [ + "dep:test-counter", + "dep:tokio", + "dep:env_logger", + "dep:rand", +] + +# Builds the wasm-bindgen-test based client tests. +wasm = [ + "spacetimedb-sdk/web", + "dep:wasm-bindgen-test", + "dep:wasm-bindgen-futures", + "dep:js-sys", +] + +[[bin]] +name = "test-client" +path = "src/main.rs" +required-features = ["native"] + [dependencies] spacetimedb-sdk = { path = "../.." } -test-counter = { path = "../test-counter" } -tokio.workspace = true anyhow.workspace = true -env_logger.workspace = true -rand.workspace = true + +test-counter = { path = "../test-counter", optional = true } +tokio = { workspace = true, optional = true } +env_logger = { workspace = true, optional = true } +rand = { workspace = true, optional = true } + +wasm-bindgen-test = { version = "0.3", optional = true } +wasm-bindgen-futures = { version = "0.4", optional = true } +js-sys = { version = "0.3", optional = true } [lints] workspace = true diff --git a/sdks/rust/tests/test.rs b/sdks/rust/tests/test.rs index 76a33f0aee4..a183c0a54e7 100644 --- a/sdks/rust/tests/test.rs +++ b/sdks/rust/tests/test.rs @@ -18,6 +18,24 @@ macro_rules! declare_tests_with_suffix { .build() } + #[test] + fn wasm_smoke() { + Test::builder() + .with_name("wasm-smoke") + .with_module(MODULE) + .with_client(CLIENT) + .with_language("rust") + .with_bindings_dir("src/module_bindings") + .with_compile_command( + "cargo test --target wasm32-unknown-unknown --no-default-features --features wasm --no-run", + ) + .with_run_command( + "cargo test --target wasm32-unknown-unknown --no-default-features --features wasm", + ) + .build() + .run(); + } + #[test] fn insert_primitive() { make_test("insert-primitive").run(); From 7134d6ad9f18c4ee763a76ff574af1abb8564164 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Mon, 2 Feb 2026 15:11:38 -0800 Subject: [PATCH 21/32] [bfops/wasm-test]: new test client? --- sdks/rust/tests/test-client/src/lib.rs | 28 ++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 sdks/rust/tests/test-client/src/lib.rs diff --git a/sdks/rust/tests/test-client/src/lib.rs b/sdks/rust/tests/test-client/src/lib.rs new file mode 100644 index 00000000000..1f8e623ea00 --- /dev/null +++ b/sdks/rust/tests/test-client/src/lib.rs @@ -0,0 +1,28 @@ +#[cfg(all(target_arch = "wasm32", feature = "wasm"))] +mod wasm_tests { + mod module_bindings; + + use module_bindings::{DbConnection, RemoteDbContext}; + use wasm_bindgen_test::wasm_bindgen_test; + + const LOCALHOST: &str = "http://localhost:3000"; + + fn db_name_or_panic() -> String { + std::env::var("SPACETIME_SDK_TEST_DB_NAME").expect("Failed to read db name from env") + } + + #[wasm_bindgen_test] + fn wasm_smoke_connect_and_disconnect() { + let name = db_name_or_panic(); + + let conn = DbConnection::builder() + .with_module_name(name) + .with_uri(LOCALHOST) + .build() + .expect("Failed to build DbConnection"); + + // Basic smoke: immediately disconnect. If the wasm build is broken (missing web feature + // plumbing), this tends to fail before here. + conn.disconnect().expect("disconnect failed"); + } +} From e6a44b2004d47dfd8a1eb40751f00555c7f82d05 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Wed, 4 Feb 2026 10:49:22 -0800 Subject: [PATCH 22/32] [bfops/wasm-test]: wip --- crates/testing/src/sdk.rs | 103 ++++++++++++++++++------ sdks/rust/tests/test-client/Cargo.toml | 17 ++-- sdks/rust/tests/test-client/src/lib.rs | 29 +------ sdks/rust/tests/test-client/src/main.rs | 5 +- sdks/rust/tests/test.rs | 22 ++--- 5 files changed, 103 insertions(+), 73 deletions(-) diff --git a/crates/testing/src/sdk.rs b/crates/testing/src/sdk.rs index 2b7f9b40d35..9824a4238b1 100644 --- a/crates/testing/src/sdk.rs +++ b/crates/testing/src/sdk.rs @@ -103,6 +103,14 @@ pub struct Test { /// - `SPACETIME_SDK_TEST_CLIENT_PROJECT` bound to the `client_project` path. /// - `SPACETIME_SDK_TEST_DB_NAME` bound to the database identity or name. run_command: String, + + client_runner: ClientRunner, +} + +#[derive(Clone)] +enum ClientRunner { + Default, + Wasi { wasm_path: String }, } pub const TEST_MODULE_PROJECT_ENV_VAR: &str = "SPACETIME_SDK_TEST_MODULE_PROJECT"; @@ -135,7 +143,7 @@ impl Test { let db_name = publish_module(paths, &file, host_type); - run_client(&self.run_command, &self.client_project, &db_name); + run_client(&self.client_runner, &self.run_command, &self.client_project, &db_name); } } @@ -376,31 +384,67 @@ fn compile_client(compile_command: &str, client_project: &str) { }) } -fn run_client(run_command: &str, client_project: &str, db_name: &str) { - let (exe, args) = split_command_string(run_command); +fn run_client(runner: &ClientRunner, run_command: &str, client_project: &str, db_name: &str) { + match runner { + ClientRunner::Default => { + let (exe, args) = split_command_string(run_command); - let is_wasm32_unknown_unknown = run_command.contains("--target wasm32-unknown-unknown"); + let is_wasm32_unknown_unknown = run_command.contains("--target wasm32-unknown-unknown"); - let mut command = cmd(exe, args); - if is_wasm32_unknown_unknown { - command = command.env("CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER", "wasm-bindgen-test-runner"); - } + let mut command = cmd(exe, args); + if is_wasm32_unknown_unknown { + command = command.env("CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER", "wasm-bindgen-test-runner"); + } - let output = command - .dir(client_project) - .env(TEST_CLIENT_PROJECT_ENV_VAR, client_project) - .env(TEST_DB_NAME_ENV_VAR, db_name) - .env( - "RUST_LOG", - "spacetimedb=debug,spacetimedb_client_api=debug,spacetimedb_lib=debug,spacetimedb_standalone=debug", - ) - .stderr_to_stdout() - .stdout_capture() - .unchecked() - .run() - .expect("Error running run command"); - - status_ok_or_panic(output, run_command, "(running)"); + let output = command + .dir(client_project) + .env(TEST_CLIENT_PROJECT_ENV_VAR, client_project) + .env(TEST_DB_NAME_ENV_VAR, db_name) + .env( + "RUST_LOG", + "spacetimedb=debug,spacetimedb_client_api=debug,spacetimedb_lib=debug,spacetimedb_standalone=debug", + ) + .stderr_to_stdout() + .stdout_capture() + .unchecked() + .run() + .expect("Error running run command"); + + status_ok_or_panic(output, run_command, "(running)"); + } + ClientRunner::Wasi { wasm_path } => { + let (exe, args) = split_command_string(run_command); + + let rust_log = + "spacetimedb=debug,spacetimedb_client_api=debug,spacetimedb_lib=debug,spacetimedb_standalone=debug"; + + let mut wasmtime_args: Vec = vec![ + "run".to_owned(), + "--dir".to_owned(), + client_project.to_owned(), + "--env".to_owned(), + format!("{}={}", TEST_CLIENT_PROJECT_ENV_VAR, client_project), + "--env".to_owned(), + format!("{}={}", TEST_DB_NAME_ENV_VAR, db_name), + "--env".to_owned(), + format!("RUST_LOG={rust_log}"), + wasm_path.clone(), + "--".to_owned(), + ]; + wasmtime_args.push(exe); + wasmtime_args.extend(args); + + let output = cmd("wasmtime", wasmtime_args) + .dir(client_project) + .stderr_to_stdout() + .stdout_capture() + .unchecked() + .run() + .expect("Error running WASI client via wasmtime"); + + status_ok_or_panic(output, run_command, "(running wasi)"); + } + } } #[derive(Clone, Default)] @@ -412,6 +456,8 @@ pub struct TestBuilder { generate_subdir: Option, compile_command: Option, run_command: Option, + + client_runner: Option, } impl TestBuilder { @@ -472,6 +518,15 @@ impl TestBuilder { } } + pub fn with_wasi_client(self, wasm_path: impl Into) -> Self { + TestBuilder { + client_runner: Some(ClientRunner::Wasi { + wasm_path: wasm_path.into(), + }), + ..self + } + } + pub fn build(self) -> Test { let generate_language = self .generate_language @@ -502,6 +557,8 @@ impl TestBuilder { run_command: self .run_command .expect("Supply a run command using TestBuilder::with_run_command"), + + client_runner: self.client_runner.unwrap_or(ClientRunner::Default), } } } diff --git a/sdks/rust/tests/test-client/Cargo.toml b/sdks/rust/tests/test-client/Cargo.toml index 24e5ccabbdc..a1ab8da07f6 100644 --- a/sdks/rust/tests/test-client/Cargo.toml +++ b/sdks/rust/tests/test-client/Cargo.toml @@ -17,18 +17,17 @@ native = [ "dep:rand", ] -# Builds the wasm-bindgen-test based client tests. -wasm = [ - "spacetimedb-sdk/web", - "dep:wasm-bindgen-test", - "dep:wasm-bindgen-futures", - "dep:js-sys", +# Builds the client for `wasm32-wasi` so it can be executed via `wasmtime`. +wasi = [ + "dep:test-counter", + "dep:tokio", + "dep:env_logger", + "dep:rand", ] [[bin]] name = "test-client" path = "src/main.rs" -required-features = ["native"] [dependencies] spacetimedb-sdk = { path = "../.." } @@ -39,9 +38,5 @@ tokio = { workspace = true, optional = true } env_logger = { workspace = true, optional = true } rand = { workspace = true, optional = true } -wasm-bindgen-test = { version = "0.3", optional = true } -wasm-bindgen-futures = { version = "0.4", optional = true } -js-sys = { version = "0.3", optional = true } - [lints] workspace = true diff --git a/sdks/rust/tests/test-client/src/lib.rs b/sdks/rust/tests/test-client/src/lib.rs index 1f8e623ea00..c144e574bac 100644 --- a/sdks/rust/tests/test-client/src/lib.rs +++ b/sdks/rust/tests/test-client/src/lib.rs @@ -1,28 +1 @@ -#[cfg(all(target_arch = "wasm32", feature = "wasm"))] -mod wasm_tests { - mod module_bindings; - - use module_bindings::{DbConnection, RemoteDbContext}; - use wasm_bindgen_test::wasm_bindgen_test; - - const LOCALHOST: &str = "http://localhost:3000"; - - fn db_name_or_panic() -> String { - std::env::var("SPACETIME_SDK_TEST_DB_NAME").expect("Failed to read db name from env") - } - - #[wasm_bindgen_test] - fn wasm_smoke_connect_and_disconnect() { - let name = db_name_or_panic(); - - let conn = DbConnection::builder() - .with_module_name(name) - .with_uri(LOCALHOST) - .build() - .expect("Failed to build DbConnection"); - - // Basic smoke: immediately disconnect. If the wasm build is broken (missing web feature - // plumbing), this tends to fail before here. - conn.disconnect().expect("disconnect failed"); - } -} +#![allow(clippy::disallowed_macros)] diff --git a/sdks/rust/tests/test-client/src/main.rs b/sdks/rust/tests/test-client/src/main.rs index c6c6a5fc02d..295831b622a 100644 --- a/sdks/rust/tests/test-client/src/main.rs +++ b/sdks/rust/tests/test-client/src/main.rs @@ -70,8 +70,11 @@ fn main() { let test = std::env::args() .nth(1) .expect("Pass a test name as a command-line argument to the test client"); + dispatch(&test); +} - match &*test { +pub(crate) fn dispatch(test: &str) { + match test { "insert-primitive" => exec_insert_primitive(), "subscribe-and-cancel" => exec_subscribe_and_cancel(), "subscribe-and-unsubscribe" => exec_subscribe_and_unsubscribe(), diff --git a/sdks/rust/tests/test.rs b/sdks/rust/tests/test.rs index a183c0a54e7..47dc8a0c952 100644 --- a/sdks/rust/tests/test.rs +++ b/sdks/rust/tests/test.rs @@ -18,22 +18,24 @@ macro_rules! declare_tests_with_suffix { .build() } - #[test] - fn wasm_smoke() { + fn make_wasi_test(subcommand: &str) -> Test { Test::builder() - .with_name("wasm-smoke") + .with_name(subcommand) .with_module(MODULE) .with_client(CLIENT) .with_language("rust") .with_bindings_dir("src/module_bindings") - .with_compile_command( - "cargo test --target wasm32-unknown-unknown --no-default-features --features wasm --no-run", - ) - .with_run_command( - "cargo test --target wasm32-unknown-unknown --no-default-features --features wasm", - ) + .with_compile_command("cargo build --target wasm32-wasi --no-default-features --features wasi") + // The WASI runner uses this string as argv passed to the wasm module. + // `test-client` expects the test name as argv[1]. + .with_run_command(format!("test-client {}", subcommand)) + .with_wasi_client("target/wasm32-wasi/debug/test-client.wasm") .build() - .run(); + } + + #[test] + fn wasm_smoke() { + make_wasi_test("insert-primitive").run(); } #[test] From 99209d361db4faa05d73f81892cbf2f53c82b5af Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Wed, 4 Feb 2026 11:51:46 -0800 Subject: [PATCH 23/32] [bfops/wasm-test]: wip --- crates/testing/src/sdk.rs | 92 ++++++++++++++++--------- sdks/rust/tests/test-client/Cargo.toml | 14 ++-- sdks/rust/tests/test-client/src/lib.rs | 12 ++++ sdks/rust/tests/test-client/src/main.rs | 2 + sdks/rust/tests/test.rs | 18 +++-- 5 files changed, 94 insertions(+), 44 deletions(-) diff --git a/crates/testing/src/sdk.rs b/crates/testing/src/sdk.rs index 9824a4238b1..920873e788a 100644 --- a/crates/testing/src/sdk.rs +++ b/crates/testing/src/sdk.rs @@ -6,6 +6,7 @@ use spacetimedb_paths::{RootDir, SpacetimePaths}; use std::fs::create_dir_all; use std::sync::{Mutex, OnceLock}; use std::thread::JoinHandle; +use std::{path::Path, path::PathBuf}; use crate::invoke_cli; use crate::modules::{start_runtime, CompilationMode, CompiledModule}; @@ -110,7 +111,7 @@ pub struct Test { #[derive(Clone)] enum ClientRunner { Default, - Wasi { wasm_path: String }, + Web { wasm_path: String, bindgen_out_dir: String }, } pub const TEST_MODULE_PROJECT_ENV_VAR: &str = "SPACETIME_SDK_TEST_MODULE_PROJECT"; @@ -389,12 +390,7 @@ fn run_client(runner: &ClientRunner, run_command: &str, client_project: &str, db ClientRunner::Default => { let (exe, args) = split_command_string(run_command); - let is_wasm32_unknown_unknown = run_command.contains("--target wasm32-unknown-unknown"); - - let mut command = cmd(exe, args); - if is_wasm32_unknown_unknown { - command = command.env("CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER", "wasm-bindgen-test-runner"); - } + let command = cmd(exe, args); let output = command .dir(client_project) @@ -412,37 +408,70 @@ fn run_client(runner: &ClientRunner, run_command: &str, client_project: &str, db status_ok_or_panic(output, run_command, "(running)"); } - ClientRunner::Wasi { wasm_path } => { - let (exe, args) = split_command_string(run_command); - + ClientRunner::Web { + wasm_path, + bindgen_out_dir, + } => { let rust_log = "spacetimedb=debug,spacetimedb_client_api=debug,spacetimedb_lib=debug,spacetimedb_standalone=debug"; - let mut wasmtime_args: Vec = vec![ - "run".to_owned(), - "--dir".to_owned(), - client_project.to_owned(), - "--env".to_owned(), - format!("{}={}", TEST_CLIENT_PROJECT_ENV_VAR, client_project), - "--env".to_owned(), - format!("{}={}", TEST_DB_NAME_ENV_VAR, db_name), - "--env".to_owned(), - format!("RUST_LOG={rust_log}"), - wasm_path.clone(), - "--".to_owned(), - ]; - wasmtime_args.push(exe); - wasmtime_args.extend(args); - - let output = cmd("wasmtime", wasmtime_args) + let wasm_path = Path::new(wasm_path); + let bindgen_out_dir = PathBuf::from(bindgen_out_dir); + let bindgen_out_dir = if bindgen_out_dir.is_absolute() { + bindgen_out_dir + } else { + Path::new(client_project).join(bindgen_out_dir) + }; + + create_dir_all(&bindgen_out_dir).expect("Failed to create wasm-bindgen out dir"); + + let output = cmd( + "wasm-bindgen", + [ + "--target".to_owned(), + "nodejs".to_owned(), + "--out-dir".to_owned(), + bindgen_out_dir + .to_str() + .expect("bindgen_out_dir should be valid utf-8") + .to_owned(), + wasm_path.to_str().expect("wasm_path should be valid utf-8").to_owned(), + ], + ) + .dir(client_project) + .stderr_to_stdout() + .stdout_capture() + .unchecked() + .run() + .expect("Error running wasm-bindgen"); + status_ok_or_panic(output, "wasm-bindgen", "(wasm-bindgen)"); + + // Crate name test-client becomes test_client for wasm-bindgen output. + let js_module = bindgen_out_dir.join("test_client.js"); + let js_module = js_module + .to_str() + .expect("js_module path should be valid utf-8") + .to_owned(); + + let node_script = format!( + "(async () => {{\n const m = require({js_module:?});\n if (m.default) {{ await m.default(); }}\n const run = m.run || m.main || m.start;\n if (!run) throw new Error('No exported run/main/start function from wasm module');\n await run(process.argv[2]);\n}})().catch((e) => {{ console.error(e); process.exit(1); }});" + ); + + let mut node_args: Vec = + vec!["-e".to_owned(), node_script, "--".to_owned(), run_command.to_owned()]; + + let output = cmd("node", node_args) .dir(client_project) + .env(TEST_CLIENT_PROJECT_ENV_VAR, client_project) + .env(TEST_DB_NAME_ENV_VAR, db_name) + .env("RUST_LOG", rust_log) .stderr_to_stdout() .stdout_capture() .unchecked() .run() - .expect("Error running WASI client via wasmtime"); + .expect("Error running wasm client via node"); - status_ok_or_panic(output, run_command, "(running wasi)"); + status_ok_or_panic(output, run_command, "(running web)"); } } } @@ -518,10 +547,11 @@ impl TestBuilder { } } - pub fn with_wasi_client(self, wasm_path: impl Into) -> Self { + pub fn with_web_client(self, wasm_path: impl Into, bindgen_out_dir: impl Into) -> Self { TestBuilder { - client_runner: Some(ClientRunner::Wasi { + client_runner: Some(ClientRunner::Web { wasm_path: wasm_path.into(), + bindgen_out_dir: bindgen_out_dir.into(), }), ..self } diff --git a/sdks/rust/tests/test-client/Cargo.toml b/sdks/rust/tests/test-client/Cargo.toml index a1ab8da07f6..3e593770a0a 100644 --- a/sdks/rust/tests/test-client/Cargo.toml +++ b/sdks/rust/tests/test-client/Cargo.toml @@ -17,12 +17,11 @@ native = [ "dep:rand", ] -# Builds the client for `wasm32-wasi` so it can be executed via `wasmtime`. -wasi = [ - "dep:test-counter", - "dep:tokio", - "dep:env_logger", - "dep:rand", +# Builds the client for wasm32-unknown-unknown using the Rust SDK `web` backend. +web = [ + "spacetimedb-sdk/web", + "dep:wasm-bindgen", + "dep:wasm-bindgen-futures", ] [[bin]] @@ -38,5 +37,8 @@ tokio = { workspace = true, optional = true } env_logger = { workspace = true, optional = true } rand = { workspace = true, optional = true } +wasm-bindgen = { workspace = true, optional = true } +wasm-bindgen-futures = { workspace = true, optional = true } + [lints] workspace = true diff --git a/sdks/rust/tests/test-client/src/lib.rs b/sdks/rust/tests/test-client/src/lib.rs index c144e574bac..9dd50d80e2b 100644 --- a/sdks/rust/tests/test-client/src/lib.rs +++ b/sdks/rust/tests/test-client/src/lib.rs @@ -1 +1,13 @@ #![allow(clippy::disallowed_macros)] + +#[path = "main.rs"] +mod cli; + +#[cfg(all(target_arch = "wasm32", feature = "web"))] +use wasm_bindgen::prelude::wasm_bindgen; + +#[cfg(all(target_arch = "wasm32", feature = "web"))] +#[wasm_bindgen] +pub async fn run(test_name: String) { + cli::dispatch(&test_name); +} diff --git a/sdks/rust/tests/test-client/src/main.rs b/sdks/rust/tests/test-client/src/main.rs index 295831b622a..1a1f972421a 100644 --- a/sdks/rust/tests/test-client/src/main.rs +++ b/sdks/rust/tests/test-client/src/main.rs @@ -34,6 +34,7 @@ fn db_name_or_panic() -> String { /// Register a panic hook which will exit the process whenever any thread panics. /// /// This allows us to fail tests by panicking in callbacks. +#[cfg(not(target_arch = "wasm32"))] fn exit_on_panic() { // The default panic hook is responsible for printing the panic message and backtrace to stderr. // Grab a handle on it, and invoke it in our custom hook before exiting. @@ -63,6 +64,7 @@ macro_rules! assert_eq_or_bail { }}; } +#[cfg(not(target_arch = "wasm32"))] fn main() { env_logger::init(); exit_on_panic(); diff --git a/sdks/rust/tests/test.rs b/sdks/rust/tests/test.rs index 47dc8a0c952..88b41dbe294 100644 --- a/sdks/rust/tests/test.rs +++ b/sdks/rust/tests/test.rs @@ -18,24 +18,28 @@ macro_rules! declare_tests_with_suffix { .build() } - fn make_wasi_test(subcommand: &str) -> Test { + fn make_web_test(subcommand: &str) -> Test { Test::builder() .with_name(subcommand) .with_module(MODULE) .with_client(CLIENT) .with_language("rust") .with_bindings_dir("src/module_bindings") - .with_compile_command("cargo build --target wasm32-wasi --no-default-features --features wasi") - // The WASI runner uses this string as argv passed to the wasm module. - // `test-client` expects the test name as argv[1]. - .with_run_command(format!("test-client {}", subcommand)) - .with_wasi_client("target/wasm32-wasi/debug/test-client.wasm") + .with_compile_command( + "cargo build --target wasm32-unknown-unknown --no-default-features --features web", + ) + // The web runner passes this string to the wasm export `run(test_name)`. + .with_run_command(subcommand) + .with_web_client( + "target/wasm32-unknown-unknown/debug/test-client.wasm", + "target/sdk-test-web-bindgen", + ) .build() } #[test] fn wasm_smoke() { - make_wasi_test("insert-primitive").run(); + make_web_test("insert-primitive").run(); } #[test] From 32e4cf5aae425cabbcf625800c7d1f72bf602303 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Wed, 4 Feb 2026 13:38:09 -0800 Subject: [PATCH 24/32] [bfops/wasm-test]: --- sdks/rust/tests/test-client/Cargo.toml | 40 +++++++++++++++++++++++++ sdks/rust/tests/test-client/src/lib.rs | 41 ++++++++++++++++++++++++++ sdks/rust/tests/test.rs | 27 +++++++++++++++++ 3 files changed, 108 insertions(+) diff --git a/sdks/rust/tests/test-client/Cargo.toml b/sdks/rust/tests/test-client/Cargo.toml index 3e593770a0a..a81f46c76d3 100644 --- a/sdks/rust/tests/test-client/Cargo.toml +++ b/sdks/rust/tests/test-client/Cargo.toml @@ -6,6 +6,7 @@ license-file = "LICENSE" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +<<<<<<< Updated upstream [features] default = ["native"] @@ -28,9 +29,36 @@ web = [ name = "test-client" path = "src/main.rs" +||||||| Stash base +======= +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +default = ["native"] + +native = [ + "dep:test-counter", + "dep:tokio", + "dep:env_logger", + "dep:rand", +] + +web = [ + "spacetimedb-sdk/web", + "dep:wasm-bindgen", +] + +[[bin]] +name = "test-client" +path = "src/main.rs" +required-features = ["native"] + +>>>>>>> Stashed changes [dependencies] spacetimedb-sdk = { path = "../.." } anyhow.workspace = true +<<<<<<< Updated upstream test-counter = { path = "../test-counter", optional = true } tokio = { workspace = true, optional = true } @@ -39,6 +67,18 @@ rand = { workspace = true, optional = true } wasm-bindgen = { workspace = true, optional = true } wasm-bindgen-futures = { workspace = true, optional = true } +||||||| Stash base +env_logger.workspace = true +rand.workspace = true +======= + +test-counter = { path = "../test-counter", optional = true } +tokio = { workspace = true, optional = true } +env_logger = { workspace = true, optional = true } +rand = { workspace = true, optional = true } + +wasm-bindgen = { workspace = true, optional = true } +>>>>>>> Stashed changes [lints] workspace = true diff --git a/sdks/rust/tests/test-client/src/lib.rs b/sdks/rust/tests/test-client/src/lib.rs index 9dd50d80e2b..a932c534eb1 100644 --- a/sdks/rust/tests/test-client/src/lib.rs +++ b/sdks/rust/tests/test-client/src/lib.rs @@ -1,3 +1,4 @@ +<<<<<<< Updated upstream #![allow(clippy::disallowed_macros)] #[path = "main.rs"] @@ -11,3 +12,43 @@ use wasm_bindgen::prelude::wasm_bindgen; pub async fn run(test_name: String) { cli::dispatch(&test_name); } +||||||| Stash base +======= +#![allow(clippy::disallowed_macros)] + +#[cfg(all(target_arch = "wasm32", feature = "web"))] +use wasm_bindgen::prelude::wasm_bindgen; + +#[cfg(all(target_arch = "wasm32", feature = "web"))] +const LOCALHOST: &str = "http://localhost:3000"; + +#[cfg(all(target_arch = "wasm32", feature = "web"))] +fn db_name_or_panic() -> String { + std::env::var("SPACETIME_SDK_TEST_DB_NAME").expect("Failed to read db name from env") +} + +#[cfg(all(target_arch = "wasm32", feature = "web"))] +#[wasm_bindgen] +pub async fn run(test_name: String) { + match test_name.as_str() { + "wasm-smoke-connect" => wasm_smoke_connect().await, + _ => panic!("Unknown test: {test_name}"), + } +} + +#[cfg(all(target_arch = "wasm32", feature = "web"))] +async fn wasm_smoke_connect() { + mod module_bindings; + use module_bindings::DbConnection; + + let name = db_name_or_panic(); + let conn = DbConnection::builder() + .with_module_name(name) + .with_uri(LOCALHOST) + .build() + .await + .expect("Failed to build DbConnection"); + + conn.disconnect().expect("disconnect failed"); +} +>>>>>>> Stashed changes diff --git a/sdks/rust/tests/test.rs b/sdks/rust/tests/test.rs index 88b41dbe294..bae598e9a9e 100644 --- a/sdks/rust/tests/test.rs +++ b/sdks/rust/tests/test.rs @@ -18,6 +18,7 @@ macro_rules! declare_tests_with_suffix { .build() } +<<<<<<< Updated upstream fn make_web_test(subcommand: &str) -> Test { Test::builder() .with_name(subcommand) @@ -42,6 +43,32 @@ macro_rules! declare_tests_with_suffix { make_web_test("insert-primitive").run(); } +||||||| Stash base +======= + fn make_web_smoke_test() -> Test { + Test::builder() + .with_name("wasm-smoke") + .with_module(MODULE) + .with_client(CLIENT) + .with_language("rust") + .with_bindings_dir("src/module_bindings") + .with_compile_command( + "cargo build --target wasm32-unknown-unknown --no-default-features --features web", + ) + .with_run_command("wasm-smoke-connect") + .with_web_client( + "target/wasm32-unknown-unknown/debug/test-client.wasm", + "target/sdk-test-web-bindgen", + ) + .build() + } + + #[test] + fn wasm_smoke() { + make_web_smoke_test().run(); + } + +>>>>>>> Stashed changes #[test] fn insert_primitive() { make_test("insert-primitive").run(); From 0761826f66ebe476f75842564990f8ea97491e39 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Wed, 11 Mar 2026 11:23:56 -0700 Subject: [PATCH 25/32] [bfops/wasm-test]: WIP --- Cargo.lock | 4 +- sdks/rust/Cargo.toml | 2 + sdks/rust/src/db_connection.rs | 3 +- sdks/rust/tests/test-client/Cargo.toml | 46 +---- sdks/rust/tests/test-client/src/lib.rs | 41 ---- sdks/rust/tests/test.rs | 273 +++++++++++++------------ tools/ci/src/main.rs | 14 ++ 7 files changed, 163 insertions(+), 220 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7c40b29f076..b51ba0f444e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8354,7 +8354,7 @@ dependencies = [ "spacetimedb-lib 2.0.4", "thiserror 1.0.69", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.27.0", ] [[package]] @@ -9216,6 +9216,8 @@ dependencies = [ "spacetimedb-sdk", "test-counter", "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", ] [[package]] diff --git a/sdks/rust/Cargo.toml b/sdks/rust/Cargo.toml index c98e3cfa87a..8fbbfe6d7eb 100644 --- a/sdks/rust/Cargo.toml +++ b/sdks/rust/Cargo.toml @@ -11,6 +11,8 @@ rust-version.workspace = true [features] default = [] allow_loopback_http_for_tests = ["spacetimedb-testing/allow_loopback_http_for_tests"] +# Run SDK integration tests with wasm+web test clients instead of native test clients. +sdk-tests-web-client = [] web = [ "dep:getrandom", "dep:gloo-console", diff --git a/sdks/rust/src/db_connection.rs b/sdks/rust/src/db_connection.rs index 39f1487caa7..a1f311c7032 100644 --- a/sdks/rust/src/db_connection.rs +++ b/sdks/rust/src/db_connection.rs @@ -972,7 +972,6 @@ but you must call one of them, or else the connection will never progress. )) } - /// Set the URI of the SpacetimeDB host which is running the remote database. /// /// The URI must have either no scheme or one of the schemes `http`, `https`, `ws` or `wss`. @@ -1117,7 +1116,7 @@ fn build_db_ctx( #[cfg(not(feature = "web"))] runtime_handle: runtime::Handle, inner_ctx: Arc>>, - raw_msg_send: mpsc::UnboundedSender>, + raw_msg_send: mpsc::UnboundedSender, parsed_msg_recv: SharedAsyncCell>>, pending_mutations_send: mpsc::UnboundedSender>, pending_mutations_recv: SharedAsyncCell>>, diff --git a/sdks/rust/tests/test-client/Cargo.toml b/sdks/rust/tests/test-client/Cargo.toml index a81f46c76d3..7ce5bd3160a 100644 --- a/sdks/rust/tests/test-client/Cargo.toml +++ b/sdks/rust/tests/test-client/Cargo.toml @@ -6,37 +6,13 @@ license-file = "LICENSE" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html -<<<<<<< Updated upstream -[features] -default = ["native"] - -# Builds the existing CLI test client. -native = [ - "dep:test-counter", - "dep:tokio", - "dep:env_logger", - "dep:rand", -] - -# Builds the client for wasm32-unknown-unknown using the Rust SDK `web` backend. -web = [ - "spacetimedb-sdk/web", - "dep:wasm-bindgen", - "dep:wasm-bindgen-futures", -] - -[[bin]] -name = "test-client" -path = "src/main.rs" - -||||||| Stash base -======= [lib] crate-type = ["cdylib", "rlib"] [features] default = ["native"] +# Builds the existing CLI test client. native = [ "dep:test-counter", "dep:tokio", @@ -44,9 +20,11 @@ native = [ "dep:rand", ] +# Builds the client for wasm32-unknown-unknown using the Rust SDK `web` backend. web = [ "spacetimedb-sdk/web", "dep:wasm-bindgen", + "dep:wasm-bindgen-futures", ] [[bin]] @@ -54,31 +32,17 @@ name = "test-client" path = "src/main.rs" required-features = ["native"] ->>>>>>> Stashed changes [dependencies] spacetimedb-sdk = { path = "../.." } anyhow.workspace = true -<<<<<<< Updated upstream - -test-counter = { path = "../test-counter", optional = true } -tokio = { workspace = true, optional = true } -env_logger = { workspace = true, optional = true } -rand = { workspace = true, optional = true } - -wasm-bindgen = { workspace = true, optional = true } -wasm-bindgen-futures = { workspace = true, optional = true } -||||||| Stash base -env_logger.workspace = true -rand.workspace = true -======= test-counter = { path = "../test-counter", optional = true } tokio = { workspace = true, optional = true } env_logger = { workspace = true, optional = true } rand = { workspace = true, optional = true } -wasm-bindgen = { workspace = true, optional = true } ->>>>>>> Stashed changes +wasm-bindgen = { version = "0.2.100", optional = true } +wasm-bindgen-futures = { version = "0.4.45", optional = true } [lints] workspace = true diff --git a/sdks/rust/tests/test-client/src/lib.rs b/sdks/rust/tests/test-client/src/lib.rs index a932c534eb1..9dd50d80e2b 100644 --- a/sdks/rust/tests/test-client/src/lib.rs +++ b/sdks/rust/tests/test-client/src/lib.rs @@ -1,4 +1,3 @@ -<<<<<<< Updated upstream #![allow(clippy::disallowed_macros)] #[path = "main.rs"] @@ -12,43 +11,3 @@ use wasm_bindgen::prelude::wasm_bindgen; pub async fn run(test_name: String) { cli::dispatch(&test_name); } -||||||| Stash base -======= -#![allow(clippy::disallowed_macros)] - -#[cfg(all(target_arch = "wasm32", feature = "web"))] -use wasm_bindgen::prelude::wasm_bindgen; - -#[cfg(all(target_arch = "wasm32", feature = "web"))] -const LOCALHOST: &str = "http://localhost:3000"; - -#[cfg(all(target_arch = "wasm32", feature = "web"))] -fn db_name_or_panic() -> String { - std::env::var("SPACETIME_SDK_TEST_DB_NAME").expect("Failed to read db name from env") -} - -#[cfg(all(target_arch = "wasm32", feature = "web"))] -#[wasm_bindgen] -pub async fn run(test_name: String) { - match test_name.as_str() { - "wasm-smoke-connect" => wasm_smoke_connect().await, - _ => panic!("Unknown test: {test_name}"), - } -} - -#[cfg(all(target_arch = "wasm32", feature = "web"))] -async fn wasm_smoke_connect() { - mod module_bindings; - use module_bindings::DbConnection; - - let name = db_name_or_panic(); - let conn = DbConnection::builder() - .with_module_name(name) - .with_uri(LOCALHOST) - .build() - .await - .expect("Failed to build DbConnection"); - - conn.disconnect().expect("disconnect failed"); -} ->>>>>>> Stashed changes diff --git a/sdks/rust/tests/test.rs b/sdks/rust/tests/test.rs index 8c2062d64d6..7f1c067d12e 100644 --- a/sdks/rust/tests/test.rs +++ b/sdks/rust/tests/test.rs @@ -1,3 +1,45 @@ +#[cfg(feature = "sdk-tests-web-client")] +use std::path::Path; + +use spacetimedb_testing::sdk::TestBuilder; + +fn configure_test_client_commands( + builder: TestBuilder, + client_project: &str, + run_selector: Option<&str>, +) -> TestBuilder { + // Note: `run_selector` is intentionally interpreted differently by mode: + // - Native mode uses it as a CLI subcommand (`cargo run -- `), with `None` => `cargo run`. + // - Web mode forwards it to the wasm export `run(test_name)`, with `None` => empty string. + // This mirrors how `run_command` is consumed by the native vs web runners in `crates/testing/src/sdk.rs`. + #[cfg(feature = "sdk-tests-web-client")] + { + let package_name = Path::new(client_project) + .file_name() + .and_then(|name| name.to_str()) + .expect("client project path should end in a UTF-8 directory name"); + let wasm_path = format!("target/wasm32-unknown-unknown/debug/{package_name}.wasm"); + let bindgen_out_dir = format!("target/sdk-test-web-bindgen/{package_name}"); + + builder + .with_compile_command("cargo build --target wasm32-unknown-unknown --no-default-features --features web") + .with_run_command(run_selector.unwrap_or_default()) + .with_web_client(wasm_path, bindgen_out_dir) + } + + #[cfg(not(feature = "sdk-tests-web-client"))] + { + let run_command = match run_selector { + Some(subcommand) => format!("cargo run -- {}", subcommand), + None => "cargo run".to_owned(), + }; + + builder + .with_compile_command("cargo build") + .with_run_command(run_command) + } +} + macro_rules! declare_tests_with_suffix { ($lang:ident, $suffix:literal) => { mod $lang { @@ -7,74 +49,25 @@ macro_rules! declare_tests_with_suffix { const CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/test-client"); fn make_test(subcommand: &str) -> Test { - Test::builder() - .with_name(subcommand) - .with_module(MODULE) - .with_client(CLIENT) - .with_language("rust") - // We test against multiple modules in different languages, - // and as of writing (pgoldman 2026-02-12), - // some of those languages have not yet been updated to make scheduled and lifecycle reducers - // private by default. As such, generating only public items results in different bindings - // depending on which module is the source. - .with_generate_private_items(true) - .with_bindings_dir("src/module_bindings") - .with_compile_command("cargo build") - .with_run_command(format!("cargo run -- {}", subcommand)) - .build() - } - -<<<<<<< Updated upstream - fn make_web_test(subcommand: &str) -> Test { - Test::builder() - .with_name(subcommand) - .with_module(MODULE) - .with_client(CLIENT) - .with_language("rust") - .with_bindings_dir("src/module_bindings") - .with_compile_command( - "cargo build --target wasm32-unknown-unknown --no-default-features --features web", - ) - // The web runner passes this string to the wasm export `run(test_name)`. - .with_run_command(subcommand) - .with_web_client( - "target/wasm32-unknown-unknown/debug/test-client.wasm", - "target/sdk-test-web-bindgen", - ) - .build() - } - - #[test] - fn wasm_smoke() { - make_web_test("insert-primitive").run(); - } - -||||||| Stash base -======= - fn make_web_smoke_test() -> Test { - Test::builder() - .with_name("wasm-smoke") - .with_module(MODULE) - .with_client(CLIENT) - .with_language("rust") - .with_bindings_dir("src/module_bindings") - .with_compile_command( - "cargo build --target wasm32-unknown-unknown --no-default-features --features web", - ) - .with_run_command("wasm-smoke-connect") - .with_web_client( - "target/wasm32-unknown-unknown/debug/test-client.wasm", - "target/sdk-test-web-bindgen", - ) - .build() - } - - #[test] - fn wasm_smoke() { - make_web_smoke_test().run(); - } - ->>>>>>> Stashed changes + super::configure_test_client_commands( + Test::builder() + .with_name(subcommand) + .with_module(MODULE) + .with_client(CLIENT) + .with_language("rust") + // We test against multiple modules in different languages, + // and as of writing (pgoldman 2026-02-12), + // some of those languages have not yet been updated to make scheduled and lifecycle reducers + // private by default. As such, generating only public items results in different bindings + // depending on which module is the source. + .with_generate_private_items(true) + .with_bindings_dir("src/module_bindings"), + CLIENT, + Some(subcommand), + ) + .build() + } + #[test] fn insert_primitive() { make_test("insert-primitive").run(); @@ -248,25 +241,27 @@ macro_rules! declare_tests_with_suffix { #[test] fn connect_disconnect_callbacks() { - Test::builder() - .with_name(concat!("connect-disconnect-callback-", stringify!($lang))) - .with_module(concat!("sdk-test-connect-disconnect", $suffix)) - .with_client(concat!( - env!("CARGO_MANIFEST_DIR"), - "/tests/connect_disconnect_client" - )) - .with_language("rust") - // We test against multiple modules in different languages, - // and as of writing (pgoldman 2026-02-12), - // some of those languages have not yet been updated to make scheduled and lifecycle reducers - // private by default. As such, generating only public items results in different bindings - // depending on which module is the source. - .with_generate_private_items(true) - .with_bindings_dir("src/module_bindings") - .with_compile_command("cargo build") - .with_run_command("cargo run") - .build() - .run(); + const CONNECT_DISCONNECT_CLIENT: &str = + concat!(env!("CARGO_MANIFEST_DIR"), "/tests/connect_disconnect_client"); + + super::configure_test_client_commands( + Test::builder() + .with_name(concat!("connect-disconnect-callback-", stringify!($lang))) + .with_module(concat!("sdk-test-connect-disconnect", $suffix)) + .with_client(CONNECT_DISCONNECT_CLIENT) + .with_language("rust") + // We test against multiple modules in different languages, + // and as of writing (pgoldman 2026-02-12), + // some of those languages have not yet been updated to make scheduled and lifecycle reducers + // private by default. As such, generating only public items results in different bindings + // depending on which module is the source. + .with_generate_private_items(true) + .with_bindings_dir("src/module_bindings"), + CONNECT_DISCONNECT_CLIENT, + None, + ) + .build() + .run(); } #[test] @@ -378,15 +373,17 @@ mod event_table_tests { const CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/event-table-client"); fn make_test(subcommand: &str) -> Test { - Test::builder() - .with_name(subcommand) - .with_module(MODULE) - .with_client(CLIENT) - .with_language("rust") - .with_bindings_dir("src/module_bindings") - .with_compile_command("cargo build") - .with_run_command(format!("cargo run -- {}", subcommand)) - .build() + super::configure_test_client_commands( + Test::builder() + .with_name(subcommand) + .with_module(MODULE) + .with_client(CLIENT) + .with_language("rust") + .with_bindings_dir("src/module_bindings"), + CLIENT, + Some(subcommand), + ) + .build() } #[test] @@ -419,21 +416,23 @@ macro_rules! procedure_tests { const CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/procedure-client"); fn make_test(subcommand: &str) -> Test { - Test::builder() - .with_name(subcommand) - .with_module(MODULE) - .with_client(CLIENT) - .with_language("rust") - // We test against multiple modules in different languages, - // and as of writing (pgoldman 2026-02-12), - // some of those languages have not yet been updated to make scheduled and lifecycle reducers - // private by default. As such, generating only public items results in different bindings - // depending on which module is the source. - .with_generate_private_items(true) - .with_bindings_dir("src/module_bindings") - .with_compile_command("cargo build") - .with_run_command(format!("cargo run -- {}", subcommand)) - .build() + super::configure_test_client_commands( + Test::builder() + .with_name(subcommand) + .with_module(MODULE) + .with_client(CLIENT) + .with_language("rust") + // We test against multiple modules in different languages, + // and as of writing (pgoldman 2026-02-12), + // some of those languages have not yet been updated to make scheduled and lifecycle reducers + // private by default. As such, generating only public items results in different bindings + // depending on which module is the source. + .with_generate_private_items(true) + .with_bindings_dir("src/module_bindings"), + CLIENT, + Some(subcommand), + ) + .build() } #[test] @@ -487,21 +486,23 @@ macro_rules! view_tests { const CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/view-client"); fn make_test(subcommand: &str) -> Test { - Test::builder() - .with_name(subcommand) - .with_module(MODULE) - .with_client(CLIENT) - .with_language("rust") - // We test against multiple modules in different languages, - // and as of writing (pgoldman 2026-02-12), - // some of those languages have not yet been updated to make scheduled and lifecycle reducers - // private by default. As such, generating only public items results in different bindings - // depending on which module is the source. - .with_generate_private_items(true) - .with_bindings_dir("src/module_bindings") - .with_compile_command("cargo build") - .with_run_command(format!("cargo run -- {}", subcommand)) - .build() + super::configure_test_client_commands( + Test::builder() + .with_name(subcommand) + .with_module(MODULE) + .with_client(CLIENT) + .with_language("rust") + // We test against multiple modules in different languages, + // and as of writing (pgoldman 2026-02-12), + // some of those languages have not yet been updated to make scheduled and lifecycle reducers + // private by default. As such, generating only public items results in different bindings + // depending on which module is the source. + .with_generate_private_items(true) + .with_bindings_dir("src/module_bindings"), + CLIENT, + Some(subcommand), + ) + .build() } #[test] @@ -549,15 +550,17 @@ macro_rules! view_pk_tests { const CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/view-pk-client"); fn make_test(subcommand: &str) -> Test { - Test::builder() - .with_name(subcommand) - .with_module(MODULE) - .with_client(CLIENT) - .with_language("rust") - .with_bindings_dir("src/module_bindings") - .with_compile_command("cargo build") - .with_run_command(format!("cargo run -- {}", subcommand)) - .build() + super::configure_test_client_commands( + Test::builder() + .with_name(subcommand) + .with_module(MODULE) + .with_client(CLIENT) + .with_language("rust") + .with_bindings_dir("src/module_bindings"), + CLIENT, + Some(subcommand), + ) + .build() } #[test] diff --git a/tools/ci/src/main.rs b/tools/ci/src/main.rs index fa5d851807e..f942f950f38 100644 --- a/tools/ci/src/main.rs +++ b/tools/ci/src/main.rs @@ -338,6 +338,20 @@ fn main() -> Result<()> { "unreal" ) .run()?; + // Run the same SDK suite against wasm+web test clients. + cmd!( + "cargo", + "test", + "-p", + "spacetimedb-sdk", + "--features", + "allow_loopback_http_for_tests,sdk-tests-web-client", + "--", + "--test-threads=2", + "--skip", + "unreal" + ) + .run()?; // TODO: This should check for a diff at the start. If there is one, we should alert the user // that we're disabling diff checks because they have a dirty git repo, and to re-run in a clean one // if they want those checks. From f65f69ec427cacfc3676e06f0ca716fc94bd3546 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Wed, 11 Mar 2026 11:25:36 -0700 Subject: [PATCH 26/32] [bfops/wasm-test]: CI updates --- .github/workflows/ci.yml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40088d65bf1..2f6775f978f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -228,11 +228,22 @@ jobs: with: run_install: true - # Install cmake and emscripten for C++ module compilation tests. - - name: Install cmake and emscripten + # Install native and wasm toolchains required by SDK tests: + # - `emcc` for C++ module compilation tests. + # - `wasm32-unknown-unknown` target + `wasm-bindgen` CLI for Rust web client tests. + - name: Install native and wasm test prerequisites run: | sudo apt-get update sudo apt-get install -y cmake + + rustup target add wasm32-unknown-unknown + + REQUIRED_WASM_BINDGEN_VERSION="0.2.100" + INSTALLED_WASM_BINDGEN_VERSION="$(wasm-bindgen --version 2>/dev/null | awk '{print $2}' || true)" + if [ "${INSTALLED_WASM_BINDGEN_VERSION}" != "${REQUIRED_WASM_BINDGEN_VERSION}" ]; then + cargo install --locked --force wasm-bindgen-cli --version "${REQUIRED_WASM_BINDGEN_VERSION}" + fi + git clone https://github.com/emscripten-core/emsdk.git ~/emsdk cd ~/emsdk ./emsdk install 4.0.21 From f52f0e55e481b9de428058977693b4bdfbebedda Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Wed, 11 Mar 2026 12:37:05 -0700 Subject: [PATCH 27/32] [bfops/wasm-test]: WIP --- Cargo.lock | 1 + crates/sql-parser/src/parser/mod.rs | 5 +- crates/testing/src/sdk.rs | 5 +- sdks/rust/src/db_connection.rs | 9 +- sdks/rust/src/websocket.rs | 11 ++- .../src/module_bindings/mod.rs | 18 +++- .../src/module_bindings/mod.rs | 18 +++- .../src/module_bindings/mod.rs | 18 +++- sdks/rust/tests/test-client/Cargo.toml | 4 + sdks/rust/tests/test-client/src/lib.rs | 2 + sdks/rust/tests/test-client/src/main.rs | 99 +++++++++++++------ .../test-client/src/module_bindings/mod.rs | 18 +++- .../view-client/src/module_bindings/mod.rs | 18 +++- .../view-pk-client/src/module_bindings/mod.rs | 18 +++- 14 files changed, 179 insertions(+), 65 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b51ba0f444e..c2828a2747c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9212,6 +9212,7 @@ version = "2.0.4" dependencies = [ "anyhow", "env_logger 0.10.2", + "futures", "rand 0.9.2", "spacetimedb-sdk", "test-counter", diff --git a/crates/sql-parser/src/parser/mod.rs b/crates/sql-parser/src/parser/mod.rs index 9e6e5642bda..090ef55020a 100644 --- a/crates/sql-parser/src/parser/mod.rs +++ b/crates/sql-parser/src/parser/mod.rs @@ -207,9 +207,12 @@ pub(crate) fn parse_proj(expr: Expr) -> SqlParseResult { } } -// These types determine the size of [`parse_expr`]'s stack frame. +// These types determine the size of [`parse_expr`]'s stack frame on 64-bit targets. // Changing their sizes will require updating the recursion limit to avoid stack overflows. +// wasm32 has different type layouts, so this guard does not apply there. +#[cfg(target_pointer_width = "64")] const _: () = assert!(size_of::() == 168); +#[cfg(target_pointer_width = "64")] const _: () = assert!(size_of::>() == 40); /// Parse a scalar expression diff --git a/crates/testing/src/sdk.rs b/crates/testing/src/sdk.rs index 3c767cc5490..67ce42eeb69 100644 --- a/crates/testing/src/sdk.rs +++ b/crates/testing/src/sdk.rs @@ -197,7 +197,7 @@ macro_rules! memoized { MEMOIZED .lock() - .unwrap() + .unwrap_or_else(|e| e.into_inner()) .get_or_insert_default() .entry(($($key_tuple,)*)) .or_insert_with_key(|($($key_tuple,)*)| -> $value_ty { $body }) @@ -466,8 +466,7 @@ fn run_client(runner: &ClientRunner, run_command: &str, client_project: &str, db "(async () => {{\n const m = require({js_module:?});\n if (m.default) {{ await m.default(); }}\n const run = m.run || m.main || m.start;\n if (!run) throw new Error('No exported run/main/start function from wasm module');\n await run(process.argv[2]);\n}})().catch((e) => {{ console.error(e); process.exit(1); }});" ); - let mut node_args: Vec = - vec!["-e".to_owned(), node_script, "--".to_owned(), run_command.to_owned()]; + let node_args: Vec = vec!["-e".to_owned(), node_script, "--".to_owned(), run_command.to_owned()]; let output = cmd("node", node_args) .dir(client_project) diff --git a/sdks/rust/src/db_connection.rs b/sdks/rust/src/db_connection.rs index a1f311c7032..a9b0ef97ed9 100644 --- a/sdks/rust/src/db_connection.rs +++ b/sdks/rust/src/db_connection.rs @@ -39,10 +39,7 @@ use http::Uri; use spacetimedb_client_api_messages::websocket::{self as ws, common::QuerySetId}; use spacetimedb_lib::{bsatn, ser::Serialize, ConnectionId, Identity, Timestamp}; use spacetimedb_sats::Deserialize; -use std::{ - collections::HashMap, - sync::{atomic::AtomicU32, Arc, Mutex as StdMutex, OnceLock}, -}; +use std::sync::{atomic::AtomicU32, Arc, Mutex as StdMutex, OnceLock}; #[cfg(not(feature = "web"))] use tokio::{ runtime::{self, Runtime}, @@ -944,7 +941,7 @@ but you must call one of them, or else the connection will never progress. let connection_id_override = get_connection_id_override(); let ws_connection = WsConnection::connect( self.uri.clone().unwrap(), - self.module_name.as_ref().unwrap(), + self.database_name.as_ref().unwrap(), self.token.as_deref(), connection_id_override, self.params, @@ -1230,7 +1227,7 @@ fn spawn_parse_loop( #[cfg(feature = "web")] fn spawn_parse_loop( - raw_message_recv: mpsc::UnboundedReceiver>, + raw_message_recv: mpsc::UnboundedReceiver, ) -> mpsc::UnboundedReceiver> { let (parsed_message_send, parsed_message_recv) = mpsc::unbounded(); wasm_bindgen_futures::spawn_local(parse_loop(raw_message_recv, parsed_message_send)); diff --git a/sdks/rust/src/websocket.rs b/sdks/rust/src/websocket.rs index fd20caa7269..f48d148ce9b 100644 --- a/sdks/rust/src/websocket.rs +++ b/sdks/rust/src/websocket.rs @@ -8,6 +8,7 @@ use std::sync::Arc; #[cfg(not(feature = "web"))] use std::time::Duration; +#[cfg(not(feature = "web"))] use bytes::Bytes; #[cfg(not(feature = "web"))] use futures::TryStreamExt; @@ -358,7 +359,7 @@ impl WsConnection { }; let uri = make_uri(host, db_name, connection_id, params, token.as_deref())?; - let sock = tokio_tungstenite_wasm::connect_with_protocols(&uri.to_string(), &[BIN_PROTOCOL]) + let sock = tokio_tungstenite_wasm::connect_with_protocols(&uri.to_string(), &[ws::v2::BIN_PROTOCOL]) .await .map_err(|source| WsError::Tungstenite { uri, @@ -537,8 +538,8 @@ impl WsConnection { pub(crate) fn spawn_message_loop( self, ) -> ( - mpsc::UnboundedReceiver>, - mpsc::UnboundedSender>, + mpsc::UnboundedReceiver, + mpsc::UnboundedSender, ) { let websocket_received = CLIENT_METRICS.websocket_received.with_label_values(&self.db_name); let websocket_received_msg_size = CLIENT_METRICS @@ -549,8 +550,8 @@ impl WsConnection { websocket_received_msg_size.observe(msg_size as f64); }; - let (outgoing_tx, outgoing_rx) = mpsc::unbounded::>(); - let (incoming_tx, incoming_rx) = mpsc::unbounded::>(); + let (outgoing_tx, outgoing_rx) = mpsc::unbounded::(); + let (incoming_tx, incoming_rx) = mpsc::unbounded::(); let (mut ws_writer, ws_reader) = self.sock.split(); diff --git a/sdks/rust/tests/connect_disconnect_client/src/module_bindings/mod.rs b/sdks/rust/tests/connect_disconnect_client/src/module_bindings/mod.rs index fcd85e7f5d0..1cc593c42fd 100644 --- a/sdks/rust/tests/connect_disconnect_client/src/module_bindings/mod.rs +++ b/sdks/rust/tests/connect_disconnect_client/src/module_bindings/mod.rs @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 2.0.0 (commit 85095cfa85e3addc29ce58bfe670b6003271b288). +// This was generated using spacetimedb cli version 2.0.4 (commit ff89def28fe867afc5281ec7edf5ea018b283b4a). #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; @@ -200,10 +200,14 @@ impl __sdk::InModule for RemoteTables { /// You must explicitly advance the connection by calling any one of: /// /// - [`DbConnection::frame_tick`]. -/// - [`DbConnection::run_threaded`]. +#[cfg_attr(not(target_arch = "wasm32"), doc = "- [`DbConnection::run_threaded`].")] +#[cfg_attr(target_arch = "wasm32", doc = "- [`DbConnection::run_background_task`].")] /// - [`DbConnection::run_async`]. /// - [`DbConnection::advance_one_message`]. -/// - [`DbConnection::advance_one_message_blocking`]. +#[cfg_attr( + not(target_arch = "wasm32"), + doc = "- [`DbConnection::advance_one_message_blocking`]." +)] /// - [`DbConnection::advance_one_message_async`]. /// /// Which of these methods you should call depends on the specific needs of your application, @@ -301,6 +305,7 @@ impl DbConnection { /// This is a low-level primitive exposed for power users who need significant control over scheduling. /// Most applications should call [`Self::run_threaded`] to spawn a thread /// which advances the connection automatically. + #[cfg(not(target_arch = "wasm32"))] pub fn advance_one_message_blocking(&self) -> __sdk::Result<()> { self.imp.advance_one_message_blocking() } @@ -326,10 +331,17 @@ impl DbConnection { } /// Spawn a thread which processes WebSocket messages as they are received. + #[cfg(not(target_arch = "wasm32"))] pub fn run_threaded(&self) -> std::thread::JoinHandle<()> { self.imp.run_threaded() } + /// Spawn a background task which processes WebSocket messages as they are received. + #[cfg(target_arch = "wasm32")] + pub fn run_background_task(&self) { + self.imp.run_background_task() + } + /// Run an `async` loop which processes WebSocket messages when polled. pub async fn run_async(&self) -> __sdk::Result<()> { self.imp.run_async().await diff --git a/sdks/rust/tests/event-table-client/src/module_bindings/mod.rs b/sdks/rust/tests/event-table-client/src/module_bindings/mod.rs index 76f5f7db170..18ec9e0c225 100644 --- a/sdks/rust/tests/event-table-client/src/module_bindings/mod.rs +++ b/sdks/rust/tests/event-table-client/src/module_bindings/mod.rs @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 2.0.0 (commit 85095cfa85e3addc29ce58bfe670b6003271b288). +// This was generated using spacetimedb cli version 2.0.4 (commit ff89def28fe867afc5281ec7edf5ea018b283b4a). #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; @@ -193,10 +193,14 @@ impl __sdk::InModule for RemoteTables { /// You must explicitly advance the connection by calling any one of: /// /// - [`DbConnection::frame_tick`]. -/// - [`DbConnection::run_threaded`]. +#[cfg_attr(not(target_arch = "wasm32"), doc = "- [`DbConnection::run_threaded`].")] +#[cfg_attr(target_arch = "wasm32", doc = "- [`DbConnection::run_background_task`].")] /// - [`DbConnection::run_async`]. /// - [`DbConnection::advance_one_message`]. -/// - [`DbConnection::advance_one_message_blocking`]. +#[cfg_attr( + not(target_arch = "wasm32"), + doc = "- [`DbConnection::advance_one_message_blocking`]." +)] /// - [`DbConnection::advance_one_message_async`]. /// /// Which of these methods you should call depends on the specific needs of your application, @@ -294,6 +298,7 @@ impl DbConnection { /// This is a low-level primitive exposed for power users who need significant control over scheduling. /// Most applications should call [`Self::run_threaded`] to spawn a thread /// which advances the connection automatically. + #[cfg(not(target_arch = "wasm32"))] pub fn advance_one_message_blocking(&self) -> __sdk::Result<()> { self.imp.advance_one_message_blocking() } @@ -319,10 +324,17 @@ impl DbConnection { } /// Spawn a thread which processes WebSocket messages as they are received. + #[cfg(not(target_arch = "wasm32"))] pub fn run_threaded(&self) -> std::thread::JoinHandle<()> { self.imp.run_threaded() } + /// Spawn a background task which processes WebSocket messages as they are received. + #[cfg(target_arch = "wasm32")] + pub fn run_background_task(&self) { + self.imp.run_background_task() + } + /// Run an `async` loop which processes WebSocket messages when polled. pub async fn run_async(&self) -> __sdk::Result<()> { self.imp.run_async().await diff --git a/sdks/rust/tests/procedure-client/src/module_bindings/mod.rs b/sdks/rust/tests/procedure-client/src/module_bindings/mod.rs index e4e34268510..c8b67939c65 100644 --- a/sdks/rust/tests/procedure-client/src/module_bindings/mod.rs +++ b/sdks/rust/tests/procedure-client/src/module_bindings/mod.rs @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 2.0.0 (commit 85095cfa85e3addc29ce58bfe670b6003271b288). +// This was generated using spacetimedb cli version 2.0.4 (commit ff89def28fe867afc5281ec7edf5ea018b283b4a). #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; @@ -260,10 +260,14 @@ impl __sdk::InModule for RemoteTables { /// You must explicitly advance the connection by calling any one of: /// /// - [`DbConnection::frame_tick`]. -/// - [`DbConnection::run_threaded`]. +#[cfg_attr(not(target_arch = "wasm32"), doc = "- [`DbConnection::run_threaded`].")] +#[cfg_attr(target_arch = "wasm32", doc = "- [`DbConnection::run_background_task`].")] /// - [`DbConnection::run_async`]. /// - [`DbConnection::advance_one_message`]. -/// - [`DbConnection::advance_one_message_blocking`]. +#[cfg_attr( + not(target_arch = "wasm32"), + doc = "- [`DbConnection::advance_one_message_blocking`]." +)] /// - [`DbConnection::advance_one_message_async`]. /// /// Which of these methods you should call depends on the specific needs of your application, @@ -361,6 +365,7 @@ impl DbConnection { /// This is a low-level primitive exposed for power users who need significant control over scheduling. /// Most applications should call [`Self::run_threaded`] to spawn a thread /// which advances the connection automatically. + #[cfg(not(target_arch = "wasm32"))] pub fn advance_one_message_blocking(&self) -> __sdk::Result<()> { self.imp.advance_one_message_blocking() } @@ -386,10 +391,17 @@ impl DbConnection { } /// Spawn a thread which processes WebSocket messages as they are received. + #[cfg(not(target_arch = "wasm32"))] pub fn run_threaded(&self) -> std::thread::JoinHandle<()> { self.imp.run_threaded() } + /// Spawn a background task which processes WebSocket messages as they are received. + #[cfg(target_arch = "wasm32")] + pub fn run_background_task(&self) { + self.imp.run_background_task() + } + /// Run an `async` loop which processes WebSocket messages when polled. pub async fn run_async(&self) -> __sdk::Result<()> { self.imp.run_async().await diff --git a/sdks/rust/tests/test-client/Cargo.toml b/sdks/rust/tests/test-client/Cargo.toml index 7ce5bd3160a..c3097c28a10 100644 --- a/sdks/rust/tests/test-client/Cargo.toml +++ b/sdks/rust/tests/test-client/Cargo.toml @@ -25,6 +25,9 @@ web = [ "spacetimedb-sdk/web", "dep:wasm-bindgen", "dep:wasm-bindgen-futures", + "dep:futures", + "dep:test-counter", + "dep:rand", ] [[bin]] @@ -40,6 +43,7 @@ test-counter = { path = "../test-counter", optional = true } tokio = { workspace = true, optional = true } env_logger = { workspace = true, optional = true } rand = { workspace = true, optional = true } +futures = { workspace = true, optional = true } wasm-bindgen = { version = "0.2.100", optional = true } wasm-bindgen-futures = { version = "0.4.45", optional = true } diff --git a/sdks/rust/tests/test-client/src/lib.rs b/sdks/rust/tests/test-client/src/lib.rs index 9dd50d80e2b..f9844c7f28f 100644 --- a/sdks/rust/tests/test-client/src/lib.rs +++ b/sdks/rust/tests/test-client/src/lib.rs @@ -3,6 +3,8 @@ #[path = "main.rs"] mod cli; +pub(crate) use cli::module_bindings; + #[cfg(all(target_arch = "wasm32", feature = "web"))] use wasm_bindgen::prelude::wasm_bindgen; diff --git a/sdks/rust/tests/test-client/src/main.rs b/sdks/rust/tests/test-client/src/main.rs index 78178187911..c4e59f160ca 100644 --- a/sdks/rust/tests/test-client/src/main.rs +++ b/sdks/rust/tests/test-client/src/main.rs @@ -1,6 +1,6 @@ #[allow(clippy::too_many_arguments)] #[allow(clippy::large_enum_variant)] -mod module_bindings; +pub(crate) mod module_bindings; use core::fmt::Display; use core::sync::atomic::{AtomicUsize, Ordering}; @@ -9,11 +9,13 @@ use std::sync::{Arc, Barrier, Mutex}; use module_bindings::*; use rand::RngCore; +#[cfg(not(target_arch = "wasm32"))] +use spacetimedb_sdk::credentials; use spacetimedb_sdk::error::InternalError; use spacetimedb_sdk::TableWithPrimaryKey; use spacetimedb_sdk::{ - credentials, i256, u256, Compression, ConnectionId, DbConnectionBuilder, DbContext, Event, Identity, ReducerEvent, - Status, SubscriptionHandle, Table, TimeDuration, Timestamp, Uuid, + i256, u256, Compression, ConnectionId, DbConnectionBuilder, DbContext, Event, Identity, ReducerEvent, Status, + SubscriptionHandle, Table, TimeDuration, Timestamp, Uuid, }; use test_counter::TestCounter; @@ -408,8 +410,11 @@ fn connect_with_then( connected_result(Ok(())); }) .on_connect_error(|_ctx, error| panic!("Connect errored: {error:?}")); - let conn = with_builder(builder).build().unwrap(); + let conn = build_connection(with_builder(builder)); + #[cfg(not(target_arch = "wasm32"))] conn.run_threaded(); + #[cfg(target_arch = "wasm32")] + conn.run_background_task(); conn } @@ -424,6 +429,16 @@ fn connect(test_counter: &std::sync::Arc) -> DbConnection { connect_then(test_counter, |_| {}) } +#[cfg(not(target_arch = "wasm32"))] +fn build_connection(builder: DbConnectionBuilder) -> DbConnection { + builder.build().unwrap() +} + +#[cfg(target_arch = "wasm32")] +fn build_connection(builder: DbConnectionBuilder) -> DbConnection { + futures::executor::block_on(builder.build()).unwrap() +} + fn subscribe_all_then(ctx: &impl RemoteDbContext, callback: impl FnOnce(&SubscriptionEventContext) + Send + 'static) { subscribe_these_then(ctx, SUBSCRIBE_ALL, callback) } @@ -1716,12 +1731,14 @@ fn exec_insert_primitives_as_strings() { // test_counter.wait_for_all(); // } +#[cfg(not(target_arch = "wasm32"))] fn creds_store() -> credentials::File { credentials::File::new("rust-sdk-test") } /// Part of the `reauth` test, this connects to Spacetime to get new credentials, /// and saves them to a file. +#[cfg(not(target_arch = "wasm32"))] fn exec_reauth_part_1() { let test_counter = TestCounter::new(); @@ -1747,6 +1764,7 @@ fn exec_reauth_part_1() { /// and passes them to `connect`. /// /// Must run after `exec_reauth_part_1`. +#[cfg(not(target_arch = "wasm32"))] fn exec_reauth_part_2() { let test_counter = TestCounter::new(); @@ -1778,6 +1796,17 @@ fn exec_reauth_part_2() { test_counter.wait_for_all(); } +#[cfg(target_arch = "wasm32")] +fn exec_reauth_part_1() { + // Native-only: requires file-backed credentials via `credentials::File`, + // which is unavailable in wasm/web. +} + +#[cfg(target_arch = "wasm32")] +fn exec_reauth_part_2() { + // Native-only: requires persisted credentials from `exec_reauth_part_1`. +} + // Ensure a new connection gets a different connection id. fn exec_reconnect_different_connection_id() { let initial_test_counter = TestCounter::new(); @@ -1786,21 +1815,24 @@ fn exec_reconnect_different_connection_id() { let disconnect_test_counter = TestCounter::new(); let disconnect_result = disconnect_test_counter.add_test("disconnect"); - let initial_connection = DbConnection::builder() - .with_database_name(db_name_or_panic()) - .with_uri(LOCALHOST) - .on_connect_error(|_ctx, error| panic!("on_connect_error: {error:?}")) - .on_connect(move |_, _, _| { - initial_connect_result(Ok(())); - }) - .on_disconnect(|_, error| match error { - None => disconnect_result(Ok(())), - Some(err) => disconnect_result(Err(anyhow::anyhow!("{err:?}"))), - }) - .build() - .unwrap(); + let initial_connection = build_connection( + DbConnection::builder() + .with_database_name(db_name_or_panic()) + .with_uri(LOCALHOST) + .on_connect_error(|_ctx, error| panic!("on_connect_error: {error:?}")) + .on_connect(move |_, _, _| { + initial_connect_result(Ok(())); + }) + .on_disconnect(|_, error| match error { + None => disconnect_result(Ok(())), + Some(err) => disconnect_result(Err(anyhow::anyhow!("{err:?}"))), + }), + ); + #[cfg(not(target_arch = "wasm32"))] initial_connection.run_threaded(); + #[cfg(target_arch = "wasm32")] + initial_connection.run_background_task(); initial_test_counter.wait_for_all(); @@ -1814,22 +1846,25 @@ fn exec_reconnect_different_connection_id() { let reconnect_result = reconnect_test_counter.add_test("reconnect"); let addr_after_reconnect_result = reconnect_test_counter.add_test("addr_after_reconnect"); - let re_connection = DbConnection::builder() - .with_database_name(db_name_or_panic()) - .with_uri(LOCALHOST) - .on_connect_error(|_ctx, error| panic!("on_connect_error: {error:?}")) - .on_connect(move |ctx, _, _| { - reconnect_result(Ok(())); - let run_checks = || { - // A new connection should have a different connection id. - anyhow::ensure!(ctx.connection_id() != my_connection_id); - Ok(()) - }; - addr_after_reconnect_result(run_checks()); - }) - .build() - .unwrap(); + let re_connection = build_connection( + DbConnection::builder() + .with_database_name(db_name_or_panic()) + .with_uri(LOCALHOST) + .on_connect_error(|_ctx, error| panic!("on_connect_error: {error:?}")) + .on_connect(move |ctx, _, _| { + reconnect_result(Ok(())); + let run_checks = || { + // A new connection should have a different connection id. + anyhow::ensure!(ctx.connection_id() != my_connection_id); + Ok(()) + }; + addr_after_reconnect_result(run_checks()); + }), + ); + #[cfg(not(target_arch = "wasm32"))] re_connection.run_threaded(); + #[cfg(target_arch = "wasm32")] + re_connection.run_background_task(); reconnect_test_counter.wait_for_all(); } diff --git a/sdks/rust/tests/test-client/src/module_bindings/mod.rs b/sdks/rust/tests/test-client/src/module_bindings/mod.rs index de9afc6155f..524f7f85d08 100644 --- a/sdks/rust/tests/test-client/src/module_bindings/mod.rs +++ b/sdks/rust/tests/test-client/src/module_bindings/mod.rs @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 2.0.0 (commit e528393902d8cc982769e3b1a0f250d7d53edfa1). +// This was generated using spacetimedb cli version 2.0.4 (commit ff89def28fe867afc5281ec7edf5ea018b283b4a). #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; @@ -4303,10 +4303,14 @@ impl __sdk::InModule for RemoteTables { /// You must explicitly advance the connection by calling any one of: /// /// - [`DbConnection::frame_tick`]. -/// - [`DbConnection::run_threaded`]. +#[cfg_attr(not(target_arch = "wasm32"), doc = "- [`DbConnection::run_threaded`].")] +#[cfg_attr(target_arch = "wasm32", doc = "- [`DbConnection::run_background_task`].")] /// - [`DbConnection::run_async`]. /// - [`DbConnection::advance_one_message`]. -/// - [`DbConnection::advance_one_message_blocking`]. +#[cfg_attr( + not(target_arch = "wasm32"), + doc = "- [`DbConnection::advance_one_message_blocking`]." +)] /// - [`DbConnection::advance_one_message_async`]. /// /// Which of these methods you should call depends on the specific needs of your application, @@ -4404,6 +4408,7 @@ impl DbConnection { /// This is a low-level primitive exposed for power users who need significant control over scheduling. /// Most applications should call [`Self::run_threaded`] to spawn a thread /// which advances the connection automatically. + #[cfg(not(target_arch = "wasm32"))] pub fn advance_one_message_blocking(&self) -> __sdk::Result<()> { self.imp.advance_one_message_blocking() } @@ -4429,10 +4434,17 @@ impl DbConnection { } /// Spawn a thread which processes WebSocket messages as they are received. + #[cfg(not(target_arch = "wasm32"))] pub fn run_threaded(&self) -> std::thread::JoinHandle<()> { self.imp.run_threaded() } + /// Spawn a background task which processes WebSocket messages as they are received. + #[cfg(target_arch = "wasm32")] + pub fn run_background_task(&self) { + self.imp.run_background_task() + } + /// Run an `async` loop which processes WebSocket messages when polled. pub async fn run_async(&self) -> __sdk::Result<()> { self.imp.run_async().await diff --git a/sdks/rust/tests/view-client/src/module_bindings/mod.rs b/sdks/rust/tests/view-client/src/module_bindings/mod.rs index 525e09f98b9..e55125ee7cf 100644 --- a/sdks/rust/tests/view-client/src/module_bindings/mod.rs +++ b/sdks/rust/tests/view-client/src/module_bindings/mod.rs @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 2.0.0 (commit 85095cfa85e3addc29ce58bfe670b6003271b288). +// This was generated using spacetimedb cli version 2.0.4 (commit ff89def28fe867afc5281ec7edf5ea018b283b4a). #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; @@ -293,10 +293,14 @@ impl __sdk::InModule for RemoteTables { /// You must explicitly advance the connection by calling any one of: /// /// - [`DbConnection::frame_tick`]. -/// - [`DbConnection::run_threaded`]. +#[cfg_attr(not(target_arch = "wasm32"), doc = "- [`DbConnection::run_threaded`].")] +#[cfg_attr(target_arch = "wasm32", doc = "- [`DbConnection::run_background_task`].")] /// - [`DbConnection::run_async`]. /// - [`DbConnection::advance_one_message`]. -/// - [`DbConnection::advance_one_message_blocking`]. +#[cfg_attr( + not(target_arch = "wasm32"), + doc = "- [`DbConnection::advance_one_message_blocking`]." +)] /// - [`DbConnection::advance_one_message_async`]. /// /// Which of these methods you should call depends on the specific needs of your application, @@ -394,6 +398,7 @@ impl DbConnection { /// This is a low-level primitive exposed for power users who need significant control over scheduling. /// Most applications should call [`Self::run_threaded`] to spawn a thread /// which advances the connection automatically. + #[cfg(not(target_arch = "wasm32"))] pub fn advance_one_message_blocking(&self) -> __sdk::Result<()> { self.imp.advance_one_message_blocking() } @@ -419,10 +424,17 @@ impl DbConnection { } /// Spawn a thread which processes WebSocket messages as they are received. + #[cfg(not(target_arch = "wasm32"))] pub fn run_threaded(&self) -> std::thread::JoinHandle<()> { self.imp.run_threaded() } + /// Spawn a background task which processes WebSocket messages as they are received. + #[cfg(target_arch = "wasm32")] + pub fn run_background_task(&self) { + self.imp.run_background_task() + } + /// Run an `async` loop which processes WebSocket messages when polled. pub async fn run_async(&self) -> __sdk::Result<()> { self.imp.run_async().await diff --git a/sdks/rust/tests/view-pk-client/src/module_bindings/mod.rs b/sdks/rust/tests/view-pk-client/src/module_bindings/mod.rs index 3057c6eda03..e48ae03738e 100644 --- a/sdks/rust/tests/view-pk-client/src/module_bindings/mod.rs +++ b/sdks/rust/tests/view-pk-client/src/module_bindings/mod.rs @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 2.0.3 (commit 995798d29d314301cb475e2cd499f32a1691ea90). +// This was generated using spacetimedb cli version 2.0.4 (commit ff89def28fe867afc5281ec7edf5ea018b283b4a). #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; @@ -317,10 +317,14 @@ impl __sdk::InModule for RemoteTables { /// You must explicitly advance the connection by calling any one of: /// /// - [`DbConnection::frame_tick`]. -/// - [`DbConnection::run_threaded`]. +#[cfg_attr(not(target_arch = "wasm32"), doc = "- [`DbConnection::run_threaded`].")] +#[cfg_attr(target_arch = "wasm32", doc = "- [`DbConnection::run_background_task`].")] /// - [`DbConnection::run_async`]. /// - [`DbConnection::advance_one_message`]. -/// - [`DbConnection::advance_one_message_blocking`]. +#[cfg_attr( + not(target_arch = "wasm32"), + doc = "- [`DbConnection::advance_one_message_blocking`]." +)] /// - [`DbConnection::advance_one_message_async`]. /// /// Which of these methods you should call depends on the specific needs of your application, @@ -418,6 +422,7 @@ impl DbConnection { /// This is a low-level primitive exposed for power users who need significant control over scheduling. /// Most applications should call [`Self::run_threaded`] to spawn a thread /// which advances the connection automatically. + #[cfg(not(target_arch = "wasm32"))] pub fn advance_one_message_blocking(&self) -> __sdk::Result<()> { self.imp.advance_one_message_blocking() } @@ -443,10 +448,17 @@ impl DbConnection { } /// Spawn a thread which processes WebSocket messages as they are received. + #[cfg(not(target_arch = "wasm32"))] pub fn run_threaded(&self) -> std::thread::JoinHandle<()> { self.imp.run_threaded() } + /// Spawn a background task which processes WebSocket messages as they are received. + #[cfg(target_arch = "wasm32")] + pub fn run_background_task(&self) { + self.imp.run_background_task() + } + /// Run an `async` loop which processes WebSocket messages when polled. pub async fn run_async(&self) -> __sdk::Result<()> { self.imp.run_async().await From 2ef4988c2b3de0106f114a594d17e6da684eb5a4 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Wed, 11 Mar 2026 14:08:16 -0700 Subject: [PATCH 28/32] [bfops/wasm-test]: WIP --- Cargo.lock | 15 ++++++++ crates/testing/src/sdk.rs | 8 +++-- .../connect_disconnect_client/Cargo.toml | 29 ++++++++++++++- .../connect_disconnect_client/src/lib.rs | 13 +++++++ .../connect_disconnect_client/src/main.rs | 36 ++++++++++++++----- sdks/rust/tests/event-table-client/Cargo.toml | 34 ++++++++++++++++-- sdks/rust/tests/event-table-client/src/lib.rs | 13 +++++++ .../rust/tests/event-table-client/src/main.rs | 28 ++++++++++++--- sdks/rust/tests/procedure-client/Cargo.toml | 34 ++++++++++++++++-- sdks/rust/tests/procedure-client/src/lib.rs | 13 +++++++ sdks/rust/tests/procedure-client/src/main.rs | 23 ++++++++++-- .../src/module_bindings/mod.rs | 6 +++- .../src/module_bindings/pk_uuid_table.rs | 17 +++++++++ .../src/module_bindings/pk_uuid_type.rs | 12 ++++--- .../src/module_bindings/schedule_proc_type.rs | 13 +++++++ sdks/rust/tests/test.rs | 3 +- sdks/rust/tests/view-client/Cargo.toml | 34 ++++++++++++++++-- sdks/rust/tests/view-client/src/lib.rs | 13 +++++++ sdks/rust/tests/view-client/src/main.rs | 23 ++++++++++-- sdks/rust/tests/view-pk-client/Cargo.toml | 34 ++++++++++++++++-- sdks/rust/tests/view-pk-client/src/lib.rs | 13 +++++++ sdks/rust/tests/view-pk-client/src/main.rs | 28 ++++++++++++--- 22 files changed, 403 insertions(+), 39 deletions(-) create mode 100644 sdks/rust/tests/connect_disconnect_client/src/lib.rs create mode 100644 sdks/rust/tests/event-table-client/src/lib.rs create mode 100644 sdks/rust/tests/procedure-client/src/lib.rs create mode 100644 sdks/rust/tests/procedure-client/src/module_bindings/schedule_proc_type.rs create mode 100644 sdks/rust/tests/view-client/src/lib.rs create mode 100644 sdks/rust/tests/view-pk-client/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index c2828a2747c..d46298abcce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1094,8 +1094,11 @@ name = "connect_disconnect_client" version = "2.0.4" dependencies = [ "anyhow", + "futures", "spacetimedb-sdk", "test-counter", + "wasm-bindgen", + "wasm-bindgen-futures", ] [[package]] @@ -2090,8 +2093,11 @@ version = "2.0.4" dependencies = [ "anyhow", "env_logger 0.10.2", + "futures", "spacetimedb-sdk", "test-counter", + "wasm-bindgen", + "wasm-bindgen-futures", ] [[package]] @@ -5673,10 +5679,13 @@ version = "2.0.4" dependencies = [ "anyhow", "env_logger 0.10.2", + "futures", "serde_json", "spacetimedb-lib 2.0.4", "spacetimedb-sdk", "test-counter", + "wasm-bindgen", + "wasm-bindgen-futures", ] [[package]] @@ -10219,9 +10228,12 @@ version = "2.0.4" dependencies = [ "anyhow", "env_logger 0.10.2", + "futures", "spacetimedb-lib 2.0.4", "spacetimedb-sdk", "test-counter", + "wasm-bindgen", + "wasm-bindgen-futures", ] [[package]] @@ -10230,8 +10242,11 @@ version = "2.0.4" dependencies = [ "anyhow", "env_logger 0.10.2", + "futures", "spacetimedb-sdk", "test-counter", + "wasm-bindgen", + "wasm-bindgen-futures", ] [[package]] diff --git a/crates/testing/src/sdk.rs b/crates/testing/src/sdk.rs index 67ce42eeb69..778c5dafa37 100644 --- a/crates/testing/src/sdk.rs +++ b/crates/testing/src/sdk.rs @@ -455,8 +455,12 @@ fn run_client(runner: &ClientRunner, run_command: &str, client_project: &str, db .expect("Error running wasm-bindgen"); status_ok_or_panic(output, "wasm-bindgen", "(wasm-bindgen)"); - // Crate name test-client becomes test_client for wasm-bindgen output. - let js_module = bindgen_out_dir.join("test_client.js"); + let js_module_name = wasm_path + .file_stem() + .expect("wasm_path should have a filename stem") + .to_str() + .expect("wasm_path stem should be valid utf-8"); + let js_module = bindgen_out_dir.join(format!("{js_module_name}.js")); let js_module = js_module .to_str() .expect("js_module path should be valid utf-8") diff --git a/sdks/rust/tests/connect_disconnect_client/Cargo.toml b/sdks/rust/tests/connect_disconnect_client/Cargo.toml index 126d8568ac5..3a39984c091 100644 --- a/sdks/rust/tests/connect_disconnect_client/Cargo.toml +++ b/sdks/rust/tests/connect_disconnect_client/Cargo.toml @@ -6,10 +6,37 @@ license-file = "LICENSE" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +default = ["native"] + +# Builds the existing CLI test client. +native = ["dep:test-counter"] + +# Builds the client for wasm32-unknown-unknown using the Rust SDK `web` backend. +web = [ + "spacetimedb-sdk/web", + "dep:wasm-bindgen", + "dep:wasm-bindgen-futures", + "dep:futures", + "dep:test-counter", +] + +[[bin]] +name = "connect_disconnect_client" +path = "src/main.rs" +required-features = ["native"] + [dependencies] spacetimedb-sdk = { path = "../.." } -test-counter = { path = "../test-counter" } +test-counter = { path = "../test-counter", optional = true } anyhow.workspace = true +futures = { workspace = true, optional = true } + +wasm-bindgen = { version = "0.2.100", optional = true } +wasm-bindgen-futures = { version = "0.4.45", optional = true } [lints] workspace = true diff --git a/sdks/rust/tests/connect_disconnect_client/src/lib.rs b/sdks/rust/tests/connect_disconnect_client/src/lib.rs new file mode 100644 index 00000000000..91f0bcf09ee --- /dev/null +++ b/sdks/rust/tests/connect_disconnect_client/src/lib.rs @@ -0,0 +1,13 @@ +#![allow(clippy::disallowed_macros)] + +#[path = "main.rs"] +mod cli; + +#[cfg(all(target_arch = "wasm32", feature = "web"))] +use wasm_bindgen::prelude::wasm_bindgen; + +#[cfg(all(target_arch = "wasm32", feature = "web"))] +#[wasm_bindgen] +pub async fn run(_test_name: String) { + cli::dispatch(); +} diff --git a/sdks/rust/tests/connect_disconnect_client/src/main.rs b/sdks/rust/tests/connect_disconnect_client/src/main.rs index 9b17f1bf9c1..a7d546980d6 100644 --- a/sdks/rust/tests/connect_disconnect_client/src/main.rs +++ b/sdks/rust/tests/connect_disconnect_client/src/main.rs @@ -1,8 +1,8 @@ -mod module_bindings; +pub(crate) mod module_bindings; use module_bindings::*; -use spacetimedb_sdk::{DbContext, Table}; +use spacetimedb_sdk::{DbConnectionBuilder, DbContext, Table}; use test_counter::TestCounter; @@ -12,7 +12,12 @@ fn db_name_or_panic() -> String { std::env::var("SPACETIME_SDK_TEST_DB_NAME").expect("Failed to read db name from env") } +#[cfg(not(target_arch = "wasm32"))] fn main() { + dispatch(); +} + +pub(crate) fn dispatch() { let disconnect_test_counter = TestCounter::new(); let disconnect_result = disconnect_test_counter.add_test("disconnect"); @@ -56,15 +61,18 @@ fn main() { Some(err) => disconnect_result(Err(anyhow::anyhow!("{err:?}"))), None => disconnect_result(Ok(())), } - }) - .build() - .unwrap(); + }); + let connection = build_connection(connection); + #[cfg(not(target_arch = "wasm32"))] let join_handle = connection.run_threaded(); + #[cfg(target_arch = "wasm32")] + connection.run_background_task(); connect_test_counter.wait_for_all(); connection.disconnect().unwrap(); + #[cfg(not(target_arch = "wasm32"))] join_handle.join().unwrap(); disconnect_test_counter.wait_for_all(); @@ -79,9 +87,8 @@ fn main() { reconnected_result(Ok(())); }) .with_database_name(db_name_or_panic()) - .with_uri(LOCALHOST) - .build() - .unwrap(); + .with_uri(LOCALHOST); + let new_connection = build_connection(new_connection); new_connection .subscription_builder() @@ -103,7 +110,20 @@ fn main() { .on_error(|_ctx, error| panic!("subscription on_error: {error:?}")) .subscribe("SELECT * FROM disconnected"); + #[cfg(not(target_arch = "wasm32"))] new_connection.run_threaded(); + #[cfg(target_arch = "wasm32")] + new_connection.run_background_task(); reconnect_test_counter.wait_for_all(); } + +#[cfg(not(target_arch = "wasm32"))] +fn build_connection(builder: DbConnectionBuilder) -> DbConnection { + builder.build().unwrap() +} + +#[cfg(target_arch = "wasm32")] +fn build_connection(builder: DbConnectionBuilder) -> DbConnection { + futures::executor::block_on(builder.build()).unwrap() +} diff --git a/sdks/rust/tests/event-table-client/Cargo.toml b/sdks/rust/tests/event-table-client/Cargo.toml index f6502644119..1e3f20c640f 100644 --- a/sdks/rust/tests/event-table-client/Cargo.toml +++ b/sdks/rust/tests/event-table-client/Cargo.toml @@ -3,11 +3,41 @@ name = "event-table-client" version.workspace = true edition.workspace = true +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +default = ["native"] + +# Builds the existing CLI test client. +native = [ + "dep:test-counter", + "dep:env_logger", +] + +# Builds the client for wasm32-unknown-unknown using the Rust SDK `web` backend. +web = [ + "spacetimedb-sdk/web", + "dep:wasm-bindgen", + "dep:wasm-bindgen-futures", + "dep:futures", + "dep:test-counter", +] + +[[bin]] +name = "event-table-client" +path = "src/main.rs" +required-features = ["native"] + [dependencies] spacetimedb-sdk = { path = "../.." } -test-counter = { path = "../test-counter" } +test-counter = { path = "../test-counter", optional = true } anyhow.workspace = true -env_logger.workspace = true +env_logger = { workspace = true, optional = true } +futures = { workspace = true, optional = true } + +wasm-bindgen = { version = "0.2.100", optional = true } +wasm-bindgen-futures = { version = "0.4.45", optional = true } [lints] workspace = true diff --git a/sdks/rust/tests/event-table-client/src/lib.rs b/sdks/rust/tests/event-table-client/src/lib.rs new file mode 100644 index 00000000000..9dd50d80e2b --- /dev/null +++ b/sdks/rust/tests/event-table-client/src/lib.rs @@ -0,0 +1,13 @@ +#![allow(clippy::disallowed_macros)] + +#[path = "main.rs"] +mod cli; + +#[cfg(all(target_arch = "wasm32", feature = "web"))] +use wasm_bindgen::prelude::wasm_bindgen; + +#[cfg(all(target_arch = "wasm32", feature = "web"))] +#[wasm_bindgen] +pub async fn run(test_name: String) { + cli::dispatch(&test_name); +} diff --git a/sdks/rust/tests/event-table-client/src/main.rs b/sdks/rust/tests/event-table-client/src/main.rs index 5edb1fdfe0a..700bb486ed9 100644 --- a/sdks/rust/tests/event-table-client/src/main.rs +++ b/sdks/rust/tests/event-table-client/src/main.rs @@ -1,10 +1,10 @@ #[allow(clippy::too_many_arguments)] #[allow(clippy::large_enum_variant)] -mod module_bindings; +pub(crate) mod module_bindings; use module_bindings::*; -use spacetimedb_sdk::{DbContext, Event, EventTable}; +use spacetimedb_sdk::{DbConnectionBuilder, DbContext, Event, EventTable}; use std::sync::atomic::{AtomicU32, Ordering}; use test_counter::TestCounter; @@ -17,6 +17,7 @@ fn db_name_or_panic() -> String { /// Register a panic hook which will exit the process whenever any thread panics. /// /// This allows us to fail tests by panicking in callbacks. +#[cfg(not(target_arch = "wasm32"))] fn exit_on_panic() { let default_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |panic_info| { @@ -41,6 +42,7 @@ macro_rules! assert_eq_or_bail { }}; } +#[cfg(not(target_arch = "wasm32"))] fn main() { env_logger::init(); exit_on_panic(); @@ -49,6 +51,10 @@ fn main() { .nth(1) .expect("Pass a test name as a command-line argument to the test client"); + dispatch(&test); +} + +pub(crate) fn dispatch(test: &str) { match &*test { "event-table" => exec_event_table(), "multiple-events" => exec_multiple_events(), @@ -58,6 +64,16 @@ fn main() { } } +#[cfg(not(target_arch = "wasm32"))] +fn build_connection(builder: DbConnectionBuilder) -> DbConnection { + builder.build().unwrap() +} + +#[cfg(target_arch = "wasm32")] +fn build_connection(builder: DbConnectionBuilder) -> DbConnection { + futures::executor::block_on(builder.build()).unwrap() +} + fn connect_then( test_counter: &std::sync::Arc, callback: impl FnOnce(&DbConnection) + Send + 'static, @@ -71,10 +87,12 @@ fn connect_then( callback(ctx); connected_result(Ok(())); }) - .on_connect_error(|_ctx, error| panic!("Connect errored: {error:?}")) - .build() - .unwrap(); + .on_connect_error(|_ctx, error| panic!("Connect errored: {error:?}")); + let conn = build_connection(conn); + #[cfg(not(target_arch = "wasm32"))] conn.run_threaded(); + #[cfg(target_arch = "wasm32")] + conn.run_background_task(); conn } diff --git a/sdks/rust/tests/procedure-client/Cargo.toml b/sdks/rust/tests/procedure-client/Cargo.toml index 665d4b0582d..78b28bd86db 100644 --- a/sdks/rust/tests/procedure-client/Cargo.toml +++ b/sdks/rust/tests/procedure-client/Cargo.toml @@ -6,13 +6,43 @@ license-file = "LICENSE" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +default = ["native"] + +# Builds the existing CLI test client. +native = [ + "dep:test-counter", + "dep:env_logger", +] + +# Builds the client for wasm32-unknown-unknown using the Rust SDK `web` backend. +web = [ + "spacetimedb-sdk/web", + "dep:wasm-bindgen", + "dep:wasm-bindgen-futures", + "dep:futures", + "dep:test-counter", +] + +[[bin]] +name = "procedure-client" +path = "src/main.rs" +required-features = ["native"] + [dependencies] spacetimedb-sdk = { path = "../.." } spacetimedb-lib.workspace = true -test-counter = { path = "../test-counter" } +test-counter = { path = "../test-counter", optional = true } anyhow.workspace = true -env_logger.workspace = true +env_logger = { workspace = true, optional = true } serde_json.workspace = true +futures = { workspace = true, optional = true } + +wasm-bindgen = { version = "0.2.100", optional = true } +wasm-bindgen-futures = { version = "0.4.45", optional = true } [lints] workspace = true diff --git a/sdks/rust/tests/procedure-client/src/lib.rs b/sdks/rust/tests/procedure-client/src/lib.rs new file mode 100644 index 00000000000..9dd50d80e2b --- /dev/null +++ b/sdks/rust/tests/procedure-client/src/lib.rs @@ -0,0 +1,13 @@ +#![allow(clippy::disallowed_macros)] + +#[path = "main.rs"] +mod cli; + +#[cfg(all(target_arch = "wasm32", feature = "web"))] +use wasm_bindgen::prelude::wasm_bindgen; + +#[cfg(all(target_arch = "wasm32", feature = "web"))] +#[wasm_bindgen] +pub async fn run(test_name: String) { + cli::dispatch(&test_name); +} diff --git a/sdks/rust/tests/procedure-client/src/main.rs b/sdks/rust/tests/procedure-client/src/main.rs index cdafbff4dc9..b99b6eef0b4 100644 --- a/sdks/rust/tests/procedure-client/src/main.rs +++ b/sdks/rust/tests/procedure-client/src/main.rs @@ -1,4 +1,4 @@ -mod module_bindings; +pub(crate) mod module_bindings; use core::time::Duration; @@ -13,6 +13,7 @@ const LOCALHOST: &str = "http://localhost:3000"; /// Register a panic hook which will exit the process whenever any thread panics. /// /// This allows us to fail tests by panicking in callbacks. +#[cfg(not(target_arch = "wasm32"))] fn exit_on_panic() { // The default panic hook is responsible for printing the panic message and backtrace to stderr. // Grab a handle on it, and invoke it in our custom hook before exiting. @@ -30,6 +31,7 @@ fn db_name_or_panic() -> String { std::env::var("SPACETIME_SDK_TEST_DB_NAME").expect("Failed to read db name from env") } +#[cfg(not(target_arch = "wasm32"))] fn main() { env_logger::init(); exit_on_panic(); @@ -38,6 +40,10 @@ fn main() { .nth(1) .expect("Pass a test name as a command-line argument to the test client"); + dispatch(&test); +} + +pub(crate) fn dispatch(test: &str) { match &*test { "procedure-return-values" => exec_procedure_return_values(), "procedure-observe-panic" => exec_procedure_panic(), @@ -51,6 +57,16 @@ fn main() { } } +#[cfg(not(target_arch = "wasm32"))] +fn build_connection(builder: DbConnectionBuilder) -> DbConnection { + builder.build().unwrap() +} + +#[cfg(target_arch = "wasm32")] +fn build_connection(builder: DbConnectionBuilder) -> DbConnection { + futures::executor::block_on(builder.build()).unwrap() +} + fn assert_table_empty(tbl: T) -> anyhow::Result<()> { let count = tbl.count(); if count != 0 { @@ -88,8 +104,11 @@ fn connect_with_then( connected_result(Ok(())); }) .on_connect_error(|_ctx, error| panic!("Connect errored: {error:?}")); - let conn = with_builder(builder).build().unwrap(); + let conn = build_connection(with_builder(builder)); + #[cfg(not(target_arch = "wasm32"))] conn.run_threaded(); + #[cfg(target_arch = "wasm32")] + conn.run_background_task(); conn } diff --git a/sdks/rust/tests/procedure-client/src/module_bindings/mod.rs b/sdks/rust/tests/procedure-client/src/module_bindings/mod.rs index c8b67939c65..87d4b919bcf 100644 --- a/sdks/rust/tests/procedure-client/src/module_bindings/mod.rs +++ b/sdks/rust/tests/procedure-client/src/module_bindings/mod.rs @@ -23,6 +23,7 @@ pub mod return_primitive_procedure; pub mod return_struct_procedure; pub mod return_struct_type; pub mod schedule_proc_reducer; +pub mod schedule_proc_type; pub mod scheduled_proc_procedure; pub mod scheduled_proc_table_table; pub mod scheduled_proc_table_type; @@ -46,6 +47,7 @@ pub use return_primitive_procedure::return_primitive; pub use return_struct_procedure::return_struct; pub use return_struct_type::ReturnStruct; pub use schedule_proc_reducer::schedule_proc; +pub use schedule_proc_type::ScheduleProc; pub use scheduled_proc_procedure::scheduled_proc; pub use scheduled_proc_table_table::*; pub use scheduled_proc_table_type::ScheduledProcTable; @@ -130,7 +132,9 @@ impl __sdk::DbUpdate for DbUpdate { let mut diff = AppliedDiff::default(); diff.my_table = cache.apply_diff_to_table::("my_table", &self.my_table); - diff.pk_uuid = cache.apply_diff_to_table::("pk_uuid", &self.pk_uuid); + diff.pk_uuid = cache + .apply_diff_to_table::("pk_uuid", &self.pk_uuid) + .with_updates_by_pk(|row| &row.u); diff.proc_inserts_into = cache.apply_diff_to_table::("proc_inserts_into", &self.proc_inserts_into); diff.scheduled_proc_table = cache diff --git a/sdks/rust/tests/procedure-client/src/module_bindings/pk_uuid_table.rs b/sdks/rust/tests/procedure-client/src/module_bindings/pk_uuid_table.rs index f177d36ead8..2e72f521768 100644 --- a/sdks/rust/tests/procedure-client/src/module_bindings/pk_uuid_table.rs +++ b/sdks/rust/tests/procedure-client/src/module_bindings/pk_uuid_table.rs @@ -78,6 +78,23 @@ impl<'ctx> __sdk::Table for PkUuidTableHandle<'ctx> { } } +pub struct PkUuidUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for PkUuidTableHandle<'ctx> { + type UpdateCallbackId = PkUuidUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> PkUuidUpdateCallbackId { + PkUuidUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: PkUuidUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + #[doc(hidden)] pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { let _table = client_cache.get_or_make_table::("pk_uuid"); diff --git a/sdks/rust/tests/procedure-client/src/module_bindings/pk_uuid_type.rs b/sdks/rust/tests/procedure-client/src/module_bindings/pk_uuid_type.rs index dcc8174a6b3..62acd5125be 100644 --- a/sdks/rust/tests/procedure-client/src/module_bindings/pk_uuid_type.rs +++ b/sdks/rust/tests/procedure-client/src/module_bindings/pk_uuid_type.rs @@ -8,7 +8,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; #[sats(crate = __lib)] pub struct PkUuid { pub u: __sdk::Uuid, - pub data: u8, + pub data: i32, } impl __sdk::InModule for PkUuid { @@ -20,7 +20,7 @@ impl __sdk::InModule for PkUuid { /// Provides typed access to columns for query building. pub struct PkUuidCols { pub u: __sdk::__query_builder::Col, - pub data: __sdk::__query_builder::Col, + pub data: __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for PkUuid { @@ -36,12 +36,16 @@ impl __sdk::__query_builder::HasCols for PkUuid { /// Indexed column accessor struct for the table `PkUuid`. /// /// Provides typed access to indexed columns for query building. -pub struct PkUuidIxCols {} +pub struct PkUuidIxCols { + pub u: __sdk::__query_builder::IxCol, +} impl __sdk::__query_builder::HasIxCols for PkUuid { type IxCols = PkUuidIxCols; fn ix_cols(table_name: &'static str) -> Self::IxCols { - PkUuidIxCols {} + PkUuidIxCols { + u: __sdk::__query_builder::IxCol::new(table_name, "u"), + } } } diff --git a/sdks/rust/tests/procedure-client/src/module_bindings/schedule_proc_type.rs b/sdks/rust/tests/procedure-client/src/module_bindings/schedule_proc_type.rs new file mode 100644 index 00000000000..46ca35e45a4 --- /dev/null +++ b/sdks/rust/tests/procedure-client/src/module_bindings/schedule_proc_type.rs @@ -0,0 +1,13 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ScheduleProc {} + +impl __sdk::InModule for ScheduleProc { + type Module = super::RemoteModule; +} diff --git a/sdks/rust/tests/test.rs b/sdks/rust/tests/test.rs index 7f1c067d12e..b3643d55217 100644 --- a/sdks/rust/tests/test.rs +++ b/sdks/rust/tests/test.rs @@ -18,7 +18,8 @@ fn configure_test_client_commands( .file_name() .and_then(|name| name.to_str()) .expect("client project path should end in a UTF-8 directory name"); - let wasm_path = format!("target/wasm32-unknown-unknown/debug/{package_name}.wasm"); + let artifact_name = package_name.replace('-', "_"); + let wasm_path = format!("target/wasm32-unknown-unknown/debug/{artifact_name}.wasm"); let bindgen_out_dir = format!("target/sdk-test-web-bindgen/{package_name}"); builder diff --git a/sdks/rust/tests/view-client/Cargo.toml b/sdks/rust/tests/view-client/Cargo.toml index 76c6bb58a8e..9e16db60b74 100644 --- a/sdks/rust/tests/view-client/Cargo.toml +++ b/sdks/rust/tests/view-client/Cargo.toml @@ -6,12 +6,42 @@ license-file = "LICENSE" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +default = ["native"] + +# Builds the existing CLI test client. +native = [ + "dep:test-counter", + "dep:env_logger", +] + +# Builds the client for wasm32-unknown-unknown using the Rust SDK `web` backend. +web = [ + "spacetimedb-sdk/web", + "dep:wasm-bindgen", + "dep:wasm-bindgen-futures", + "dep:futures", + "dep:test-counter", +] + +[[bin]] +name = "view-client" +path = "src/main.rs" +required-features = ["native"] + [dependencies] spacetimedb-sdk = { path = "../.." } spacetimedb-lib.workspace = true -test-counter = { path = "../test-counter" } +test-counter = { path = "../test-counter", optional = true } anyhow.workspace = true -env_logger.workspace = true +env_logger = { workspace = true, optional = true } +futures = { workspace = true, optional = true } + +wasm-bindgen = { version = "0.2.100", optional = true } +wasm-bindgen-futures = { version = "0.4.45", optional = true } [lints] workspace = true diff --git a/sdks/rust/tests/view-client/src/lib.rs b/sdks/rust/tests/view-client/src/lib.rs new file mode 100644 index 00000000000..9dd50d80e2b --- /dev/null +++ b/sdks/rust/tests/view-client/src/lib.rs @@ -0,0 +1,13 @@ +#![allow(clippy::disallowed_macros)] + +#[path = "main.rs"] +mod cli; + +#[cfg(all(target_arch = "wasm32", feature = "web"))] +use wasm_bindgen::prelude::wasm_bindgen; + +#[cfg(all(target_arch = "wasm32", feature = "web"))] +#[wasm_bindgen] +pub async fn run(test_name: String) { + cli::dispatch(&test_name); +} diff --git a/sdks/rust/tests/view-client/src/main.rs b/sdks/rust/tests/view-client/src/main.rs index 96312b4f40e..d105ed2d555 100644 --- a/sdks/rust/tests/view-client/src/main.rs +++ b/sdks/rust/tests/view-client/src/main.rs @@ -1,4 +1,4 @@ -mod module_bindings; +pub(crate) mod module_bindings; use module_bindings::*; use spacetimedb_lib::Identity; @@ -10,6 +10,7 @@ const LOCALHOST: &str = "http://localhost:3000"; /// Register a panic hook which will exit the process whenever any thread panics. /// /// This allows us to fail tests by panicking in callbacks. +#[cfg(not(target_arch = "wasm32"))] fn exit_on_panic() { // The default panic hook is responsible for printing the panic message and backtrace to stderr. // Grab a handle on it, and invoke it in our custom hook before exiting. @@ -27,6 +28,7 @@ fn db_name_or_panic() -> String { std::env::var("SPACETIME_SDK_TEST_DB_NAME").expect("Failed to read db name from env") } +#[cfg(not(target_arch = "wasm32"))] fn main() { env_logger::init(); exit_on_panic(); @@ -35,6 +37,10 @@ fn main() { .nth(1) .expect("Pass a test name as a command-line argument to the test client"); + dispatch(&test); +} + +pub(crate) fn dispatch(test: &str) { match &*test { "view-anonymous-subscribe" => exec_anonymous_subscribe(), "view-anonymous-subscribe-with-query-builder" => exec_anonymous_subscribe_with_query_builder(), @@ -47,6 +53,16 @@ fn main() { } } +#[cfg(not(target_arch = "wasm32"))] +fn build_connection(builder: DbConnectionBuilder) -> DbConnection { + builder.build().unwrap() +} + +#[cfg(target_arch = "wasm32")] +fn build_connection(builder: DbConnectionBuilder) -> DbConnection { + futures::executor::block_on(builder.build()).unwrap() +} + fn connect_with_then( test_counter: &std::sync::Arc, on_connect_suffix: &str, @@ -63,8 +79,11 @@ fn connect_with_then( connected_result(Ok(())); }) .on_connect_error(|_ctx, error| panic!("Connect errored: {error:?}")); - let conn = with_builder(builder).build().unwrap(); + let conn = build_connection(with_builder(builder)); + #[cfg(not(target_arch = "wasm32"))] conn.run_threaded(); + #[cfg(target_arch = "wasm32")] + conn.run_background_task(); conn } diff --git a/sdks/rust/tests/view-pk-client/Cargo.toml b/sdks/rust/tests/view-pk-client/Cargo.toml index f872b1e7f17..04f14147f5f 100644 --- a/sdks/rust/tests/view-pk-client/Cargo.toml +++ b/sdks/rust/tests/view-pk-client/Cargo.toml @@ -4,11 +4,41 @@ version.workspace = true edition.workspace = true license-file = "LICENSE" +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +default = ["native"] + +# Builds the existing CLI test client. +native = [ + "dep:test-counter", + "dep:env_logger", +] + +# Builds the client for wasm32-unknown-unknown using the Rust SDK `web` backend. +web = [ + "spacetimedb-sdk/web", + "dep:wasm-bindgen", + "dep:wasm-bindgen-futures", + "dep:futures", + "dep:test-counter", +] + +[[bin]] +name = "view-pk-client" +path = "src/main.rs" +required-features = ["native"] + [dependencies] spacetimedb-sdk = { path = "../.." } -test-counter = { path = "../test-counter" } +test-counter = { path = "../test-counter", optional = true } anyhow.workspace = true -env_logger.workspace = true +env_logger = { workspace = true, optional = true } +futures = { workspace = true, optional = true } + +wasm-bindgen = { version = "0.2.100", optional = true } +wasm-bindgen-futures = { version = "0.4.45", optional = true } [lints] workspace = true diff --git a/sdks/rust/tests/view-pk-client/src/lib.rs b/sdks/rust/tests/view-pk-client/src/lib.rs new file mode 100644 index 00000000000..9dd50d80e2b --- /dev/null +++ b/sdks/rust/tests/view-pk-client/src/lib.rs @@ -0,0 +1,13 @@ +#![allow(clippy::disallowed_macros)] + +#[path = "main.rs"] +mod cli; + +#[cfg(all(target_arch = "wasm32", feature = "web"))] +use wasm_bindgen::prelude::wasm_bindgen; + +#[cfg(all(target_arch = "wasm32", feature = "web"))] +#[wasm_bindgen] +pub async fn run(test_name: String) { + cli::dispatch(&test_name); +} diff --git a/sdks/rust/tests/view-pk-client/src/main.rs b/sdks/rust/tests/view-pk-client/src/main.rs index 38f73e5f91e..01e3b432dc3 100644 --- a/sdks/rust/tests/view-pk-client/src/main.rs +++ b/sdks/rust/tests/view-pk-client/src/main.rs @@ -1,14 +1,15 @@ -mod module_bindings; +pub(crate) mod module_bindings; use module_bindings::*; use spacetimedb_sdk::TableWithPrimaryKey; -use spacetimedb_sdk::{error::InternalError, DbContext}; +use spacetimedb_sdk::{error::InternalError, DbConnectionBuilder, DbContext}; use test_counter::TestCounter; const LOCALHOST: &str = "http://localhost:3000"; type ResultRecorder = Box)>; +#[cfg(not(target_arch = "wasm32"))] fn exit_on_panic() { let default_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |panic_info| { @@ -21,6 +22,16 @@ fn db_name_or_panic() -> String { std::env::var("SPACETIME_SDK_TEST_DB_NAME").expect("Failed to read db name from env") } +#[cfg(not(target_arch = "wasm32"))] +fn build_connection(builder: DbConnectionBuilder) -> DbConnection { + builder.build().unwrap() +} + +#[cfg(target_arch = "wasm32")] +fn build_connection(builder: DbConnectionBuilder) -> DbConnection { + futures::executor::block_on(builder.build()).unwrap() +} + fn put_result(result: &mut Option, res: Result<(), anyhow::Error>) { (result.take().unwrap())(res); } @@ -48,10 +59,12 @@ fn connect_then( callback(ctx); connected_result(Ok(())); }) - .on_connect_error(|_ctx, error| panic!("Connect errored: {error:?}")) - .build() - .unwrap(); + .on_connect_error(|_ctx, error| panic!("Connect errored: {error:?}")); + let conn = build_connection(conn); + #[cfg(not(target_arch = "wasm32"))] conn.run_threaded(); + #[cfg(target_arch = "wasm32")] + conn.run_background_task(); conn } @@ -279,6 +292,7 @@ fn exec_view_pk_semijoin_two_sender_views_query_builder() { test_counter.wait_for_all(); } +#[cfg(not(target_arch = "wasm32"))] fn main() { env_logger::init(); exit_on_panic(); @@ -287,6 +301,10 @@ fn main() { .nth(1) .expect("Pass a test name as a command-line argument to the test client"); + dispatch(&test); +} + +pub(crate) fn dispatch(test: &str) { match &*test { "view-pk-on-update" => exec_view_pk_on_update(), "view-pk-join-query-builder" => exec_view_pk_join_query_builder(), From d5edc95de942ba86b17577efcef1bd42d5b4e91e Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Wed, 11 Mar 2026 14:09:26 -0700 Subject: [PATCH 29/32] [bfops/wasm-test]: CI --- .github/workflows/ci.yml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f6775f978f..e3cf06e4c92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -230,7 +230,7 @@ jobs: # Install native and wasm toolchains required by SDK tests: # - `emcc` for C++ module compilation tests. - # - `wasm32-unknown-unknown` target + `wasm-bindgen` CLI for Rust web client tests. + # - `wasm32-unknown-unknown` target for Rust web client tests. - name: Install native and wasm test prerequisites run: | sudo apt-get update @@ -238,16 +238,20 @@ jobs: rustup target add wasm32-unknown-unknown + git clone https://github.com/emscripten-core/emsdk.git ~/emsdk + cd ~/emsdk + ./emsdk install 4.0.21 + ./emsdk activate 4.0.21 + + - name: Install wasm-bindgen CLI + run: | REQUIRED_WASM_BINDGEN_VERSION="0.2.100" INSTALLED_WASM_BINDGEN_VERSION="$(wasm-bindgen --version 2>/dev/null | awk '{print $2}' || true)" if [ "${INSTALLED_WASM_BINDGEN_VERSION}" != "${REQUIRED_WASM_BINDGEN_VERSION}" ]; then cargo install --locked --force wasm-bindgen-cli --version "${REQUIRED_WASM_BINDGEN_VERSION}" fi - git clone https://github.com/emscripten-core/emsdk.git ~/emsdk - cd ~/emsdk - ./emsdk install 4.0.21 - ./emsdk activate 4.0.21 + wasm-bindgen --version - name: Build typescript module sdk working-directory: crates/bindings-typescript From a6809f8c3c76608ff75ee0c72a225939550c590f Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Wed, 11 Mar 2026 14:19:17 -0700 Subject: [PATCH 30/32] [bfops/wasm-test]: gen --- .../procedure-client/src/module_bindings/mod.rs | 6 +----- .../src/module_bindings/pk_uuid_table.rs | 17 ----------------- .../src/module_bindings/pk_uuid_type.rs | 12 ++++-------- .../src/module_bindings/schedule_proc_type.rs | 13 ------------- 4 files changed, 5 insertions(+), 43 deletions(-) delete mode 100644 sdks/rust/tests/procedure-client/src/module_bindings/schedule_proc_type.rs diff --git a/sdks/rust/tests/procedure-client/src/module_bindings/mod.rs b/sdks/rust/tests/procedure-client/src/module_bindings/mod.rs index 87d4b919bcf..c8b67939c65 100644 --- a/sdks/rust/tests/procedure-client/src/module_bindings/mod.rs +++ b/sdks/rust/tests/procedure-client/src/module_bindings/mod.rs @@ -23,7 +23,6 @@ pub mod return_primitive_procedure; pub mod return_struct_procedure; pub mod return_struct_type; pub mod schedule_proc_reducer; -pub mod schedule_proc_type; pub mod scheduled_proc_procedure; pub mod scheduled_proc_table_table; pub mod scheduled_proc_table_type; @@ -47,7 +46,6 @@ pub use return_primitive_procedure::return_primitive; pub use return_struct_procedure::return_struct; pub use return_struct_type::ReturnStruct; pub use schedule_proc_reducer::schedule_proc; -pub use schedule_proc_type::ScheduleProc; pub use scheduled_proc_procedure::scheduled_proc; pub use scheduled_proc_table_table::*; pub use scheduled_proc_table_type::ScheduledProcTable; @@ -132,9 +130,7 @@ impl __sdk::DbUpdate for DbUpdate { let mut diff = AppliedDiff::default(); diff.my_table = cache.apply_diff_to_table::("my_table", &self.my_table); - diff.pk_uuid = cache - .apply_diff_to_table::("pk_uuid", &self.pk_uuid) - .with_updates_by_pk(|row| &row.u); + diff.pk_uuid = cache.apply_diff_to_table::("pk_uuid", &self.pk_uuid); diff.proc_inserts_into = cache.apply_diff_to_table::("proc_inserts_into", &self.proc_inserts_into); diff.scheduled_proc_table = cache diff --git a/sdks/rust/tests/procedure-client/src/module_bindings/pk_uuid_table.rs b/sdks/rust/tests/procedure-client/src/module_bindings/pk_uuid_table.rs index 2e72f521768..f177d36ead8 100644 --- a/sdks/rust/tests/procedure-client/src/module_bindings/pk_uuid_table.rs +++ b/sdks/rust/tests/procedure-client/src/module_bindings/pk_uuid_table.rs @@ -78,23 +78,6 @@ impl<'ctx> __sdk::Table for PkUuidTableHandle<'ctx> { } } -pub struct PkUuidUpdateCallbackId(__sdk::CallbackId); - -impl<'ctx> __sdk::TableWithPrimaryKey for PkUuidTableHandle<'ctx> { - type UpdateCallbackId = PkUuidUpdateCallbackId; - - fn on_update( - &self, - callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, - ) -> PkUuidUpdateCallbackId { - PkUuidUpdateCallbackId(self.imp.on_update(Box::new(callback))) - } - - fn remove_on_update(&self, callback: PkUuidUpdateCallbackId) { - self.imp.remove_on_update(callback.0) - } -} - #[doc(hidden)] pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { let _table = client_cache.get_or_make_table::("pk_uuid"); diff --git a/sdks/rust/tests/procedure-client/src/module_bindings/pk_uuid_type.rs b/sdks/rust/tests/procedure-client/src/module_bindings/pk_uuid_type.rs index 62acd5125be..dcc8174a6b3 100644 --- a/sdks/rust/tests/procedure-client/src/module_bindings/pk_uuid_type.rs +++ b/sdks/rust/tests/procedure-client/src/module_bindings/pk_uuid_type.rs @@ -8,7 +8,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; #[sats(crate = __lib)] pub struct PkUuid { pub u: __sdk::Uuid, - pub data: i32, + pub data: u8, } impl __sdk::InModule for PkUuid { @@ -20,7 +20,7 @@ impl __sdk::InModule for PkUuid { /// Provides typed access to columns for query building. pub struct PkUuidCols { pub u: __sdk::__query_builder::Col, - pub data: __sdk::__query_builder::Col, + pub data: __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for PkUuid { @@ -36,16 +36,12 @@ impl __sdk::__query_builder::HasCols for PkUuid { /// Indexed column accessor struct for the table `PkUuid`. /// /// Provides typed access to indexed columns for query building. -pub struct PkUuidIxCols { - pub u: __sdk::__query_builder::IxCol, -} +pub struct PkUuidIxCols {} impl __sdk::__query_builder::HasIxCols for PkUuid { type IxCols = PkUuidIxCols; fn ix_cols(table_name: &'static str) -> Self::IxCols { - PkUuidIxCols { - u: __sdk::__query_builder::IxCol::new(table_name, "u"), - } + PkUuidIxCols {} } } diff --git a/sdks/rust/tests/procedure-client/src/module_bindings/schedule_proc_type.rs b/sdks/rust/tests/procedure-client/src/module_bindings/schedule_proc_type.rs deleted file mode 100644 index 46ca35e45a4..00000000000 --- a/sdks/rust/tests/procedure-client/src/module_bindings/schedule_proc_type.rs +++ /dev/null @@ -1,13 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#![allow(unused, clippy::all)] -use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; - -#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] -#[sats(crate = __lib)] -pub struct ScheduleProc {} - -impl __sdk::InModule for ScheduleProc { - type Module = super::RemoteModule; -} From b0a69539661aad34da2b58f9b90208e3f06e2967 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Wed, 11 Mar 2026 14:27:11 -0700 Subject: [PATCH 31/32] [bfops/wasm-test]: tweaks --- .github/workflows/ci.yml | 16 +++++++++++++++- sdks/rust/tests/test.rs | 11 ++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3cf06e4c92..50bc26f9be8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -245,7 +245,21 @@ jobs: - name: Install wasm-bindgen CLI run: | - REQUIRED_WASM_BINDGEN_VERSION="0.2.100" + REQUIRED_WASM_BINDGEN_VERSION="$( + awk ' + $1 == "name" && $3 == "\"wasm-bindgen\"" { in_pkg = 1; next } + in_pkg && $1 == "version" { + gsub(/"/, "", $3); + print $3; + exit; + } + ' Cargo.lock + )" + if [ -z "${REQUIRED_WASM_BINDGEN_VERSION}" ]; then + echo "Failed to determine wasm-bindgen version from Cargo.lock" + exit 1 + fi + INSTALLED_WASM_BINDGEN_VERSION="$(wasm-bindgen --version 2>/dev/null | awk '{print $2}' || true)" if [ "${INSTALLED_WASM_BINDGEN_VERSION}" != "${REQUIRED_WASM_BINDGEN_VERSION}" ]; then cargo install --locked --force wasm-bindgen-cli --version "${REQUIRED_WASM_BINDGEN_VERSION}" diff --git a/sdks/rust/tests/test.rs b/sdks/rust/tests/test.rs index b3643d55217..1527a1b68ba 100644 --- a/sdks/rust/tests/test.rs +++ b/sdks/rust/tests/test.rs @@ -19,7 +19,16 @@ fn configure_test_client_commands( .and_then(|name| name.to_str()) .expect("client project path should end in a UTF-8 directory name"); let artifact_name = package_name.replace('-', "_"); - let wasm_path = format!("target/wasm32-unknown-unknown/debug/{artifact_name}.wasm"); + + // Cargo workspace members emit into the workspace target directory, not each crate's local `./target`. + // Use CARGO_TARGET_DIR when set (e.g. in CI), otherwise fall back to `/target`. + let target_dir = std::env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../target") + .to_string_lossy() + .into_owned() + }); + let wasm_path = format!("{target_dir}/wasm32-unknown-unknown/debug/{artifact_name}.wasm"); let bindgen_out_dir = format!("target/sdk-test-web-bindgen/{package_name}"); builder From 55a8042415535dc1e22d80585aa2bfbd42cd344c Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Thu, 12 Mar 2026 09:38:11 -0700 Subject: [PATCH 32/32] [bfops/wasm-test]: fixes --- Cargo.lock | 25 +++- crates/testing/src/sdk.rs | 14 ++- sdks/rust/Cargo.toml | 1 - sdks/rust/src/client_cache.rs | 42 ++++++- sdks/rust/tests/test-client/Cargo.toml | 4 + sdks/rust/tests/test-client/src/lib.rs | 6 +- sdks/rust/tests/test-client/src/main.rs | 146 +++++++++++++++++++++++- sdks/rust/tests/test.rs | 2 +- 8 files changed, 223 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d46298abcce..4a7dd1ac3c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1114,6 +1114,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + [[package]] name = "constant_time_eq" version = "0.3.1" @@ -2607,6 +2617,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "gloo-utils" version = "0.2.0" @@ -8464,7 +8486,6 @@ dependencies = [ name = "spacetimedb-sdk" version = "2.0.4" dependencies = [ - "anymap", "base64 0.21.7", "brotli", "bytes", @@ -9220,8 +9241,10 @@ name = "test-client" version = "2.0.4" dependencies = [ "anyhow", + "console_error_panic_hook", "env_logger 0.10.2", "futures", + "gloo-timers", "rand 0.9.2", "spacetimedb-sdk", "test-counter", diff --git a/crates/testing/src/sdk.rs b/crates/testing/src/sdk.rs index 778c5dafa37..84e8159f28b 100644 --- a/crates/testing/src/sdk.rs +++ b/crates/testing/src/sdk.rs @@ -3,7 +3,7 @@ use rand::seq::IteratorRandom; use spacetimedb::messages::control_db::HostType; use spacetimedb_data_structures::map::HashMap; use spacetimedb_paths::{RootDir, SpacetimePaths}; -use std::fs::create_dir_all; +use std::fs::{copy, create_dir_all}; use std::sync::{Mutex, OnceLock}; use std::thread::JoinHandle; use std::{path::Path, path::PathBuf}; @@ -120,6 +120,7 @@ enum ClientRunner { pub const TEST_MODULE_PROJECT_ENV_VAR: &str = "SPACETIME_SDK_TEST_MODULE_PROJECT"; pub const TEST_DB_NAME_ENV_VAR: &str = "SPACETIME_SDK_TEST_DB_NAME"; pub const TEST_CLIENT_PROJECT_ENV_VAR: &str = "SPACETIME_SDK_TEST_CLIENT_PROJECT"; +pub const TEST_RUN_SELECTOR_ENV_VAR: &str = "SPACETIME_SDK_TEST_RUN_SELECTOR"; fn language_is_unreal(language: &str) -> bool { language.eq_ignore_ascii_case("unrealcpp") @@ -461,21 +462,24 @@ fn run_client(runner: &ClientRunner, run_command: &str, client_project: &str, db .to_str() .expect("wasm_path stem should be valid utf-8"); let js_module = bindgen_out_dir.join(format!("{js_module_name}.js")); - let js_module = js_module + let js_module_cjs = bindgen_out_dir.join(format!("{js_module_name}.cjs")); + copy(&js_module, &js_module_cjs).expect("Failed to create .cjs wrapper for wasm-bindgen output"); + let js_module = js_module_cjs .to_str() .expect("js_module path should be valid utf-8") .to_owned(); let node_script = format!( - "(async () => {{\n const m = require({js_module:?});\n if (m.default) {{ await m.default(); }}\n const run = m.run || m.main || m.start;\n if (!run) throw new Error('No exported run/main/start function from wasm module');\n await run(process.argv[2]);\n}})().catch((e) => {{ console.error(e); process.exit(1); }});" + "(async () => {{\n const m = require({js_module:?});\n if (m.default) {{ await m.default(); }}\n const run = m.run || m.main || m.start;\n if (!run) throw new Error('No exported run/main/start function from wasm module');\n const runSelector = process.env.{TEST_RUN_SELECTOR_ENV_VAR} ?? '';\n const dbName = process.env.{TEST_DB_NAME_ENV_VAR};\n if (!dbName) throw new Error('Missing {TEST_DB_NAME_ENV_VAR}');\n await run(runSelector, dbName);\n}})().catch((e) => {{ console.error(e); process.exit(1); }});" ); - let node_args: Vec = vec!["-e".to_owned(), node_script, "--".to_owned(), run_command.to_owned()]; + let node_args: Vec = vec!["--experimental-websocket".to_owned(), "-e".to_owned(), node_script]; let output = cmd("node", node_args) - .dir(client_project) + .dir(&bindgen_out_dir) .env(TEST_CLIENT_PROJECT_ENV_VAR, client_project) .env(TEST_DB_NAME_ENV_VAR, db_name) + .env(TEST_RUN_SELECTOR_ENV_VAR, run_command) .env("RUST_LOG", rust_log) .stderr_to_stdout() .stdout_capture() diff --git a/sdks/rust/Cargo.toml b/sdks/rust/Cargo.toml index 8fbbfe6d7eb..92d9781982e 100644 --- a/sdks/rust/Cargo.toml +++ b/sdks/rust/Cargo.toml @@ -36,7 +36,6 @@ spacetimedb-query-builder.workspace = true spacetimedb-schema.workspace = true thiserror.workspace = true -anymap.workspace = true base64.workspace = true brotli.workspace = true bytes.workspace = true diff --git a/sdks/rust/src/client_cache.rs b/sdks/rust/src/client_cache.rs index 7c4b511c72a..5b4ca6510b4 100644 --- a/sdks/rust/src/client_cache.rs +++ b/sdks/rust/src/client_cache.rs @@ -5,7 +5,6 @@ use crate::callbacks::CallbackId; use crate::db_connection::{PendingMutation, SharedCell}; use crate::spacetime_module::{InModule, SpacetimeModule, TableUpdate, WithBsatn}; -use anymap::{any::Any, Map}; use bytes::Bytes; use core::any::type_name; use core::hash::Hash; @@ -13,6 +12,10 @@ use futures_channel::mpsc; use spacetimedb_data_structures::map::{hash_map::Entry, HashCollectionExt, HashMap}; use std::marker::PhantomData; use std::sync::Arc; +use std::{ + any::{Any, TypeId}, + boxed::Box, +}; /// A local mirror of the subscribed rows of one table in the database. pub struct TableCache { @@ -318,7 +321,7 @@ pub struct ClientCache { /// "keyed" on the type `HashMap<&'static str, TableCache`. /// /// The strings are table names, since we may have multiple tables with the same row type. - tables: Map, + tables: TypeMap, _module: PhantomData, } @@ -326,12 +329,42 @@ pub struct ClientCache { impl Default for ClientCache { fn default() -> Self { Self { - tables: Map::new(), + tables: Default::default(), _module: PhantomData, } } } +// We intentionally avoid `anymap` here. +// +// In wasm test-client runs (`wasm32-unknown-unknown` under Node), +// `anymap`'s TypeId hasher path can trigger an alignment-UB check panic: +// `ptr::copy_nonoverlapping requires aligned pointers`. +// Using this local `TypeId -> Box` map preserves the +// required functionality without that runtime failure. +#[derive(Default)] +struct TypeMap { + values: HashMap>, +} + +impl TypeMap { + fn get(&self) -> Option<&T> { + self.values + .get(&TypeId::of::()) + .and_then(|value| value.downcast_ref::()) + } + + fn get_or_insert_with(&mut self, make: impl FnOnce() -> T) -> &mut T { + let value = match self.values.entry(TypeId::of::()) { + Entry::Occupied(entry) => entry.into_mut(), + Entry::Vacant(entry) => entry.insert(Box::new(make())), + }; + value + .downcast_mut::() + .expect("TypeMap entry did not match stored TypeId") + } +} + impl ClientCache { /// Get a handle on the [`TableCache`] which stores rows of type `Row` for the table `table_name`. pub(crate) fn get_table + Send + Sync + 'static>( @@ -350,8 +383,7 @@ impl ClientCache { table_name: &'static str, ) -> &mut TableCache { self.tables - .entry::>>() - .or_insert_with(Default::default) + .get_or_insert_with::>>(Default::default) .entry(table_name) .or_default() } diff --git a/sdks/rust/tests/test-client/Cargo.toml b/sdks/rust/tests/test-client/Cargo.toml index c3097c28a10..37d819d40f5 100644 --- a/sdks/rust/tests/test-client/Cargo.toml +++ b/sdks/rust/tests/test-client/Cargo.toml @@ -25,6 +25,8 @@ web = [ "spacetimedb-sdk/web", "dep:wasm-bindgen", "dep:wasm-bindgen-futures", + "dep:console_error_panic_hook", + "dep:gloo-timers", "dep:futures", "dep:test-counter", "dep:rand", @@ -44,9 +46,11 @@ tokio = { workspace = true, optional = true } env_logger = { workspace = true, optional = true } rand = { workspace = true, optional = true } futures = { workspace = true, optional = true } +gloo-timers = { version = "0.3.0", features = ["futures"], optional = true } wasm-bindgen = { version = "0.2.100", optional = true } wasm-bindgen-futures = { version = "0.4.45", optional = true } +console_error_panic_hook = { version = "0.1.7", optional = true } [lints] workspace = true diff --git a/sdks/rust/tests/test-client/src/lib.rs b/sdks/rust/tests/test-client/src/lib.rs index f9844c7f28f..00ba85b22ec 100644 --- a/sdks/rust/tests/test-client/src/lib.rs +++ b/sdks/rust/tests/test-client/src/lib.rs @@ -10,6 +10,8 @@ use wasm_bindgen::prelude::wasm_bindgen; #[cfg(all(target_arch = "wasm32", feature = "web"))] #[wasm_bindgen] -pub async fn run(test_name: String) { - cli::dispatch(&test_name); +pub async fn run(test_name: String, db_name: String) { + console_error_panic_hook::set_once(); + cli::set_web_db_name(db_name); + cli::dispatch_async(&test_name).await; } diff --git a/sdks/rust/tests/test-client/src/main.rs b/sdks/rust/tests/test-client/src/main.rs index c4e59f160ca..f6a9d8ae512 100644 --- a/sdks/rust/tests/test-client/src/main.rs +++ b/sdks/rust/tests/test-client/src/main.rs @@ -3,7 +3,7 @@ pub(crate) mod module_bindings; use core::fmt::Display; -use core::sync::atomic::{AtomicUsize, Ordering}; +use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Barrier, Mutex}; use module_bindings::*; @@ -30,8 +30,27 @@ use unique_test_table::{insert_then_delete_one, UniqueTestTable}; const LOCALHOST: &str = "http://localhost:3000"; +#[cfg(all(target_arch = "wasm32", feature = "web"))] +static WEB_DB_NAME: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[cfg(all(target_arch = "wasm32", feature = "web"))] +pub(crate) fn set_web_db_name(db_name: String) { + WEB_DB_NAME.set(db_name).expect("WASM DB name was already initialized"); +} + fn db_name_or_panic() -> String { - std::env::var("SPACETIME_SDK_TEST_DB_NAME").expect("Failed to read db name from env") + #[cfg(all(target_arch = "wasm32", feature = "web"))] + { + return WEB_DB_NAME + .get() + .cloned() + .expect("Failed to read db name from wasm runner"); + } + + #[cfg(not(all(target_arch = "wasm32", feature = "web")))] + { + std::env::var("SPACETIME_SDK_TEST_DB_NAME").expect("Failed to read db name from env") + } } /// Register a panic hook which will exit the process whenever any thread panics. @@ -154,6 +173,16 @@ pub(crate) fn dispatch(test: &str) { } } +#[cfg(all(target_arch = "wasm32", feature = "web"))] +pub(crate) async fn dispatch_async(test: &str) { + match test { + "row-deduplication-r-join-s-and-r-joint" => { + exec_row_deduplication_r_join_s_and_r_join_t_async().await; + } + _ => dispatch(test), + } +} + fn assert_table_empty(tbl: T) -> anyhow::Result<()> { let count = tbl.count(); if count != 0 { @@ -2270,6 +2299,119 @@ fn exec_row_deduplication_r_join_s_and_r_join_t() { assert_eq!(count_unique_u32_on_insert.load(Ordering::SeqCst), 1); } +#[cfg(all(target_arch = "wasm32", feature = "web"))] +async fn exec_row_deduplication_r_join_s_and_r_join_t_async() { + use gloo_timers::future::TimeoutFuture; + + let on_subscription_applied = Arc::new(AtomicBool::new(false)); + let pk_u32_on_insert = Arc::new(AtomicBool::new(false)); + let pk_u32_on_delete = Arc::new(AtomicBool::new(false)); + let pk_u32_two_on_insert = Arc::new(AtomicBool::new(false)); + let count_unique_u32_on_insert = Arc::new(AtomicUsize::new(0)); + + let name = db_name_or_panic(); + let builder = DbConnection::builder() + .with_database_name(name) + .with_uri(LOCALHOST) + .on_connect({ + let on_subscription_applied = on_subscription_applied.clone(); + let pk_u32_on_insert = pk_u32_on_insert.clone(); + let pk_u32_on_delete = pk_u32_on_delete.clone(); + let pk_u32_two_on_insert = pk_u32_two_on_insert.clone(); + let count_unique_u32_on_insert = count_unique_u32_on_insert.clone(); + + move |ctx, _, _| { + let queries = [ + "SELECT * FROM pk_u_32;", + "SELECT * FROM pk_u_32_two;", + "SELECT unique_u_32.* FROM unique_u_32 JOIN pk_u_32 ON unique_u_32.n = pk_u_32.n;", + "SELECT unique_u_32.* FROM unique_u_32 JOIN pk_u_32_two ON unique_u_32.n = pk_u_32_two.n;", + ]; + + const KEY: u32 = 42; + const DATA: i32 = 0xbeef; + + UniqueU32::insert(ctx, KEY, DATA); + + subscribe_these_then(ctx, &queries, { + let on_subscription_applied = on_subscription_applied.clone(); + move |ctx| { + PkU32::insert(ctx, KEY, DATA); + assert_all_tables_empty(ctx).unwrap(); + on_subscription_applied.store(true, Ordering::SeqCst); + } + }); + PkU32::on_insert(ctx, { + let pk_u32_on_insert = pk_u32_on_insert.clone(); + move |ctx, val| { + assert_eq!(val, &PkU32 { n: KEY, data: DATA }); + pk_u32_on_insert.store(true, Ordering::SeqCst); + ctx.reducers + .delete_pk_u_32_insert_pk_u_32_two_then( + KEY, + DATA, + reducer_callback_assert_committed("delete_pk_u_32_insert_pk_u_32_two"), + ) + .unwrap(); + } + }); + PkU32Two::on_insert(ctx, { + let pk_u32_two_on_insert = pk_u32_two_on_insert.clone(); + move |_, val| { + assert_eq!(val, &PkU32Two { n: KEY, data: DATA }); + pk_u32_two_on_insert.store(true, Ordering::SeqCst); + } + }); + PkU32::on_delete(ctx, { + let pk_u32_on_delete = pk_u32_on_delete.clone(); + move |_, val| { + assert_eq!(val, &PkU32 { n: KEY, data: DATA }); + pk_u32_on_delete.store(true, Ordering::SeqCst); + } + }); + UniqueU32::on_insert(ctx, { + let count_unique_u32_on_insert = count_unique_u32_on_insert.clone(); + move |_, _| { + count_unique_u32_on_insert.fetch_add(1, Ordering::SeqCst); + } + }); + UniqueU32::on_delete(ctx, move |_, _| panic!()); + PkU32Two::on_delete(ctx, move |_, _| panic!()); + } + }) + .on_connect_error(|_ctx, error| panic!("Connect errored: {error:?}")); + + let conn = builder.build().await.unwrap(); + conn.run_background_task(); + + const WAIT_INTERVAL_MS: u32 = 10; + const MAX_WAIT_ITERATIONS: u32 = 3000; + let all_callbacks_observed = || { + on_subscription_applied.load(Ordering::SeqCst) + && pk_u32_on_insert.load(Ordering::SeqCst) + && pk_u32_on_delete.load(Ordering::SeqCst) + && pk_u32_two_on_insert.load(Ordering::SeqCst) + }; + for _ in 0..MAX_WAIT_ITERATIONS { + if all_callbacks_observed() { + break; + } + TimeoutFuture::new(WAIT_INTERVAL_MS).await; + } + if !all_callbacks_observed() { + panic!( + "Timeout waiting for callbacks: on_subscription_applied={}, pk_u32_on_insert={}, pk_u32_on_delete={}, pk_u32_two_on_insert={}", + on_subscription_applied.load(Ordering::SeqCst), + pk_u32_on_insert.load(Ordering::SeqCst), + pk_u32_on_delete.load(Ordering::SeqCst), + pk_u32_two_on_insert.load(Ordering::SeqCst), + ); + } + + assert_eq!(count_unique_u32_on_insert.load(Ordering::SeqCst), 1); + conn.disconnect().unwrap(); +} + /// This test asserts that the correct callbacks are invoked when updating the lhs table of a join fn test_lhs_join_update() { let insert_counter = TestCounter::new(); diff --git a/sdks/rust/tests/test.rs b/sdks/rust/tests/test.rs index 1527a1b68ba..7e36e7b5fe2 100644 --- a/sdks/rust/tests/test.rs +++ b/sdks/rust/tests/test.rs @@ -28,7 +28,7 @@ fn configure_test_client_commands( .to_string_lossy() .into_owned() }); - let wasm_path = format!("{target_dir}/wasm32-unknown-unknown/debug/{artifact_name}.wasm"); + let wasm_path = format!("{target_dir}/wasm32-unknown-unknown/debug/deps/{artifact_name}.wasm"); let bindgen_out_dir = format!("target/sdk-test-web-bindgen/{package_name}"); builder