diff --git a/Cargo.lock b/Cargo.lock index 8f28421..3172a62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -98,6 +98,7 @@ name = "ascend" version = "0.0.0" dependencies = [ "axum", + "camino", "clap", "console_error_panic_hook", "derive_more", @@ -105,12 +106,15 @@ dependencies = [ "leptos", "leptos_axum", "leptos_meta", + "leptos_router", "serde", + "serde_json", "tokio", "tower 0.4.13", "tower-http 0.5.2", "tracing", "tracing-subscriber", + "type-toppings", "wasm-bindgen", ] @@ -514,6 +518,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[package]] +name = "error_reporter" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31ae425815400e5ed474178a7a22e275a9687086a12ca63ec793ff292d8fdae8" + [[package]] name = "event-listener" version = "5.4.0" @@ -647,8 +657,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1036,15 +1048,16 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "leptos" -version = "0.7.0" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5046c590aea121f6ad5e71fcb75453a933425d39527b9a3b1b295235afc8df" +checksum = "a8a90c679094979aa12927e8e925fe8eead1420d69420b2d8c6540863937ca75" dependencies = [ "any_spawner", "base64", "cfg-if", "either_of", "futures", + "getrandom", "hydration_context", "leptos_config", "leptos_dom", @@ -1065,6 +1078,7 @@ dependencies = [ "tachys", "thiserror 2.0.11", "throw_error", + "tracing", "typed-builder", "typed-builder-macro", "wasm-bindgen", @@ -1110,15 +1124,16 @@ dependencies = [ [[package]] name = "leptos_dom" -version = "0.7.0" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c15aca81dc2edd040b51c46734f65c6f36e6ba8a31347c1354c94b958044ae0" +checksum = "99803be421344a2184fd5796e1a7645c2090738b2ab5d1a856084816853ec322" dependencies = [ "js-sys", "or_poisoned", "reactive_graph", "send_wrapper", "tachys", + "tracing", "wasm-bindgen", "web-sys", ] @@ -1175,6 +1190,7 @@ dependencies = [ "rstml", "server_fn_macro", "syn", + "tracing", "uuid", ] @@ -1232,9 +1248,9 @@ dependencies = [ [[package]] name = "leptos_server" -version = "0.7.0" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93450589df3b3e398c7f5ea64d8f1c8369b1ba9b90e1f70f6cb996b8d443ca3e" +checksum = "0fb23bd110ac04c7276aae3d8ba523f94cf06989d00b4e76eaee89451b06b494" dependencies = [ "any_spawner", "base64", @@ -1248,6 +1264,7 @@ dependencies = [ "serde_json", "server_fn", "tachys", + "tracing", ] [[package]] @@ -1715,6 +1732,7 @@ dependencies = [ "serde", "slotmap", "thiserror 2.0.11", + "tracing", "web-sys", ] @@ -1938,9 +1956,9 @@ dependencies = [ [[package]] name = "server_fn" -version = "0.7.0" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "033cb8014aa86a7ce0c6ee58d23dce1a078b2e320dc6c53bb439663993199b1f" +checksum = "d0b9f0d2eecb2bf4f909661acc731009e3657574dec93a0ec9f114e250f74bc4" dependencies = [ "axum", "bytes", @@ -2125,6 +2143,7 @@ dependencies = [ "send_wrapper", "slotmap", "throw_error", + "tracing", "wasm-bindgen", "web-sys", ] @@ -2423,6 +2442,15 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "type-toppings" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efc827a3c37071ebc9812b9bee5d6c0154fdf9ba6a7ec78b38515dbd4fde8e5" +dependencies = [ + "error_reporter", +] + [[package]] name = "typed-builder" version = "0.20.0" diff --git a/Cargo.toml b/Cargo.toml index 89d60ec..77620b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,20 +9,28 @@ crate-type = ["cdylib", "rlib"] [dependencies] axum = { version = "0.7", optional = true } console_error_panic_hook = "0.1" -leptos = { version = "=0.7.0" } +leptos = { version = "0.7.3" } leptos_axum = { version = "0.7", optional = true } leptos_meta = { version = "0.7" } -# leptos_router = { version = "0.7.0" } +leptos_router = { version = "0.7.0" } tokio = { version = "1", features = ["rt-multi-thread"], optional = true } tower = { version = "0.4", optional = true } tower-http = { version = "0.5", features = ["fs"], optional = true } wasm-bindgen = "=0.2.99" -tracing = { version = "0.1", optional = true } http = "1" serde = { version = "1", features = ["derive"] } -tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } derive_more = { version = "1", features = ["display", "error", "from"] } clap = { version = "4.5.7", features = ["derive"] } +serde_json = { version = "1", optional = true } +camino = { version = "1.1", optional = true } +type-toppings = { version = "0.2.1", features = ["result"] } + +# Tracing +tracing = { version = "0.1", optional = true } +tracing-subscriber = { version = "0.3.18", features = [ + "env-filter", +], optional = true } + [features] hydrate = ["leptos/hydrate"] @@ -32,11 +40,15 @@ ssr = [ "dep:tower", "dep:tower-http", "dep:leptos_axum", + "dep:camino", + "dep:serde_json", "leptos/ssr", "leptos_meta/ssr", - # "leptos_router/ssr", - "dep:tracing", + "leptos_router/ssr", + + "tracing", ] +tracing = ["leptos/tracing", "dep:tracing", "dep:tracing-subscriber"] # Defines a size-optimized profile for the WASM bundle in release mode [profile.wasm-release] diff --git a/justfile b/justfile index 9725619..61490bc 100644 --- a/justfile +++ b/justfile @@ -22,3 +22,7 @@ run-release: #!/usr/bin/env bash cd dist LEPTOS_SITE_ROOT="site" LEPTOS_SITE_ADDR="127.0.0.1:1337" ./ascend serve + +reset-state: + cargo leptos serve -- reset-state + diff --git a/src/app.rs b/src/app.rs index b47b709..725ad20 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,3 @@ -use leptos::logging; use leptos::prelude::*; pub fn shell(options: LeptosOptions) -> impl IntoView { @@ -26,6 +25,9 @@ pub fn App() -> impl leptos::IntoView { use leptos_meta::Stylesheet; use leptos_meta::Title; + #[cfg(feature = "ssr")] + tracing::debug!("Rendering root component"); + // Provides context that manages stylesheets, titles, meta tags, etc. leptos_meta::provide_meta_context(); @@ -35,17 +37,15 @@ pub fn App() -> impl leptos::IntoView { // sets the document title - <Ascend /> + <main> + <Ascend /> + </main> } } #[leptos::component] fn Ascend() -> impl leptos::IntoView { - logging::log!("Rendering root component"); - leptos::view! { - <div> - { "hello world" } - </div> + <crate::pages::wall::Wall /> } } diff --git a/src/lib.rs b/src/lib.rs index b17fb55..ec43a61 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,11 @@ pub mod app; -pub mod pages {} +pub mod pages { + pub mod wall; +} +pub mod components {} + +#[cfg(feature = "ssr")] +pub mod server; #[cfg(feature = "hydrate")] #[wasm_bindgen::prelude::wasm_bindgen] @@ -8,9 +14,3 @@ pub fn hydrate() { console_error_panic_hook::set_once(); leptos::mount::hydrate_body(App); } - -#[cfg(feature = "ssr")] -pub mod server { - #[derive(Debug, Clone)] - pub struct AppState {} -} diff --git a/src/main.rs b/src/main.rs index 4e79b79..0a360d0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,73 +1,15 @@ -#[cfg(feature = "ssr")] -mod cli { - #[derive(clap::Parser)] - #[command(version, about, long_about = None)] - pub struct Cli { - #[command(subcommand)] - pub command: Command, - } - - #[derive(clap::Subcommand, Default)] - pub enum Command { - #[default] - Serve, - } -} - +/// Server-side main function #[cfg(feature = "ssr")] #[tokio::main] async fn main() { - use ascend::app::App; - use ascend::app::shell; - use ascend::server::AppState; - use axum::Router; - use clap::Parser as _; - use leptos::logging; - use leptos::prelude::*; - use leptos_axum::LeptosRoutes; - use leptos_axum::generate_route_list; - use tracing_subscriber::EnvFilter; - - tracing_subscriber::fmt() - .without_time() - .with_env_filter(EnvFilter::from_default_env()) - .pretty() - .init(); - - let cli = cli::Cli::parse(); - match cli.command { - cli::Command::Serve => {} - } - - // Setting get_configuration(None) means we'll be using cargo-leptos's env values - // For deployment these variables are: - // <https://github.com/leptos-rs/start-axum#executing-a-server-on-a-remote-machine-without-the-toolchain> - // Alternately a file can be specified such as Some("Cargo.toml") - // The file would need to be included with the executable when moved to deployment - let conf = get_configuration(None).unwrap(); - let leptos_options = conf.leptos_options; - let addr = leptos_options.site_addr; - let routes = generate_route_list(App); - - let app_state = AppState {}; - - // build our application with a route - let app = Router::new() - .leptos_routes_with_context(&leptos_options, routes, move || provide_context(app_state.clone()), { - let leptos_options = leptos_options.clone(); - move || shell(leptos_options.clone()) - }) - .fallback(leptos_axum::file_and_error_handler(shell)) - .with_state(leptos_options); - - let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); - logging::log!("listening on http://{addr}"); - axum::serve(listener, app.into_make_service()).await.unwrap(); + ascend::server::main().await } +/// Client-side main function #[cfg(not(feature = "ssr"))] pub fn main() { // no client-side main function // unless we want this to work with e.g., Trunk for a purely client-side app // see lib.rs for hydration function instead + eprintln!("Main function is empty. Run with the \"ssr\" feature."); } diff --git a/src/pages/wall.rs b/src/pages/wall.rs new file mode 100644 index 0000000..ed29d63 --- /dev/null +++ b/src/pages/wall.rs @@ -0,0 +1,22 @@ +use leptos::prelude::*; + +#[leptos::component] +pub fn Wall() -> impl leptos::IntoView { + let cells = (1..=(12 * 12)) + .into_iter() + .map(|i| { + let i = i.to_string(); + view! { + <div class="aspect-square rounded border-2 border-dashed border-sky-500 bg-indigo-100"> { i } </div> + } + }) + .collect_view(); + + leptos::view! { + <div class="container mx-auto border"> + <div class="grid grid-rows-4 grid-cols-12 gap-4"> + {cells} + </div> + </div> + } +} diff --git a/src/server.rs b/src/server.rs new file mode 100644 index 0000000..c8670b2 --- /dev/null +++ b/src/server.rs @@ -0,0 +1,178 @@ +//! Server-only features + +use persistence::Persistent; +use state::PersistentState; +use tracing::level_filters::LevelFilter; +use type_toppings::ResultExt; + +pub mod cli { + //! Server CLI interface + + #[derive(clap::Parser)] + #[command(version, about, long_about = None)] + pub struct Cli { + #[command(subcommand)] + pub command: Command, + } + + #[derive(clap::Subcommand, Default)] + pub enum Command { + #[default] + Serve, + + /// Resets state, replacing it with defaults + ResetState, + } +} + +pub mod state { + //! Server state + + use super::persistence::Persistent; + use serde::Deserialize; + use serde::Serialize; + use std::collections::BTreeMap; + use std::collections::BTreeSet; + + #[derive(Clone, Debug)] + pub struct State { + pub persistent: Persistent<PersistentState>, + } + + #[derive(Serialize, Deserialize, Clone, Debug, Default)] + pub struct PersistentState { + pub wall: Wall, + } + + #[derive(Serialize, Deserialize, Clone, Debug, Default)] + pub struct Wall { + pub rows: u32, + pub cols: u32, + pub holds: BTreeMap<HoldPosition, Hold>, + pub routes: BTreeSet<route::Route>, + } + + #[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] + pub struct HoldPosition { + pub row: u32, + pub col: u32, + } + + #[derive(Serialize, Deserialize, Clone, Debug, Default)] + pub struct Hold { + pub position: HoldPosition, + pub image: Option<Image>, + } + + #[derive(Serialize, Deserialize, Clone, Debug, Default)] + pub struct Image {} + + mod route { + use super::HoldPosition; + use serde::Deserialize; + use serde::Serialize; + use std::collections::BTreeMap; + + #[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] + pub struct Route { + pub holds: BTreeMap<HoldPosition, HoldRole>, + } + + /// The role of a hold on a route + #[derive(Serialize, Deserialize, Clone, Debug, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] + pub enum HoldRole { + /// Start hold + Start, + + /// Any hold on the route without a specific role + Normal, + + /// Zone hold + Zone, + + /// End hold + End, + } + } +} + +pub mod persistence; + +pub const STATE_FILE: &str = "state.json"; + +#[tracing::instrument] +pub async fn main() { + use crate::server::cli::Cli; + use crate::server::cli::Command; + use clap::Parser as _; + use tracing_subscriber::EnvFilter; + + tracing_subscriber::fmt() + .without_time() + .with_file(true) + .with_line_number(true) + .with_target(false) + .with_ansi(true) + .with_env_filter(EnvFilter::from_default_env().add_directive(LevelFilter::INFO.into())) + .pretty() + .init(); + + let cli = Cli::parse(); + match cli.command { + Command::Serve => serve().await.unwrap_or_report(), + Command::ResetState => { + let s = PersistentState::default(); + let p = camino::Utf8Path::new(STATE_FILE); + tracing::info!("Resetting state to default: {p}"); + Persistent::persist(p, &s).await.unwrap_or_report(); + } + } +} + +#[tracing::instrument(err)] +async fn serve() -> Result<(), Error> { + use crate::app::App; + use crate::app::shell; + use crate::server::state::State; + use axum::Router; + use leptos::prelude::*; + use leptos_axum::LeptosRoutes; + use leptos_axum::generate_route_list; + + // Setting get_configuration(None) means we'll be using cargo-leptos's env values + // For deployment these variables are: + // <https://github.com/leptos-rs/start-axum#executing-a-server-on-a-remote-machine-without-the-toolchain> + // Alternately a file can be specified such as Some("Cargo.toml") + // The file would need to be included with the executable when moved to deployment + let conf = get_configuration(None).unwrap_or_report(); + let leptos_options = conf.leptos_options; + let addr = leptos_options.site_addr; + let routes = generate_route_list(App); + + tracing::info!("Loading state"); + let server_state = State { + persistent: Persistent::<PersistentState>::load(STATE_FILE.into()).await?, + }; + + // build our application with a route + let app = Router::new() + .leptos_routes_with_context(&leptos_options, routes, move || provide_context(server_state.clone()), { + let leptos_options = leptos_options.clone(); + move || shell(leptos_options.clone()) + }) + .fallback(leptos_axum::file_and_error_handler(shell)) + .with_state(leptos_options); + + let listener = tokio::net::TcpListener::bind(&addr).await?; + tracing::info!("Listening on http://{addr}"); + axum::serve(listener, app.into_make_service()).await?; + + Ok(()) +} + +#[derive(Debug, derive_more::Error, derive_more::Display, derive_more::From)] +#[display("Server crash")] +pub enum Error { + Io(std::io::Error), + Persistence(persistence::Error), +} diff --git a/src/server/persistence.rs b/src/server/persistence.rs new file mode 100644 index 0000000..70e90cb --- /dev/null +++ b/src/server/persistence.rs @@ -0,0 +1,122 @@ +use camino::Utf8Path; +use camino::Utf8PathBuf; +use serde::Serialize; +use serde::de::DeserializeOwned; +use std::sync::Arc; +use tokio::sync::Mutex; + +#[derive(Debug, Clone)] +pub struct Persistent<T> { + state: Arc<Mutex<T>>, + file_path: Utf8PathBuf, +} + +impl<T> Persistent<T> { + #[tracing::instrument(skip(state))] + pub fn new(state: T, file_path: Utf8PathBuf) -> Self { + Self { + state: Arc::new(Mutex::new(state)), + file_path, + } + } + + /// Instantiates state from file system + #[tracing::instrument] + pub async fn load(file_path: Utf8PathBuf) -> Result<Self, Error> + where + T: DeserializeOwned, + { + let content = tokio::fs::read_to_string(&file_path).await.map_err(|source| Error::Read { + file_path: file_path.to_owned(), + source, + })?; + + let t = serde_json::from_str(&content).map_err(|source| Error::Deserialize { + file_path: file_path.to_owned(), + source, + })?; + + let persistent = Self { + state: Arc::new(Mutex::new(t)), + file_path, + }; + Ok(persistent) + } + + /// Returns state + #[tracing::instrument(skip(self))] + pub async fn get(&self) -> T + where + T: Clone, + { + let state = self.state.lock().await; + state.clone() + } + + /// Returns state + #[tracing::instrument(skip_all)] + pub async fn with<F, R>(&self, f: F) -> R + where + F: FnOnce(&T) -> R, + { + let state = self.state.lock().await; + f(&state) + } + + /// Updates and persists state + #[tracing::instrument(skip_all)] + pub async fn update<F>(&self, f: F) -> Result<(), Error> + where + F: FnOnce(&mut T), + T: Serialize, + { + let mut state = self.state.lock().await; + f(&mut state); + Self::persist(&self.file_path, &state).await?; + Ok(()) + } + + /// Sets and persists state + #[tracing::instrument(skip_all)] + pub async fn set(&self, new_state: T) -> Result<(), Error> + where + T: Serialize, + { + self.update(move |state| { + *state = new_state; + }) + .await + } + + /// Persist. + /// + /// Implicitly called by `set` and `update`. + #[tracing::instrument(skip_all, err)] + pub async fn persist(file_path: &Utf8Path, state: &T) -> Result<(), Error> + where + T: Serialize, + { + let serialized = serde_json::to_string(state).map_err(|source| Error::Serialize { source })?; + tokio::fs::write(file_path, serialized).await.map_err(|source| Error::Write { + file_path: file_path.to_owned(), + source, + })?; + Ok(()) + } +} + +#[derive(Debug, derive_more::Error, derive_more::Display)] +#[display("Persistent state error: {_variant}")] +pub enum Error { + #[display("Failed to read file: {file_path}")] + Read { file_path: Utf8PathBuf, source: std::io::Error }, + + #[display("Failed to deserialize state from file: {file_path}")] + Deserialize { file_path: Utf8PathBuf, source: serde_json::Error }, + + #[display("Failed to serialize state")] + Serialize { source: serde_json::Error }, + + #[display("Failed to write file: {file_path}")] + Write { file_path: Utf8PathBuf, source: std::io::Error }, +} diff --git a/state.json b/state.json new file mode 100644 index 0000000..c283cc2 --- /dev/null +++ b/state.json @@ -0,0 +1 @@ +{"wall":{"rows":0,"cols":0,"holds":{},"routes":[]}} \ No newline at end of file