├── .github └── workflows │ └── CI.yml ├── .gitignore ├── .vscode └── settings.json ├── Cargo.toml ├── LICENSE ├── README.md ├── 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 /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.event.pull_request.number || github.sha }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | check: 16 | name: Check 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v3 20 | - uses: dtolnay/rust-toolchain@stable 21 | - run: cargo check --all-features 22 | 23 | test: 24 | name: Test Suite 25 | runs-on: ubuntu-latest 26 | env: 27 | SCCACHE_GHA_ENABLED: "true" 28 | RUSTC_WRAPPER: "sccache" 29 | steps: 30 | - uses: actions/checkout@v3 31 | - uses: dtolnay/rust-toolchain@stable 32 | with: 33 | targets: wasm32-unknown-unknown 34 | - uses: mozilla-actions/sccache-action@v0.0.3 35 | - run: cargo build 36 | - run: cargo build --target wasm32-unknown-unknown 37 | - run: cargo build --features axum,ssr 38 | - run: cargo build --features actix,ssr 39 | - uses: taiki-e/install-action@v2 40 | with: 41 | tool: cargo-leptos 42 | - name: Build axum example 43 | working-directory: examples/axum-example 44 | run: cargo leptos build 45 | - name: Build actix-web example 46 | working-directory: examples/actix-example 47 | run: cargo leptos build 48 | 49 | fmt: 50 | name: Rustfmt 51 | runs-on: ubuntu-latest 52 | steps: 53 | - uses: actions/checkout@v3 54 | - uses: dtolnay/rust-toolchain@stable 55 | with: 56 | components: rustfmt 57 | - run: cargo fmt --all --check 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/target 2 | **/Cargo.lock 3 | pkg 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "rust-analyzer.showUnlinkedFileNotification": false 3 | } -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [".", "examples/*"] 3 | 4 | [package] 5 | name = "leptos_sse" 6 | version = "0.4.0" 7 | edition = "2021" 8 | description = "Leptos server signals synced through server-sent-events (SSE)" 9 | repository = "https://github.com/messense/leptos_sse" 10 | license = "MIT" 11 | keywords = ["leptos", "server", "signal", "sse"] 12 | categories = [ 13 | "wasm", 14 | "web-programming", 15 | "web-programming::http-client", 16 | "web-programming::http-server", 17 | ] 18 | 19 | [dependencies] 20 | cfg-if = "1" 21 | js-sys = "0.3.61" 22 | json-patch = "1.0.0" 23 | leptos = { version = "0.6", default-features = false } 24 | serde = { version = "1.0.160", features = ["derive"] } 25 | serde_json = "1.0" 26 | wasm-bindgen = { version = "0.2.84", default-features = false } 27 | web-sys = { version = "0.3.61", features = ["EventSource", "MessageEvent"] } 28 | pin-project-lite = "0.2.12" 29 | tokio = { version = "1.36.0", optional = true } 30 | tokio-stream = { version = "0.1.14", optional = true } 31 | 32 | # Actix 33 | actix-web-lab = { version = "0.20.0", optional = true } 34 | 35 | # Axum 36 | axum = { version = "0.7", default-features = false, features = [ 37 | "tokio", 38 | "json", 39 | ], optional = true } 40 | futures = { version = "0.3.28", default-features = false, optional = true } 41 | 42 | [features] 43 | default = [] 44 | ssr = [] 45 | actix = ["dep:actix-web-lab", "dep:futures", "dep:tokio", "dep:tokio-stream"] 46 | axum = ["dep:axum", "dep:futures", "dep:tokio", "dep:tokio-stream"] 47 | 48 | [package.metadata.docs.rs] 49 | features = ["axum", "ssr"] 50 | rustdoc-args = ["--cfg", "docsrs"] 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 messense 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. 22 | 23 | ------ 24 | 25 | leptos_server_signal license: 26 | 27 | MIT License 28 | 29 | Copyright (c) 2023 Ari Seyhun 30 | 31 | Permission is hereby granted, free of charge, to any person obtaining a copy 32 | of this software and associated documentation files (the "Software"), to deal 33 | in the Software without restriction, including without limitation the rights 34 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 35 | copies of the Software, and to permit persons to whom the Software is 36 | furnished to do so, subject to the following conditions: 37 | 38 | The above copyright notice and this permission notice shall be included in all 39 | copies or substantial portions of the Software. 40 | 41 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 42 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 43 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 44 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 45 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 46 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 47 | SOFTWARE. 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Leptos Server Sent Events 2 | 3 | [![GitHub Actions](https://github.com/messense/leptos_sse/workflows/CI/badge.svg)](https://github.com/messense/leptos_sse/actions?query=workflow%3ACI) 4 | [![Crates.io](https://img.shields.io/crates/v/leptos_sse.svg)](https://crates.io/crates/leptos_sse) 5 | [![docs.rs](https://docs.rs/leptos_sse/badge.svg)](https://docs.rs/leptos_sse/) 6 | 7 | Server signals are [leptos] [signals] kept in sync with the server through server-sent-events (SSE). 8 | 9 | The signals are read-only on the client side, and can be written to by the server. 10 | This is useful if you want real-time updates on the UI controlled by the server. 11 | 12 | Changes to a signal are sent through a SSE to the client as [json patches]. 13 | 14 | [leptos]: https://crates.io/crates/leptos 15 | [signals]: https://docs.rs/leptos/latest/leptos/struct.Signal.html 16 | [json patches]: https://docs.rs/json-patch/latest/json_patch/struct.Patch.html 17 | 18 | This project is heavily based on [leptos_server_signal](https://github.com/tqwewe/leptos_server_signal). 19 | 20 | ## Feature flags 21 | 22 | - `ssr`: ssr is enabled when rendering the app on the server. 23 | - `actix`: integration with the [Actix] web framework. 24 | - `axum`: integration with the [Axum] web framework. 25 | 26 | [actix]: https://crates.io/crates/actix-web 27 | [axum]: https://crates.io/crates/axum 28 | 29 | # Example 30 | 31 | **Cargo.toml** 32 | 33 | ```toml 34 | [dependencies] 35 | leptos_sse = "*" 36 | serde = { version = "*", features = ["derive"] } 37 | 38 | [features] 39 | ssr = [ 40 | "leptos_sse/ssr", 41 | "leptos_sse/axum", # or actix 42 | ] 43 | ``` 44 | 45 | **Client** 46 | 47 | ```rust 48 | use leptos::*; 49 | use leptos_sse::create_sse_signal; 50 | use serde::{Deserialize, Serialize}; 51 | 52 | #[derive(Clone, Default, Serialize, Deserialize)] 53 | pub struct Count { 54 | pub value: i32, 55 | } 56 | 57 | #[component] 58 | pub fn App() -> impl IntoView { 59 | // Provide SSE connection 60 | leptos_sse::provide_sse("http://localhost:3000/sse").unwrap(); 61 | 62 | // Create server signal 63 | let count = create_sse_signal::("counter"); 64 | 65 | view! { 66 |

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

