├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md ├── bacon.toml ├── examples ├── actix-example │ ├── Cargo.toml │ ├── README.md │ └── src │ │ ├── app.rs │ │ ├── lib.rs │ │ └── main.rs └── axum-example │ ├── Cargo.toml │ ├── README.md │ └── src │ ├── app.rs │ ├── fileserv.rs │ ├── lib.rs │ └── main.rs └── src ├── actix.rs ├── axum.rs └── lib.rs /.gitignore: -------------------------------------------------------------------------------- 1 | **/target 2 | **/Cargo.lock 3 | pkg 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "leptos_server_signal" 3 | version = "0.8.0" 4 | edition = "2021" 5 | authors = ["Ari Seyhun "] 6 | description = "Leptos server signals synced through websockets" 7 | repository = "https://github.com/tqwewe/leptos_server_signal" 8 | license = "MIT" 9 | keywords = ["leptos", "server", "signal", "websocket"] 10 | categories = [ 11 | "wasm", 12 | "web-programming", 13 | "web-programming::http-client", 14 | "web-programming::http-server", 15 | "web-programming::websocket" 16 | ] 17 | 18 | [dependencies] 19 | cfg-if = "1" 20 | js-sys = "0.3" 21 | json-patch = "1" 22 | leptos = { version = "0.7", default-features = false } 23 | serde = { version = "1", features = ["derive"] } 24 | serde_json = "1" 25 | wasm-bindgen = { version = "0.2", default-features = false } 26 | web-sys = { version = "0.3", features = ["WebSocket", "MessageEvent", "Window"] } 27 | thiserror = { version = "1", optional = true } 28 | 29 | # Actix 30 | actix-ws = { version = "0.2", optional = true } 31 | 32 | # Axum 33 | axum = { version = "0.7", default-features = false, features = ["ws"], optional = true } 34 | futures = { version = "0.3", default-features = false, optional = true } 35 | 36 | [features] 37 | default = [] 38 | ssr = [] 39 | actix = ["dep:actix-ws", "dep:thiserror"] 40 | axum = ["dep:axum", "dep:futures", "dep:thiserror"] 41 | 42 | [package.metadata.docs.rs] 43 | all-features = true 44 | rustdoc-args = ["--cfg", "docsrs"] 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Ari Seyhun 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **Leptos Server Signals** 2 | 3 | Server signals are [leptos] [signals] kept in sync with the server through websockets. 4 | 5 | The signals are read-only on the client side, and can be written to by the server. 6 | This is useful if you want real-time updates on the UI controlled by the server. 7 | 8 | Changes to a signal are sent through a websocket to the client as [json patches]. 9 | 10 | [leptos]: https://crates.io/crates/leptos 11 | [signals]: https://docs.rs/leptos/latest/leptos/struct.Signal.html 12 | [json patches]: https://docs.rs/json-patch/latest/json_patch/struct.Patch.html 13 | 14 | ## Feature flags 15 | 16 | - `ssr`: ssr is enabled when rendering the app on the server. 17 | - `actix`: integration with the [Actix] web framework. 18 | - `axum`: integration with the [Axum] web framework. 19 | 20 | [actix]: https://crates.io/crates/actix-web 21 | [axum]: https://crates.io/crates/axum 22 | 23 | # Example 24 | 25 | **Cargo.toml** 26 | 27 | ```toml 28 | [dependencies] 29 | leptos_server_signal = "*" 30 | serde = { version = "*", features = ["derive"] } 31 | 32 | [features] 33 | ssr = [ 34 | "leptos_server_signal/ssr", 35 | "leptos_server_signal/axum", # or actix 36 | ] 37 | ``` 38 | 39 | **Client** 40 | 41 | ```rust 42 | use leptos::*; 43 | use leptos_server_signal::create_server_signal; 44 | use serde::{Deserialize, Serialize}; 45 | 46 | #[derive(Clone, Default, Serialize, Deserialize)] 47 | pub struct Count { 48 | pub value: i32, 49 | } 50 | 51 | #[component] 52 | pub fn App() -> impl IntoView { 53 | // Provide websocket connection 54 | leptos_server_signal::provide_websocket("ws://localhost:3000/ws").unwrap(); 55 | 56 | // Create server signal 57 | let count = create_server_signal::("counter"); 58 | 59 | view! { 60 |

"Count: " {move || count.get().value.to_string()}

