mirror of
https://github.com/tuono-labs/tuono
synced 2026-07-27 13:52:47 -07:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3de9d1be66 | |||
| 00916f188d | |||
| fff5f92893 | |||
| 8b796fc1f4 | |||
| 5ac679785b | |||
| 38b6cc8c65 | |||
| abb2d658c6 | |||
| 3587c085b9 | |||
| 581a7d79b3 | |||
| e21b80a5d8 | |||
| e0fc19ecaf | |||
| 0be0ce4c15 | |||
| 792358ea9c | |||
| 26c0c2488d | |||
| 70b4b9c28f | |||
| 8b4db423b4 | |||
| 7d65ede581 | |||
| 5b11f68fbb | |||
| 0d33e6887b | |||
| 5bb620a590 | |||
| 469386388b | |||
| 8728499f90 | |||
| 2e82dde2c3 |
@@ -25,9 +25,6 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Install turbo
|
||||
run: pnpm install turbo --global
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
@@ -76,9 +73,6 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Install turbo
|
||||
run: pnpm install turbo --global
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
|
||||
@@ -61,14 +61,14 @@ tuono dev
|
||||
## Folder structure
|
||||
|
||||
```
|
||||
| public/
|
||||
- src/
|
||||
| routes/
|
||||
| styles/
|
||||
| package.json
|
||||
| Cargo.toml
|
||||
| .gitignore
|
||||
| tsconfig.json
|
||||
├── package.json
|
||||
├── public
|
||||
├── src
|
||||
│ ├── routes
|
||||
│ └── styles
|
||||
├── Cargo.toml
|
||||
├── README.md
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "tuono"
|
||||
version = "0.1.0"
|
||||
version = "0.1.4"
|
||||
edition = "2021"
|
||||
authors = ["V. Ageno <valerioageno@yahoo.it>"]
|
||||
description = "The react/rust fullstack framework"
|
||||
|
||||
+13
-3
@@ -15,7 +15,14 @@ enum Actions {
|
||||
/// Build the production assets
|
||||
Build,
|
||||
/// Scaffold a new project
|
||||
New { folder_name: Option<String> },
|
||||
New {
|
||||
/// The folder in which load the project. Default is the current directory.
|
||||
folder_name: Option<String>,
|
||||
/// The template to use to scaffold the project. The template should match one of the tuono
|
||||
/// examples
|
||||
#[arg(short, long)]
|
||||
template: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
@@ -46,8 +53,11 @@ pub fn cli() -> std::io::Result<()> {
|
||||
let mut vite_build = Command::new("./node_modules/.bin/tuono-build-prod");
|
||||
let _ = &vite_build.output()?;
|
||||
}
|
||||
Actions::New { folder_name } => {
|
||||
scaffold_project::create_new_project(folder_name);
|
||||
Actions::New {
|
||||
folder_name,
|
||||
template,
|
||||
} => {
|
||||
scaffold_project::create_new_project(folder_name, template);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,12 +38,11 @@ fn create_file(path: PathBuf, content: String) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn create_new_project(folder_name: Option<String>) {
|
||||
pub fn create_new_project(folder_name: Option<String>, template: Option<String>) {
|
||||
let folder = folder_name.unwrap_or(".".to_string());
|
||||
|
||||
if folder != "." {
|
||||
create_dir(&folder).unwrap();
|
||||
}
|
||||
// In case of missing select the tuono example
|
||||
let template = template.unwrap_or("tuono".to_string());
|
||||
|
||||
let client = blocking::Client::builder()
|
||||
.user_agent("")
|
||||
@@ -60,21 +59,25 @@ pub fn create_new_project(folder_name: Option<String>) {
|
||||
let new_project_files = res
|
||||
.tree
|
||||
.iter()
|
||||
.filter(|GithubFile { path, .. }| {
|
||||
// TODO: Handle custom example download by CLI argument --template
|
||||
if path.starts_with("examples/tuono/") {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
})
|
||||
.filter(|GithubFile { path, .. }| path.starts_with(&format!("examples/{template}/")))
|
||||
.collect::<Vec<&GithubFile>>();
|
||||
|
||||
if new_project_files.is_empty() {
|
||||
println!("Template not found: {template}");
|
||||
return;
|
||||
}
|
||||
|
||||
if folder != "." {
|
||||
create_dir(&folder).unwrap();
|
||||
}
|
||||
|
||||
let folder_name = PathBuf::from(&folder);
|
||||
let current_dir = env::current_dir().expect("Failed to get current working directory");
|
||||
|
||||
let folder_path = current_dir.join(folder_name);
|
||||
|
||||
create_directories(&new_project_files, &folder_path).expect("Failed to create directories");
|
||||
create_directories(&new_project_files, &folder_path, &template)
|
||||
.expect("Failed to create directories");
|
||||
|
||||
for GithubFile {
|
||||
element_type, path, ..
|
||||
@@ -88,7 +91,7 @@ pub fn create_new_project(folder_name: Option<String>) {
|
||||
.text()
|
||||
.expect("Failed to parse the repo structure");
|
||||
|
||||
let path = PathBuf::from(&path.replace("examples/tuono/", ""));
|
||||
let path = PathBuf::from(&path.replace(&format!("examples/{template}/"), ""));
|
||||
|
||||
let file_path = folder_path.join(&path);
|
||||
|
||||
@@ -101,13 +104,17 @@ pub fn create_new_project(folder_name: Option<String>) {
|
||||
outro(folder);
|
||||
}
|
||||
|
||||
fn create_directories(new_project_files: &Vec<&GithubFile>, folder_path: &Path) -> io::Result<()> {
|
||||
fn create_directories(
|
||||
new_project_files: &[&GithubFile],
|
||||
folder_path: &Path,
|
||||
template: &String,
|
||||
) -> io::Result<()> {
|
||||
for GithubFile {
|
||||
element_type, path, ..
|
||||
} in new_project_files.iter()
|
||||
{
|
||||
if let GithubFileType::Tree = element_type {
|
||||
let path = PathBuf::from(&path.replace("examples/tuono/", ""));
|
||||
let path = PathBuf::from(&path.replace(&format!("examples/{template}/"), ""));
|
||||
|
||||
let dir_path = folder_path.join(&path);
|
||||
create_dir(&dir_path).unwrap();
|
||||
|
||||
@@ -54,6 +54,8 @@ async fn main() {
|
||||
.with_state(fetch);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
|
||||
|
||||
println!("\nDevelopment app ready at http://localhost:3000/");
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
|
||||
@@ -65,7 +67,7 @@ async fn catch_all(Path(params): Path<HashMap<String, String>>, request: Request
|
||||
|
||||
|
||||
// TODO: remove unwrap
|
||||
let payload = tuono_lib::Payload::new(&req, Box::new(""))
|
||||
let payload = tuono_lib::Payload::new(&req, &"")
|
||||
.client_payload()
|
||||
.unwrap();
|
||||
|
||||
@@ -216,8 +218,6 @@ mod {module_name};
|
||||
}
|
||||
|
||||
pub fn bundle_axum_source() -> io::Result<()> {
|
||||
println!("Axum project bundling");
|
||||
|
||||
let base_path = std::env::current_dir().unwrap();
|
||||
|
||||
let mut source_builder = SourceBuilder::new(Mode::Dev);
|
||||
@@ -242,7 +242,6 @@ pub fn bundle_axum_source() -> io::Result<()> {
|
||||
pub fn check_tuono_folder() -> io::Result<()> {
|
||||
let dev_folder = Path::new(DEV_FOLDER);
|
||||
if !&dev_folder.is_dir() {
|
||||
println!("exists");
|
||||
fs::create_dir(dev_folder)?;
|
||||
}
|
||||
|
||||
|
||||
+44
-30
@@ -4,69 +4,83 @@ use watchexec_supervisor::command::{Command, Program};
|
||||
use miette::{IntoDiagnostic, Result};
|
||||
use watchexec::Watchexec;
|
||||
use watchexec_signals::Signal;
|
||||
use watchexec_supervisor::job::start_job;
|
||||
use watchexec_supervisor::job::{start_job, Job};
|
||||
|
||||
use crate::source_builder::bundle_axum_source;
|
||||
|
||||
// What is the development pipeline?
|
||||
//
|
||||
// 1. Any file gets updates (rs/js/ts)
|
||||
// 2. Client side vite works separately
|
||||
// 3. Stop the dev server
|
||||
// 4. Build the ssr client bundle
|
||||
// 5. Restart the server
|
||||
//
|
||||
//
|
||||
// The current solution is not optimized
|
||||
// - We should avoid to bundle static lib (i.e. react) in the main bundle
|
||||
|
||||
#[tokio::main]
|
||||
pub async fn watch() -> Result<()> {
|
||||
let (watch_client, _) = start_job(Arc::new(Command {
|
||||
fn watch_react_src() -> Job {
|
||||
start_job(Arc::new(Command {
|
||||
program: Program::Exec {
|
||||
prog: "node_modules/.bin/tuono-dev-watch".into(),
|
||||
args: vec![],
|
||||
},
|
||||
options: Default::default(),
|
||||
}));
|
||||
}))
|
||||
.0
|
||||
}
|
||||
|
||||
watch_client.start().await;
|
||||
|
||||
let (run_server, _) = start_job(Arc::new(Command {
|
||||
fn build_rust_src() -> Job {
|
||||
start_job(Arc::new(Command {
|
||||
program: Program::Exec {
|
||||
prog: "cargo".into(),
|
||||
args: vec!["run".to_string()],
|
||||
args: vec!["run".to_string(), "-q".to_string()],
|
||||
},
|
||||
options: Default::default(),
|
||||
}));
|
||||
}))
|
||||
.0
|
||||
}
|
||||
|
||||
let (build_ssr_bundle, _) = start_job(Arc::new(Command {
|
||||
fn build_react_ssr_src() -> Job {
|
||||
start_job(Arc::new(Command {
|
||||
program: Program::Exec {
|
||||
prog: "node_modules/.bin/tuono-dev-ssr".into(),
|
||||
args: vec![],
|
||||
},
|
||||
options: Default::default(),
|
||||
}));
|
||||
}))
|
||||
.0
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
pub async fn watch() -> Result<()> {
|
||||
println!("Starting development environment...");
|
||||
watch_react_src().start().await;
|
||||
|
||||
let run_server = build_rust_src();
|
||||
|
||||
let build_ssr_bundle = build_react_ssr_src();
|
||||
|
||||
build_ssr_bundle.start().await;
|
||||
|
||||
run_server.start().await;
|
||||
|
||||
build_ssr_bundle.to_wait().await;
|
||||
|
||||
let wx = Watchexec::new(move |mut action| {
|
||||
let mut should_reload = false;
|
||||
|
||||
for event in action.events.iter() {
|
||||
for path in event.paths() {
|
||||
if path.0.to_string_lossy().ends_with(".rs")
|
||||
// Either tsx and jsx
|
||||
|| path.0.to_string_lossy().ends_with("sx")
|
||||
{
|
||||
run_server.stop();
|
||||
println!("Reloading server...");
|
||||
build_ssr_bundle.stop();
|
||||
build_ssr_bundle.start();
|
||||
bundle_axum_source();
|
||||
run_server.start();
|
||||
should_reload = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if should_reload {
|
||||
println!("Reloading...");
|
||||
run_server.stop();
|
||||
build_ssr_bundle.stop();
|
||||
build_ssr_bundle.start();
|
||||
bundle_axum_source().expect("Failed to bunlde rust source");
|
||||
run_server.start();
|
||||
println!("Ready!");
|
||||
should_reload = false;
|
||||
}
|
||||
|
||||
// if Ctrl-C is received, quit
|
||||
if action.signals().any(|sig| sig == Signal::Interrupt) {
|
||||
action.quit();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "tuono_lib"
|
||||
version = "0.1.0"
|
||||
version = "0.1.4"
|
||||
edition = "2021"
|
||||
authors = ["V. Ageno <valerioageno@yahoo.it>"]
|
||||
description = "The react/rust fullstack framework"
|
||||
@@ -24,4 +24,5 @@ serde = { version = "1.0.202", features = ["derive"] }
|
||||
erased-serde = "0.4.5"
|
||||
serde_json = "1.0"
|
||||
|
||||
tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.1.0"}
|
||||
tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.1.4"}
|
||||
|
||||
|
||||
@@ -6,6 +6,6 @@ pub mod ssr;
|
||||
|
||||
pub use payload::Payload;
|
||||
pub use request::Request;
|
||||
pub use response::Response;
|
||||
pub use response::{Props, Response};
|
||||
pub use ssr_rs::Ssr;
|
||||
pub use tuono_lib_macros::handler;
|
||||
|
||||
@@ -8,11 +8,11 @@ use crate::Request;
|
||||
/// This is the data shared to the client
|
||||
pub struct Payload<'a> {
|
||||
router: Location<'a>,
|
||||
props: Box<dyn Serialize>,
|
||||
props: &'a dyn Serialize,
|
||||
}
|
||||
|
||||
impl<'a> Payload<'a> {
|
||||
pub fn new(req: &Request<'a>, props: Box<dyn Serialize>) -> Payload<'a> {
|
||||
pub fn new(req: &Request<'a>, props: &'a dyn Serialize) -> Payload<'a> {
|
||||
Payload {
|
||||
router: req.location(),
|
||||
props,
|
||||
|
||||
@@ -6,7 +6,7 @@ use axum::http::{HeaderMap, Uri};
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Request<'a> {
|
||||
uri: &'a Uri,
|
||||
headers: &'a HeaderMap,
|
||||
pub headers: &'a HeaderMap,
|
||||
pub params: HashMap<String, String>,
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ impl<'a> Request<'a> {
|
||||
Location {
|
||||
href: self.uri.to_string(),
|
||||
pathname: &self.uri.path(),
|
||||
// TODO: hanler search map
|
||||
// TODO: handler search map
|
||||
search: HashMap::new(),
|
||||
search_str: &self.uri.query().unwrap_or(""),
|
||||
hash: "",
|
||||
|
||||
@@ -1,6 +1,56 @@
|
||||
use crate::Request;
|
||||
use crate::{ssr::Js, Payload};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{Html, IntoResponse};
|
||||
use axum::Json;
|
||||
use erased_serde::Serialize;
|
||||
|
||||
pub struct Props {
|
||||
data: Box<dyn Serialize>,
|
||||
http_code: StatusCode,
|
||||
}
|
||||
|
||||
pub enum Response {
|
||||
Redirect(String),
|
||||
Props(Box<dyn Serialize>),
|
||||
Props(Props),
|
||||
}
|
||||
|
||||
impl Props {
|
||||
pub fn new(data: impl Serialize + 'static) -> Self {
|
||||
Props {
|
||||
data: Box::new(data),
|
||||
http_code: StatusCode::OK,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_with_status(data: impl Serialize + 'static, http_code: StatusCode) -> Self {
|
||||
Props {
|
||||
data: Box::new(data),
|
||||
http_code,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Response {
|
||||
pub fn render_to_string(&self, req: Request) -> impl IntoResponse {
|
||||
match self {
|
||||
Self::Props(Props { data, http_code }) => {
|
||||
let payload = Payload::new(&req, data).client_payload().unwrap();
|
||||
|
||||
match Js::SSR.with(|ssr| ssr.borrow_mut().render_to_string(Some(&payload))) {
|
||||
Ok(html) => (*http_code, Html(html)),
|
||||
Err(_) => (*http_code, Html("500 Internal server error".to_string())),
|
||||
}
|
||||
}
|
||||
// TODO: Handle here other enum arms
|
||||
_ => todo!(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn json(&self) -> impl IntoResponse {
|
||||
match self {
|
||||
Self::Props(Props { data, http_code }) => (*http_code, Json(data)).into_response(),
|
||||
_ => (StatusCode::INTERNAL_SERVER_ERROR, axum::Json("{}")).into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "tuono_lib_macros"
|
||||
version = "0.1.0"
|
||||
version = "0.1.4"
|
||||
edition = "2021"
|
||||
description = "The react/rust fullstack framework"
|
||||
homepage = "https://github.com/Valerioageno/tuono"
|
||||
|
||||
@@ -8,7 +8,7 @@ pub fn handler_core(_args: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let fn_name = item.clone().sig.ident;
|
||||
|
||||
quote! {
|
||||
use axum::response::{Html, IntoResponse};
|
||||
use axum::response::IntoResponse;
|
||||
use std::collections::HashMap;
|
||||
use axum::extract::{State, Path};
|
||||
use reqwest::Client;
|
||||
@@ -16,51 +16,29 @@ pub fn handler_core(_args: TokenStream, item: TokenStream) -> TokenStream {
|
||||
#item
|
||||
|
||||
pub async fn route(
|
||||
Path(params): Path<HashMap<String, String>>,
|
||||
State(client): State<Client>,
|
||||
Path(params): Path<HashMap<String, String>>,
|
||||
State(client): State<Client>,
|
||||
request: axum::extract::Request
|
||||
) -> Html<String> {
|
||||
) -> impl IntoResponse {
|
||||
let pathname = &request.uri();
|
||||
let headers = &request.headers();
|
||||
|
||||
let req = tuono_lib::Request::new(pathname, headers, params);
|
||||
|
||||
let local_response = #fn_name(req.clone(), client).await;
|
||||
|
||||
let res = match local_response {
|
||||
tuono_lib::Response::Props(val) => {
|
||||
|
||||
// TODO: remove unwrap
|
||||
let payload = tuono_lib::Payload::new(&req, val).client_payload().unwrap();
|
||||
|
||||
tuono_lib::ssr::Js::SSR.with(|ssr| ssr.borrow_mut().render_to_string(Some(&payload)))
|
||||
},
|
||||
/// TODO: handle here redirection and rewrite
|
||||
_ => Ok("500 Internal server error".to_string())
|
||||
};
|
||||
|
||||
match res {
|
||||
Ok(html) => Html(html),
|
||||
_ => Html("500 internal server error".to_string())
|
||||
}
|
||||
#fn_name(req.clone(), client).await.render_to_string(req)
|
||||
}
|
||||
|
||||
pub async fn api(
|
||||
Path(params): Path<HashMap<String, String>>,
|
||||
State(client): State<Client>,
|
||||
Path(params): Path<HashMap<String, String>>,
|
||||
State(client): State<Client>,
|
||||
request: axum::extract::Request
|
||||
) -> axum::response::Response {
|
||||
let pathname = &request.uri();
|
||||
) -> impl IntoResponse{
|
||||
let pathname = &request.uri();
|
||||
let headers = &request.headers();
|
||||
|
||||
let req = tuono_lib::Request::new(pathname, headers, params);
|
||||
|
||||
let local_response = #fn_name(req.clone(), client).await;
|
||||
|
||||
let res = match local_response{
|
||||
tuono_lib::Response::Props(val) => return axum::Json(val).into_response(),
|
||||
_ => return axum::Json("").into_response()
|
||||
};
|
||||
#fn_name(req.clone(), client).await.json()
|
||||
}
|
||||
}
|
||||
.into()
|
||||
|
||||
+123
-27
@@ -4,7 +4,22 @@ 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).
|
||||
|
||||
## Installation
|
||||
> If you have already installed the tuono CLI you can download the tutorial source with `tuono new tuono-tutorial --template tutorial`
|
||||
|
||||
## Table of Content
|
||||
|
||||
* [CLI Installation](#cli-installation)
|
||||
* [Project scaffold](#project-scaffold)
|
||||
* [Start the dev environment](#start-the-dev-environment)
|
||||
* [The “/” route](#the--route)
|
||||
* [Tutorial introduction](#tutorial-introduction)
|
||||
* [Fetch all the pokemons](#fetch-all-the-pokemons)
|
||||
* [Create a stand-alone component](#create-a-stand-alone-component)
|
||||
* [Create the /pokemons/[pokemon] route](#create-the-pokemonspokemon-route)
|
||||
* [Error handling](#error-handling)
|
||||
* [Conclusion](#conclusion)
|
||||
|
||||
## CLI Installation
|
||||
|
||||
The tuono CLI is hosted on [crates.io](https://crates.io/crates/tuono); to download and install it just run on a terminal:
|
||||
|
||||
@@ -18,9 +33,11 @@ To check that is correctly installed run:
|
||||
$ tuono --version
|
||||
```
|
||||
|
||||
Run `tuono -h` to see all the available commands.
|
||||
|
||||
## Project scaffold
|
||||
|
||||
To setup a new project you just need to run the following command:
|
||||
To setup a new fresh project you just need to run the following command:
|
||||
|
||||
```bash
|
||||
$ tuono new tuono-tutorial
|
||||
@@ -36,15 +53,15 @@ Open it with your favourite code editor.
|
||||
|
||||
The project will have the following structure:
|
||||
|
||||
```bash
|
||||
| public/
|
||||
- src/
|
||||
| routes/
|
||||
| styles/
|
||||
| package.json
|
||||
| Cargo.toml
|
||||
| .gitignore
|
||||
| tsconfig.json
|
||||
```
|
||||
├── package.json
|
||||
├── public
|
||||
├── src
|
||||
│ ├── routes
|
||||
│ └── styles
|
||||
├── Cargo.toml
|
||||
├── README.md
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
**public/**: put here all the files you want to be public
|
||||
@@ -91,7 +108,7 @@ Clear the `index.rs` file and paste:
|
||||
```rust
|
||||
// src/routes/index.rs
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tuono_lib::{Request, Response};
|
||||
use tuono_lib::{Props, Request, Response};
|
||||
|
||||
const ALL_POKEMON: &str = "https://pokeapi.co/api/v2/pokemon?limit=151";
|
||||
|
||||
@@ -108,15 +125,13 @@ struct Pokemon {
|
||||
|
||||
#[tuono_lib::handler]
|
||||
async fn get_all_pokemons(_req: Request<'_>, fetch: reqwest::Client) -> Response {
|
||||
|
||||
return match fetch.get(ALL_POKEMON).send().await {
|
||||
Ok(res) => {
|
||||
let data = res.json::<Pokemons>().await.unwrap();
|
||||
Response::Props(Box::new(data))
|
||||
Response::Props(Props::new(data))
|
||||
}
|
||||
Err(_err) => Response::Props(Box::new(Pokemons { results: vec![] })),
|
||||
Err(_err) => Response::Props(Props::new("{}")),
|
||||
};
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
@@ -300,8 +315,9 @@ These two will handle every requests that points to `http://localhost:3000/pokem
|
||||
Let’s first work on the server side file. Paste into the new `[pokemon].rs` file the following code:
|
||||
|
||||
```rust
|
||||
// src/routes/pokemons/[pokemon].rs
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tuono_lib::{Request, Response};
|
||||
use tuono_lib::{Props, Request, Response};
|
||||
|
||||
const POKEMON_API: &str = "https://pokeapi.co/api/v2/pokemon";
|
||||
|
||||
@@ -315,22 +331,16 @@ struct Pokemon {
|
||||
|
||||
#[tuono_lib::handler]
|
||||
async fn get_pokemon(req: Request<'_>, fetch: reqwest::Client) -> Response {
|
||||
// The param `pokemon` is defined in the route filename [pokemon].rs
|
||||
// The param `pokemon` is defined in the route filename [pokemon].rs
|
||||
let pokemon = req.params.get("pokemon").unwrap();
|
||||
|
||||
|
||||
return match fetch.get(format!("{POKEMON_API}/{pokemon}")).send().await {
|
||||
Ok(res) => {
|
||||
let data = res.json::<Pokemon>().await.unwrap();
|
||||
Response::Props(Box::new(data))
|
||||
Response::Props(Props::new(data))
|
||||
}
|
||||
Err(_err) => Response::Props(Box::new(Pokemon {
|
||||
name: "Nope".to_string(),
|
||||
id: 0,
|
||||
weight: 0,
|
||||
height: 0,
|
||||
})),
|
||||
Err(_err) => Response::Props(Props::new("{}"))
|
||||
};
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
@@ -436,6 +446,92 @@ export default function PokemonView({
|
||||
}
|
||||
```
|
||||
|
||||
## Error handling
|
||||
|
||||
With the current setup all the routes always return a `200 Success` http status no matter the response type.
|
||||
|
||||
In order to return a more meaningful status code to the browser the `Props` struct can be initialized with also the
|
||||
`Props::new_with_status()` method.
|
||||
|
||||
Let's see how it works!
|
||||
|
||||
```diff
|
||||
// src/routes/pokemons/[pokemon].rs
|
||||
++ use reqwest::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tuono_lib::{Props, Request, Response};
|
||||
|
||||
const POKEMON_API: &str = "https://pokeapi.co/api/v2/pokemon";
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Pokemon {
|
||||
name: String,
|
||||
id: u16,
|
||||
weight: u16,
|
||||
height: u16,
|
||||
}
|
||||
|
||||
#[tuono_lib::handler]
|
||||
async fn get_pokemon(req: Request<'_>, fetch: reqwest::Client) -> Response {
|
||||
// The param `pokemon` is defined in the route filename [pokemon].rs
|
||||
let pokemon = req.params.get("pokemon").unwrap();
|
||||
|
||||
return match fetch.get(format!("{POKEMON_API}/{pokemon}")).send().await {
|
||||
Ok(res) => {
|
||||
++ if res.status() == StatusCode::NOT_FOUND {
|
||||
++ return Response::Props(Props::new_with_status("{}", StatusCode::NOT_FOUND));
|
||||
++ }
|
||||
|
||||
let data = res.json::<Pokemon>().await.unwrap();
|
||||
Response::Props(Props::new(data))
|
||||
}
|
||||
-- Err(_err) => Response::Props(Props::new(
|
||||
++ Err(_err) => Response::Props(Props::new_with_status(
|
||||
++ "{}",
|
||||
++ StatusCode::INTERNAL_SERVER_ERROR,
|
||||
)),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
```diff
|
||||
// src/routes/index.rs
|
||||
++ use reqwest::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tuono_lib::{Props, Request, Response};
|
||||
|
||||
const ALL_POKEMON: &str = "https://pokeapi.co/api/v2/pokemon?limit=151";
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Pokemons {
|
||||
results: Vec<Pokemon>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Pokemon {
|
||||
name: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[tuono_lib::handler]
|
||||
async fn get_all_pokemons(_req: Request<'_>, fetch: reqwest::Client) -> Response {
|
||||
return match fetch.get(ALL_POKEMON).send().await {
|
||||
Ok(res) => {
|
||||
let data = res.json::<Pokemons>().await.unwrap();
|
||||
Response::Props(Props::new(data))
|
||||
}
|
||||
-- Err(_err) => Response::Props(Props::new(
|
||||
++ Err(_err) => Response::Props(Props::new_with_status(
|
||||
++ "{}", // Return empty JSON
|
||||
++ StatusCode::INTERNAL_SERVER_ERROR,
|
||||
)),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Conclusion
|
||||
|
||||
That’s it! You just created a multi thread full stack application with rust and react.
|
||||
|
||||
@@ -7,3 +7,13 @@ To simply scaffold the base project run in your terminal:
|
||||
```shell
|
||||
$ tuono new [NAME]
|
||||
```
|
||||
|
||||
You can install any example included in this folder by just using the `--template` flag:
|
||||
|
||||
```shell
|
||||
$ tuono new [NAME] --template [TEMPLATE]
|
||||
```
|
||||
|
||||
`[TEMPLATE]` is the folder name.
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use serde::Serialize;
|
||||
use tuono_lib::{Request, Response};
|
||||
use tuono_lib::{Props, Request, Response};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct MyResponse<'a> {
|
||||
@@ -8,7 +8,7 @@ struct MyResponse<'a> {
|
||||
|
||||
#[tuono_lib::handler]
|
||||
async fn get_server_side_props(_req: Request<'_>, _fetch: reqwest::Client) -> Response {
|
||||
Response::Props(Box::new(MyResponse {
|
||||
Response::Props(Props::new(MyResponse {
|
||||
subtitle: "The react / rust fullstack framework",
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
# Tuono tutorial
|
||||
|
||||
This project is the outcome of the [tuono tutorial](https://github.com/Valerioageno/tuono/blob/main/docs/tutorial.md)
|
||||
|
||||
If you want to directly install it you can run:
|
||||
|
||||
```bash
|
||||
$ tuono new my-project -t tutorial
|
||||
```
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// src/routes/index.rs
|
||||
use reqwest::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tuono_lib::{Request, Response};
|
||||
use tuono_lib::{Props, Request, Response};
|
||||
|
||||
const ALL_POKEMON: &str = "https://pokeapi.co/api/v2/pokemon?limit=151";
|
||||
|
||||
@@ -20,8 +21,11 @@ async fn get_all_pokemons(_req: Request<'_>, fetch: reqwest::Client) -> Response
|
||||
return match fetch.get(ALL_POKEMON).send().await {
|
||||
Ok(res) => {
|
||||
let data = res.json::<Pokemons>().await.unwrap();
|
||||
Response::Props(Box::new(data))
|
||||
Response::Props(Props::new(data))
|
||||
}
|
||||
Err(_err) => Response::Props(Box::new(Pokemons { results: vec![] })),
|
||||
Err(_err) => Response::Props(Props::new_with_status(
|
||||
"{}", // Return empty JSON
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// src/routes/pokemons/[pokemon].rs
|
||||
use reqwest::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tuono_lib::{Request, Response};
|
||||
use tuono_lib::{Props, Request, Response};
|
||||
|
||||
const POKEMON_API: &str = "https://pokeapi.co/api/v2/pokemon";
|
||||
|
||||
@@ -18,14 +20,15 @@ async fn get_pokemon(req: Request<'_>, fetch: reqwest::Client) -> Response {
|
||||
|
||||
return match fetch.get(format!("{POKEMON_API}/{pokemon}")).send().await {
|
||||
Ok(res) => {
|
||||
if res.status() == StatusCode::NOT_FOUND {
|
||||
return Response::Props(Props::new_with_status("{}", StatusCode::NOT_FOUND));
|
||||
}
|
||||
let data = res.json::<Pokemon>().await.unwrap();
|
||||
Response::Props(Box::new(data))
|
||||
Response::Props(Props::new(data))
|
||||
}
|
||||
Err(_err) => Response::Props(Box::new(Pokemon {
|
||||
name: "Nope".to_string(),
|
||||
id: 0,
|
||||
weight: 0,
|
||||
height: 0,
|
||||
})),
|
||||
Err(_err) => Response::Props(Props::new_with_status(
|
||||
"{}",
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { TuonoProps } from 'tuono'
|
||||
import type { TuonoProps } from 'tuono'
|
||||
import PokemonView from '../../components/PokemonView'
|
||||
|
||||
export default function Pokemon({ data }: TuonoProps): JSX.Element {
|
||||
interface Pokemon {
|
||||
name: string
|
||||
id: string
|
||||
weight: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export default function PokemonPage({
|
||||
data,
|
||||
}: TuonoProps<Pokemon>): JSX.Element {
|
||||
return <PokemonView pokemon={data} />
|
||||
}
|
||||
|
||||
+5
-2
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "tuono",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.4",
|
||||
"description": "",
|
||||
"main": "src/index.js",
|
||||
"packageManager": "pnpm@8.12.0",
|
||||
"packageManager": "pnpm@9.1.1",
|
||||
"scripts": {
|
||||
"dev": "turbo dev",
|
||||
"build": "turbo build",
|
||||
@@ -32,5 +32,8 @@
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.11",
|
||||
"vitest": "^1.5.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"turbo": "^2.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tuono",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.4",
|
||||
"description": "The react/rust fullstack framework",
|
||||
"scripts": {
|
||||
"dev": "vite build --watch",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { build, createServer } from 'vite'
|
||||
import { build, createServer, InlineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import { ViteFsRouter } from './tuono-vite-plugin'
|
||||
|
||||
const BASE_CONFIG = {
|
||||
silent: true,
|
||||
const BASE_CONFIG: InlineConfig = {
|
||||
root: '.tuono',
|
||||
logLevel: 'silent',
|
||||
publicDir: '../public',
|
||||
cacheDir: 'cache',
|
||||
envDir: '../',
|
||||
@@ -13,7 +13,6 @@ const BASE_CONFIG = {
|
||||
|
||||
export function developmentSSRBundle() {
|
||||
;(async () => {
|
||||
console.log('Build SSR')
|
||||
await build({
|
||||
...BASE_CONFIG,
|
||||
build: {
|
||||
@@ -23,6 +22,8 @@ export function developmentSSRBundle() {
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
input: './.tuono/server-main.tsx',
|
||||
// Silent all logs
|
||||
onLog() {},
|
||||
output: {
|
||||
entryFileNames: 'dev-server.js',
|
||||
format: 'iife',
|
||||
@@ -39,7 +40,6 @@ export function developmentSSRBundle() {
|
||||
|
||||
export function developmentCSRWatch() {
|
||||
;(async () => {
|
||||
console.log('Watch files')
|
||||
const server = await createServer({
|
||||
...BASE_CONFIG,
|
||||
server: {
|
||||
|
||||
@@ -134,12 +134,9 @@ export function hasParentRoute(
|
||||
|
||||
export async function routeGenerator(config = defaultConfig): Promise<void> {
|
||||
if (!isFirst) {
|
||||
console.log('Generating routes...')
|
||||
isFirst = true
|
||||
} else if (skipMessage) {
|
||||
skipMessage = false
|
||||
} else {
|
||||
console.log('Regenerating routes')
|
||||
}
|
||||
|
||||
const checkLatest = (): boolean => {
|
||||
@@ -194,7 +191,6 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
|
||||
node.parent.children = node.parent.children ?? []
|
||||
node.parent.children.push(node)
|
||||
}
|
||||
console.log('pushed')
|
||||
routeNodes.push(node)
|
||||
}
|
||||
|
||||
@@ -349,10 +345,4 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
|
||||
routeConfigFileContent,
|
||||
)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[emoticon] Processed ${routeNodes.length === 1 ? 'route' : 'routes'} in ${
|
||||
Date.now() - start
|
||||
}ms`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -100,7 +100,6 @@ export const splitFile = async ({
|
||||
compile,
|
||||
filename,
|
||||
}: SplitFileFnArgs): Promise<CompileOutput> => {
|
||||
console.log('Split file [splitFile fn]', code)
|
||||
return compile({
|
||||
code,
|
||||
filename,
|
||||
@@ -220,7 +219,6 @@ export const splitFile = async ({
|
||||
]),
|
||||
)
|
||||
} else {
|
||||
console.log(splitNode)
|
||||
throw new Error(
|
||||
`Unexpected splitNode type ${splitNode.type}`,
|
||||
)
|
||||
|
||||
@@ -9,7 +9,6 @@ import { SPLIT_PREFIX } from './constants'
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
const ROUTES_DIRECTORY_PATH = './src/routes'
|
||||
const DEBUG = true
|
||||
|
||||
let lock = false
|
||||
|
||||
@@ -20,10 +19,8 @@ export function RouterGenerator(): Plugin {
|
||||
|
||||
try {
|
||||
// TODO: generator function
|
||||
console.log('Generating [generate fn]')
|
||||
await routeGenerator()
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
} finally {
|
||||
lock = false
|
||||
}
|
||||
@@ -34,7 +31,6 @@ export function RouterGenerator(): Plugin {
|
||||
|
||||
if (filePath.startsWith(ROUTES_DIRECTORY_PATH)) {
|
||||
// TODO: generator function
|
||||
console.log('Generating [handleFile fn]')
|
||||
await generate()
|
||||
}
|
||||
}
|
||||
@@ -57,7 +53,6 @@ export function RouterGenerator(): Plugin {
|
||||
|
||||
export function RouterCodeSplitter(): Plugin {
|
||||
const ROOT: string = process.cwd()
|
||||
console.log('ROOT', ROOT)
|
||||
|
||||
return {
|
||||
name: 'vite-plugin-tuono-fs-router-code-splitter',
|
||||
@@ -75,36 +70,14 @@ export function RouterCodeSplitter(): Plugin {
|
||||
|
||||
const compile = makeCompile({ root: ROOT })
|
||||
|
||||
if (DEBUG) console.info('Route: ', id)
|
||||
|
||||
if (id.includes(SPLIT_PREFIX)) {
|
||||
console.log('Split')
|
||||
if (DEBUG) console.info('Splitting route: ', id)
|
||||
|
||||
const compiled = await splitFile({
|
||||
code,
|
||||
compile,
|
||||
filename: id,
|
||||
})
|
||||
|
||||
if (DEBUG) {
|
||||
console.info('')
|
||||
console.info('Split Output')
|
||||
console.info('')
|
||||
//console.info(compiled.code)
|
||||
console.info('')
|
||||
console.info('')
|
||||
console.info('')
|
||||
console.info('')
|
||||
console.info('')
|
||||
console.info('')
|
||||
console.info('')
|
||||
console.info('')
|
||||
}
|
||||
|
||||
return compiled
|
||||
} else {
|
||||
console.log('Non split')
|
||||
}
|
||||
|
||||
return null
|
||||
|
||||
Reference in New Issue
Block a user