67 | } 68 | } 69 | ``` 70 | 71 | > If on stable, use `count.get().value` instead of `count().value`. 72 | 73 | **Server (Axum)** 74 | 75 | ```rust 76 | #[cfg(feature = "ssr")] 77 | use { 78 | axum::response::sse::{Event, KeepAlive, Sse}, 79 | futures::stream::Stream, 80 | }; 81 | 82 | #[cfg(feature = "ssr")] 83 | async fn handle_sse() -> Sse>> { 84 | use futures::stream; 85 | use leptos_sse::ServerSentEvents; 86 | use std::time::Duration; 87 | use tokio_stream::StreamExt as _; 88 | 89 | let mut value = 0; 90 | let stream = ServerSentEvents::new( 91 | "counter", 92 | stream::repeat_with(move || { 93 | let curr = value; 94 | value += 1; 95 | Ok(Count { value: curr }) 96 | }) 97 | .throttle(Duration::from_secs(1)), 98 | ) 99 | .unwrap(); 100 | Sse::new(stream).keep_alive(KeepAlive::default()) 101 | } 102 | ``` 103 | 104 | ## License 105 | 106 | This work is released under the MIT license. A copy of the license is provided in the [LICENSE](./LICENSE) file. 107 | -------------------------------------------------------------------------------- /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-web-lab = { version = "0.20.0", optional = true } 13 | console_error_panic_hook = "0.1" 14 | console_log = "1" 15 | cfg-if = "1" 16 | futures = { version = "0.3.28", default-features = false, optional = true } 17 | leptos = { version = "0.6", default-features = false, features = ["serde"] } 18 | leptos_actix = { version = "0.6", optional = true } 19 | leptos_sse = { path = "../.." } 20 | log = "0.4" 21 | serde = { version = "1.0", features = ["derive"] } 22 | tokio-stream = { version = "0.1.14", optional = true } 23 | wasm-bindgen = "0.2.87" 24 | 25 | [features] 26 | hydrate = ["leptos/hydrate"] 27 | ssr = [ 28 | "dep:actix-files", 29 | "dep:actix-web", 30 | "dep:actix-web-lab", 31 | "dep:futures", 32 | "dep:leptos_actix", 33 | "dep:tokio-stream", 34 | "leptos/ssr", 35 | "leptos_sse/ssr", 36 | "leptos_sse/actix", 37 | ] 38 | 39 | [package.metadata.leptos] 40 | # The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name 41 | output-name = "actix_example" 42 | # 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. 43 | site-root = "target/site" 44 | # The site-root relative folder where all compiled output (JS, WASM and CSS) is written 45 | # Defaults to pkg 46 | site-pkg-dir = "pkg" 47 | # [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 48 | # style-file = "style/main.scss" 49 | # Assets source dir. All files found here will be copied and synchronized to site-root. 50 | # The assets-dir cannot have a sub directory with the same name/path as site-pkg-dir. 51 | # 52 | # Optional. Env: LEPTOS_ASSETS_DIR. 53 | # assets-dir = "assets" 54 | # The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup. 55 | site-addr = "127.0.0.1:3000" 56 | # The port to use for automatic reload monitoring 57 | reload-port = 3001 58 | # [Optional] Command to use when running end2end tests. It will run in the end2end dir. 59 | # [Windows] for non-WSL use "npx.cmd playwright test" 60 | # This binary name can be checked in Powershell with Get-Command npx 61 | end2end-cmd = "npx playwright test" 62 | end2end-dir = "end2end" 63 | # The browserlist query used for optimizing the CSS. 64 | browserquery = "defaults" 65 | # Set by cargo-leptos watch when building with that tool. Controls whether autoreload JS will be included in the head 66 | watch = false 67 | # The environment Leptos will run in, usually either "DEV" or "PROD" 68 | env = "DEV" 69 | # The features to use when compiling the bin target 70 | # 71 | # Optional. Can be over-ridden with the command line parameter --bin-features 72 | bin-features = ["ssr"] 73 | 74 | # If the --no-default-features flag should be used when compiling the bin target 75 | # 76 | # Optional. Defaults to false. 77 | bin-default-features = false 78 | 79 | # The features to use when compiling the lib target 80 | # 81 | # Optional. Can be over-ridden with the command line parameter --lib-features 82 | lib-features = ["hydrate"] 83 | 84 | # If the --no-default-features flag should be used when compiling the lib target 85 | # 86 | # Optional. Defaults to false. 87 | lib-default-features = false 88 | -------------------------------------------------------------------------------- /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_sse::create_sse_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 sse connection 13 | leptos_sse::provide_sse("http://localhost:3000/sse").unwrap(); 14 | 15 | // Create server signal 16 | let count = create_sse_signal::("counter"); 17 | 18 | view! { 19 |

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

