From 992983efd9a294b9ceeeccfab958744451fa6af1 Mon Sep 17 00:00:00 2001 From: Ben Wishovich Date: Mon, 14 Nov 2022 12:04:26 -0800 Subject: [PATCH] Commit WIP version of isomorphic counter --- Cargo.toml | 1 + examples/counter-isomorphic-sfa/Cargo.toml | 35 +++ examples/counter-isomorphic-sfa/LICENSE | 21 ++ examples/counter-isomorphic-sfa/README.md | 18 ++ examples/counter-isomorphic-sfa/index.html | 7 + .../counter-isomorphic-sfa/src/counters.rs | 237 ++++++++++++++++++ examples/counter-isomorphic-sfa/src/lib.rs | 33 +++ examples/counter-isomorphic-sfa/src/main.rs | 125 +++++++++ examples/counter/README.md | 6 + examples/hackernews/src/main.rs | 2 +- 10 files changed, 484 insertions(+), 1 deletion(-) create mode 100644 examples/counter-isomorphic-sfa/Cargo.toml create mode 100644 examples/counter-isomorphic-sfa/LICENSE create mode 100644 examples/counter-isomorphic-sfa/README.md create mode 100644 examples/counter-isomorphic-sfa/index.html create mode 100644 examples/counter-isomorphic-sfa/src/counters.rs create mode 100644 examples/counter-isomorphic-sfa/src/lib.rs create mode 100644 examples/counter-isomorphic-sfa/src/main.rs create mode 100644 examples/counter/README.md diff --git a/Cargo.toml b/Cargo.toml index dfa6b92b0..3af02c491 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ members = [ "examples/counter-isomorphic/client", "examples/counter-isomorphic/server", "examples/counter-isomorphic/counter", + "examples/counter-isomorphic-sfa", "examples/counters", "examples/counters-stable", "examples/fetch", diff --git a/examples/counter-isomorphic-sfa/Cargo.toml b/examples/counter-isomorphic-sfa/Cargo.toml new file mode 100644 index 000000000..429930900 --- /dev/null +++ b/examples/counter-isomorphic-sfa/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "leptos-counter-isomorphic" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +actix-files = { version = "0.6", optional = true } +actix-web = { version = "4", optional = true, features = ["openssl", "macros"] } +console_log = "0.2" +console_error_panic_hook = "0.1" +futures = "0.3" +cfg-if = "1" +leptos = { path = "../../../leptos/leptos", default-features = false, features = [ + "serde", +] } +leptos_meta = { path = "../../../leptos/meta", default-features = false } +leptos_router = { path = "../../../leptos/router", default-features = false } +log = "0.4" +simple_logger = "2" +#counter = { path = "../counter", default-features = false} + +[features] +default = ["csr"] +csr = ["leptos/csr", "leptos_meta/csr", "leptos_router/csr"] +hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"] +ssr = [ + "dep:actix-files", + "dep:actix-web", + "leptos/ssr", + "leptos_meta/ssr", + "leptos_router/ssr", +] diff --git a/examples/counter-isomorphic-sfa/LICENSE b/examples/counter-isomorphic-sfa/LICENSE new file mode 100644 index 000000000..77d5625cb --- /dev/null +++ b/examples/counter-isomorphic-sfa/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Greg Johnston + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/examples/counter-isomorphic-sfa/README.md b/examples/counter-isomorphic-sfa/README.md new file mode 100644 index 000000000..27e7a7d92 --- /dev/null +++ b/examples/counter-isomorphic-sfa/README.md @@ -0,0 +1,18 @@ +# Leptos Counter Isomorphic Example + +This example demonstrates how to use a function isomorphically, to run a server side function from the browser and receive a result. + +## Server Side Rendering With Hydration +To run it as a server side app with hydration, first you should run +```bash +wasm-pack build --target=web --no-default-features --features=hydrate +``` +to generate the Webassembly to provide hydration features for the server. +Then run the server with `cargo run` to serve the server side rendered HTML and the WASM bundle for hydration. +```bash +cargo run --no-default-features --features=ssr` +``` +> Note that if your hydration code changes, you will have to rerun the wasm-pack command above +> This should be temporary, and vastly improve once cargo-leptos becomes ready for prime time! + +If for some reason you want to run it as a fully client side app, that can be done with the instructions below. diff --git a/examples/counter-isomorphic-sfa/index.html b/examples/counter-isomorphic-sfa/index.html new file mode 100644 index 000000000..b5ec4d243 --- /dev/null +++ b/examples/counter-isomorphic-sfa/index.html @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/examples/counter-isomorphic-sfa/src/counters.rs b/examples/counter-isomorphic-sfa/src/counters.rs new file mode 100644 index 000000000..20413e865 --- /dev/null +++ b/examples/counter-isomorphic-sfa/src/counters.rs @@ -0,0 +1,237 @@ +use leptos::*; +use leptos_router::*; +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "ssr")] +use std::sync::atomic::{AtomicI32, Ordering}; + +#[cfg(feature = "ssr")] +use broadcaster::BroadcastChannel; + +#[cfg(feature = "ssr")] +pub fn register_server_functions() { + GetServerCount::register(); + AdjustServerCount::register(); + ClearServerCount::register(); +} + +#[cfg(feature = "ssr")] +static COUNT: AtomicI32 = AtomicI32::new(0); + +#[cfg(feature = "ssr")] +lazy_static::lazy_static! { + pub static ref COUNT_CHANNEL: BroadcastChannel = BroadcastChannel::new(); +} +// "/api" is an optional prefix that allows you to locate server functions wherever you'd like on the server +#[server(GetServerCount, "/api")] +pub async fn get_server_count() -> Result { + Ok(COUNT.load(Ordering::Relaxed)) +} + +#[server(AdjustServerCount, "/api")] +pub async fn adjust_server_count(delta: i32, msg: String) -> Result { + let new = COUNT.load(Ordering::Relaxed) + delta; + COUNT.store(new, Ordering::Relaxed); + _ = COUNT_CHANNEL.send(&new).await; + println!("message = {:?}", msg); + Ok(new) +} + +#[server(ClearServerCount, "/api")] +pub async fn clear_server_count() -> Result { + COUNT.store(0, Ordering::Relaxed); + _ = COUNT_CHANNEL.send(&0).await; + Ok(0) +} +#[component] +pub fn Counters(cx: Scope) -> Element { + view! { + cx, +
+ +
+

"Server-Side Counters"

+

"Each of these counters stores its data in the same variable on the server."

+

"The value is shared across connections. Try opening this is another browser tab to see what I mean."

+
+ +
+ + + }/> + + }/> + + }/> + +
+
+
+ } +} + +// This is an example of "single-user" server functions +// The counter value is loaded from the server, and re-fetches whenever +// it's invalidated by one of the user's own actions +// This is the typical pattern for a CRUD app +#[component] +pub fn Counter(cx: Scope) -> Element { + let dec = create_action(cx, |_| adjust_server_count(-1, "decing".into())); + let inc = create_action(cx, |_| adjust_server_count(1, "incing".into())); + let clear = create_action(cx, |_| clear_server_count()); + let counter = create_resource( + cx, + move || (dec.version.get(), inc.version.get(), clear.version.get()), + |_| get_server_count(), + ); + + let value = move || counter.read().map(|count| count.unwrap_or(0)).unwrap_or(0); + let error_msg = move || { + counter + .read() + .map(|res| match res { + Ok(_) => None, + Err(e) => Some(e), + }) + .flatten() + }; + + view! { + cx, +
+