61 | } 62 | } 63 | ``` 64 | 65 | **Server (Axum)** 66 | 67 | ```rust 68 | #[cfg(feature = "ssr")] 69 | pub async fn websocket(ws: WebSocketUpgrade) -> Response { 70 | ws.on_upgrade(handle_socket) 71 | } 72 | 73 | #[cfg(feature = "ssr")] 74 | async fn handle_socket(mut socket: WebSocket) { 75 | let mut count = ServerSignal::::new("counter").unwrap(); 76 | 77 | loop { 78 | tokio::time::sleep(Duration::from_millis(10)).await; 79 | let result = count.with(&mut socket, |count| count.value += 1).await; 80 | if result.is_err() { 81 | break; 82 | } 83 | } 84 | } 85 | ``` 86 | 87 | # Connection Retry 88 | 89 | With the example above, the connection does not get reestablished after a connection lost. 90 | To regularly try to reconnect again, the function `provide_websocket_with_retry(...)` can 91 | be used: 92 | 93 | ```rust 94 | #[component] 95 | pub fn App() -> impl IntoView { 96 | // Provide websocket connection 97 | leptos_server_signal::provide_websocket_with_retry( 98 | "ws://localhost:3000/ws", 99 | 5000, // retry in 5000 milliseconds 100 | ).unwrap(); 101 | 102 | // ... code from above 103 | } 104 | ``` 105 | 106 | -------------------------------------------------------------------------------- /bacon.toml: -------------------------------------------------------------------------------- 1 | # This is a configuration file for the bacon tool 2 | # 3 | # Bacon repository: https://github.com/Canop/bacon 4 | # Complete help on configuration: https://dystroy.org/bacon/config/ 5 | # You can also check bacon's own bacon.toml file 6 | # as an example: https://github.com/Canop/bacon/blob/main/bacon.toml 7 | 8 | default_job = "check" 9 | 10 | [jobs.check] 11 | command = ["cargo", "check", "--target", "wasm32-unknown-unknown", "--color", "always"] 12 | need_stdout = false 13 | 14 | [jobs.check-ssr] 15 | command = ["cargo", "check", "--all-features", "--color", "always"] 16 | need_stdout = false 17 | 18 | # [jobs.check-all] 19 | # command = ["cargo", "check", "--all-targets", "--color", "always"] 20 | # need_stdout = false 21 | 22 | [jobs.clippy] 23 | command = [ 24 | "cargo", "clippy", 25 | "--all-targets", 26 | "--color", "always", 27 | ] 28 | need_stdout = false 29 | 30 | [jobs.test] 31 | command = [ 32 | "cargo", "test", "--color", "always", 33 | "--", "--color", "always", # see https://github.com/Canop/bacon/issues/124 34 | ] 35 | need_stdout = true 36 | 37 | [jobs.doc] 38 | command = ["cargo", "doc", "--color", "always", "--no-deps"] 39 | need_stdout = false 40 | 41 | # If the doc compiles, then it opens in your browser and bacon switches 42 | # to the previous job 43 | [jobs.doc-open] 44 | command = ["cargo", "doc", "--color", "always", "--no-deps", "--open"] 45 | need_stdout = false 46 | on_success = "back" # so that we don't open the browser at each change 47 | 48 | # You can run your application and have the result displayed in bacon, 49 | # *if* it makes sense for this crate. You can run an example the same 50 | # way. Don't forget the `--color always` part or the errors won't be 51 | # properly parsed. 52 | [jobs.run] 53 | command = [ 54 | "cargo", "run", 55 | "--color", "always", 56 | # put launch parameters for your program behind a `--` separator 57 | ] 58 | need_stdout = true 59 | allow_warnings = true 60 | 61 | # You may define here keybindings that would be specific to 62 | # a project, for example a shortcut to launch a specific job. 63 | # Shortcuts to internal functions (scrolling, toggling, etc.) 64 | # should go in your personal global prefs.toml file instead. 65 | [keybindings] 66 | # alt-m = "job:my-job" 67 | -------------------------------------------------------------------------------- /examples/actix-example/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "actix_example" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [lib] 7 | crate-type = ["cdylib", "rlib"] 8 | 9 | [dependencies] 10 | actix-files = { version = "0.6", optional = true } 11 | actix-web = { version = "4", optional = true, features = ["macros"] } 12 | actix-ws = { version = "0.2.5", optional = true } 13 | actix-web-lab = { version = "0.19.1", optional = true } 14 | bincode = "1.3.3" 15 | console_error_panic_hook = "0.1" 16 | console_log = "1" 17 | cfg-if = "1" 18 | leptos = { version = "0.6", default-features = false, features = [ 19 | "serde", 20 | ] } 21 | leptos_meta = { version = "0.6", default-features = false } 22 | leptos_actix = { version = "0.6", optional = true } 23 | leptos_router = { version = "0.6", default-features = false } 24 | leptos_server_signal = { path = "../.." } 25 | log = "0.4" 26 | serde = { version = "1.0", features = ["derive"] } 27 | serde_json = "1.0" 28 | simple_logger = "4" 29 | wasm-bindgen = "0.2" 30 | 31 | [features] 32 | hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"] 33 | ssr = [ 34 | "dep:actix-files", 35 | "dep:actix-web", 36 | "dep:actix-web-lab", 37 | "dep:actix-ws", 38 | "dep:leptos_actix", 39 | "leptos/ssr", 40 | "leptos_meta/ssr", 41 | "leptos_router/ssr", 42 | "leptos_server_signal/ssr", 43 | "leptos_server_signal/actix", 44 | ] 45 | 46 | [package.metadata.leptos] 47 | # The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name 48 | output-name = "actix_example" 49 | # The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup. 50 | site-root = "target/site" 51 | # The site-root relative folder where all compiled output (JS, WASM and CSS) is written 52 | # Defaults to pkg 53 | site-pkg-dir = "pkg" 54 | # [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to //app.css 55 | # style-file = "style/main.scss" 56 | # Assets source dir. All files found here will be copied and synchronized to site-root. 57 | # The assets-dir cannot have a sub directory with the same name/path as site-pkg-dir. 58 | # 59 | # Optional. Env: LEPTOS_ASSETS_DIR. 60 | # assets-dir = "assets" 61 | # The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup. 62 | site-addr = "127.0.0.1:3000" 63 | # The port to use for automatic reload monitoring 64 | reload-port = 3001 65 | # [Optional] Command to use when running end2end tests. It will run in the end2end dir. 66 | # [Windows] for non-WSL use "npx.cmd playwright test" 67 | # This binary name can be checked in Powershell with Get-Command npx 68 | end2end-cmd = "npx playwright test" 69 | end2end-dir = "end2end" 70 | # The browserlist query used for optimizing the CSS. 71 | browserquery = "defaults" 72 | # Set by cargo-leptos watch when building with that tool. Controls whether autoreload JS will be included in the head 73 | watch = false 74 | # The environment Leptos will run in, usually either "DEV" or "PROD" 75 | env = "DEV" 76 | # The features to use when compiling the bin target 77 | # 78 | # Optional. Can be over-ridden with the command line parameter --bin-features 79 | bin-features = ["ssr"] 80 | 81 | # If the --no-default-features flag should be used when compiling the bin target 82 | # 83 | # Optional. Defaults to false. 84 | bin-default-features = false 85 | 86 | # The features to use when compiling the lib target 87 | # 88 | # Optional. Can be over-ridden with the command line parameter --lib-features 89 | lib-features = ["hydrate"] 90 | 91 | # If the --no-default-features flag should be used when compiling the lib target 92 | # 93 | # Optional. Defaults to false. 94 | lib-default-features = false 95 | -------------------------------------------------------------------------------- /examples/actix-example/README.md: -------------------------------------------------------------------------------- 1 | # Actix Example 2 | 3 | Example of using server signals with actix. 4 | 5 | ```console 6 | $ cd examples/actix-example 7 | $ cargo leptos serve 8 | ``` -------------------------------------------------------------------------------- /examples/actix-example/src/app.rs: -------------------------------------------------------------------------------- 1 | use leptos::*; 2 | use leptos_server_signal::create_server_signal; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Clone, Default, Serialize, Deserialize)] 6 | pub struct Count { 7 | pub value: i32, 8 | } 9 | 10 | #[component] 11 | pub fn App() -> impl IntoView { 12 | // Provide websocket connection 13 | leptos_server_signal::provide_websocket("ws://localhost:3000/ws").unwrap(); 14 | 15 | // Create server signal 16 | let count = create_server_signal::("counter"); 17 | 18 | view! {

