mirror of
https://github.com/tuono-labs/tuono
synced 2026-07-29 13:52:46 -07:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7401a59346 | |||
| 622ae93d68 | |||
| b4207feb7e | |||
| 332fdb70ca | |||
| b9abfb64c0 | |||
| 3ca8861d85 | |||
| 0c93aab13f | |||
| 208b49cfc8 | |||
| db0a2d9d1d | |||
| c123e93433 | |||
| 35557d296d | |||
| d0d6550d7e | |||
| 38d9be2bb0 | |||
| fcb33d208c | |||
| f831ad6c7a | |||
| 3bfed5fabc | |||
| e1ce3ba2fd | |||
| a31f048ffd | |||
| 9c707466db | |||
| 4ff899729b | |||
| a8345d3509 | |||
| 1c3d6e2d78 | |||
| dde5ae7827 | |||
| aeef19cda3 | |||
| adec05c6f8 | |||
| 2c1e8f7db8 | |||
| 2c1e8f0cc2 |
@@ -14,6 +14,15 @@ env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
lint_and_fmt:
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: cargo fmt --all -- --check
|
||||
- run: cargo clippy -- -D warnings
|
||||
|
||||
build_and_test:
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name
|
||||
name: Build and test rust crates
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
valerioageno@yahoo.it.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
@@ -0,0 +1,21 @@
|
||||

|
||||

