diff --git a/README.md b/README.md index 0a058e3c..1bb708a8 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,8 @@ React is still React - it is just superpowered. **Rust is an hard language then writing server side code is hard as well right? (no again)** Tuono provides a collection of utilities to handle the server side code seamlessly with the React code. -Each server side route is managed with a separate file alongside the React route (the client/server -logic matching is handled by Tuono itself). +Each server side route is managed with a separate file alongside the React route. The routing is handled +by Tuono based on the files defined within the `./src/routes` directory. ## Features @@ -39,18 +39,18 @@ logic matching is handled by Tuono itself). - ๐ŸŒ Routing - ๐Ÿ”ฅ Hot Module Reload - ๐Ÿญ CSS modules -- ๐Ÿ“จ Server Side Rendering -- ๐ŸŒ Multi thread backend -- Create custom APIs* +- ๐Ÿงฌ Server Side Rendering +- ๐Ÿงต Multi thread backend +- โš™๏ธ Build optimizations +- Custom APIs* - Image optimization* -- Build optimization* - Server streamed content* > *Development in progress ## Getting started -As already mentioned above I strongly suggest to take a look to the +As already mentioned above I strongly suggest you to take a look at the [tutorial](https://github.com/Valerioageno/tuono/blob/main/docs/tutorial.md). Tuono is basically a CLI that provides all the commands to handle the fullstack project. @@ -64,7 +64,10 @@ To create a new project run `tuono new [NAME]` (optionally you can pass the `--t Then to run the local development environment run inside the project folder `tuono dev` -> ๐Ÿ’ก The `tuono build` command is not ready yet - I'm working on it right now. +Finally when the project will be ready to be deployed just run `tuono build` to create the final React assets +and to set the server project to the `production` mode. + +Now to execute it just run `cargo run --release`. ## Requirements @@ -87,7 +90,7 @@ Then to run the local development environment run inside the project folder `tuo ``` ## Contributing -Any help or suggestion will be appreciated. +Any help or suggestion will be appreciated and encouraged. ## License diff --git a/crates/tuono/src/lib.rs b/crates/tuono/src/lib.rs index ad0b9b3c..91e8a17a 100644 --- a/crates/tuono/src/lib.rs +++ b/crates/tuono/src/lib.rs @@ -4,7 +4,8 @@ use std::process::Command; mod source_builder; use source_builder::{bundle_axum_source, create_client_entry_files}; -use crate::source_builder::check_tuono_folder; +use crate::source_builder::{check_tuono_folder, Mode}; + mod scaffold_project; mod watch; @@ -32,9 +33,9 @@ struct Args { action: Actions, } -fn init_tuono_folder() -> std::io::Result<()> { +fn init_tuono_folder(mode: Mode) -> std::io::Result<()> { check_tuono_folder()?; - bundle_axum_source()?; + bundle_axum_source(mode)?; create_client_entry_files()?; Ok(()) @@ -45,11 +46,11 @@ pub fn cli() -> std::io::Result<()> { match args.action { Actions::Dev => { - init_tuono_folder()?; + init_tuono_folder(Mode::Dev)?; watch::watch().unwrap(); } Actions::Build => { - init_tuono_folder()?; + init_tuono_folder(Mode::Prod)?; let mut vite_build = Command::new("./node_modules/.bin/tuono-build-prod"); let _ = &vite_build.output()?; } diff --git a/crates/tuono/src/source_builder.rs b/crates/tuono/src/source_builder.rs index fb5a43bb..0795a155 100644 --- a/crates/tuono/src/source_builder.rs +++ b/crates/tuono/src/source_builder.rs @@ -8,6 +8,21 @@ use std::io::prelude::*; use std::path::Path; use std::path::PathBuf; +#[derive(PartialEq, Eq)] +pub enum Mode { + Prod, + Dev, +} + +impl Mode { + pub fn as_str<'a>(&self) -> &'a str { + if *self == Mode::Dev { + return "Mode::Dev"; + } + "Mode::Prod" + } +} + pub const SERVER_ENTRY_DATA: &str = "// File automatically generated by tuono // Do not manually update this file import { routeTree } from './routeTree.gen' @@ -37,9 +52,11 @@ use axum::response::Html; use axum::{routing::get, Router}; use tower_http::services::ServeDir; use std::collections::HashMap; -use tuono_lib::{ssr, Ssr}; +use tuono_lib::{ssr, Ssr, Mode, GLOBAL_MODE, manifest::load_manifest}; use reqwest::Client; +const MODE: Mode = /*MODE*/; + // MODULE_IMPORTS #[tokio::main] @@ -48,9 +65,18 @@ async fn main() { let fetch = Client::new(); + GLOBAL_MODE.set(MODE).unwrap(); + + if MODE == Mode::Prod { + load_manifest() + } + let app = Router::new() // ROUTE_BUILDER - .fallback_service(ServeDir::new("public").fallback(get(catch_all))) + .fallback_service( + ServeDir::new("public")) + .fallback_service(ServeDir::new("out/client") + .fallback(get(catch_all))) .with_state(fetch); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); @@ -83,11 +109,6 @@ async fn catch_all(Path(params): Path>, request: Request const ROOT_FOLDER: &str = "src/routes"; const DEV_FOLDER: &str = ".tuono"; -pub enum Mode { - Prod, - Dev, -} - #[derive(Debug, PartialEq, Eq)] struct Route { /// Every module import is the path with a _ instead of / @@ -137,17 +158,15 @@ impl Route { struct SourceBuilder { route_map: HashMap, - mode: Mode, base_path: PathBuf, } impl SourceBuilder { - pub fn new(mode: Mode) -> Self { + pub fn new() -> Self { let base_path = std::env::current_dir().unwrap(); SourceBuilder { route_map: HashMap::new(), - mode, base_path, } } @@ -217,14 +236,22 @@ mod {module_name}; route_declarations } -pub fn bundle_axum_source() -> io::Result<()> { +pub fn bundle_axum_source(mode: Mode) -> io::Result<()> { let base_path = std::env::current_dir().unwrap(); - let mut source_builder = SourceBuilder::new(Mode::Dev); + let mut source_builder = SourceBuilder::new(); source_builder.collect_routes(); - let bundled_file = AXUM_ENTRY_POINT + let bundled_file = generate_axum_source(&source_builder, mode); + + create_main_file(&base_path, &bundled_file); + + Ok(()) +} + +fn generate_axum_source(source_builder: &SourceBuilder, mode: Mode) -> String { + AXUM_ENTRY_POINT .replace( "// ROUTE_BUILDER\n", &create_routes_declaration(&source_builder.route_map), @@ -232,11 +259,8 @@ pub fn bundle_axum_source() -> io::Result<()> { .replace( "// MODULE_IMPORTS\n", &create_modules_declaration(&source_builder.route_map), - ); - - create_main_file(&base_path, &bundled_file); - - Ok(()) + ) + .replace("/*MODE*/", mode.as_str()) } pub fn check_tuono_folder() -> io::Result<()> { @@ -266,7 +290,7 @@ mod tests { use super::*; #[test] - fn find_dynamic_paths() { + fn should_find_dynamic_paths() { let routes = [ ("/home/user/Documents/tuono/src/routes/about.rs", false), ("/home/user/Documents/tuono/src/routes/index.rs", false), @@ -286,8 +310,8 @@ mod tests { } #[test] - fn collect_routes() { - let mut source_builder = SourceBuilder::new(Mode::Dev); + fn should_collect_routes() { + let mut source_builder = SourceBuilder::new(); source_builder.base_path = "/home/user/Documents/tuono".into(); let routes = [ @@ -321,8 +345,8 @@ mod tests { } #[test] - fn create_multi_level_axum_paths() { - let mut source_builder = SourceBuilder::new(Mode::Dev); + fn should_create_multi_level_axum_paths() { + let mut source_builder = SourceBuilder::new(); source_builder.base_path = "/home/user/Documents/tuono".into(); let routes = [ @@ -356,4 +380,24 @@ mod tests { ) }) } + + #[test] + fn should_set_the_correct_mode() { + let source_builder = SourceBuilder::new(); + + let dev_bundle = generate_axum_source(&source_builder, Mode::Dev); + assert!(dev_bundle.contains("const MODE: Mode = Mode::Dev;")); + + let prod_bundle = generate_axum_source(&source_builder, Mode::Prod); + + assert!(prod_bundle.contains("const MODE: Mode = Mode::Prod;")); + } + + #[test] + fn should_correctly_print_the_mode_as_str() { + let dev = Mode::Dev.as_str(); + let prod = Mode::Prod.as_str(); + assert_eq!(dev, "Mode::Dev"); + assert_eq!(prod, "Mode::Prod"); + } } diff --git a/crates/tuono/src/watch.rs b/crates/tuono/src/watch.rs index 72d3cfe0..c30d7451 100644 --- a/crates/tuono/src/watch.rs +++ b/crates/tuono/src/watch.rs @@ -6,7 +6,7 @@ use watchexec::Watchexec; use watchexec_signals::Signal; use watchexec_supervisor::job::{start_job, Job}; -use crate::source_builder::bundle_axum_source; +use crate::source_builder::{bundle_axum_source, Mode}; fn watch_react_src() -> Job { start_job(Arc::new(Command { @@ -75,10 +75,9 @@ pub async fn watch() -> Result<()> { run_server.stop(); build_ssr_bundle.stop(); build_ssr_bundle.start(); - bundle_axum_source().expect("Failed to bunlde rust source"); + bundle_axum_source(Mode::Dev).expect("Failed to bunlde rust source"); run_server.start(); println!("Ready!"); - should_reload = false; } // if Ctrl-C is received, quit diff --git a/crates/tuono_lib/Cargo.toml b/crates/tuono_lib/Cargo.toml index d4a67669..8f851a07 100644 --- a/crates/tuono_lib/Cargo.toml +++ b/crates/tuono_lib/Cargo.toml @@ -25,4 +25,7 @@ erased-serde = "0.4.5" serde_json = "1.0" tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.1.9"} +once_cell = "1.19.0" +lazy_static = "1.5.0" +regex = "1.10.5" diff --git a/crates/tuono_lib/src/lib.rs b/crates/tuono_lib/src/lib.rs index 4b5fdb79..ac224d28 100644 --- a/crates/tuono_lib/src/lib.rs +++ b/crates/tuono_lib/src/lib.rs @@ -1,9 +1,12 @@ +pub mod manifest; +mod mode; mod payload; mod request; mod response; pub mod ssr; +pub use mode::{Mode, GLOBAL_MODE}; pub use payload::Payload; pub use request::Request; pub use response::{Props, Response}; diff --git a/crates/tuono_lib/src/manifest.rs b/crates/tuono_lib/src/manifest.rs new file mode 100644 index 00000000..a89da9d8 --- /dev/null +++ b/crates/tuono_lib/src/manifest.rs @@ -0,0 +1,99 @@ +use once_cell::sync::OnceCell; +use serde::Deserialize; +use std::collections::HashMap; +use std::fs::File; +use std::io::BufReader; +use std::path::PathBuf; + +const VITE_MANIFEST_PATH: &str = "./out/client/.vite/manifest.json"; + +#[derive(Deserialize, Debug, Clone)] +pub struct BundleInfo { + pub file: String, + pub css: Vec, +} + +/// Manifest is the mapping between the vite output bundled files +/// and the originals. +/// Vite doc: https://vitejs.dev/config/build-options.html#build-manifest +pub static MANIFEST: OnceCell> = OnceCell::new(); + +pub fn load_manifest() { + let file = File::open(PathBuf::from(VITE_MANIFEST_PATH)).unwrap(); + let reader = BufReader::new(file); + let manifest = serde_json::from_reader(reader).unwrap(); + MANIFEST.set(remap_manifest_keys(manifest)).unwrap(); +} + +fn remap_manifest_keys(manifest: HashMap) -> HashMap { + let mut new_hashmap = HashMap::new(); + + manifest.keys().for_each(|key| { + let new_key = key + .replace("../src/routes", "") + .replace(".tsx", "") + .replace(".jsx", "") + .replace("index", ""); + + new_hashmap.insert(new_key, manifest.get(key).unwrap().clone()); + }); + + new_hashmap +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_correctly_remap_the_manifest() { + let mut parsed_manifest: HashMap = HashMap::new(); + parsed_manifest.insert( + "../src/routes/index.tsx".to_string(), + BundleInfo { + file: "assets/index.js".to_string(), + css: vec!["assets/index.css".to_string()], + }, + ); + parsed_manifest.insert( + "../src/routes/about.jsx".to_string(), + BundleInfo { + file: "assets/about.js".to_string(), + css: vec!["assets/about.css".to_string()], + }, + ); + parsed_manifest.insert( + "../src/routes/posts/[post].tsx".to_string(), + BundleInfo { + file: "assets/posts/[post].js".to_string(), + css: vec!["assets/posts/[post].css".to_string()], + }, + ); + parsed_manifest.insert( + "client-main.tsx".to_string(), + BundleInfo { + file: "assets/main.js".to_string(), + css: vec!["assets/main.css".to_string()], + }, + ); + + let remapped = remap_manifest_keys(parsed_manifest); + + assert_eq!( + remapped.get("/").unwrap().file, + "assets/index.js".to_string() + ); + assert_eq!( + remapped.get("/about").unwrap().file, + "assets/about.js".to_string() + ); + assert_eq!( + remapped.get("/posts/[post]").unwrap().file, + "assets/posts/[post].js".to_string() + ); + assert_eq!( + remapped.get("client-main").unwrap().file, + "assets/main.js".to_string() + ); + } +} diff --git a/crates/tuono_lib/src/mode.rs b/crates/tuono_lib/src/mode.rs new file mode 100644 index 00000000..a48aff14 --- /dev/null +++ b/crates/tuono_lib/src/mode.rs @@ -0,0 +1,10 @@ +use once_cell::sync::OnceCell; +use serde::Serialize; + +#[derive(Debug, PartialEq, Eq, Serialize, Clone, Copy)] +pub enum Mode { + Dev, + Prod, +} + +pub static GLOBAL_MODE: OnceCell = OnceCell::new(); diff --git a/crates/tuono_lib/src/payload.rs b/crates/tuono_lib/src/payload.rs index f0f45dcc..8277c49c 100644 --- a/crates/tuono_lib/src/payload.rs +++ b/crates/tuono_lib/src/payload.rs @@ -1,14 +1,25 @@ +use crate::{manifest::MANIFEST, Mode, GLOBAL_MODE}; use erased_serde::Serialize; +use regex::Regex; use serde::Serialize as SerdeSerialize; -use crate::request::Location; -use crate::Request; +use crate::request::{Location, Request}; + +fn has_dynamic_path(route: &str) -> bool { + let regex = Regex::new(r"\[(.*?)\]").expect("Failed to create the regex"); + regex.is_match(route) +} #[derive(SerdeSerialize)] -/// This is the data shared to the client +/// This is the payload sent to the client for hydration pub struct Payload<'a> { - router: Location<'a>, + router: Location, props: &'a dyn Serialize, + mode: Mode, + #[serde(rename(serialize = "jsBundles"))] + js_bundles: Option>, + #[serde(rename(serialize = "cssBundles"))] + css_bundles: Option>, } impl<'a> Payload<'a> { @@ -16,10 +27,267 @@ impl<'a> Payload<'a> { Payload { router: req.location(), props, + mode: *GLOBAL_MODE.get().expect("Failed to load the current mode"), + js_bundles: None, + css_bundles: None, } } - pub fn client_payload(&self) -> Result { + pub fn client_payload(&mut self) -> Result { + if self.mode == Mode::Prod { + self.add_bundle_sources(); + } serde_json::to_string(&self) } + + /// This method adds the route specific bundles to the server + /// side rendered HTML. + /// + /// The same matching algorithm is implemented on the client side in + /// this file (packages/tuono/src/router/components/Matches.ts). + /// + /// Optimizations should occour on both. + fn add_bundle_sources(&mut self) { + // Manifest should always be loaded. The load happen before starting + // the server. + let manifest = MANIFEST.get().expect("Failed to load manifest"); + + // The main bundle should always exist. + // The extension should be tsx even with JS only projects. + let main_bundle = manifest + .get("client-main") + .expect("Failed to get client-main bundle"); + + let mut js_bundles_sources = vec![&main_bundle.file]; + let mut css_bundles_sources = main_bundle.css.iter().collect::>(); + + let pathname = &self.router.pathname(); + + let bundle_data = manifest.get(*pathname); + + if let Some(data) = bundle_data { + js_bundles_sources.push(&data.file); + + data.css + .iter() + .for_each(|source| css_bundles_sources.push(source)) + } else { + let dynamic_routes = manifest + .keys() + .filter(|path| has_dynamic_path(path)) + .collect::>(); + + if !dynamic_routes.is_empty() { + let path_segments = pathname + .split('/') + .filter(|path| !path.is_empty()) + .collect::>(); + + for dyn_route in dynamic_routes.iter() { + let dyn_route_segments = dyn_route + .split('/') + .filter(|path| !path.is_empty()) + .collect::>(); + + let mut route_segments_collector: Vec<&str> = vec![]; + + for i in 0..dyn_route_segments.len() { + if path_segments.len() == i { + break; + } + if dyn_route_segments[i] == path_segments[i] + || has_dynamic_path(dyn_route_segments[i]) + { + route_segments_collector.push(dyn_route_segments[i]) + } else { + break; + } + } + + if route_segments_collector.len() == path_segments.len() { + let manifest_key = route_segments_collector.join("/"); + + let route_data = manifest.get(&format!("/{manifest_key}")); + if let Some(data) = route_data { + js_bundles_sources.push(&data.file); + data.css + .iter() + .for_each(|source| css_bundles_sources.push(source)) + } + break; + } + } + } + } + + self.js_bundles = Some(js_bundles_sources); + self.css_bundles = Some(css_bundles_sources); + } +} + +#[cfg(test)] +mod tests { + + use super::*; + use axum::http::Uri; + use std::collections::HashMap; + + use crate::manifest::BundleInfo; + + fn prepare_payload(uri: Option<&str>, mode: Mode) -> Payload<'static> { + let mut manifest_mock = HashMap::new(); + manifest_mock.insert( + "client-main".to_string(), + BundleInfo { + file: "assets/bundled-file.js".to_string(), + css: vec!["assets/bundled-file.css".to_string()], + }, + ); + manifest_mock.insert( + "/".to_string(), + BundleInfo { + file: "assets/index.js".to_string(), + + css: vec!["assets/index.css".to_string()], + }, + ); + manifest_mock.insert( + "/posts/[post]".to_string(), + BundleInfo { + file: "assets/posts/[post].js".to_string(), + + css: vec!["assets/posts/[post].css".to_string()], + }, + ); + manifest_mock.insert( + "/posts/[post]/[comment]".to_string(), + BundleInfo { + file: "assets/posts/[post]/[comment].js".to_string(), + + css: vec!["assets/posts/[post]/[comment].css".to_string()], + }, + ); + manifest_mock.insert( + "/posts/custom-post".to_string(), + BundleInfo { + file: "assets/custom-post.js".to_string(), + + css: vec!["assets/custom-post.css".to_string()], + }, + ); + manifest_mock.insert( + "/about".to_string(), + BundleInfo { + file: "assets/about.js".to_string(), + + css: vec!["assets/about.css".to_string()], + }, + ); + MANIFEST.get_or_init(|| manifest_mock); + let location = Location::from( + &uri.unwrap_or("http://localhost:3000/") + .parse::() + .unwrap(), + ); + + Payload { + router: location, + props: &None::>, + mode, + js_bundles: None, + css_bundles: None, + } + } + + #[test] + fn should_load_the_bundles_on_mode_prod() { + let mut payload = prepare_payload(None, Mode::Prod); + + let _ = payload.client_payload(); + assert_eq!( + payload.js_bundles, + Some(vec![ + &"assets/bundled-file.js".to_string(), + &"assets/index.js".to_string() + ]) + ); + assert_eq!( + payload.css_bundles, + Some(vec![ + &"assets/bundled-file.css".to_string(), + &"assets/index.css".to_string() + ]) + ); + } + + #[test] + fn should_not_load_the_bundles_on_mode_dev() { + let mut payload = prepare_payload(None, Mode::Dev); + let _ = payload.client_payload(); + assert!(payload.js_bundles.is_none()); + assert!(payload.css_bundles.is_none()); + } + + #[test] + fn should_load_the_correct_single_dyn_path_bundles() { + let mut payload = prepare_payload(Some("http://localhost:3000/posts/a-post"), Mode::Prod); + let _ = payload.client_payload(); + assert_eq!( + payload.js_bundles, + Some(vec![ + &"assets/bundled-file.js".to_string(), + &"assets/posts/[post].js".to_string() + ]) + ); + assert_eq!( + payload.css_bundles, + Some(vec![ + &"assets/bundled-file.css".to_string(), + &"assets/posts/[post].css".to_string() + ]) + ); + } + + #[test] + fn should_load_the_correct_nested_dyn_path_bundles() { + let mut payload = prepare_payload( + Some("http://localhost:3000/posts/a-post/a-comment"), + Mode::Prod, + ); + let _ = payload.client_payload(); + assert_eq!( + payload.js_bundles, + Some(vec![ + &"assets/bundled-file.js".to_string(), + &"assets/posts/[post]/[comment].js".to_string() + ]) + ); + assert_eq!( + payload.css_bundles, + Some(vec![ + &"assets/bundled-file.css".to_string(), + &"assets/posts/[post]/[comment].css".to_string() + ]) + ); + } + + #[test] + fn should_load_the_defined_path_bundles() { + let mut payload = prepare_payload(Some("http://localhost:3000/about"), Mode::Prod); + let _ = payload.client_payload(); + assert_eq!( + payload.js_bundles, + Some(vec![ + &"assets/bundled-file.js".to_string(), + &"assets/about.js".to_string() + ]) + ); + assert_eq!( + payload.css_bundles, + Some(vec![ + &"assets/bundled-file.css".to_string(), + &"assets/about.css".to_string() + ]) + ); + } } diff --git a/crates/tuono_lib/src/request.rs b/crates/tuono_lib/src/request.rs index 1b318cf3..9bc8a91e 100644 --- a/crates/tuono_lib/src/request.rs +++ b/crates/tuono_lib/src/request.rs @@ -3,6 +3,35 @@ use std::collections::HashMap; use axum::http::{HeaderMap, Uri}; +/// Location must match client side interface +#[derive(Serialize, Debug)] +pub struct Location { + href: String, + pathname: String, + search: HashMap, + search_str: String, + hash: String, +} + +impl Location { + pub fn pathname(&self) -> &String { + &self.pathname + } +} + +impl<'a> From<&'a Uri> for Location { + fn from(uri: &Uri) -> Self { + Location { + href: uri.to_string(), + pathname: uri.path().to_string(), + // TODO: handler search map + search: HashMap::new(), + search_str: uri.query().unwrap_or("").to_string(), + hash: "".to_string(), + } + } +} + #[derive(Debug, Clone)] pub struct Request<'a> { uri: &'a Uri, @@ -10,17 +39,6 @@ pub struct Request<'a> { pub params: HashMap, } -/// Location must match client side interface -#[derive(Serialize, Debug)] -pub struct Location<'a> { - href: String, - pathname: &'a str, - search: HashMap, - search_str: &'a str, - /// Server does not need it. Will be hanlder client side - hash: &'a str, -} - impl<'a> Request<'a> { pub fn new( uri: &'a Uri, @@ -34,14 +52,7 @@ impl<'a> Request<'a> { } } - pub fn location(&self) -> Location<'a> { - Location { - href: self.uri.to_string(), - pathname: &self.uri.path(), - // TODO: handler search map - search: HashMap::new(), - search_str: &self.uri.query().unwrap_or(""), - hash: "", - } + pub fn location(&self) -> Location { + Location::from(self.uri) } } diff --git a/crates/tuono_lib/src/ssr.rs b/crates/tuono_lib/src/ssr.rs index a1e5e8ff..d79cab16 100644 --- a/crates/tuono_lib/src/ssr.rs +++ b/crates/tuono_lib/src/ssr.rs @@ -1,16 +1,26 @@ +use crate::{Mode, GLOBAL_MODE}; +use lazy_static::lazy_static; use ssr_rs::Ssr; use std::cell::RefCell; use std::fs::read_to_string; +use std::path::PathBuf; + +lazy_static! { + static ref BUNDLE_PATH: &'static str = { + if GLOBAL_MODE.get().unwrap() == &Mode::Dev { + return "./.tuono/server/dev-server.js"; + } + "./out/server/prod-server.js" + }; +} pub struct Js; impl Js { thread_local! { pub static SSR: RefCell> = RefCell::new( - // TODO: handle here dev/prod source Ssr::from( - read_to_string("./.tuono/server/dev-server.js").unwrap(), - "" + read_to_string(PathBuf::from(*BUNDLE_PATH)).expect("Server bundle not found"), "" ).unwrap() ) } diff --git a/docs/tutorial.md b/docs/tutorial.md index adf7ee50..8b78451c 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -4,7 +4,13 @@ This tutorial is meant for giving you a sneak peek of the framework and is inten The first part is about the project setup and the base knowledge needed to work with tuono. The actual tutorial starts at [Tutorial introduction](#tutorial-introduction). -> If you have already installed the tuono CLI you can download the tutorial source with `tuono new tuono-tutorial --template tutorial` +> I'd love to hear your thoughts about the framework and the tutorial - feel free to reach me +at [valerioageno@yahoo.it](mailto:valerioageno@yahoo.it) or in Twitter (X) DMs +[@valerioageno](https://twitter.com/valerioageno + +This tutorial is **not meant** for people that don't know React - in that case I suggest you to first read the [React doc](https://react.dev/); + +Typescript and Rust knowledge is not a requirement though! ## Table of Content @@ -17,6 +23,7 @@ The first part is about the project setup and the base knowledge needed to work * [Create a stand-alone component](#create-a-stand-alone-component) * [Create the /pokemons/[pokemon] route](#create-the-pokemonspokemon-route) * [Error handling](#error-handling) +* [Building for production](#building-for-production) * [Conclusion](#conclusion) ## CLI Installation @@ -99,6 +106,9 @@ Now that we have some knowledge about the project structure letโ€™s start the re The goal is to use the [PokeAPI](https://pokeapi.co/docs/v2) to list all the pokemons of the first generation (the best one btw) and then reserve a dynamic page for each one separately. +> If you have already installed the tuono CLI and you prefer read the code instead of writing it yourself +you can download the tutorial source with `tuono new tuono-tutorial --template tutorial` + ## Fetch all the pokemons To start letโ€™s fetch all of them in the root page; since we want to render them on the server side we gonna need to implement the logic in the `index.rs` file. @@ -532,9 +542,33 @@ async fn get_all_pokemons(_req: Request<'_>, fetch: reqwest::Client) -> Response If you now try to load a not existing pokemon (`http://localhost:3000/pokemons/tuono-pokemon`) you will correctly receive a 404 status code in the console. +## Building for production + +The source now is ready to be released. Both server and client have been managed in a unoptimized way +to easy the development experience. To build the project to the production state just run: + +```shell +$ tuono build +``` + +This command just created the final assets within the `out` directory. To run then the prodiction server +run: + +```shell +$ cargo run --release +``` + +Check again [`http://localhost:3000/`](http://localhost:3000/) - This environment now has all the +optimizations ready to unleash the power of a rust server that seamessly renders a React application!๐Ÿš€ + +> Note: The `out` directory is not standalone. You can't rely just on it to run the production server. + ## Conclusion Thatโ€™s it! You just created a multi thread full stack application with rust and react. -The project is still under heavy development and the script for the production build (`tuono build`) is not ready yet but +The project is still under heavy development and many features are not ready yet but I hope you got the taste of what is like working with rust and react in the same stack. + +As I mentioned in the introduction I'd love to hear what you thought about the framework and the tutorial - feel free to reach me +at [valerioageno@yahoo.it](mailto:valerioageno@yahoo.it) or in Twitter (X) DMs [@valerioageno](https://twitter.com/valerioageno). diff --git a/package.json b/package.json index d64ab4e0..5b8e8b0c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "workspace", "packageManager": "pnpm@9.1.1", "scripts": { - "dev": "turbo dev", + "dev": "turbo dev --filter tuono", "build": "turbo build", "lint": "turbo lint", "format": "turbo format", diff --git a/packages/tuono/src/build/index.ts b/packages/tuono/src/build/index.ts index 457f1099..3dfd1e1a 100644 --- a/packages/tuono/src/build/index.ts +++ b/packages/tuono/src/build/index.ts @@ -77,7 +77,7 @@ export function buildProd() { ...BASE_CONFIG, build: { ssr: true, - minify: false, + minify: true, outDir: '../out/server', emptyOutDir: true, rollupOptions: { diff --git a/packages/tuono/src/router/components/Matches.tsx b/packages/tuono/src/router/components/Matches.tsx index 518888a0..d070428f 100644 --- a/packages/tuono/src/router/components/Matches.tsx +++ b/packages/tuono/src/router/components/Matches.tsx @@ -12,6 +12,14 @@ interface MatchesProps { const DYNAMIC_PATH_REGEX = /\[(.*?)\]/ +/* + * This function is also implemented on server side to match the bundle + * file to load at the first rendering. + * + * File: crates/tuono_lib/src/payload.rs + * + * Optimizations should occour on both + */ export function getRouteByPathname(pathname: string): Route | undefined { const { routesById } = useRouter() diff --git a/packages/tuono/src/ssr/index.tsx b/packages/tuono/src/ssr/index.tsx index 690c31dd..2abc05a3 100644 --- a/packages/tuono/src/ssr/index.tsx +++ b/packages/tuono/src/ssr/index.tsx @@ -5,33 +5,52 @@ import { RouterProvider } from '../router' import { createRouter } from '../router' type RouteTree = any +type Mode = 'Dev' | 'Prod' + +const VITE_DEV_AND_HMR = ` + +` + +function generateCssLinks(cssBundles: string[], mode: Mode): string { + if (mode === 'Dev') return '' + return cssBundles.reduce((acc, value) => { + return acc + `\n` + }, '') +} + +function generateJsScripts(jsBundles: string[], mode: Mode): string { + if (mode === 'Dev') return '' + return jsBundles.reduce((acc, value) => { + return acc + `\n` + }, '') +} export function serverSideRendering(routeTree: RouteTree) { return function render(payload: string | undefined): string { const props = payload ? JSON.parse(payload) : {} + const mode = props.mode as Mode + const jsBundles = props.jsBundles as string[] + const cssBundles = props.cssBundles as string[] const router = createRouter({ routeTree }) // Render the app const app = renderToString( , ) - const developmentScript = ` - - ` - return ` Playground + ${generateCssLinks(cssBundles, mode)}
${app}
@@ -42,7 +61,8 @@ export function serverSideRendering(routeTree: RouteTree) { }} />, )} - ${developmentScript} + ${generateJsScripts(jsBundles, mode)} + ${mode === 'Dev' ? VITE_DEV_AND_HMR : ''} `