20 | } 21 | } 22 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | // Generate the list of routes in your Leptos App 13 | let routes = generate_route_list(|| view! { }); 14 | 15 | HttpServer::new(move || { 16 | let leptos_options = &conf.leptos_options; 17 | let site_root = &leptos_options.site_root; 18 | 19 | App::new() 20 | .route("/api/{tail:.*}", leptos_actix::handle_server_fns()) 21 | .route("/sse", web::get().to(handle_sse)) 22 | .leptos_routes( 23 | leptos_options.to_owned(), 24 | routes.to_owned(), 25 | || view! { }, 26 | ) 27 | .service(Files::new("/", site_root)) 28 | //.wrap(middleware::Compress::default()) 29 | }) 30 | .bind(&addr)? 31 | .run() 32 | .await 33 | } 34 | 35 | #[cfg(not(feature = "ssr"))] 36 | pub fn main() { 37 | // no client-side main function 38 | // unless we want this to work with e.g., Trunk for pure client-side testing 39 | // see lib.rs for hydration function instead 40 | } 41 | 42 | #[cfg(feature = "ssr")] 43 | pub async fn handle_sse() -> impl actix_web::Responder { 44 | use actix_example::app::Count; 45 | use actix_web_lab::sse; 46 | use futures::stream; 47 | use leptos_sse::ServerSentEvents; 48 | use std::time::Duration; 49 | use tokio_stream::StreamExt as _; 50 | 51 | let mut value = 0; 52 | let stream = ServerSentEvents::new( 53 | "counter", 54 | stream::repeat_with(move || { 55 | let curr = value; 56 | value += 1; 57 | Ok(Count { value: curr }) 58 | }) 59 | .throttle(Duration::from_secs(1)), 60 | ) 61 | .unwrap(); 62 | sse::Sse::from_stream(stream).with_keep_alive(Duration::from_secs(5)) 63 | } 64 | -------------------------------------------------------------------------------- /examples/axum-example/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "axum_example" 3 | version = "0.2.0" 4 | edition = "2021" 5 | 6 | [lib] 7 | crate-type = ["cdylib", "rlib"] 8 | 9 | [dependencies] 10 | axum = { version = "0.7", optional = true } 11 | console_error_panic_hook = "0.1" 12 | console_log = "1" 13 | cfg-if = "1" 14 | futures = { version = "0.3.28", default-features = false, optional = true } 15 | leptos = { version = "0.6", default-features = false, features = ["serde"] } 16 | leptos_axum = { version = "0.6", optional = true } 17 | leptos_sse = { path = "../.." } 18 | log = "0.4" 19 | serde = { version = "1.0", features = ["derive"] } 20 | simple_logger = "4" 21 | tokio = { version = "1.36", features = ["rt", "macros", "rt-multi-thread"], optional = true } 22 | tokio-stream = { version = "0.1.14", optional = true } 23 | tower = { version = "0.4", optional = true } 24 | tower-http = { version = "0.5", features = ["fs"], optional = true } 25 | wasm-bindgen = "0.2" 26 | http = "1.0" 27 | 28 | [features] 29 | hydrate = ["leptos/hydrate"] 30 | ssr = [ 31 | "dep:axum", 32 | "dep:futures", 33 | "dep:tokio", 34 | "dep:tokio-stream", 35 | "dep:tower", 36 | "dep:tower-http", 37 | "dep:leptos_axum", 38 | "leptos/ssr", 39 | "leptos_sse/ssr", 40 | "leptos_sse/axum", 41 | ] 42 | 43 | [package.metadata.cargo-all-features] 44 | denylist = ["axum", "tokio", "tower", "tower-http", "leptos_axum"] 45 | skip_feature_sets = [["ssr", "hydrate"]] 46 | 47 | [package.metadata.leptos] 48 | # The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name 49 | output-name = "axum_example" 50 | 51 | # 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. 52 | site-root = "target/site" 53 | 54 | # The site-root relative folder where all compiled output (JS, WASM and CSS) is written 55 | # Defaults to pkg 56 | site-pkg-dir = "pkg" 57 | 58 | # [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 59 | # style-file = "style/main.scss" 60 | # Assets source dir. All files found here will be copied and synchronized to site-root. 61 | # The assets-dir cannot have a sub directory with the same name/path as site-pkg-dir. 62 | # 63 | # Optional. Env: LEPTOS_ASSETS_DIR. 64 | # assets-dir = "public" 65 | 66 | # The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup. 67 | site-addr = "127.0.0.1:3000" 68 | 69 | # The port to use for automatic reload monitoring 70 | reload-port = 3001 71 | 72 | # [Optional] Command to use when running end2end tests. It will run in the end2end dir. 73 | # [Windows] for non-WSL use "npx.cmd playwright test" 74 | # This binary name can be checked in Powershell with Get-Command npx 75 | end2end-cmd = "npx playwright test" 76 | end2end-dir = "end2end" 77 | 78 | # The browserlist query used for optimizing the CSS. 79 | browserquery = "defaults" 80 | 81 | # Set by cargo-leptos watch when building with that tool. Controls whether autoreload JS will be included in the head 82 | watch = false 83 | 84 | # The environment Leptos will run in, usually either "DEV" or "PROD" 85 | env = "DEV" 86 | 87 | # The features to use when compiling the bin target 88 | # 89 | # Optional. Can be over-ridden with the command line parameter --bin-features 90 | bin-features = ["ssr"] 91 | 92 | # If the --no-default-features flag should be used when compiling the bin target 93 | # 94 | # Optional. Defaults to false. 95 | bin-default-features = false 96 | 97 | # The features to use when compiling the lib target 98 | # 99 | # Optional. Can be over-ridden with the command line parameter --lib-features 100 | lib-features = ["hydrate"] 101 | 102 | # If the --no-default-features flag should be used when compiling the lib target 103 | # 104 | # Optional. Defaults to false. 105 | lib-default-features = false 106 | -------------------------------------------------------------------------------- /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_sse::create_sse_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_sse::provide_sse("http://localhost:3000/sse").unwrap(); 14 | 15 | // Create sse signal 16 | let count = create_sse_signal::("counter"); 17 | 18 | view! { 19 |

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