"Simple Counter"

+

"This counter sets the value on the server and automatically reloads the new value."

+
+ + + "Value: " {move || value().to_string()} "!" + +
+
+ } +} + +// This is the
counter +// It uses the same invalidation pattern as the plain counter, +// but uses HTML forms to submit the actions +#[component] +pub fn FormCounter(cx: Scope) -> Element { + let adjust = create_server_action::(cx); + let clear = create_server_action::(cx); + + let counter = create_resource( + cx, + { + let adjust = adjust.version; + let clear = clear.version; + move || (adjust.get(), clear.get()) + }, + |_| { + log::debug!("FormCounter running fetcher"); + + get_server_count() + }, + ); + let value = move || { + log::debug!("FormCounter looking for value"); + counter + .read() + .map(|n| n.ok()) + .flatten() + .map(|n| n) + .unwrap_or(0) + }; + + let adjust2 = adjust.clone(); + + view! { + cx, +
+

"Form Counter"

+

"This counter uses forms to set the value on the server. When progressively enhanced, it should behave identically to the “Simple Counter.”"

+
+ // calling a server function is the same as POSTing to its API URL + // so we can just do that with a form and button + + + + // We can submit named arguments to the server functions + // by including them as input values with the same name + + + + + + "Value: " {move || value().to_string()} "!" + + + + + +
+
+ } +} + +// This is a kind of "multi-user" counter +// It relies on a stream of server-sent events (SSE) for the counter's value +// Whenever another user updates the value, it will update here +// This is the primitive pattern for live chat, collaborative editing, etc. +#[component] +pub fn MultiuserCounter(cx: Scope) -> Element { + let dec = create_action(cx, |_| adjust_server_count(-1, "dec dec goose".into())); + let inc = create_action(cx, |_| adjust_server_count(1, "inc inc moose".into())); + let clear = create_action(cx, |_| clear_server_count()); + + #[cfg(not(feature = "ssr"))] + let multiplayer_value = { + use futures::StreamExt; + + let mut source = gloo::net::eventsource::futures::EventSource::new("/api/events") + .expect_throw("couldn't connect to SSE stream"); + let s = create_signal_from_stream( + cx, + source.subscribe("message").unwrap().map(|value| { + value + .expect_throw("no message event") + .1 + .data() + .as_string() + .expect_throw("expected string value") + }), + ); + + on_cleanup(cx, move || source.close()); + s + }; + + #[cfg(feature = "ssr")] + let multiplayer_value = + create_signal_from_stream(cx, futures::stream::once(Box::pin(async { 0.to_string() }))); + + view! { + cx, +
+