|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
# How to contribute to Tuono
|
||||
|
||||
## Contributions
|
||||
Any feature contribution or suggestion is strongly appreciated.
|
||||
Since the current project size there isn't yet a defined way to start a discussion. Consider to [open a new issue](https://github.com/Valerioageno/tuono/issues/new)
|
||||
or to reach me using my email address [valerioageno@yahoo.it](mailto:valerioageno@ahoo.it). I'm also available on twitter (X) DMs @valerioageno.
|
||||
|
||||
## Bugs
|
||||
|
||||
**Did you find a bug?**
|
||||
- Ensure the bug was not already reported by searching on GitHub under [Issues](https://github.com/Valerioageno/tuono/issues).
|
||||
- If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/Valerioageno/tuono/issues/new). Be sure to include a title and clear description, as much relevant information as possible, and a code sample or an executable test case demonstrating the expected behavior that is not occurring.
|
||||
|
||||
**Did you write a patch that fixes a bug?**
|
||||
- Open a new GitHub pull request with the patch.
|
||||
- Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable.
|
||||
- The pull requests must pass all the CI pipelines
|
||||
@@ -17,53 +17,66 @@
|
||||
Tuono (Italian word for "thunder", pronounced /2 Oh No/).
|
||||
Why Tuono? Just a badass name.
|
||||
|
||||
> If you want to see how this project actually works check the [tutorial](https://github.com/Valerioageno/tuono/blob/main/docs/tutorial.md) page.
|
||||
> Check out the [tutorial](https://github.com/Valerioageno/tuono/blob/main/docs/tutorial.md) to get started.
|
||||
|
||||
## Introduction
|
||||
|
||||
NodeJs/Deno/Bun are the only tools that make a React app fullstack right? (no)
|
||||
**NodeJs/Deno/Bun are the only runtimes that allow a React app to be fullstack right? (no)**
|
||||
|
||||
Tuono wants to prove that it's possible creating fully fledged react applications without the need to host them on a JS runtime server leveraging the best of the two worlds:
|
||||
super powered server and amazing development experience.
|
||||
Tuono is a fullstack React framework with the server side written in Rust.
|
||||
Because of this Tuono is extremely fast and the requests are handled by multithreaded Rust server.
|
||||
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 routing is handled
|
||||
by Tuono based on the files defined within the `./src/routes` directory.
|
||||
|
||||
## Features
|
||||
|
||||
- [x] Typescript
|
||||
- [x] FS routing
|
||||
- [x] Hot Module Reload
|
||||
- [x] CSS modules
|
||||
- [x] Rust based SSR
|
||||
- [x] Multi thread backend
|
||||
- [x] Development environment
|
||||
- [ ] Create custom APIs
|
||||
- [ ] Image optimization
|
||||
- [ ] Build optimization
|
||||
- [ ] Server streamed content
|
||||
- 🟦 Typescript
|
||||
- 🌐 Routing
|
||||
- 🔥 Hot Module Reload
|
||||
- 🍭 CSS modules
|
||||
- 🧬 Server Side Rendering
|
||||
- 🧵 Multi thread backend
|
||||
- ⚙️ Build optimizations
|
||||
- Custom APIs*
|
||||
- Image optimization*
|
||||
- Server streamed content*
|
||||
|
||||
> *Development in progress
|
||||
|
||||
## Getting started
|
||||
|
||||
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.
|
||||
To download it you need [cargo](https://doc.rust-lang.org/cargo/) which is the [rust](https://www.rust-lang.org/)
|
||||
package manager.
|
||||
|
||||
To download and install it you just need to run `cargo install tuono`.
|
||||
|
||||
To create a new project run `tuono new [NAME]` (optionally you can pass the `--template` flag - check the
|
||||
[examples](https://github.com/Valerioageno/tuono/tree/main/examples) folder).
|
||||
|
||||
Then to run the local development environment run inside the project folder `tuono dev`
|
||||
|
||||
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
|
||||
|
||||
- rust
|
||||
- cargo
|
||||
- node
|
||||
- pnpm (other package managers support will be added soon)
|
||||
- npm/pnpm/yarn*
|
||||
|
||||
## Installation
|
||||
|
||||
```
|
||||
cargo install tuono
|
||||
```
|
||||
|
||||
## Create a new project
|
||||
|
||||
```
|
||||
tuono new [NAME]
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```
|
||||
tuono dev
|
||||
```
|
||||
> yarn [pnp](https://yarnpkg.com/features/pnp) is not supported yet
|
||||
|
||||
## Folder structure
|
||||
|
||||
@@ -79,7 +92,7 @@ tuono dev
|
||||
```
|
||||
|
||||
## Contributing
|
||||
Any help or suggestion will be appreciated.
|
||||
Any help or suggestion will be appreciated and encouraged.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "tuono"
|
||||
version = "0.1.8"
|
||||
version = "0.2.5"
|
||||
edition = "2021"
|
||||
authors = ["V. Ageno <valerioageno@yahoo.it>"]
|
||||
description = "The react/rust fullstack framework"
|
||||
homepage = "https://github.com/Valerioageno/tuono"
|
||||
repository = "https://github.com/Valerioageno/tuono"
|
||||
readme = "../../README.md"
|
||||
license-file = "../../LICENSE.md"
|
||||
categories = ["web-programming"]
|
||||
|
||||
@@ -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()?;
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ fn outro(folder_name: String) {
|
||||
}
|
||||
|
||||
println!("\nInstall the dependencies:");
|
||||
println!("pnpm install");
|
||||
println!("npm install");
|
||||
|
||||
println!("\nRun the local environment:");
|
||||
println!("tuono dev");
|
||||
|
||||
@@ -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,14 +65,24 @@ 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_dir*/").fallback(get(catch_all)))
|
||||
.with_state(fetch);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
|
||||
|
||||
if MODE == Mode::Dev {
|
||||
println!("\nDevelopment app ready at http://localhost:3000/");
|
||||
} else {
|
||||
println!("\nProduction app ready at http://localhost:3000/");
|
||||
}
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
|
||||
@@ -82,11 +109,8 @@ async fn catch_all(Path(params): Path<HashMap<String, String>>, request: Request
|
||||
|
||||
const ROOT_FOLDER: &str = "src/routes";
|
||||
const DEV_FOLDER: &str = ".tuono";
|
||||
|
||||
pub enum Mode {
|
||||
Prod,
|
||||
Dev,
|
||||
}
|
||||
const DEV_PUBLIC_DIR: &str = "public";
|
||||
const PROD_PUBLIC_DIR: &str = "out/client";
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct Route {
|
||||
@@ -137,17 +161,15 @@ impl Route {
|
||||
|
||||
struct SourceBuilder {
|
||||
route_map: HashMap<PathBuf, Route>,
|
||||
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 +239,28 @@ 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 {
|
||||
let public_dir = if mode == Mode::Prod {
|
||||
PROD_PUBLIC_DIR
|
||||
} else {
|
||||
DEV_PUBLIC_DIR
|
||||
};
|
||||
|
||||
AXUM_ENTRY_POINT
|
||||
.replace(
|
||||
"// ROUTE_BUILDER\n",
|
||||
&create_routes_declaration(&source_builder.route_map),
|
||||
@@ -232,11 +268,9 @@ 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("/*public_dir*/", public_dir)
|
||||
.replace("/*MODE*/", mode.as_str())
|
||||
}
|
||||
|
||||
pub fn check_tuono_folder() -> io::Result<()> {
|
||||
@@ -266,7 +300,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 +320,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 +355,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 +390,40 @@ 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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_replace_the_correct_public_folder_dev() {
|
||||
let source_builder = SourceBuilder::new();
|
||||
let source = generate_axum_source(&source_builder, Mode::Dev);
|
||||
|
||||
assert!(source.contains(r#"ServeDir::new("public")"#))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_replace_the_correct_public_folder_prod() {
|
||||
let source_builder = SourceBuilder::new();
|
||||
let source = generate_axum_source(&source_builder, Mode::Prod);
|
||||
|
||||
assert!(source.contains(r#"ServeDir::new("out/client")"#))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "tuono_lib"
|
||||
version = "0.1.8"
|
||||
version = "0.2.5"
|
||||
edition = "2021"
|
||||
authors = ["V. Ageno <valerioageno@yahoo.it>"]
|
||||
description = "The react/rust fullstack framework"
|
||||
homepage = "https://github.com/Valerioageno/tuono"
|
||||
repository = "https://github.com/Valerioageno/tuono"
|
||||
readme = "../../README.md"
|
||||
license-file = "../../LICENSE.md"
|
||||
categories = ["web-programming"]
|
||||
@@ -18,11 +18,14 @@ name = "tuono_lib"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
ssr_rs = "0.5.2"
|
||||
ssr_rs = "0.5.5"
|
||||
axum = "0.7.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.8"}
|
||||
tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.2.5"}
|
||||
once_cell = "1.19.0"
|
||||
lazy_static = "1.5.0"
|
||||
regex = "1.10.5"
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<HashMap<String, BundleInfo>> = 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<String, BundleInfo>) -> HashMap<String, BundleInfo> {
|
||||
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<String, BundleInfo> = 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Mode> = OnceCell::new();
|
||||
@@ -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<Vec<&'a String>>,
|
||||
#[serde(rename(serialize = "cssBundles"))]
|
||||
css_bundles: Option<Vec<&'a String>>,
|
||||
}
|
||||
|
||||
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<String, serde_json::Error> {
|
||||
pub fn client_payload(&mut self) -> Result<String, serde_json::Error> {
|
||||
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::<Vec<&String>>();
|
||||
|
||||
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::<Vec<&String>>();
|
||||
|
||||
if !dynamic_routes.is_empty() {
|
||||
let path_segments = pathname
|
||||
.split('/')
|
||||
.filter(|path| !path.is_empty())
|
||||
.collect::<Vec<&str>>();
|
||||
|
||||
for dyn_route in dynamic_routes.iter() {
|
||||
let dyn_route_segments = dyn_route
|
||||
.split('/')
|
||||
.filter(|path| !path.is_empty())
|
||||
.collect::<Vec<&str>>();
|
||||
|
||||
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::<Uri>()
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
Payload {
|
||||
router: location,
|
||||
props: &None::<Option<()>>,
|
||||
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()
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, String>,
|
||||
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<String, String>,
|
||||
}
|
||||
|
||||
/// Location must match client side interface
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct Location<'a> {
|
||||
href: String,
|
||||
pathname: &'a str,
|
||||
search: HashMap<String, String>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Ssr<'static, 'static>> = 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()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
[package]
|
||||
name = "tuono_lib_macros"
|
||||
version = "0.1.8"
|
||||
version = "0.2.5"
|
||||
edition = "2021"
|
||||
description = "The react/rust fullstack framework"
|
||||
homepage = "https://github.com/Valerioageno/tuono"
|
||||
repository = "https://github.com/Valerioageno/tuono"
|
||||
readme = "../../README.md"
|
||||
license-file = "../../LICENSE.md"
|
||||
categories = ["web-programming"]
|
||||
|
||||
@@ -7,5 +7,3 @@ mod handler;
|
||||
pub fn handler(args: TokenStream, item: TokenStream) -> TokenStream {
|
||||
handler::handler_core(args, item)
|
||||
}
|
||||
|
||||
|
||||
|
||||
+38
-4
@@ -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 on 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
|
||||
@@ -46,7 +53,7 @@ $ tuono new tuono-tutorial
|
||||
Get into the project folder and install the dependencies with:
|
||||
|
||||
```bash
|
||||
$ pnpm install
|
||||
$ npm install
|
||||
```
|
||||
|
||||
Open it with your favourite code editor.
|
||||
@@ -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.
|
||||
@@ -273,7 +283,7 @@ Create alongside the `PokemonLink.tsx` component the CSS module `PokemonLink.mod
|
||||
}
|
||||
```
|
||||
|
||||
> 💡 SASS is supported out of the box. Just install the processor in the devDependencies `pnpm i -D sass` and run again `tuono dev`
|
||||
> 💡 SASS is supported out of the box. Just install the processor in the devDependencies `npm i -D sass` and run again `tuono dev`
|
||||
|
||||
Then import the styles into the `PokemonLink` component as following:
|
||||
|
||||
@@ -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).
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -1 +1,8 @@
|
||||
# tuono-lazy-fn-vite-plugin
|
||||
|
||||
This is a vite plugin for [tuono](https://github.com/Valerioageno/tuono).
|
||||
|
||||
This package specifically handles the transpiling of the `dynamic` function
|
||||
allowing custom componenents to be lazy loaded but also server side rendered.
|
||||
|
||||
Check [tuono](https://github.com/Valerioageno/tuono) for more.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tuono-lazy-fn-vite-plugin",
|
||||
"version": "0.1.8",
|
||||
"version": "0.2.5",
|
||||
"description": "Plugin for the tuono's lazy fn. Tuono is the react/rust fullstack framework",
|
||||
"scripts": {
|
||||
"dev": "vite build --watch",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tuono",
|
||||
"version": "0.1.8",
|
||||
"version": "0.2.5",
|
||||
"description": "The react/rust fullstack framework",
|
||||
"scripts": {
|
||||
"dev": "vite build --watch",
|
||||
@@ -104,7 +104,13 @@
|
||||
"vitest": "^1.5.2"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"keywords": [],
|
||||
"keywords": [
|
||||
"react",
|
||||
"typescript",
|
||||
"fullstack",
|
||||
"framework",
|
||||
"rust"
|
||||
],
|
||||
"author": "Valerio Ageno",
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ export function buildProd() {
|
||||
...BASE_CONFIG,
|
||||
build: {
|
||||
ssr: true,
|
||||
minify: false,
|
||||
minify: true,
|
||||
outDir: '../out/server',
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -5,18 +5,9 @@ import { RouterProvider } from '../router'
|
||||
import { createRouter } from '../router'
|
||||
|
||||
type RouteTree = any
|
||||
type Mode = 'Dev' | 'Prod'
|
||||
|
||||
export function serverSideRendering(routeTree: RouteTree) {
|
||||
return function render(payload: string | undefined): string {
|
||||
const props = payload ? JSON.parse(payload) : {}
|
||||
|
||||
const router = createRouter({ routeTree }) // Render the app
|
||||
|
||||
const app = renderToString(
|
||||
<RouterProvider router={router} serverProps={props} />,
|
||||
)
|
||||
|
||||
const developmentScript = `<script type="module">
|
||||
const VITE_DEV_AND_HMR = `<script type="module">
|
||||
import RefreshRuntime from 'http://localhost:3001/@react-refresh'
|
||||
RefreshRuntime.injectIntoGlobalHook(window)
|
||||
window.$RefreshReg$ = () => {}
|
||||
@@ -26,12 +17,40 @@ export function serverSideRendering(routeTree: RouteTree) {
|
||||
<script type="module" src="http://localhost:3001/@vite/client"></script>
|
||||
<script type="module" src="http://localhost:3001/client-main.tsx"></script>`
|
||||
|
||||
function generateCssLinks(cssBundles: string[], mode: Mode): string {
|
||||
if (mode === 'Dev') return ''
|
||||
return cssBundles.reduce((acc, value) => {
|
||||
return acc + `<link rel="stylesheet" type="text/css" href="/${value}" />\n`
|
||||
}, '')
|
||||
}
|
||||
|
||||
function generateJsScripts(jsBundles: string[], mode: Mode): string {
|
||||
if (mode === 'Dev') return ''
|
||||
return jsBundles.reduce((acc, value) => {
|
||||
return acc + `<script type="module" src="/${value}"></script>\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(
|
||||
<RouterProvider router={router} serverProps={props} />,
|
||||
)
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Playground</title>
|
||||
${generateCssLinks(cssBundles, mode)}
|
||||
</head>
|
||||
<body>
|
||||
<div id="__tuono">${app}</div>
|
||||
@@ -42,7 +61,8 @@ export function serverSideRendering(routeTree: RouteTree) {
|
||||
}}
|
||||
/>,
|
||||
)}
|
||||
${developmentScript}
|
||||
${generateJsScripts(jsBundles, mode)}
|
||||
${mode === 'Dev' ? VITE_DEV_AND_HMR : ''}
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
Reference in New Issue
Block a user