"Count: " {move || count.get().value.to_string()}

} 19 | } 20 | -------------------------------------------------------------------------------- /examples/actix-example/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod app; 2 | use cfg_if::cfg_if; 3 | 4 | cfg_if! { 5 | if #[cfg(feature = "hydrate")] { 6 | 7 | use wasm_bindgen::prelude::wasm_bindgen; 8 | 9 | #[wasm_bindgen] 10 | pub fn hydrate() { 11 | use app::*; 12 | use leptos::*; 13 | 14 | // initializes logging using the `log` crate 15 | _ = console_log::init_with_level(log::Level::Debug); 16 | console_error_panic_hook::set_once(); 17 | 18 | leptos::mount_to_body(move || { 19 | view! { } 20 | }); 21 | } 22 | } 23 | } 24 | 25 | -------------------------------------------------------------------------------- /examples/actix-example/src/main.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "ssr")] 2 | #[actix_web::main] 3 | async fn main() -> std::io::Result<()> { 4 | use actix_example::app::*; 5 | use actix_files::Files; 6 | use actix_web::*; 7 | use leptos::*; 8 | use leptos_actix::{generate_route_list, LeptosRoutes}; 9 | 10 | let conf = get_configuration(None).await.unwrap(); 11 | let addr = conf.leptos_options.site_addr; 12 | let routes = generate_route_list(|| view! { }); 13 | 14 | HttpServer::new(move || { 15 | let leptos_options = &conf.leptos_options; 16 | let site_root = &leptos_options.site_root; 17 | 18 | App::new() 19 | .route("/api/{tail:.*}", leptos_actix::handle_server_fns()) 20 | .route("/ws", web::get().to(websocket)) 21 | .leptos_routes( 22 | leptos_options.to_owned(), 23 | routes.to_owned(), 24 | || view! { }, 25 | ) 26 | .service(Files::new("/", site_root)) 27 | }) 28 | .bind(&addr)? 29 | .run() 30 | .await 31 | } 32 | 33 | #[cfg(not(feature = "ssr"))] 34 | pub fn main() { 35 | // no client-side main function 36 | // unless we want this to work with e.g., Trunk for pure client-side testing 37 | // see lib.rs for hydration function instead 38 | } 39 | 40 | #[cfg(feature = "ssr")] 41 | pub async fn websocket( 42 | req: actix_web::HttpRequest, 43 | stream: actix_web::web::Payload, 44 | ) -> impl actix_web::Responder { 45 | use std::time::Duration; 46 | 47 | use actix_example::app::Count; 48 | use leptos_server_signal::ServerSignal; 49 | 50 | let (res, session, _msg_stream) = actix_ws::handle(&req, stream).unwrap(); 51 | let mut count = ServerSignal::::new("counter", session).unwrap(); 52 | 53 | actix_web::rt::spawn(async move { 54 | loop { 55 | actix_web::rt::time::sleep(Duration::from_millis(100)).await; 56 | let result = count.with(|count| count.value += 1).await; 57 | if result.is_err() { 58 | break; 59 | } 60 | } 61 | }); 62 | 63 | res 64 | } 65 | 66 | -------------------------------------------------------------------------------- /examples/axum-example/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "axum_example" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [lib] 7 | crate-type = ["cdylib", "rlib"] 8 | 9 | [dependencies] 10 | axum = { version = "0.7.4", optional = true } 11 | console_error_panic_hook = "0.1" 12 | console_log = "1" 13 | cfg-if = "1" 14 | leptos = { version = "0.6", default-features = false, features = [ 15 | "serde", 16 | ] } 17 | leptos_meta = { version = "0.6", default-features = false } 18 | leptos_axum = { version = "0.6", optional = true } 19 | leptos_router = { version = "0.6", default-features = false } 20 | leptos_server_signal = { path = "../.." } 21 | log = "0.4" 22 | serde = { version = "1.0", features = ["derive"] } 23 | tokio = { version = "1.33.0", features = ["full"], optional = true } 24 | tower = { version = "0.4", optional = true } 25 | tower-http = { version = "0.5", features = ["fs"], optional = true } 26 | wasm-bindgen = "0.2" 27 | thiserror = "1.0.38" 28 | tracing = { version = "0.1.37", optional = true } 29 | http = "1" 30 | 31 | [features] 32 | hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"] 33 | ssr = [ 34 | "dep:axum", 35 | "dep:tokio", 36 | "dep:tower", 37 | "dep:tower-http", 38 | "dep:leptos_axum", 39 | "leptos/ssr", 40 | "leptos_meta/ssr", 41 | "leptos_router/ssr", 42 | "leptos_server_signal/ssr", 43 | "leptos_server_signal/axum", 44 | "dep:tracing" 45 | ] 46 | 47 | [package.metadata.cargo-all-features] 48 | denylist = [ 49 | "axum", 50 | "tokio", 51 | "tower", 52 | "tower-http", 53 | "leptos_axum", 54 | ] 55 | skip_feature_sets = [["ssr", "hydrate"]] 56 | 57 | [package.metadata.leptos] 58 | # The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name 59 | output-name = "axum_example" 60 | 61 | # The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup. 62 | site-root = "target/site" 63 | 64 | # The site-root relative folder where all compiled output (JS, WASM and CSS) is written 65 | # Defaults to pkg 66 | site-pkg-dir = "pkg" 67 | 68 | # [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to //app.css 69 | # style-file = "style/main.scss" 70 | # Assets source dir. All files found here will be copied and synchronized to site-root. 71 | # The assets-dir cannot have a sub directory with the same name/path as site-pkg-dir. 72 | # 73 | # Optional. Env: LEPTOS_ASSETS_DIR. 74 | # assets-dir = "public" 75 | 76 | # The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup. 77 | site-addr = "127.0.0.1:3000" 78 | 79 | # The port to use for automatic reload monitoring 80 | reload-port = 3001 81 | 82 | # [Optional] Command to use when running end2end tests. It will run in the end2end dir. 83 | # [Windows] for non-WSL use "npx.cmd playwright test" 84 | # This binary name can be checked in Powershell with Get-Command npx 85 | end2end-cmd = "npx playwright test" 86 | end2end-dir = "end2end" 87 | 88 | # The browserlist query used for optimizing the CSS. 89 | browserquery = "defaults" 90 | 91 | # Set by cargo-leptos watch when building with that tool. Controls whether autoreload JS will be included in the head 92 | watch = false 93 | 94 | # The environment Leptos will run in, usually either "DEV" or "PROD" 95 | env = "DEV" 96 | 97 | # The features to use when compiling the bin target 98 | # 99 | # Optional. Can be over-ridden with the command line parameter --bin-features 100 | bin-features = ["ssr"] 101 | 102 | # If the --no-default-features flag should be used when compiling the bin target 103 | # 104 | # Optional. Defaults to false. 105 | bin-default-features = false 106 | 107 | # The features to use when compiling the lib target 108 | # 109 | # Optional. Can be over-ridden with the command line parameter --lib-features 110 | lib-features = ["hydrate"] 111 | 112 | # If the --no-default-features flag should be used when compiling the lib target 113 | # 114 | # Optional. Defaults to false. 115 | lib-default-features = false 116 | -------------------------------------------------------------------------------- /examples/axum-example/README.md: -------------------------------------------------------------------------------- 1 | # Axum Example 2 | 3 | Example of using server signals with axum. 4 | 5 | ```console 6 | $ cd examples/axum-example 7 | $ cargo leptos serve 8 | ``` -------------------------------------------------------------------------------- /examples/axum-example/src/app.rs: -------------------------------------------------------------------------------- 1 | use leptos::*; 2 | use leptos_server_signal::create_server_signal; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Clone, Default, Serialize, Deserialize)] 6 | pub struct Count { 7 | pub value: i32, 8 | } 9 | 10 | #[component] 11 | pub fn App() -> impl IntoView { 12 | // Provide websocket connection 13 | leptos_server_signal::provide_websocket("ws://localhost:3000/ws").unwrap(); 14 | 15 | // Create server signal 16 | let count = create_server_signal::("counter"); 17 | 18 | view! {

"Count: " {move || count.get().value.to_string()}

} 19 | } 20 | -------------------------------------------------------------------------------- /examples/axum-example/src/fileserv.rs: -------------------------------------------------------------------------------- 1 | use cfg_if::cfg_if; 2 | 3 | cfg_if! { if #[cfg(feature = "ssr")] { 4 | use axum::{ 5 | body::Body, 6 | extract::State, 7 | response::IntoResponse, 8 | http::{Request, Response, StatusCode, Uri}, 9 | }; 10 | use axum::response::Response as AxumResponse; 11 | use tower::ServiceExt; 12 | use tower_http::services::ServeDir; 13 | use leptos::*; 14 | 15 | pub async fn file_and_error_handler(uri: Uri, State(options): State) -> AxumResponse { 16 | let root = options.site_root.clone(); 17 | let res = get_static_file(uri.clone(), &root).await.unwrap(); 18 | 19 | if res.status() == StatusCode::OK { 20 | res.into_response() 21 | } else { 22 | "404 not found".into_response() 23 | } 24 | } 25 | 26 | async fn get_static_file(uri: Uri, root: &str) -> Result, (StatusCode, String)> { 27 | let req = Request::builder().uri(uri.clone()).body(Body::empty()).unwrap(); 28 | // `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot` 29 | // This path is relative to the cargo root 30 | match ServeDir::new(root).oneshot(req).await { 31 | Ok(res) => Ok(res.map(Box::new).into_response()), 32 | Err(err) => Err(( 33 | StatusCode::INTERNAL_SERVER_ERROR, 34 | format!("Something went wrong: {err}"), 35 | )), 36 | } 37 | } 38 | }} 39 | -------------------------------------------------------------------------------- /examples/axum-example/src/lib.rs: -------------------------------------------------------------------------------- 1 | use cfg_if::cfg_if; 2 | pub mod app; 3 | pub mod fileserv; 4 | 5 | cfg_if! { if #[cfg(feature = "hydrate")] { 6 | use leptos::*; 7 | use wasm_bindgen::prelude::wasm_bindgen; 8 | use crate::app::*; 9 | 10 | #[wasm_bindgen] 11 | pub fn hydrate() { 12 | // initializes logging using the `log` crate 13 | _ = console_log::init_with_level(log::Level::Debug); 14 | console_error_panic_hook::set_once(); 15 | 16 | leptos::mount_to_body(move || { 17 | view! { } 18 | }); 19 | } 20 | }} 21 | 22 | -------------------------------------------------------------------------------- /examples/axum-example/src/main.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "ssr")] 2 | #[tokio::main] 3 | async fn main() { 4 | use axum::{ 5 | routing::{get, post}, 6 | Router, 7 | }; 8 | use axum_example::app::*; 9 | use axum_example::fileserv::file_and_error_handler; 10 | use leptos::*; 11 | use leptos_axum::{generate_route_list, LeptosRoutes}; 12 | 13 | let conf = get_configuration(None).await.unwrap(); 14 | let leptos_options = conf.leptos_options; 15 | let addr = leptos_options.site_addr; 16 | let routes = generate_route_list(|| view! { }); 17 | 18 | // build our application with a route 19 | let app = Router::new() 20 | .route("/api/*fn_name", post(leptos_axum::handle_server_fns)) 21 | .route("/ws", get(websocket)) 22 | .leptos_routes(&leptos_options, routes, || view! { }) 23 | .fallback(file_and_error_handler) 24 | .with_state(leptos_options); 25 | 26 | let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); 27 | axum::serve(listener, app.into_make_service()) 28 | .await 29 | .unwrap(); 30 | } 31 | 32 | #[cfg(not(feature = "ssr"))] 33 | pub fn main() { 34 | // no client-side main function 35 | // unless we want this to work with e.g., Trunk for a purely client-side app 36 | // see lib.rs for hydration function instead 37 | } 38 | 39 | #[cfg(feature = "ssr")] 40 | pub async fn websocket(ws: axum::extract::WebSocketUpgrade) -> axum::response::Response { 41 | ws.on_upgrade(handle_socket) 42 | } 43 | 44 | #[cfg(feature = "ssr")] 45 | async fn handle_socket(mut socket: axum::extract::ws::WebSocket) { 46 | use std::time::Duration; 47 | 48 | use axum_example::app::Count; 49 | use leptos_server_signal::ServerSignal; 50 | 51 | let mut count = ServerSignal::::new("counter").unwrap(); 52 | 53 | loop { 54 | tokio::time::sleep(Duration::from_millis(100)).await; 55 | let result = count.with(&mut socket, |count| count.value += 1).await; 56 | if result.is_err() { 57 | break; 58 | } 59 | } 60 | } 61 | 62 | -------------------------------------------------------------------------------- /src/actix.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | use std::{fmt, ops}; 3 | 4 | use actix_ws::Session; 5 | use serde::Serialize; 6 | use serde_json::Value; 7 | use thiserror::Error; 8 | 9 | use crate::ServerSignalUpdate; 10 | 11 | /// A signal owned by the server which writes to the websocket when mutated. 12 | #[derive(Clone)] 13 | pub struct ServerSignal { 14 | name: Cow<'static, str>, 15 | value: T, 16 | json_value: Value, 17 | session: Session, 18 | } 19 | 20 | impl ServerSignal { 21 | /// Creates a new [`ServerSignal`], initializing `T` to default. 22 | /// 23 | /// This function can fail if serilization of `T` fails. 24 | pub fn new( 25 | name: impl Into>, 26 | session: Session, 27 | ) -> Result 28 | where 29 | T: Default + Serialize, 30 | { 31 | Ok(ServerSignal { 32 | name: name.into(), 33 | value: T::default(), 34 | json_value: serde_json::to_value(T::default())?, 35 | session, 36 | }) 37 | } 38 | 39 | /// Modifies the signal in a closure, and sends the json diffs through the websocket connection after modifying. 40 | /// 41 | /// # Example 42 | /// 43 | /// ```ignore 44 | /// let count = ServerSignal::new("counter", websocket).unwrap(); 45 | /// count.with(|count| { 46 | /// count.value += 1; 47 | /// }).await?; 48 | /// ``` 49 | pub async fn with<'e, O>(&'e mut self, f: impl FnOnce(&mut T) -> O) -> Result 50 | where 51 | T: Clone + Serialize + 'static, 52 | { 53 | let output = f(&mut self.value); 54 | let new_json = serde_json::to_value(self.value.clone())?; 55 | let update = 56 | ServerSignalUpdate::new_from_json::(self.name.clone(), &self.json_value, &new_json); 57 | let update_json = serde_json::to_string(&update)?; 58 | self.session.text(update_json).await?; 59 | self.json_value = new_json; 60 | Ok(output) 61 | } 62 | 63 | /// Consumes the [`ServerSignal`], returning the inner value. 64 | pub fn into_value(self) -> T { 65 | self.value 66 | } 67 | 68 | /// Consumes the [`ServerSignal`], returning the inner json value. 69 | pub fn into_json_value(self) -> Value { 70 | self.json_value 71 | } 72 | } 73 | 74 | impl fmt::Debug for ServerSignal 75 | where 76 | T: fmt::Debug, 77 | { 78 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 79 | write!(f, "ServerSignal({:?})", self.value) 80 | } 81 | } 82 | 83 | impl ops::Deref for ServerSignal { 84 | type Target = T; 85 | 86 | fn deref(&self) -> &Self::Target { 87 | &self.value 88 | } 89 | } 90 | 91 | impl AsRef for ServerSignal { 92 | fn as_ref(&self) -> &T { 93 | &self.value 94 | } 95 | } 96 | 97 | /// A server signal error. 98 | #[derive(Debug, Error)] 99 | pub enum Error { 100 | /// Serialization of the signal value failed. 101 | #[error(transparent)] 102 | SerializationFailed(#[from] serde_json::Error), 103 | /// The websocket was closed. 104 | #[error(transparent)] 105 | WebSocket(#[from] actix_ws::Closed), 106 | } 107 | -------------------------------------------------------------------------------- /src/axum.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | use std::ops; 3 | 4 | use axum::extract::ws::Message; 5 | use futures::sink::{Sink, SinkExt}; 6 | use serde::Serialize; 7 | use serde_json::Value; 8 | use thiserror::Error; 9 | 10 | use crate::ServerSignalUpdate; 11 | 12 | /// A signal owned by the server which writes to the websocket when mutated. 13 | #[derive(Clone, Debug)] 14 | pub struct ServerSignal { 15 | name: Cow<'static, str>, 16 | value: T, 17 | json_value: Value, 18 | } 19 | 20 | impl ServerSignal { 21 | /// Creates a new [`ServerSignal`], initializing `T` to default. 22 | /// 23 | /// This function can fail if serilization of `T` fails. 24 | pub fn new(name: impl Into>) -> Result 25 | where 26 | T: Default + Serialize, 27 | { 28 | Ok(ServerSignal { 29 | name: name.into(), 30 | value: T::default(), 31 | json_value: serde_json::to_value(T::default())?, 32 | }) 33 | } 34 | 35 | /// Modifies the signal in a closure, and sends the json diffs through the websocket connection after modifying. 36 | /// 37 | /// The same websocket connection should be used for a given client, otherwise the signal could become out of sync. 38 | /// 39 | /// # Example 40 | /// 41 | /// ```ignore 42 | /// let count = ServerSignal::new("counter").unwrap(); 43 | /// count.with(&mut websocket, |count| { 44 | /// count.value += 1; 45 | /// }).await?; 46 | /// ``` 47 | pub async fn with<'e, O, S>( 48 | &'e mut self, 49 | sink: &mut S, 50 | f: impl FnOnce(&mut T) -> O, 51 | ) -> Result 52 | where 53 | T: Clone + Serialize + 'static, 54 | S: Sink + Unpin, 55 | axum::Error: From<>::Error>, 56 | { 57 | let output = f(&mut self.value); 58 | let new_json = serde_json::to_value(self.value.clone())?; 59 | let update = 60 | ServerSignalUpdate::new_from_json::(self.name.clone(), &self.json_value, &new_json); 61 | let update_json = serde_json::to_string(&update)?; 62 | sink.send(Message::Text(update_json)) 63 | .await 64 | .map_err(|err| Error::WebSocket(err.into()))?; 65 | self.json_value = new_json; 66 | Ok(output) 67 | } 68 | 69 | /// Consumes the [`ServerSignal`], returning the inner value. 70 | pub fn into_value(self) -> T { 71 | self.value 72 | } 73 | 74 | /// Consumes the [`ServerSignal`], returning the inner json value. 75 | pub fn into_json_value(self) -> Value { 76 | self.json_value 77 | } 78 | } 79 | 80 | impl ops::Deref for ServerSignal { 81 | type Target = T; 82 | 83 | fn deref(&self) -> &Self::Target { 84 | &self.value 85 | } 86 | } 87 | 88 | impl AsRef for ServerSignal { 89 | fn as_ref(&self) -> &T { 90 | &self.value 91 | } 92 | } 93 | 94 | /// A server signal error. 95 | #[derive(Debug, Error)] 96 | pub enum Error { 97 | /// Serialization of the signal value failed. 98 | #[error(transparent)] 99 | SerializationFailed(#[from] serde_json::Error), 100 | /// The websocket returned an error. 101 | #[error(transparent)] 102 | WebSocket(#[from] axum::Error), 103 | } 104 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![doc = include_str!("../README.md")] 2 | 3 | use std::borrow::Cow; 4 | 5 | use json_patch::Patch; 6 | use leptos::prelude::{signal, ReadSignal}; 7 | use serde::{Deserialize, Serialize}; 8 | use serde_json::Value; 9 | use wasm_bindgen::JsValue; 10 | use web_sys::WebSocket; 11 | 12 | cfg_if::cfg_if! { 13 | if #[cfg(all(feature = "actix", feature = "ssr"))] { 14 | mod actix; 15 | pub use crate::actix::*; 16 | } 17 | } 18 | 19 | cfg_if::cfg_if! { 20 | if #[cfg(all(feature = "axum", feature = "ssr"))] { 21 | mod axum; 22 | pub use crate::axum::*; 23 | } 24 | } 25 | 26 | /// A server signal update containing the signal type name and json patch. 27 | /// 28 | /// This is whats sent over the websocket, and is used to patch the signal if the type name matches. 29 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 30 | pub struct ServerSignalUpdate { 31 | name: Cow<'static, str>, 32 | patch: Patch, 33 | } 34 | 35 | impl ServerSignalUpdate { 36 | /// Creates a new [`ServerSignalUpdate`] from an old and new instance of `T`. 37 | pub fn new( 38 | name: impl Into>, 39 | old: &T, 40 | new: &T, 41 | ) -> Result 42 | where 43 | T: Serialize, 44 | { 45 | let left = serde_json::to_value(old)?; 46 | let right = serde_json::to_value(new)?; 47 | let patch = json_patch::diff(&left, &right); 48 | Ok(ServerSignalUpdate { 49 | name: name.into(), 50 | patch, 51 | }) 52 | } 53 | 54 | /// Creates a new [`ServerSignalUpdate`] from two json values. 55 | pub fn new_from_json(name: impl Into>, old: &Value, new: &Value) -> Self { 56 | let patch = json_patch::diff(old, new); 57 | ServerSignalUpdate { 58 | name: name.into(), 59 | patch, 60 | } 61 | } 62 | } 63 | 64 | /// Provides a websocket url for server signals, if there is not already one provided. 65 | /// 66 | /// During SSR, this function is a no-op and returns `Ok(None)`. 67 | /// During CSR, if this function returns `Ok`, then the `Option` will always be `Some`. 68 | /// 69 | /// Note, the server should have a route to handle this websocket. 70 | /// 71 | /// # Example 72 | /// 73 | /// ```ignore 74 | /// #[component] 75 | /// pub fn App() -> impl IntoView { 76 | /// // Provide websocket connection 77 | /// leptos_server_signal::provide_websocket("ws://localhost:3000/ws").unwrap(); 78 | /// 79 | /// // ... 80 | /// } 81 | /// ``` 82 | #[allow(unused_variables)] 83 | pub fn provide_websocket(url: &str) -> Result, JsValue> { 84 | provide_websocket_inner(url) 85 | } 86 | 87 | /// Provides a websocket url for server signals, if there is not already one provided. 88 | /// In case of a connection lost, the websocket will be reconnected after the specified 89 | /// timeout. 90 | /// 91 | /// During SSR, this function is a no-op and returns `Ok(None)`. 92 | /// During CSR, if this function returns `Ok`, then the `Option` will always be `Some`. 93 | /// 94 | /// Note, the server should have a route to handle this websocket. 95 | /// 96 | /// # Example 97 | /// 98 | /// ```ignore 99 | /// #[component] 100 | /// pub fn App() -> impl IntoView { 101 | /// // Provide websocket connection 102 | /// leptos_server_signal::provide_websocket_with_retry( 103 | /// "ws://localhost:3000/ws", 104 | /// 5000, // retry to connect after 5 seconds 105 | /// ).unwrap(); 106 | /// 107 | /// // ... 108 | /// } 109 | /// ``` 110 | pub fn provide_websocket_with_retry( 111 | url: &str, 112 | timeout_in_ms: i32, 113 | ) -> Result, JsValue> { 114 | let ws = provide_websocket_inner(url); 115 | if let Ok(Some(ref ws)) = ws { 116 | add_retry_timeout(&ws, timeout_in_ms); 117 | } 118 | ws 119 | } 120 | 121 | /// Creates a signal which is controlled by the server. 122 | /// 123 | /// This signal is initialized as T::default, is read-only on the client, and is updated through json patches 124 | /// sent through a websocket connection. 125 | /// 126 | /// # Example 127 | /// 128 | /// ``` 129 | /// # use leptos::{component, view, IntoView, SignalGet}; 130 | /// # use serde::{Deserialize, Serialize}; 131 | /// # use leptos_server_signal::create_server_signal; 132 | /// 133 | /// #[derive(Clone, Default, Serialize, Deserialize)] 134 | /// pub struct Count { 135 | /// pub value: i32, 136 | /// } 137 | /// 138 | /// #[component] 139 | /// pub fn App() -> impl IntoView { 140 | /// // Create server signal 141 | /// let count = create_server_signal::("counter"); 142 | /// 143 | /// view! { 144 | ///

"Count: " {move || count.get().value.to_string()}

145 | /// } 146 | /// } 147 | /// ``` 148 | #[allow(unused_variables)] 149 | pub fn create_server_signal(name: impl Into>) -> ReadSignal 150 | where 151 | T: Send + Sync + Default + Serialize + for<'de> Deserialize<'de> + 'static, 152 | { 153 | let name: Cow<'static, str> = name.into(); 154 | let (get, set) = signal(T::default()); 155 | 156 | cfg_if::cfg_if! { 157 | if #[cfg(target_arch = "wasm32")] { 158 | use leptos::{use_context, create_effect, create_rw_signal, SignalGet, SignalSet}; 159 | 160 | let signal = create_rw_signal(serde_json::to_value(T::default()).unwrap()); 161 | if let Some(ServerSignalWebSocket { state_signals, .. }) = use_context::() { 162 | let name: Cow<'static, str> = name.into(); 163 | state_signals.borrow_mut().insert(name.clone(), signal); 164 | 165 | // Note: The leptos docs advise against doing this. It seems to work 166 | // well in testing, and the primary caveats are around unnecessary 167 | // updates firing, but our state synchronization already prevents 168 | // that on the server side 169 | create_effect(move |_| { 170 | let name = name.clone(); 171 | let new_value = serde_json::from_value(signal.get()).unwrap(); 172 | set.set(new_value); 173 | }); 174 | 175 | } else { 176 | leptos::logging::error!( 177 | r#"server signal was used without a websocket being provided. 178 | 179 | Ensure you call `leptos_server_signal::provide_websocket("ws://localhost:3000/ws")` at the highest level in your app."# 180 | ); 181 | } 182 | 183 | } 184 | } 185 | 186 | get 187 | } 188 | 189 | cfg_if::cfg_if! { 190 | if #[cfg(target_arch = "wasm32")] { 191 | use std::cell::RefCell; 192 | use std::collections::HashMap; 193 | use std::rc::Rc; 194 | 195 | use leptos::{provide_context, RwSignal}; 196 | 197 | /// The websocket connection wrapper provided as a context in Leptos. 198 | #[derive(Clone, Debug, PartialEq, Eq)] 199 | pub struct ServerSignalWebSocket { 200 | ws: WebSocket, 201 | // References to these are kept by the closure for the callback 202 | // onmessage callback on the websocket 203 | state_signals: Rc, RwSignal>>>, 204 | // When the websocket is first established, the leptos may not have 205 | // completed the traversal that sets up all of the state signals. 206 | // Without that, we don't have a base state to apply the patches to, 207 | // and therefore we must keep a record of the patches to apply after 208 | // the state has been set up. 209 | delayed_updates: Rc, Vec>>>, 210 | } 211 | 212 | impl ServerSignalWebSocket { 213 | /// Returns the inner websocket. 214 | pub fn ws(&self) -> WebSocket { 215 | self.ws.clone() 216 | } 217 | } 218 | 219 | #[inline] 220 | fn provide_websocket_inner(url: &str) -> Result, JsValue> { 221 | use web_sys::MessageEvent; 222 | use wasm_bindgen::{prelude::Closure, JsCast}; 223 | use leptos::{use_context, SignalUpdate}; 224 | use js_sys::{Function, JsString}; 225 | 226 | if use_context::().is_none() { 227 | let ws = WebSocket::new(url)?; 228 | provide_context(ServerSignalWebSocket { ws, state_signals: Rc::default(), delayed_updates: Rc::default() }); 229 | } 230 | 231 | let ws = use_context::().unwrap(); 232 | 233 | let handlers = ws.state_signals.clone(); 234 | let delayed_updates = ws.delayed_updates.clone(); 235 | 236 | let callback = Closure::wrap(Box::new(move |event: MessageEvent| { 237 | let ws_string = event.data().dyn_into::().unwrap().as_string().unwrap(); 238 | if let Ok(update_signal) = serde_json::from_str::(&ws_string) { 239 | let handler_map = (*handlers).borrow(); 240 | let name = &update_signal.name; 241 | let mut delayed_map = (*delayed_updates).borrow_mut(); 242 | if let Some(signal) = handler_map.get(name) { 243 | if let Some(delayed_patches) = delayed_map.remove(name) { 244 | signal.update(|doc| { 245 | for patch in delayed_patches { 246 | json_patch::patch(doc, &patch).unwrap(); 247 | } 248 | }); 249 | } 250 | signal.update(|doc| { 251 | json_patch::patch(doc, &update_signal.patch).unwrap(); 252 | }); 253 | } else { 254 | leptos::logging::warn!("No local state for update to {}. Queuing patch.", name); 255 | delayed_map.entry(name.clone()).or_default().push(update_signal.patch.clone()); 256 | } 257 | } 258 | }) as Box); 259 | let function: &Function = callback.as_ref().unchecked_ref(); 260 | ws.ws.set_onmessage(Some(function)); 261 | 262 | // Keep the closure alive for the lifetime of the program 263 | callback.forget(); 264 | 265 | Ok(Some(ws.ws())) 266 | } 267 | 268 | #[inline] 269 | fn add_retry_timeout(ws: &WebSocket, timeout_in_ms: i32) { 270 | use web_sys::{MessageEvent, window}; 271 | use wasm_bindgen::prelude::{Closure, JsCast}; 272 | use leptos::use_context; 273 | use js_sys::Function; 274 | 275 | let mut server_signal_ws = use_context::().unwrap(); 276 | 277 | let on_timeout_callback = Closure::wrap(Box::new(move |_: MessageEvent| { 278 | leptos::logging::log!("Try to reconnect signal web-socket."); 279 | let new_ws = WebSocket::new(server_signal_ws.ws.url().as_str()).unwrap(); 280 | new_ws.set_onmessage(server_signal_ws.ws.onmessage().as_ref()); 281 | new_ws.set_onclose(server_signal_ws.ws.onclose().as_ref()); 282 | new_ws.set_onerror(server_signal_ws.ws.onerror().as_ref()); 283 | server_signal_ws.ws = new_ws; 284 | }) as Box); 285 | 286 | let on_error_callback = Closure::wrap(Box::new(move |_: MessageEvent| { 287 | let on_timeout_function: &Function = on_timeout_callback.as_ref().unchecked_ref(); 288 | leptos::logging::log!( 289 | "Connection lost to signal web-socket. Try to reconnect in {} milliseconds.", 290 | timeout_in_ms 291 | ); 292 | let _ = window().unwrap().set_timeout_with_callback_and_timeout_and_arguments_0( 293 | on_timeout_function, 294 | timeout_in_ms 295 | ); 296 | }) as Box); 297 | let on_error_function: &Function = on_error_callback.as_ref().unchecked_ref(); 298 | ws.set_onerror(Some(on_error_function)); 299 | on_error_callback.forget(); 300 | } 301 | } else { 302 | #[inline] 303 | fn provide_websocket_inner(_url: &str) -> Result, JsValue> { 304 | Ok(None) 305 | } 306 | 307 | #[inline] 308 | fn add_retry_timeout(_ws: &WebSocket, _timeout_in_ms: i32) {} 309 | } 310 | } 311 | --------------------------------------------------------------------------------