20 | } 21 | } 22 | -------------------------------------------------------------------------------- /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::{LeptosOptions}; 14 | 15 | 16 | pub async fn file_and_error_handler(uri: Uri, State(options): State, _req: Request) -> AxumResponse { 17 | let root = options.site_root.clone(); 18 | let res = get_static_file(uri.clone(), &root).await.unwrap(); 19 | 20 | if res.status() == StatusCode::OK { 21 | res.into_response() 22 | } else{ 23 | "404 not found".into_response() 24 | } 25 | } 26 | 27 | async fn get_static_file(uri: Uri, root: &str) -> Result, (StatusCode, String)> { 28 | let req = Request::builder().uri(uri.clone()).body(Body::empty()).unwrap(); 29 | // `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot` 30 | // This path is relative to the cargo root 31 | match ServeDir::new(root).oneshot(req).await { 32 | Ok(res) => Ok(res.into_response()), 33 | Err(err) => Err(( 34 | StatusCode::INTERNAL_SERVER_ERROR, 35 | format!("Something went wrong: {err}"), 36 | )), 37 | } 38 | } 39 | }} 40 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | simple_logger::init_with_level(log::Level::Debug).expect("couldn't initialize logging"); 14 | 15 | // Setting get_configuration(None) means we'll be using cargo-leptos's env values 16 | // For deployment these variables are: 17 | // 18 | // Alternately a file can be specified such as Some("Cargo.toml") 19 | // The file would need to be included with the executable when moved to deployment 20 | let conf = get_configuration(None).await.unwrap(); 21 | let leptos_options = conf.leptos_options; 22 | let addr = leptos_options.site_addr; 23 | let routes = generate_route_list(|| view! { }); 24 | 25 | // build our application with a route 26 | let app = Router::new() 27 | .route("/api/*fn_name", post(leptos_axum::handle_server_fns)) 28 | .route("/sse", get(handle_sse)) 29 | .leptos_routes(&leptos_options, routes, || view! { }) 30 | .fallback(file_and_error_handler) 31 | .with_state(leptos_options); 32 | 33 | // run our app with hyper 34 | // `axum::Server` is a re-export of `hyper::Server` 35 | leptos::logging::log!("listening on http://{}", &addr); 36 | let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); 37 | axum::serve(listener, app.into_make_service()) 38 | .await 39 | .unwrap(); 40 | } 41 | 42 | #[cfg(not(feature = "ssr"))] 43 | pub fn main() { 44 | // no client-side main function 45 | // unless we want this to work with e.g., Trunk for a purely client-side app 46 | // see lib.rs for hydration function instead 47 | } 48 | 49 | #[cfg(feature = "ssr")] 50 | use { 51 | axum::response::sse::{Event, KeepAlive, Sse}, 52 | futures::stream::Stream, 53 | }; 54 | 55 | #[cfg(feature = "ssr")] 56 | async fn handle_sse() -> Sse>> { 57 | use axum_example::app::Count; 58 | use futures::stream; 59 | use leptos_sse::ServerSentEvents; 60 | use std::time::Duration; 61 | use tokio_stream::StreamExt as _; 62 | 63 | let mut value = 0; 64 | let stream = ServerSentEvents::new( 65 | "counter", 66 | stream::repeat_with(move || { 67 | let curr = value; 68 | value += 1; 69 | Ok(Count { value: curr }) 70 | }) 71 | .throttle(Duration::from_secs(1)), 72 | ) 73 | .unwrap(); 74 | Sse::new(stream).keep_alive(KeepAlive::default()) 75 | } 76 | -------------------------------------------------------------------------------- /src/actix.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | use std::error::Error; 3 | use std::pin::Pin; 4 | use std::task::Poll; 5 | 6 | use actix_web_lab::sse::{self, Event}; 7 | use futures::stream::{Stream, StreamExt, TryStream}; 8 | use pin_project_lite::pin_project; 9 | use serde::Serialize; 10 | use serde_json::Value; 11 | use tokio::sync::mpsc; 12 | pub use tokio::sync::mpsc::error::{SendError, TrySendError}; 13 | use tokio_stream::wrappers::ReceiverStream; 14 | 15 | use crate::ServerSignalUpdate; 16 | 17 | type BoxError = Box; 18 | 19 | pin_project! { 20 | /// A signal owned by the server which writes to the SSE when mutated. 21 | #[derive(Clone, Debug)] 22 | pub struct ServerSentEvents { 23 | name: Cow<'static, str>, 24 | #[pin] 25 | stream: S, 26 | json_value: Value, 27 | } 28 | } 29 | 30 | impl ServerSentEvents { 31 | /// Create a new [`ServerSentEvents`] a stream, initializing `T` to default. 32 | /// 33 | /// This function can fail if serilization of `T` fails. 34 | pub fn new(name: impl Into>, stream: S) -> Result 35 | where 36 | T: Default + Serialize, 37 | S: TryStream, 38 | { 39 | Ok(ServerSentEvents { 40 | name: name.into(), 41 | stream, 42 | json_value: serde_json::to_value(T::default())?, 43 | }) 44 | } 45 | 46 | /// Create a server-sent-events (SSE) channel pair. 47 | /// 48 | /// The `buffer` argument controls how many unsent messages can be stored without waiting. 49 | /// 50 | /// The first item in the tuple is the MPSC channel sender half. 51 | pub fn channel( 52 | name: impl Into>, 53 | buffer: usize, 54 | ) -> Result< 55 | ( 56 | Sender, 57 | ServerSentEvents>, 58 | ), 59 | serde_json::Error, 60 | > 61 | where 62 | T: Default + Serialize, 63 | { 64 | let (sender, receiver) = mpsc::channel::(buffer); 65 | let stream = ReceiverStream::new(receiver).map(Ok); 66 | Ok((Sender(sender), ServerSentEvents::new(name, stream)?)) 67 | } 68 | } 69 | 70 | impl Stream for ServerSentEvents 71 | where 72 | S: TryStream, 73 | S::Ok: Serialize, 74 | { 75 | type Item = Result; 76 | 77 | fn poll_next( 78 | self: Pin<&mut Self>, 79 | cx: &mut std::task::Context<'_>, 80 | ) -> Poll> { 81 | let this = self.project(); 82 | match this.stream.try_poll_next(cx) { 83 | Poll::Ready(Some(Ok(value))) => { 84 | let new_json = serde_json::to_value(value)?; 85 | let update = ServerSignalUpdate::new_from_json::( 86 | this.name.clone(), 87 | this.json_value, 88 | &new_json, 89 | ); 90 | *this.json_value = new_json; 91 | let event = Event::Data(sse::Data::new_json(update)?); 92 | Poll::Ready(Some(Ok(event))) 93 | } 94 | Poll::Ready(Some(Err(err))) => Poll::Ready(Some(Err(err))), 95 | Poll::Ready(None) => Poll::Ready(None), 96 | Poll::Pending => Poll::Pending, 97 | } 98 | } 99 | } 100 | 101 | /// Sender half of a server-sent events stream. 102 | #[derive(Clone, Debug)] 103 | pub struct Sender(mpsc::Sender); 104 | 105 | impl Sender { 106 | /// Send an SSE message. 107 | pub async fn send(&self, value: T) -> Result<(), SendError> 108 | where 109 | T: Serialize, 110 | { 111 | self.0.send(value).await 112 | } 113 | 114 | /// Attempts to immediately send an SSE message. 115 | pub fn try_send(&self, value: T) -> Result<(), TrySendError> 116 | where 117 | T: Serialize, 118 | { 119 | self.0.try_send(value) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/axum.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | use std::pin::Pin; 3 | use std::task::Poll; 4 | 5 | use axum::response::sse::Event; 6 | use futures::stream::{Stream, StreamExt, TryStream}; 7 | use pin_project_lite::pin_project; 8 | use serde::Serialize; 9 | use serde_json::Value; 10 | use tokio::sync::mpsc; 11 | pub use tokio::sync::mpsc::error::{SendError, TrySendError}; 12 | use tokio_stream::wrappers::ReceiverStream; 13 | 14 | use crate::ServerSignalUpdate; 15 | 16 | pin_project! { 17 | /// A signal owned by the server which writes to the SSE when mutated. 18 | #[derive(Clone, Debug)] 19 | pub struct ServerSentEvents { 20 | name: Cow<'static, str>, 21 | #[pin] 22 | stream: S, 23 | json_value: Value, 24 | } 25 | } 26 | 27 | impl ServerSentEvents { 28 | /// Create a new [`ServerSentEvents`] a stream, initializing `T` to default. 29 | /// 30 | /// This function can fail if serilization of `T` fails. 31 | pub fn new(name: impl Into>, stream: S) -> Result 32 | where 33 | T: Default + Serialize, 34 | S: TryStream, 35 | { 36 | Ok(ServerSentEvents { 37 | name: name.into(), 38 | stream, 39 | json_value: serde_json::to_value(T::default())?, 40 | }) 41 | } 42 | 43 | /// Create a server-sent-events (SSE) channel pair. 44 | /// 45 | /// The `buffer` argument controls how many unsent messages can be stored without waiting. 46 | /// 47 | /// The first item in the tuple is the MPSC channel sender half. 48 | pub fn channel( 49 | name: impl Into>, 50 | buffer: usize, 51 | ) -> Result< 52 | ( 53 | Sender, 54 | ServerSentEvents>, 55 | ), 56 | serde_json::Error, 57 | > 58 | where 59 | T: Default + Serialize, 60 | { 61 | let (sender, receiver) = mpsc::channel::(buffer); 62 | let stream = ReceiverStream::new(receiver).map(Ok); 63 | Ok((Sender(sender), ServerSentEvents::new(name, stream)?)) 64 | } 65 | } 66 | 67 | impl Stream for ServerSentEvents 68 | where 69 | S: TryStream, 70 | S::Ok: Serialize, 71 | { 72 | type Item = Result; 73 | 74 | fn poll_next( 75 | self: Pin<&mut Self>, 76 | cx: &mut std::task::Context<'_>, 77 | ) -> Poll> { 78 | let this = self.project(); 79 | match this.stream.try_poll_next(cx) { 80 | Poll::Ready(Some(Ok(value))) => { 81 | let new_json = serde_json::to_value(value)?; 82 | let update = ServerSignalUpdate::new_from_json::( 83 | this.name.clone(), 84 | this.json_value, 85 | &new_json, 86 | ); 87 | *this.json_value = new_json; 88 | let event = Event::default().json_data(update)?; 89 | Poll::Ready(Some(Ok(event))) 90 | } 91 | Poll::Ready(Some(Err(err))) => Poll::Ready(Some(Err(err))), 92 | Poll::Ready(None) => Poll::Ready(None), 93 | Poll::Pending => Poll::Pending, 94 | } 95 | } 96 | } 97 | 98 | /// Sender half of a server-sent events stream. 99 | #[derive(Clone, Debug)] 100 | pub struct Sender(mpsc::Sender); 101 | 102 | impl Sender { 103 | /// Send an SSE message. 104 | pub async fn send(&self, value: T) -> Result<(), SendError> 105 | where 106 | T: Serialize, 107 | { 108 | self.0.send(value).await 109 | } 110 | 111 | /// Attempts to immediately send an SSE message. 112 | pub fn try_send(&self, value: T) -> Result<(), TrySendError> 113 | where 114 | T: Serialize, 115 | { 116 | self.0.try_send(value) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] 2 | #![doc = include_str!("../README.md")] 3 | 4 | use std::borrow::Cow; 5 | 6 | use json_patch::Patch; 7 | use leptos::{create_signal, ReadSignal}; 8 | use serde::{Deserialize, Serialize}; 9 | use serde_json::Value; 10 | use wasm_bindgen::JsValue; 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 SSE, and is used to patch the signal. 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 SSE url for server signals, if there is not already one provided. 65 | /// This ensures that you can provide it at the highest possible level, without overwriting a SSE 66 | /// that has already been provided (for example, by a server-rendering integration.) 67 | /// 68 | /// Note, the server should have a route to handle this SSE. 69 | /// 70 | /// # Example 71 | /// 72 | /// ```ignore 73 | /// #[component] 74 | /// pub fn App() -> impl IntoView { 75 | /// // Provide SSE connection 76 | /// leptos_sse::provide_sse("http://localhost:3000/sse").unwrap(); 77 | /// 78 | /// // ... 79 | /// } 80 | /// ``` 81 | #[allow(unused_variables)] 82 | pub fn provide_sse(url: &str) -> Result<(), JsValue> { 83 | provide_sse_inner(url) 84 | } 85 | 86 | /// Creates a signal which is controlled by the server. 87 | /// 88 | /// This signal is initialized as T::default, is read-only on the client, and is updated through json patches 89 | /// sent through a SSE connection. 90 | /// 91 | /// # Example 92 | /// 93 | /// ``` 94 | /// #[derive(Clone, Default, Serialize, Deserialize)] 95 | /// pub struct Count { 96 | /// pub value: i32, 97 | /// } 98 | /// 99 | /// #[component] 100 | /// pub fn App() -> impl IntoView { 101 | /// // Create server signal 102 | /// let count = create_sse_signal::("counter"); 103 | /// 104 | /// view! { 105 | ///

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

106 | /// } 107 | /// } 108 | /// ``` 109 | #[allow(unused_variables)] 110 | pub fn create_sse_signal(name: impl Into>) -> ReadSignal 111 | where 112 | T: Default + Serialize + for<'de> Deserialize<'de>, 113 | { 114 | let name = name.into(); 115 | let (get, set) = create_signal(T::default()); 116 | 117 | cfg_if::cfg_if! { 118 | if #[cfg(target_arch = "wasm32")] { 119 | use leptos::{use_context, create_effect, create_rw_signal, SignalSet, SignalGet}; 120 | 121 | let signal = create_rw_signal(serde_json::to_value(T::default()).unwrap()); 122 | if let Some(ServerSignalEventSourceContext { state_signals, .. }) = use_context::() { 123 | let name: Cow<'static, str> = name.into(); 124 | state_signals.borrow_mut().insert(name.clone(), signal); 125 | 126 | // Note: The leptos docs advise against doing this. It seems to work 127 | // well in testing, and the primary caveats are around unnecessary 128 | // updates firing, but our state synchronization already prevents 129 | // that on the server side 130 | create_effect(move |_| { 131 | let name = name.clone(); 132 | let new_value = serde_json::from_value(signal.get()).unwrap(); 133 | set.set(new_value); 134 | }); 135 | 136 | } else { 137 | leptos::logging::error!( 138 | r#"server signal was used without a SSE being provided. 139 | 140 | Ensure you call `leptos_sse::provide_sse("http://localhost:3000/sse")` at the highest level in your app."# 141 | ); 142 | } 143 | } 144 | } 145 | 146 | get 147 | } 148 | 149 | cfg_if::cfg_if! { 150 | if #[cfg(target_arch = "wasm32")] { 151 | use std::cell::RefCell; 152 | use std::collections::HashMap; 153 | use std::ops::{Deref, DerefMut}; 154 | use std::rc::Rc; 155 | 156 | use web_sys::EventSource; 157 | use leptos::{provide_context, RwSignal}; 158 | 159 | /// Provides the context for the server signal `web_sys::EventSource`. 160 | /// 161 | /// You can use this via `use_context::()` to 162 | /// access the `EventSource` to set up additional event listeners and etc. 163 | #[derive(Clone, Debug, PartialEq, Eq)] 164 | pub struct ServerSignalEventSource(pub EventSource); 165 | 166 | impl Deref for ServerSignalEventSource { 167 | type Target = EventSource; 168 | 169 | fn deref(&self) -> &Self::Target { 170 | &self.0 171 | } 172 | } 173 | 174 | impl DerefMut for ServerSignalEventSource { 175 | fn deref_mut(&mut self) -> &mut Self::Target { 176 | &mut self.0 177 | } 178 | } 179 | 180 | #[derive(Clone, Debug, PartialEq, Eq)] 181 | struct ServerSignalEventSourceContext { 182 | inner: EventSource, 183 | // References to these are kept by the closure for the callback 184 | // onmessage callback on the event source 185 | state_signals: Rc, RwSignal>>>, 186 | // When the event source is first established, leptos may not have 187 | // completed the traversal that sets up all of the state signals. 188 | // Without that, we don't have a base state to apply the patches to, 189 | // and therefore we must keep a record of the patches to apply after 190 | // the state has been set up. 191 | delayed_updates: Rc, Vec>>>, 192 | } 193 | 194 | #[inline] 195 | fn provide_sse_inner(url: &str) -> Result<(), JsValue> { 196 | use web_sys::MessageEvent; 197 | use wasm_bindgen::{prelude::Closure, JsCast}; 198 | use leptos::{use_context, SignalUpdate}; 199 | use js_sys::{Function, JsString}; 200 | 201 | if use_context::().is_none() { 202 | let es = EventSource::new(url)?; 203 | provide_context(ServerSignalEventSource(es.clone())); 204 | provide_context(ServerSignalEventSourceContext { inner: es, state_signals: Default::default(), delayed_updates: Default::default() }); 205 | } 206 | 207 | let es = use_context::().unwrap(); 208 | let handlers = es.state_signals.clone(); 209 | let delayed_updates = es.delayed_updates.clone(); 210 | let callback = Closure::wrap(Box::new(move |event: MessageEvent| { 211 | let ws_string = event.data().dyn_into::().unwrap().as_string().unwrap(); 212 | if let Ok(update_signal) = serde_json::from_str::(&ws_string) { 213 | let handler_map = (*handlers).borrow(); 214 | let name = &update_signal.name; 215 | let mut delayed_map = (*delayed_updates).borrow_mut(); 216 | if let Some(signal) = handler_map.get(name) { 217 | if let Some(delayed_patches) = delayed_map.remove(name) { 218 | signal.update(|doc| { 219 | for patch in delayed_patches { 220 | json_patch::patch(doc, &patch).unwrap(); 221 | } 222 | }); 223 | } 224 | signal.update(|doc| { 225 | json_patch::patch(doc, &update_signal.patch).unwrap(); 226 | }); 227 | } else { 228 | leptos::logging::warn!("No local state for update to {}. Queuing patch.", name); 229 | delayed_map.entry(name.clone()).or_default().push(update_signal.patch.clone()); 230 | } 231 | } 232 | }) as Box); 233 | let function: &Function = callback.as_ref().unchecked_ref(); 234 | es.inner.set_onmessage(Some(function)); 235 | 236 | // Keep the closure alive for the lifetime of the program 237 | callback.forget(); 238 | 239 | Ok(()) 240 | } 241 | } else { 242 | #[inline] 243 | fn provide_sse_inner(_url: &str) -> Result<(), JsValue> { 244 | Ok(()) 245 | } 246 | } 247 | } 248 | --------------------------------------------------------------------------------