"Multi-User Counter"

+

"This one uses server-sent events (SSE) to live-update when other users make changes."

+
+ + + "Multiplayer Value: " {move || multiplayer_value().unwrap_or_default().to_string()} + +
+
+ } +} diff --git a/examples/counter-isomorphic-sfa/src/lib.rs b/examples/counter-isomorphic-sfa/src/lib.rs new file mode 100644 index 000000000..161c4b71e --- /dev/null +++ b/examples/counter-isomorphic-sfa/src/lib.rs @@ -0,0 +1,33 @@ +use cfg_if::cfg_if; +mod counters; +use crate::counters::*; + +#[component] +pub fn App(cx: Scope) -> Element { + let (value, set_value) = create_signal(cx, 0); + + view! { cx, +
+ + + "Value: " {move || value().to_string()} "!" + +
+ } +} + +// Needs to be in lib.rs AFAIK because wasm-bindgen needs us to be compiling a lib. I may be wrong. +cfg_if! { + if #[cfg(feature = "hydrate")] { + #[wasm_bindgen] + pub fn main() { + console_error_panic_hook::set_once(); + _ = console_log::init_with_level(log::Level::Debug); + console_error_panic_hook::set_once(); + + leptos::hydrate(body().unwrap(), |cx| { + view! { cx, } + }); + } + } +} diff --git a/examples/counter-isomorphic-sfa/src/main.rs b/examples/counter-isomorphic-sfa/src/main.rs new file mode 100644 index 000000000..30a1f0f0d --- /dev/null +++ b/examples/counter-isomorphic-sfa/src/main.rs @@ -0,0 +1,125 @@ +use cfg_if::cfg_if; +use leptos::*; +use leptos_meta::*; +use leptos_router::*; +mod counters; + +// boilerplate to run in different modes +cfg_if! { + // server-only stuff + if #[cfg(feature = "ssr")] { + + #[get("{tail:.*}")] + async fn render(req: HttpRequest) -> impl Responder { + let path = req.path(); + let path = "http://leptos".to_string() + path; + println!("path = {path}"); + + HttpResponse::Ok().content_type("text/html").body(format!( + r#" + + + + + Isomorphic Counter + + + {} + + + "#, + run_scope({ + move |cx| { + let integration = ServerIntegration { path: path.clone() }; + provide_context(cx, RouterIntegrationContext::new(integration)); + + view! { cx, } + } + }) + )) + } + + #[post("/api/{tail:.*}")] + async fn handle_server_fns( + req: HttpRequest, + params: web::Path, + body: web::Bytes, + ) -> impl Responder { + let path = params.into_inner(); + let accept_header = req + .headers() + .get("Accept") + .and_then(|value| value.to_str().ok()); + + if let Some(server_fn) = server_fn_by_path(path.as_str()) { + let body: &[u8] = &body; + match server_fn(&body).await { + Ok(serialized) => { + // if this is Accept: application/json then send a serialized JSON response + if let Some("application/json") = accept_header { + HttpResponse::Ok().body(serialized) + } + // otherwise, it's probably a submit or something: redirect back to the referrer + else { + HttpResponse::SeeOther() + .insert_header(("Location", "/")) + .content_type("application/json") + .body(serialized) + } + } + Err(e) => { + eprintln!("server function error: {e:#?}"); + HttpResponse::InternalServerError().body(e.to_string()) + } + } + } else { + HttpResponse::BadRequest().body(format!("Could not find a server function at that route.")) + } + } + + #[get("/api/events")] + async fn counter_events() -> impl Responder { + use futures::StreamExt; + + let stream = + futures::stream::once(async { counter_isomorphic::get_server_count().await.unwrap_or(0) }) + .chain(COUNT_CHANNEL.clone()) + .map(|value| { + Ok(web::Bytes::from(format!( + "event: message\ndata: {value}\n\n" + ))) as Result + }); + HttpResponse::Ok() + .insert_header(("Content-Type", "text/event-stream")) + .streaming(stream) + } + + #[actix_web::main] + async fn main() -> std::io::Result<()> { + counter_isomorphic::register_server_functions(); + + HttpServer::new(|| { + App::new() + .service(Files::new("/pkg", "../client/pkg")) + .service(counter_events) + .service(handle_server_fns) + .service(render) + //.wrap(middleware::Compress::default()) + }) + .bind(("127.0.0.1", 8081))? + .run() + .await + } + } + + // client-only stuff for Trunk + else if #[cfg(feature = "csr")] { + use leptos_counter_isomorphic::counters::*; + + pub fn main() { + _ = console_log::init_with_level(log::Level::Debug); + console_error_panic_hook::set_once(); + mount_to_body(|cx| view! { cx, }); + } + } +} diff --git a/examples/counter/README.md b/examples/counter/README.md new file mode 100644 index 000000000..a93f2cecc --- /dev/null +++ b/examples/counter/README.md @@ -0,0 +1,6 @@ +# Leptos Counter Example + +This example creates a simple counter in a client side rendered app with Rust and WASM! + + +To run it, just issue the `trunk serve --open` command in the example root. This will build the app, run it, and open a new browser to serve it. diff --git a/examples/hackernews/src/main.rs b/examples/hackernews/src/main.rs index 8d29a0375..184f854e5 100644 --- a/examples/hackernews/src/main.rs +++ b/examples/hackernews/src/main.rs @@ -96,7 +96,7 @@ cfg_if! { // client-only stuff for Trunk else if #[cfg(feature = "csr")] { - use leptos_sfa::*; + use leptos_hackernews::*; pub fn main() { console_error_panic_hook::set_once();