diff --git a/crates/tuono_lib/Cargo.toml b/crates/tuono_lib/Cargo.toml index 8ea1c877..e542e6d2 100644 --- a/crates/tuono_lib/Cargo.toml +++ b/crates/tuono_lib/Cargo.toml @@ -15,29 +15,43 @@ include = [ "Cargo.toml" ] +[features] +full = ["api", "ssr"] +api = [] +ssr = [ + "dep:ssr_rs", + "dep:erased-serde", + "dep:serde_json", + "dep:reqwest", + "dep:tokio-tungstenite", + "dep:tungstenite", + "dep:futures-util" +] [dependencies] -ssr_rs = "0.7.0" axum = {version = "0.7.5", features = ["json", "ws"]} axum-extra = {version = "0.9.6", features = ["cookie"]} tokio = { version = "1.37.0", features = ["full"] } -serde = { version = "1.0.202", features = ["derive"] } -erased-serde = "0.4.5" -serde_json = "1.0" -serde_urlencoded = "0.7.1" -reqwest = {version = "0.12.4", features = ["json", "stream"]} -once_cell = "1.19.0" -regex = "1.10.5" -either = "1.13.0" -tower-http = {version = "0.6.0", features = ["fs"]} -colored = "2.1.0" - tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.16.2"} -# Match the same version used by axum -tokio-tungstenite = "0.24.0" -futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] } -tungstenite = "0.24.0" +regex = "1.10.5" http = "1.1.0" pin-project = "1.1.7" tower = "0.5.1" +colored = "2.1.0" +# Check if serde is mandatory for API only project +serde = { version = "1.0.202", features = ["derive"] } +# Check if once_cell is mandatory for API only project +once_cell = "1.19.0" + +ssr_rs = { version = "0.7.0", optional = true} +erased-serde = {version = "0.4.5", optional = true} +serde_json = { version = "1.0", optional = true} +serde_urlencoded = {version = "0.7.1" } +reqwest = {version = "0.12.4", features = ["json", "stream"], optional = true} + +# Match the same version used internally by axum +tokio-tungstenite = {version = "0.24.0", optional = true} +futures-util = { version = "0.3", default-features = false, features = ["sink", "std"], optional = true} +tungstenite = {version = "0.24.0", optional = true} +tower-http = {version = "0.6.0", features = ["fs"]} diff --git a/crates/tuono_lib/src/catch_all.rs b/crates/tuono_lib/src/catch_all.rs index 39b8a1c9..150dc02b 100644 --- a/crates/tuono_lib/src/catch_all.rs +++ b/crates/tuono_lib/src/catch_all.rs @@ -1,24 +1,28 @@ -use crate::{ssr::Js, Payload}; -use axum::extract::{Path, Request}; -use axum::response::Html; -use std::collections::HashMap; +#[cfg(feature = "ssr")] +mod ssr { + use crate::ssr::Js; + use crate::Payload; + use axum::extract::{Path, Request}; + use axum::response::Html; + use std::collections::HashMap; -pub async fn catch_all( - Path(params): Path>, - request: Request, -) -> Html { - let pathname = request.uri(); - let headers = request.headers(); + pub async fn catch_all( + Path(params): Path>, + request: Request, + ) -> Html { + let pathname = request.uri(); + let headers = request.headers(); - let req = crate::Request::new(pathname.to_owned(), headers.to_owned(), params); + let req = crate::Request::new(pathname.to_owned(), headers.to_owned(), params); - // TODO: remove unwrap - let payload = Payload::new(&req, &"").client_payload().unwrap(); + // TODO: remove unwrap + let payload = Payload::new(&req, &"").client_payload().unwrap(); - let result = Js::render_to_string(Some(&payload)); + let result = Js::render_to_string(Some(&payload)); - match result { - Ok(html) => Html(html), - _ => Html("500 internal server error".to_string()), + match result { + Ok(html) => Html(html), + _ => Html("500 internal server error".to_string()), + } } } diff --git a/crates/tuono_lib/src/lib.rs b/crates/tuono_lib/src/lib.rs index d94172c6..235c9156 100644 --- a/crates/tuono_lib/src/lib.rs +++ b/crates/tuono_lib/src/lib.rs @@ -1,19 +1,10 @@ mod catch_all; mod logger; -mod manifest; + mod mode; -mod payload; -mod request; -mod response; mod server; -mod ssr; -mod vite_reverse_proxy; -mod vite_websocket_proxy; pub use mode::Mode; -pub use payload::Payload; -pub use request::Request; -pub use response::{Props, Response}; pub use server::Server; pub use tuono_lib_macros::{api, handler}; @@ -21,3 +12,28 @@ pub use tuono_lib_macros::{api, handler}; pub use axum; pub use axum_extra::extract::cookie; pub use tokio; + +mod request; +pub use request::Request; + +#[cfg(feature = "ssr")] +#[cfg(feature = "ssr")] +mod manifest; +mod response; + +#[cfg(feature = "ssr")] +mod payload; + +#[cfg(feature = "ssr")] +pub use payload::Payload; +#[cfg(feature = "ssr")] +pub use response::response::{Props, Response}; + +#[cfg(feature = "ssr")] +mod ssr; + +#[cfg(feature = "ssr")] +mod vite_reverse_proxy; + +#[cfg(feature = "ssr")] +mod vite_websocket_proxy; diff --git a/crates/tuono_lib/src/response.rs b/crates/tuono_lib/src/response.rs index adfbbad2..eb45321d 100644 --- a/crates/tuono_lib/src/response.rs +++ b/crates/tuono_lib/src/response.rs @@ -1,160 +1,163 @@ -use crate::Request; -use crate::{ssr::Js, Payload}; -use axum::http::{HeaderMap, StatusCode}; -use axum::response::{Html, IntoResponse, Redirect}; -use axum::Json; -use axum_extra::extract::cookie::{Cookie, CookieJar}; -use erased_serde::Serialize; +#[cfg(feature = "ssr")] +pub(crate) mod response { + use crate::Request; + use crate::{ssr::Js, Payload}; + use axum::http::{HeaderMap, StatusCode}; + use axum::response::{Html, IntoResponse, Redirect}; + use axum::Json; + use axum_extra::extract::cookie::{Cookie, CookieJar}; + use erased_serde::Serialize; -pub struct Props { - data: Box, - http_code: StatusCode, - cookies: CookieJar, -} - -pub enum Response { - Redirect(String), - Props(Props), - // TODO: improve this tuple to support a more generic IntoResponse - Custom((StatusCode, HeaderMap, String)), -} - -#[derive(serde::Serialize)] -struct JsonResponseInfo { - redirect_destination: Option, -} - -impl JsonResponseInfo { - fn new(redirect_destination: Option) -> JsonResponseInfo { - JsonResponseInfo { - redirect_destination, - } - } -} - -#[derive(serde::Serialize)] -struct JsonResponse<'a> { - data: Option<&'a dyn Serialize>, - info: JsonResponseInfo, -} - -impl<'a> JsonResponse<'a> { - fn new(props: &'a dyn Serialize) -> Self { - JsonResponse { - data: Some(props), - info: JsonResponseInfo::new(None), - } + pub struct Props { + data: Box, + http_code: StatusCode, + cookies: CookieJar, } - fn new_redirect(destination: String) -> Self { - JsonResponse { - data: None, - info: JsonResponseInfo::new(Some(destination)), - } - } -} - -impl Props { - pub fn new(data: impl Serialize + 'static) -> Self { - Props { - data: Box::new(data), - http_code: StatusCode::OK, - cookies: CookieJar::new(), - } + pub enum Response { + Redirect(String), + Props(Props), + // TODO: improve this tuple to support a more generic IntoResponse + Custom((StatusCode, HeaderMap, String)), } - pub fn status(&mut self, http_code: StatusCode) { - self.http_code = http_code; + #[derive(serde::Serialize)] + struct JsonResponseInfo { + redirect_destination: Option, } - pub fn new_with_status(data: impl Serialize + 'static, http_code: StatusCode) -> Self { - Props { - data: Box::new(data), - http_code, - cookies: CookieJar::new(), - } - } - - pub fn add_cookie(&mut self, cookie: Cookie) { - let jar = self.cookies.clone().add(cookie.into_owned()); - self.cookies = jar - } -} - -impl Response { - pub fn render_to_string(&self, req: Request) -> impl IntoResponse { - match self { - Self::Props(Props { - data, - http_code, - cookies, - }) => { - let payload = Payload::new(&req, data.as_ref()).client_payload().unwrap(); - - match Js::render_to_string(Some(&payload)) { - Ok(html) => (*http_code, cookies.clone(), Html(html)), - Err(_) => ( - *http_code, - cookies.clone(), - Html("500 Internal server error".to_string()), - ), - } - .into_response() + impl JsonResponseInfo { + fn new(redirect_destination: Option) -> JsonResponseInfo { + JsonResponseInfo { + redirect_destination, } - Self::Redirect(to) => Redirect::permanent(to).into_response(), - Self::Custom(response) => response.clone().into_response(), } } - pub fn json(&self) -> impl IntoResponse { - match self { - Self::Props(Props { - data, + #[derive(serde::Serialize)] + struct JsonResponse<'a> { + data: Option<&'a dyn Serialize>, + info: JsonResponseInfo, + } + + impl<'a> JsonResponse<'a> { + fn new(props: &'a dyn Serialize) -> Self { + JsonResponse { + data: Some(props), + info: JsonResponseInfo::new(None), + } + } + + fn new_redirect(destination: String) -> Self { + JsonResponse { + data: None, + info: JsonResponseInfo::new(Some(destination)), + } + } + } + + impl Props { + pub fn new(data: impl Serialize + 'static) -> Self { + Props { + data: Box::new(data), + http_code: StatusCode::OK, + cookies: CookieJar::new(), + } + } + + pub fn status(&mut self, http_code: StatusCode) { + self.http_code = http_code; + } + + pub fn new_with_status(data: impl Serialize + 'static, http_code: StatusCode) -> Self { + Props { + data: Box::new(data), http_code, - cookies, - }) => ( - *http_code, - cookies.clone(), - Json(JsonResponse::new(data.as_ref())), - ) - .into_response(), - Self::Redirect(destination) => ( - StatusCode::PERMANENT_REDIRECT, - Json(JsonResponse::new_redirect(destination.to_string())), - ) - .into_response(), - // Custom never needs the "data" response since its scope - // is outside the react domain - Self::Custom(_) => (StatusCode::OK, Json("{}")).into_response(), + cookies: CookieJar::new(), + } + } + + pub fn add_cookie(&mut self, cookie: Cookie) { + let jar = self.cookies.clone().add(cookie.into_owned()); + self.cookies = jar + } + } + + impl Response { + pub fn render_to_string(&self, req: Request) -> impl IntoResponse { + match self { + Self::Props(Props { + data, + http_code, + cookies, + }) => { + let payload = Payload::new(&req, data.as_ref()).client_payload().unwrap(); + + match Js::render_to_string(Some(&payload)) { + Ok(html) => (*http_code, cookies.clone(), Html(html)), + Err(_) => ( + *http_code, + cookies.clone(), + Html("500 Internal server error".to_string()), + ), + } + .into_response() + } + Self::Redirect(to) => Redirect::permanent(to).into_response(), + Self::Custom(response) => response.clone().into_response(), + } + } + + pub fn json(&self) -> impl IntoResponse { + match self { + Self::Props(Props { + data, + http_code, + cookies, + }) => ( + *http_code, + cookies.clone(), + Json(JsonResponse::new(data.as_ref())), + ) + .into_response(), + Self::Redirect(destination) => ( + StatusCode::PERMANENT_REDIRECT, + Json(JsonResponse::new_redirect(destination.to_string())), + ) + .into_response(), + // Custom never needs the "data" response since its scope + // is outside the react domain + Self::Custom(_) => (StatusCode::OK, Json("{}")).into_response(), + } + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn should_update_the_props_status_and_cookie() { + let mut props = Props::new("{}"); + props.status(StatusCode::NOT_FOUND); + props.add_cookie(Cookie::new("test", "cookie")); + assert_eq!(props.http_code, StatusCode::NOT_FOUND); + assert_eq!( + props.cookies.get("test").unwrap(), + &Cookie::new("test", "cookie") + ); + } + + #[test] + fn should_add_a_cookie_jar() { + let mut props = Props::new("{}"); + props.status(StatusCode::NOT_FOUND); + props.add_cookie(Cookie::new("test", "cookie")); + assert_eq!(props.http_code, StatusCode::NOT_FOUND); + assert_eq!( + props.cookies.get("test").unwrap(), + &Cookie::new("test", "cookie") + ); } } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn should_update_the_props_status_and_cookie() { - let mut props = Props::new("{}"); - props.status(StatusCode::NOT_FOUND); - props.add_cookie(Cookie::new("test", "cookie")); - assert_eq!(props.http_code, StatusCode::NOT_FOUND); - assert_eq!( - props.cookies.get("test").unwrap(), - &Cookie::new("test", "cookie") - ); - } - - #[test] - fn should_add_a_cookie_jar() { - let mut props = Props::new("{}"); - props.status(StatusCode::NOT_FOUND); - props.add_cookie(Cookie::new("test", "cookie")); - assert_eq!(props.http_code, StatusCode::NOT_FOUND); - assert_eq!( - props.cookies.get("test").unwrap(), - &Cookie::new("test", "cookie") - ); - } -} diff --git a/crates/tuono_lib/src/server.rs b/crates/tuono_lib/src/server.rs index e7181132..dbbe82f5 100644 --- a/crates/tuono_lib/src/server.rs +++ b/crates/tuono_lib/src/server.rs @@ -1,16 +1,28 @@ -use crate::manifest::load_manifest; use crate::mode::{Mode, GLOBAL_MODE}; -use axum::routing::{get, Router}; +use axum::routing::Router; use colored::Colorize; -use ssr_rs::Ssr; -use tower_http::services::ServeDir; +use crate::logger::LoggerLayer; + +#[cfg(feature = "ssr")] use crate::{ - catch_all::catch_all, logger::LoggerLayer, vite_reverse_proxy::vite_reverse_proxy, + catch_all::catch_all, vite_reverse_proxy::vite_reverse_proxy, vite_websocket_proxy::vite_websocket_proxy, }; +#[cfg(feature = "ssr")] +use crate::manifest::load_manifest; +#[cfg(feature = "ssr")] +use axum::routing::get; +#[cfg(feature = "ssr")] +use ssr_rs::Ssr; +#[cfg(feature = "ssr")] +use tower_http::services::ServeDir; + +#[cfg(feature = "ssr")] const DEV_PUBLIC_DIR: &str = "public"; + +#[cfg(feature = "ssr")] const PROD_PUBLIC_DIR: &str = "out/client"; pub struct Server { @@ -19,6 +31,7 @@ pub struct Server { } impl Server { + #[cfg(feature = "ssr")] pub fn init(router: Router, mode: Mode) -> Server { Ssr::create_platform(); @@ -31,6 +44,14 @@ impl Server { Server { router, mode } } + #[cfg(not(feature = "ssr"))] + pub fn init(router: Router, mode: Mode) -> Server { + GLOBAL_MODE.set(mode).unwrap(); + + Server { router, mode } + } + + #[cfg(feature = "ssr")] pub async fn start(&self) { let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); @@ -69,4 +90,24 @@ impl Server { .expect("Failed to serve production server"); } } + + #[cfg(not(feature = "ssr"))] + pub async fn start(&self) { + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); + + if self.mode == Mode::Dev { + println!(" Ready at: {}\n", "http://localhost:3000".blue().bold()); + } else { + println!( + " Production server at: {}\n", + "http://localhost:3000".blue().bold() + ); + } + + let router = self.router.to_owned().layer(LoggerLayer::new()); + + axum::serve(listener, router) + .await + .expect("Failed to serve production server"); + } } diff --git a/crates/tuono_lib/src/ssr.rs b/crates/tuono_lib/src/ssr.rs index a468aaf0..4fedd611 100644 --- a/crates/tuono_lib/src/ssr.rs +++ b/crates/tuono_lib/src/ssr.rs @@ -36,10 +36,10 @@ struct ProdJs; impl ProdJs { thread_local! { pub static SSR: RefCell> = RefCell::new( - Ssr::from( - read_to_string(PathBuf::from(PROD_BUNDLE_PATH)).expect("Server bundle not found"), "" - ).unwrap() - ) + Ssr::from( + read_to_string(PathBuf::from(PROD_BUNDLE_PATH)).expect("Server bundle not found"), "" + ).unwrap() + ) } } diff --git a/examples/tuono-api/.tuono/client-main.tsx b/examples/tuono-api/.tuono/client-main.tsx new file mode 100644 index 00000000..bf53add2 --- /dev/null +++ b/examples/tuono-api/.tuono/client-main.tsx @@ -0,0 +1,10 @@ +// File automatically generated by tuono +// Do not manually update this file +import 'vite/modulepreload-polyfill' +import { hydrate } from 'tuono/hydration' +import '../src/styles/global.css' + +// Import the generated route tree +import { routeTree } from './routeTree.gen' + +hydrate(routeTree) diff --git a/examples/tuono-api/.tuono/main.rs b/examples/tuono-api/.tuono/main.rs new file mode 100644 index 00000000..dc27b518 --- /dev/null +++ b/examples/tuono-api/.tuono/main.rs @@ -0,0 +1,29 @@ + +// File automatically generated +// Do not manually change it + +use tuono_lib::{tokio, Mode, Server, axum::Router}; +use tuono_lib::axum::routing::get; +use tuono_lib::axum::routing::post; + + +const MODE: Mode = Mode::Dev; + +// MODULE_IMPORTS +#[path="../src/routes/index.rs"] + mod index; + + + +#[tokio::main] +async fn main() { + println!("\n ⚡ Tuono v0.16.1"); + + + + let router = Router::new() + // ROUTE_BUILDER +.route("/", get(index::get__tuono_internal_api)).route("/", post(index::post__tuono_internal_api)) ; + + Server::init(router, MODE).start().await +} diff --git a/examples/tuono-api/.tuono/server-main.tsx b/examples/tuono-api/.tuono/server-main.tsx new file mode 100644 index 00000000..1f2cd6e9 --- /dev/null +++ b/examples/tuono-api/.tuono/server-main.tsx @@ -0,0 +1,6 @@ +// File automatically generated by tuono +// Do not manually update this file +import { routeTree } from './routeTree.gen' +import { serverSideRendering } from 'tuono/ssr' + +export const renderFn = serverSideRendering(routeTree)