diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40088d65bf1..50bc26f9be8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -228,16 +228,45 @@ 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 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 + 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="$( + 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}" + fi + + wasm-bindgen --version + - name: Build typescript module sdk working-directory: crates/bindings-typescript run: pnpm build diff --git a/Cargo.lock b/Cargo.lock index 679805a4700..4a7dd1ac3c0 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]] @@ -1111,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" @@ -2090,8 +2103,11 @@ version = "2.0.4" dependencies = [ "anyhow", "env_logger 0.10.2", + "futures", "spacetimedb-sdk", "test-counter", + "wasm-bindgen", + "wasm-bindgen-futures", ] [[package]] @@ -2552,6 +2568,80 @@ 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-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" +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-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" +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" @@ -5611,10 +5701,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]] @@ -7679,7 +7772,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", @@ -7738,7 +7831,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", @@ -8292,7 +8385,7 @@ dependencies = [ "spacetimedb-lib 2.0.4", "thiserror 1.0.69", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.27.0", ] [[package]] @@ -8393,7 +8486,6 @@ dependencies = [ name = "spacetimedb-sdk" version = "2.0.4" dependencies = [ - "anymap", "base64 0.21.7", "brotli", "bytes", @@ -8401,9 +8493,15 @@ dependencies = [ "flate2", "futures", "futures-channel", + "getrandom 0.3.4", + "gloo-console", + "gloo-net", + "gloo-storage", + "gloo-utils", "hex", "home", "http 1.3.1", + "js-sys", "log", "native-tls", "once_cell", @@ -8419,7 +8517,11 @@ dependencies = [ "spacetimedb-testing", "thiserror 1.0.69", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.27.0", + "tokio-tungstenite-wasm", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[package]] @@ -9139,11 +9241,16 @@ 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", "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", ] [[package]] @@ -9438,6 +9545,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" @@ -9449,7 +9568,26 @@ dependencies = [ "native-tls", "tokio", "tokio-native-tls", - "tungstenite", + "tungstenite 0.27.0", +] + +[[package]] +name = "tokio-tungstenite-wasm" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02567f5f341725fb3e452c1f55dd4e5b0f2a685355c3b10babf0fe8e137d176e" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.3.1", + "httparse", + "js-sys", + "thiserror 2.0.17", + "tokio", + "tokio-tungstenite 0.26.2", + "wasm-bindgen", + "web-sys", ] [[package]] @@ -9823,6 +9961,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.2", + "sha1", + "thiserror 2.0.17", + "utf-8", +] + [[package]] name = "tungstenite" version = "0.27.0" @@ -10096,9 +10251,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]] @@ -10107,8 +10265,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/codegen/src/rust.rs b/crates/codegen/src/rust.rs index c6ed504f714..71200ccb3b7 100644 --- a/crates/codegen/src/rust.rs +++ b/crates/codegen/src/rust.rs @@ -1660,10 +1660,11 @@ 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, @@ -1761,6 +1762,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() }} @@ -1786,10 +1788,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/crates/codegen/tests/snapshots/codegen__codegen_rust.snap b/crates/codegen/tests/snapshots/codegen__codegen_rust.snap index 53ea2c7b113..f5dd60cec69 100644 --- a/crates/codegen/tests/snapshots/codegen__codegen_rust.snap +++ b/crates/codegen/tests/snapshots/codegen__codegen_rust.snap @@ -1390,10 +1390,17 @@ 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, @@ -1491,6 +1498,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() } @@ -1516,10 +1524,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/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 e995aa28019..84e8159f28b 100644 --- a/crates/testing/src/sdk.rs +++ b/crates/testing/src/sdk.rs @@ -3,9 +3,10 @@ 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}; use crate::invoke_cli; use crate::modules::{start_runtime, CompilationMode, CompiledModule}; @@ -106,11 +107,20 @@ 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, + Web { wasm_path: String, bindgen_out_dir: String }, } 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") @@ -139,7 +149,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); } } @@ -188,7 +198,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 }) @@ -367,8 +377,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) @@ -384,24 +395,101 @@ 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) - .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)"); +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 command = cmd(exe, args); + + 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::Web { + wasm_path, + bindgen_out_dir, + } => { + let rust_log = + "spacetimedb=debug,spacetimedb_client_api=debug,spacetimedb_lib=debug,spacetimedb_standalone=debug"; + + 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)"); + + 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_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 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!["--experimental-websocket".to_owned(), "-e".to_owned(), node_script]; + + let output = cmd("node", node_args) + .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() + .unchecked() + .run() + .expect("Error running wasm client via node"); + + status_ok_or_panic(output, run_command, "(running web)"); + } + } } #[derive(Clone, Default)] @@ -414,6 +502,8 @@ pub struct TestBuilder { generate_subdir: Option, compile_command: Option, run_command: Option, + + client_runner: Option, } impl TestBuilder { @@ -474,6 +564,16 @@ impl TestBuilder { } } + pub fn with_web_client(self, wasm_path: impl Into, bindgen_out_dir: impl Into) -> Self { + TestBuilder { + client_runner: Some(ClientRunner::Web { + wasm_path: wasm_path.into(), + bindgen_out_dir: bindgen_out_dir.into(), + }), + ..self + } + } + pub fn with_generate_private_items(self, include_private: bool) -> Self { TestBuilder { generate_include_private: include_private, @@ -512,6 +612,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/Cargo.toml b/sdks/rust/Cargo.toml index f75dc817631..92d9781982e 100644 --- a/sdks/rust/Cargo.toml +++ b/sdks/rust/Cargo.toml @@ -9,7 +9,22 @@ rust-version.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [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", + "dep:gloo-net", + "dep:gloo-storage", + "dep:gloo-utils", + "dep:js-sys", + "dep:tokio-tungstenite-wasm", + "dep:wasm-bindgen", + "dep:wasm-bindgen-futures", + "dep:web-sys", +] [dependencies] spacetimedb-data-structures.workspace = true @@ -21,19 +36,31 @@ spacetimedb-query-builder.workspace = true spacetimedb-schema.workspace = true thiserror.workspace = true -anymap.workspace = true base64.workspace = true brotli.workspace = true 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 + +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 } +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 = ["HtmlDocument"], optional = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +home.workspace = true tokio.workspace = true tokio-tungstenite.workspace = true # native-tls 0.2.17 fails to compile with Rust 1.93.0 due non-exhaustive 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/src/credentials.rs b/sdks/rust/src/credentials.rs index bdef761048c..7174e8fea15 100644 --- a/sdks/rust/src/credentials.rs +++ b/sdks/rust/src/credentials.rs @@ -8,144 +8,305 @@ //! } //! ``` -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(feature = "web"))] +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. - /// - /// 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. + /// A file on disk which stores, or can store, a JWT for authenticating with SpacetimeDB. /// - /// 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() } + } - std::fs::create_dir_all(&path).map_err(|source| CredentialFileError::CreateDir { path, source }) - } + fn determine_home_dir() -> Result { + home_dir().ok_or(CredentialFileError::DetermineHomeDir) + } - fn path(&self) -> Result { - let mut path = Self::determine_home_dir()?; - path.push(CREDENTIALS_DIR); - path.push(&self.filename); - Ok(path) - } + fn ensure_credentials_dir() -> Result<(), CredentialFileError> { + let mut path = Self::determine_home_dir()?; + path.push(CREDENTIALS_DIR); - /// 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(()) + 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 }), + }; + + let creds = bsatn::from_slice::(&bytes) + .map_err(|source| CredentialFileError::Deserialize { path, source })?; + Ok(Some(creds.token)) + } } +} - /// 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)) +#[cfg(feature = "web")] +mod web_mod { + pub use gloo_storage::{LocalStorage, SessionStorage, Storage}; + + pub mod cookies { + use thiserror::Error; + use wasm_bindgen::{JsCast, JsValue}; + use web_sys::HtmlDocument; + + #[derive(Error, Debug)] + pub enum CookieError { + #[error("Error reading cookies: {0:?}")] + Get(JsValue), + + #[error("Error setting cookie `{key}`: {js_value:?}")] + Set { key: String, js_value: JsValue }, + } + + /// A builder for constructing and setting cookies. + pub struct Cookie { + name: String, + value: String, + path: Option, + domain: Option, + max_age: Option, + secure: bool, + same_site: Option, + } + + 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::Get)?; + 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. + /// Defaults to `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}")); + } + + let cookie_str = parts.join("; "); + 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`. + 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 + } + } + + /// Controls the `SameSite` attribute for cookies. + pub enum SameSite { + Strict, + Lax, + None, + } + + impl std::fmt::Display for SameSite { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SameSite::Strict => f.write_str("Strict"), + SameSite::Lax => f.write_str("Lax"), + SameSite::None => f.write_str("None"), + } + } + } + + fn get_html_document() -> HtmlDocument { + gloo_utils::document().unchecked_into::() + } } } + +#[cfg(not(feature = "web"))] +pub use native_mod::*; + +#[cfg(feature = "web")] +pub use web_mod::*; diff --git a/sdks/rust/src/db_connection.rs b/sdks/rust/src/db_connection.rs index b5e74383adf..a9b0ef97ed9 100644 --- a/sdks/rust/src/db_connection.rs +++ b/sdks/rust/src/db_connection.rs @@ -32,12 +32,15 @@ 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::{self as ws, common::QuerySetId}; use spacetimedb_lib::{bsatn, ser::Serialize, ConnectionId, Identity, Timestamp}; use spacetimedb_sats::Deserialize; use std::sync::{atomic::AtomicU32, Arc, Mutex as StdMutex, OnceLock}; +#[cfg(not(feature = "web"))] use tokio::{ runtime::{self, Runtime}, sync::Mutex as TokioMutex, @@ -45,12 +48,18 @@ 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. /// /// 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. @@ -64,7 +73,7 @@ pub struct DbContextImpl { /// Receiver channel for WebSocket messages, /// which are pre-parsed in the background by [`parse_loop`]. - recv: Arc>>>, + recv: SharedAsyncCell>>, /// Channel into which operations which apparently mutate SDK state, /// e.g. registering callbacks, push [`PendingMutation`] messages, @@ -74,7 +83,7 @@ pub struct DbContextImpl { /// Receive end of `pending_mutations_send`, /// from which [Self::apply_pending_mutations] and friends read mutations. - pending_mutations_recv: Arc>>>, + pending_mutations_recv: SharedAsyncCell>>, /// This connection's `Identity`. /// @@ -91,6 +100,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, @@ -307,9 +317,10 @@ impl DbContextImpl { /// Apply all queued [`PendingMutation`]s. fn apply_pending_mutations(&self) -> crate::Result<()> { - while let Ok(Some(pending_mutation)) = self.pending_mutations_recv.blocking_lock().try_next() { + while let Ok(Some(pending_mutation)) = get_lock_sync(&self.pending_mutations_recv).try_next() { self.apply_mutation(pending_mutation)?; } + Ok(()) } @@ -512,7 +523,7 @@ 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() { + 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); @@ -535,8 +546,8 @@ impl DbContextImpl { // We call this out as an incorrect and unsupported thing to do. #![allow(clippy::await_holding_lock)] - let mut pending_mutations = self.pending_mutations_recv.lock().await; - let mut recv = self.recv.lock().await; + 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. @@ -547,15 +558,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), @@ -594,6 +618,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 { @@ -605,6 +630,23 @@ 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_background_task(&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. @@ -731,6 +773,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, @@ -831,6 +874,7 @@ You must explicitly advance the connection by calling any one of: - `DbConnection::frame_tick`. - `DbConnection::run_threaded`. +- `DbConnection::run_background_task`. - `DbConnection::run_async`. - `DbConnection::advance_one_message`. - `DbConnection::advance_one_message_blocking`. @@ -839,18 +883,23 @@ 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(feature = "web"))] pub fn build(self) -> crate::Result { let imp = self.build_impl()?; Ok(::new(imp)) } + #[cfg(feature = "web")] + 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(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(|| { @@ -868,39 +917,56 @@ 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 parsed_recv_chan = Arc::new(TokioMutex::new(parsed_recv_chan)); - let inner = Arc::new(StdMutex::new(DbContextImplInner { - runtime, - - db_callbacks, - reducer_callbacks, - subscriptions: SubscriptionManager::default(), + let (pending_mutations_send, pending_mutations_recv) = mpsc::unbounded(); + 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, + connection_id_override, + )) + } - on_connect: self.on_connect, - on_connect_error: self.on_connect_error, - on_disconnect: self.on_disconnect, - procedure_callbacks, - })); + /// Open a WebSocket connection, build an empty client cache, &c, + /// to construct a [`DbContextImpl`]. + #[cfg(feature = "web")] + async fn build_impl(self) -> crate::Result> { + let connection_id_override = get_connection_id_override(); + let ws_connection = WsConnection::connect( + self.uri.clone().unwrap(), + self.database_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 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 (raw_msg_recv, raw_msg_send) = ws_connection.spawn_message_loop(); + let parsed_recv_chan = spawn_parse_loop::(raw_msg_recv); + let parsed_recv_chan = Arc::new(StdMutex::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)), - 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)), - }; + 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 database. @@ -1018,9 +1084,63 @@ 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, + + 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. +#[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() => { @@ -1043,6 +1163,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 { TransactionUpdate(M::DbUpdate), IdentityToken(Identity, Box, ConnectionId), @@ -1070,6 +1215,7 @@ enum ParsedMessage { }, } +#[cfg(not(feature = "web"))] fn spawn_parse_loop( raw_message_recv: mpsc::UnboundedReceiver, handle: &runtime::Handle, @@ -1079,6 +1225,15 @@ fn spawn_parse_loop( (handle, parsed_message_recv) } +#[cfg(feature = "web")] +fn spawn_parse_loop( + 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)); + 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 5668ecb3aed..f48d148ce9b 100644 --- a/sdks/rust/src/websocket.rs +++ b/sdks/rust/src/websocket.rs @@ -2,30 +2,42 @@ //! //! This module is internal, and may incompatibly change without warning. +#[cfg(not(feature = "web"))] use std::mem; use std::sync::Arc; +#[cfg(not(feature = "web"))] use std::time::Duration; +#[cfg(not(feature = "web"))] use bytes::Bytes; -use futures::{SinkExt, StreamExt as _, TryStreamExt}; +#[cfg(not(feature = "web"))] +use futures::TryStreamExt; +use futures::{SinkExt, StreamExt as _}; use futures_channel::mpsc; use http::uri::{InvalidUri, Scheme, Uri}; use spacetimedb_client_api_messages::websocket as ws; use spacetimedb_lib::{bsatn, ConnectionId}; use thiserror::Error; -use tokio::task::JoinHandle; -use tokio::time::Instant; -use tokio::{net::TcpStream, runtime}; +#[cfg(not(feature = "web"))] +use tokio::{net::TcpStream, runtime, task::JoinHandle, time::Instant}; +#[cfg(not(feature = "web"))] use tokio_tungstenite::{ connect_async_with_config, tungstenite::client::IntoClientRequest, tungstenite::protocol::{Message as WebSocketMessage, WebSocketConfig}, MaybeTlsStream, WebSocketStream, }; +#[cfg(feature = "web")] +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")] @@ -57,7 +69,7 @@ pub enum WsError { 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")] @@ -79,11 +91,18 @@ 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 { db_name: Box, + #[cfg(not(feature = "web"))] sock: WebSocketStream>, + #[cfg(feature = "web")] + sock: WebSocketStream, } fn parse_scheme(scheme: Option) -> Result { @@ -111,7 +130,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); @@ -155,6 +196,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), })?); @@ -172,6 +218,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(feature = "web"))] fn make_request( host: Uri, db_name: &str, @@ -189,6 +236,7 @@ fn make_request( Ok(req) } +#[cfg(not(feature = "web"))] fn request_insert_protocol_header(req: &mut http::Request<()>) { req.headers_mut().insert( http::header::SEC_WEBSOCKET_PROTOCOL, @@ -196,6 +244,7 @@ fn request_insert_protocol_header(req: &mut http::Request<()>) { ); } +#[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(); @@ -203,9 +252,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!("{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), + 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(feature = "web"))] macro_rules! maybe_log_error { ($cause:expr, $res:expr) => { if let Err(e) = $res { @@ -215,6 +312,7 @@ macro_rules! maybe_log_error { } impl WsConnection { + #[cfg(not(feature = "web"))] pub(crate) async fn connect( host: Uri, db_name: &str, @@ -246,6 +344,34 @@ impl WsConnection { }) } + #[cfg(feature = "web")] + pub(crate) async fn connect( + host: Uri, + db_name: &str, + token: Option<&str>, + connection_id: Option, + params: WsParams, + ) -> Result { + 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(), &[ws::v2::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 { let bytes = &*decompress_server_message(bytes)?; bsatn::from_slice(bytes).map_err(|source| WsError::DeserializeMessage { source }) @@ -255,6 +381,7 @@ impl WsConnection { WebSocketMessage::Binary(bsatn::to_vec(&msg).unwrap().into()) } + #[cfg(not(feature = "web"))] async fn message_loop( mut self, incoming_messages: mpsc::UnboundedSender, @@ -392,6 +519,7 @@ impl WsConnection { } } + #[cfg(not(feature = "web"))] pub(crate) fn spawn_message_loop( self, runtime: &runtime::Handle, @@ -405,4 +533,99 @@ impl WsConnection { let handle = runtime.spawn(self.message_loop(incoming_send, outgoing_recv)); (handle, incoming_recv, outgoing_send) } + + #[cfg(feature = "web")] + 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 (mut ws_writer, ws_reader) = self.sock.split(); + + wasm_bindgen_futures::spawn_local(async move { + let mut incoming = ws_reader.fuse(); + let mut outgoing = outgoing_rx.fuse(); + + loop { + futures::select! { + // 1) inbound WS frames + 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` + 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(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; + }, + + 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 + 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!("Error sending outgoing message:", format!("{:?}",e)); + break; + } + } else { + // channel closed, so we're done sending + if let Err(e) = ws_writer.close().await { + gloo_console::warn!("Error sending close frame:", format!("{:?}", e)); + } + break; + }, + } + } + }); + + (incoming_rx, outgoing_tx) + } } diff --git a/sdks/rust/tests/connect_disconnect_client/Cargo.toml b/sdks/rust/tests/connect_disconnect_client/Cargo.toml index 126d8568ac5..5f315c19fe3 100644 --- a/sdks/rust/tests/connect_disconnect_client/Cargo.toml +++ b/sdks/rust/tests/connect_disconnect_client/Cargo.toml @@ -6,10 +6,36 @@ 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 = [] + +# 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", +] + +[[bin]] +name = "connect_disconnect_client" +path = "src/main.rs" +required-features = ["native"] + [dependencies] spacetimedb-sdk = { path = "../.." } test-counter = { path = "../test-counter" } 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/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/Cargo.toml b/sdks/rust/tests/event-table-client/Cargo.toml index f6502644119..b5b15b11889 100644 --- a/sdks/rust/tests/event-table-client/Cargo.toml +++ b/sdks/rust/tests/event-table-client/Cargo.toml @@ -3,11 +3,39 @@ 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: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", +] + +[[bin]] +name = "event-table-client" +path = "src/main.rs" +required-features = ["native"] + [dependencies] spacetimedb-sdk = { path = "../.." } test-counter = { path = "../test-counter" } 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/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/Cargo.toml b/sdks/rust/tests/procedure-client/Cargo.toml index 665d4b0582d..7678a691eee 100644 --- a/sdks/rust/tests/procedure-client/Cargo.toml +++ b/sdks/rust/tests/procedure-client/Cargo.toml @@ -6,13 +6,41 @@ 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: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", +] + +[[bin]] +name = "procedure-client" +path = "src/main.rs" +required-features = ["native"] + [dependencies] spacetimedb-sdk = { path = "../.." } spacetimedb-lib.workspace = true test-counter = { path = "../test-counter" } 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 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 7a7167234a5..b4a3337d8c9 100644 --- a/sdks/rust/tests/test-client/Cargo.toml +++ b/sdks/rust/tests/test-client/Cargo.toml @@ -6,13 +6,49 @@ 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: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", + "dep:console_error_panic_hook", + "dep:gloo-timers", + "dep:futures", + "dep:rand", +] + +[[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" } +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 new file mode 100644 index 00000000000..00ba85b22ec --- /dev/null +++ b/sdks/rust/tests/test-client/src/lib.rs @@ -0,0 +1,17 @@ +#![allow(clippy::disallowed_macros)] + +#[path = "main.rs"] +mod cli; + +pub(crate) use cli::module_bindings; + +#[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, 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 89dac3b00c7..d1ff1e1296a 100644 --- a/sdks/rust/tests/test-client/src/main.rs +++ b/sdks/rust/tests/test-client/src/main.rs @@ -1,19 +1,21 @@ #[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}; +use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; 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; @@ -28,13 +30,33 @@ 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. /// /// 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. @@ -64,6 +86,7 @@ macro_rules! assert_eq_or_bail { }}; } +#[cfg(not(target_arch = "wasm32"))] fn main() { env_logger::init(); exit_on_panic(); @@ -71,8 +94,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(), @@ -147,6 +173,16 @@ fn main() { } } +#[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 { @@ -403,8 +439,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 } @@ -419,6 +458,23 @@ 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 { + // Why this differs from native: + // - In the SDK, `DbConnectionBuilder::build` is sync on non-web builds, + // but async on `feature = "web"` because the websocket/token setup uses + // wasm/web async primitives. + // - This test client keeps `connect*` helpers sync so one test body works + // for both native and web runs. + // Therefore we bridge the async web `build()` into this sync test harness. + 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) } @@ -1711,12 +1767,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(); @@ -1742,6 +1800,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(); @@ -1773,6 +1832,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(); @@ -1781,21 +1851,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(); @@ -1809,22 +1882,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(); } @@ -2230,6 +2306,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-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/test.rs b/sdks/rust/tests/test.rs index ed623c7d36f..7e36e7b5fe2 100644 --- a/sdks/rust/tests/test.rs +++ b/sdks/rust/tests/test.rs @@ -1,3 +1,55 @@ +#[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 artifact_name = package_name.replace('-', "_"); + + // 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/deps/{artifact_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,21 +59,23 @@ 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() + 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] @@ -197,25 +251,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] @@ -327,15 +383,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] @@ -368,21 +426,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] @@ -436,21 +496,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] @@ -498,15 +560,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/sdks/rust/tests/view-client/Cargo.toml b/sdks/rust/tests/view-client/Cargo.toml index 76c6bb58a8e..5c57f11ff2f 100644 --- a/sdks/rust/tests/view-client/Cargo.toml +++ b/sdks/rust/tests/view-client/Cargo.toml @@ -6,12 +6,40 @@ 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: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", +] + +[[bin]] +name = "view-client" +path = "src/main.rs" +required-features = ["native"] + [dependencies] spacetimedb-sdk = { path = "../.." } spacetimedb-lib.workspace = true test-counter = { path = "../test-counter" } 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-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/Cargo.toml b/sdks/rust/tests/view-pk-client/Cargo.toml index f872b1e7f17..77658606a79 100644 --- a/sdks/rust/tests/view-pk-client/Cargo.toml +++ b/sdks/rust/tests/view-pk-client/Cargo.toml @@ -4,11 +4,39 @@ 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: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", +] + +[[bin]] +name = "view-pk-client" +path = "src/main.rs" +required-features = ["native"] + [dependencies] spacetimedb-sdk = { path = "../.." } test-counter = { path = "../test-counter" } 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(), 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 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.