From 856fac5ecef460f04f2ac98aab43076ae4df754d Mon Sep 17 00:00:00 2001 From: Jeremy Wall Date: Sat, 13 Aug 2022 18:53:22 -0400 Subject: [PATCH] Basic ssr wiring using conditional compilation --- Cargo.lock | 18 +++++++ kitchen/Cargo.toml | 1 + kitchen/src/web.rs | 26 ++++++++-- web/Cargo.toml | 9 +--- web/src/lib.rs | 30 ++++++++--- web/src/router_integration.rs | 21 ++++++++ web/src/web.rs | 97 +++++++++++++++++++++++++++++++++-- 7 files changed, 178 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8f55647..638e0c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -586,6 +586,15 @@ dependencies = [ "libc", ] +[[package]] +name = "html-escape" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e7479fa1ef38eb49fb6a42c426be515df2d063f06cb8efd3e50af073dbc26c" +dependencies = [ + "utf8-width", +] + [[package]] name = "http" version = "0.2.8" @@ -698,6 +707,7 @@ dependencies = [ "axum", "clap", "csv", + "kitchen-wasm", "mime_guess", "recipe-store", "recipes", @@ -1238,9 +1248,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f5cea65876897bb946a623e16bf3df2de4997a6872d95b99dfaed5dd8e14e264" dependencies = [ "ahash", + "html-escape", "indexmap", "js-sys", "lexical", + "once_cell", "paste", "smallvec", "sycamore-macro", @@ -1509,6 +1521,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15c61ba63f9235225a22310255a29b806b907c9b8c964bcbd0a2c70f3f2deea7" +[[package]] +name = "utf8-width" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5190c9442dcdaf0ddd50f37420417d219ae5261bbf5db120d0f9bab996c9cba1" + [[package]] name = "valuable" version = "0.1.0" diff --git a/kitchen/Cargo.toml b/kitchen/Cargo.toml index 16775d3..8b33719 100644 --- a/kitchen/Cargo.toml +++ b/kitchen/Cargo.toml @@ -11,6 +11,7 @@ tracing = "0.1.35" tracing-subscriber = "0.3.14" recipes = { path = "../recipes" } recipe-store = {path = "../recipe-store" } +kitchen-wasm = { path= "../web" } csv = "1.1.1" axum = "0.5.13" rust-embed="6.4.0" diff --git a/kitchen/src/web.rs b/kitchen/src/web.rs index cb9fe45..1c12fd9 100644 --- a/kitchen/src/web.rs +++ b/kitchen/src/web.rs @@ -32,11 +32,22 @@ use tracing::{debug, info, instrument}; #[folder = "../web/dist"] struct UiAssets; -pub struct StaticFile(pub T); +pub struct StaticFile(pub T) +where + T: Into + Clone; + +impl StaticFile +where + T: Into + Clone, +{ + pub fn exists(&self) -> bool { + UiAssets::get(self.0.clone().into().as_str()).is_some() + } +} impl IntoResponse for StaticFile where - T: Into, + T: Into + Clone, { fn into_response(self) -> Response { let path = self.0.into(); @@ -59,13 +70,18 @@ where } #[instrument] -async fn ui_static_assets(Path(path): Path) -> impl IntoResponse { +async fn ui_assets(Path(path): Path) -> impl IntoResponse { info!("Serving ui path"); let mut path = path.trim_start_matches("/"); path = if path == "" { "index.html" } else { path }; debug!(path = path, "Serving transformed path"); - StaticFile(path.to_owned()) + let file = StaticFile(path.to_owned()); + if file.exists() { + file.into_response() + } else { + kitchen_wasm::render_to_string(path).into_response() + } } #[instrument] @@ -104,7 +120,7 @@ pub async fn ui_main(recipe_dir_path: PathBuf, listen_socket: SocketAddr) { //let dir_path = (&dir_path).clone(); let router = Router::new() .route("/", get(|| async { Redirect::temporary("/ui/") })) - .route("/ui/*path", get(ui_static_assets)) + .route("/ui/*path", get(ui_assets)) // recipes api path route .route("/api/v1/recipes", get(api_recipes)) // categories api path route diff --git a/web/Cargo.toml b/web/Cargo.toml index e6d0cf7..b411023 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -3,11 +3,6 @@ name = "kitchen-wasm" version = "0.2.9" edition = "2021" -[features] -ssr = [] -web = [] -default = ["web"] - # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [lib] crate-type = ["cdylib", "rlib"] @@ -45,8 +40,8 @@ features = [ [dependencies.sycamore] version = "0.7.1" -features = ["futures", "serde", "default"] +features = ["futures", "serde", "default", "ssr"] [profile.release] lto = true -opt-level = "s" \ No newline at end of file +opt-level = "s" diff --git a/web/src/lib.rs b/web/src/lib.rs index ba81bbb..dc75d5d 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -18,19 +18,35 @@ mod router_integration; mod service; mod web; +use router_integration::DeriveRoute; use sycamore::prelude::*; -#[cfg(feature = "web")] +#[cfg(target_arch = "wasm32")] use tracing_browser_subscriber; +#[cfg(target_arch = "wasm32")] use wasm_bindgen::prelude::wasm_bindgen; use web::UI; +#[cfg(target_arch = "wasm32")] #[wasm_bindgen(start)] pub fn main() { - if cfg!(feature = "web") { - console_error_panic_hook::set_once(); - // TODO(jwall): use the tracing_subscriber_browser default setup function when it exists. - tracing_browser_subscriber::configure_as_global_default(); - } - sycamore::render(|| view! { UI() }); + console_error_panic_hook::set_once(); + tracing_browser_subscriber::configure_as_global_default(); + let root = web_sys::window() + .unwrap() + .document() + .unwrap() + .query_selector("#sycamore") + .unwrap() + .unwrap(); + + sycamore::hydrate_to(|| view! { UI() }, &root); +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn render_to_string(path: &str) -> String { + use app_state::AppRoutes; + + let route = ::from(&(String::new(), path.to_owned(), String::new())); + sycamore::render_to_string(|| view! { UI(route) }) } diff --git a/web/src/router_integration.rs b/web/src/router_integration.rs index 7063d4f..f95ed13 100644 --- a/web/src/router_integration.rs +++ b/web/src/router_integration.rs @@ -152,6 +152,27 @@ where } } +#[derive(Debug)] +pub struct StaticRouterProps +where + G: GenericNode, + R: DeriveRoute + NotFound + Clone + Default + Debug + 'static, + F: Fn(ReadSignal) -> View + 'static, +{ + pub route: R, + pub route_select: F, +} + +#[component(StaticRouter)] +pub fn static_router(props: StaticRouterProps) -> View +where + R: DeriveRoute + NotFound + Clone + Default + Debug + 'static, + F: Fn(ReadSignal) -> View + 'static, +{ + debug!("Setting up static router"); + (props.route_select)(Signal::new(props.route).handle()) +} + #[instrument(skip_all)] fn register_click_handler(view: &View, integration: Rc) where diff --git a/web/src/web.rs b/web/src/web.rs index 65094d6..d75afad 100644 --- a/web/src/web.rs +++ b/web/src/web.rs @@ -22,6 +22,7 @@ use sycamore::{ prelude::*, }; +#[cfg(target_arch = "wasm32")] #[instrument] fn route_switch(route: ReadSignal) -> View { // NOTE(jwall): This needs to not be a dynamic node. The rules around @@ -52,6 +53,36 @@ fn route_switch(route: ReadSignal) -> View { } }) } +#[cfg(not(target_arch = "wasm32"))] +fn route_switch(route: ReadSignal) -> View { + // NOTE(jwall): This needs to not be a dynamic node. The rules around + // this are somewhat unclear and underdocumented for Sycamore. But basically + // avoid conditionals in the `view!` macro calls here. + cloned!((route) => match route.get().as_ref() { + AppRoutes::Plan => view! { + PlanPage() + }, + AppRoutes::Inventory => view! { + InventoryPage() + }, + AppRoutes::Cook => view! { + CookPage() + }, + AppRoutes::Recipe(idx) => view! { + RecipePage(RecipePageProps { recipe: Signal::new(idx.clone()) }) + }, + AppRoutes::NotFound => view! { + // TODO(Create a real one) + PlanPage() + }, + AppRoutes::Error(ref e) => { + let e = e.clone(); + view! { + "Error: " (e) + } + } + }) +} #[cfg(not(target_arch = "wasm32"))] fn get_appservice() -> AppService { @@ -62,6 +93,26 @@ fn get_appservice() -> AppService { AppService::new(recipe_store::HttpStore::new("/api/v1".to_owned())) } +#[cfg(target_arch = "wasm32")] +#[component(RouterComponent)] +fn rounter_component() -> View { + view! { + Router(RouterProps{ + route: AppRoutes::default(), + browser_integration: BrowserIntegration::new(), + route_select: route_switch, + }) + } +} +#[cfg(not(target_arch = "wasm32"))] +#[component(RouterComponent)] +fn rounter_component(route: AppRoutes) -> View { + view! { + StaticRouter(StaticRouterProps{route: route, route_select: route_switch}) + } +} + +#[cfg(target_arch = "wasm32")] #[instrument] #[component(UI)] pub fn ui() -> View { @@ -94,11 +145,47 @@ pub fn ui() -> View { view! { div(class="app") { Header() - Router(RouterProps { - route: AppRoutes::Plan, - route_select: route_switch, - browser_integration: BrowserIntegration::new(), - }) + RouterComponent() + } + } + } + }) + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[instrument] +#[component(UI)] +pub fn ui(route: AppRoutes) -> View { + let app_service = get_appservice(); + info!("Rendering UI"); + view! { + // NOTE(jwall): Set the app_service in our toplevel scope. Children will be able + // to find the service as long as they are a child of this scope. + ContextProvider(ContextProviderProps { + value: app_service.clone(), + children: || { + create_effect(move || { + spawn_local_in_scope({ + let mut app_service = app_service.clone(); + async move { + debug!("fetching recipes"); + match app_service.fetch_recipes_from_storage() { + Ok((_, Some(recipes))) => { + app_service.set_recipes(recipes); + } + Ok((_, None)) => { + error!("No recipes to find"); + } + Err(msg) => error!("Failed to get recipes {}", msg), + } + } + }); + }); + view!{ + div(class="app") { + Header() + RouterComponent(route) } } }