mirror of
https://github.com/tuono-labs/tuono
synced 2026-07-27 13:52:47 -07:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2949ea4dfa | |||
| 8ab292b2f9 | |||
| 9d8bfd9826 | |||
| 8a6685a6d0 | |||
| 78990d3c08 | |||
| 227662489b | |||
| 5ce410f233 | |||
| 67d4e1bcac | |||
| 3e6aa540fa | |||
| b412371287 | |||
| f62fc0b2b9 | |||
| 35d8154649 | |||
| 6e794e9500 | |||
| e4d4a75b88 | |||
| bc7ffc2246 | |||
| 74e2f4e873 | |||
| d9d62a91f7 | |||
| 77b34fbb7b | |||
| d3040aa63f | |||
| 672e4b69d7 | |||
| 9cd90ba62a | |||
| b6d0bb13ef | |||
| 7401a59346 | |||
| 622ae93d68 | |||
| b4207feb7e | |||
| 332fdb70ca | |||
| b9abfb64c0 | |||
| 3ca8861d85 | |||
| 0c93aab13f | |||
| 208b49cfc8 | |||
| db0a2d9d1d | |||
| c123e93433 |
@@ -0,0 +1,23 @@
|
||||
## Description
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
## Expected behaviour
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
## How to reproduce
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
## Screenshots
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
## Tooling and environment
|
||||
- Tuono version [e.g. 0.4.0]
|
||||
- OS: [e.g. MacOS, Windows]
|
||||
- Browser: [e.g. chrome, safari]
|
||||
- Node version: [e.g. 20.0.0]
|
||||
- Rust version: [e.g. 1.79.0]
|
||||
- Crate version: [e.g. 1.78.0]
|
||||
- Node Package Manger version: [e.g. pnpm: 9.5.0]
|
||||
|
||||
## Additional context
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -0,0 +1,18 @@
|
||||
## Context
|
||||
|
||||
<!--
|
||||
Explain the context and why you're making that change. What is the problem
|
||||
you're trying to solve? If a new feature is being added, describe the intended
|
||||
use case that feature fulfills.
|
||||
-->
|
||||
|
||||
## Description
|
||||
|
||||
<!--
|
||||
Thank you for your Pull Request. Please provide a description above and review
|
||||
the requirements below.
|
||||
|
||||
Bug fixes and new features should include tests.
|
||||
|
||||
Contributors guide: https://github.com/Valerioageno/tuono/blob/main/CONTRIBUTING.md
|
||||
-->
|
||||
@@ -2,9 +2,6 @@
|
||||
<img src="https://raw.githubusercontent.com/Valerioageno/tuono/main/assets/logo.png" width="200px">
|
||||
</p>
|
||||
<h1 align="center">Tuono<br>The react/rust fullstack framework</h1>
|
||||
<p align="center">
|
||||
⚠️ This project is under heavy development. API might drastically change ⚠️
|
||||
</p>
|
||||
<div align="center">
|
||||
<img src="https://github.com/Valerioageno/tuono/actions/workflows/rust.yml/badge.svg" />
|
||||
<img src="https://github.com/Valerioageno/tuono/actions/workflows/typescript.yml/badge.svg" />
|
||||
@@ -53,19 +50,21 @@ by Tuono based on the files defined within the `./src/routes` directory.
|
||||
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/)
|
||||
Tuono is the CLI that provides all the needed commands to handle the fullstack project.
|
||||
To download it is required [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`.
|
||||
Then run `cargo install tuono`.
|
||||
|
||||
To create a new project run `tuono new [NAME]` (optionally you can pass the `--template` flag - check the
|
||||
To list all the available commands run `tuono -h`
|
||||
|
||||
To create a new project run `tuono new [NAME]` (optionally you can pass the `--template` (or `-t`) 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.
|
||||
and to set the server project in the `production` mode.
|
||||
|
||||
Now to execute it just run `cargo run --release`.
|
||||
|
||||
@@ -74,7 +73,9 @@ Now to execute it just run `cargo run --release`.
|
||||
- rust
|
||||
- cargo
|
||||
- node
|
||||
- pnpm (other package managers support will be added soon)
|
||||
- npm/pnpm/yarn*
|
||||
|
||||
> yarn [pnp](https://yarnpkg.com/features/pnp) is not supported yet
|
||||
|
||||
## Folder structure
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
[package]
|
||||
name = "tuono"
|
||||
version = "0.2.2"
|
||||
version = "0.6.0"
|
||||
edition = "2021"
|
||||
authors = ["V. Ageno <valerioageno@yahoo.it>"]
|
||||
description = "The react/rust fullstack framework"
|
||||
keywords = [ "react", "typescript", "fullstack", "web", "ssr"]
|
||||
repository = "https://github.com/Valerioageno/tuono"
|
||||
readme = "../../README.md"
|
||||
license-file = "../../LICENSE.md"
|
||||
categories = ["web-programming", "reactjs", "typescript", "framework", "fullstack"]
|
||||
categories = ["web-programming"]
|
||||
include = [
|
||||
"src/*.rs",
|
||||
"Cargo.toml"
|
||||
@@ -29,4 +30,5 @@ glob = "0.3.1"
|
||||
regex = "1.10.4"
|
||||
reqwest = {version = "0.12.4", features =["blocking", "json"]}
|
||||
serde_json = "1.0"
|
||||
fs_extra = "1.3.0"
|
||||
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
use glob::glob;
|
||||
use glob::GlobError;
|
||||
use std::collections::{hash_map::Entry, HashMap};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Child;
|
||||
use std::process::Command;
|
||||
use std::process::Stdio;
|
||||
|
||||
use crate::route::Route;
|
||||
|
||||
const IGNORE_EXTENSIONS: [&str; 3] = ["css", "scss", "sass"];
|
||||
const IGNORE_FILES: [&str; 1] = ["__root"];
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct App {
|
||||
pub route_map: HashMap<String, Route>,
|
||||
pub base_path: PathBuf,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new() -> Self {
|
||||
let base_path = std::env::current_dir().unwrap();
|
||||
|
||||
let mut app = App {
|
||||
route_map: HashMap::new(),
|
||||
base_path,
|
||||
};
|
||||
|
||||
app.collect_routes();
|
||||
|
||||
app
|
||||
}
|
||||
|
||||
fn collect_routes(&mut self) {
|
||||
glob(self.base_path.join("src/routes/**/*.*").to_str().unwrap())
|
||||
.expect("Failed to read glob pattern")
|
||||
.for_each(|entry| {
|
||||
if self.should_collect_route(&entry) {
|
||||
self.collect_route(entry)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn should_collect_route(&self, entry: &Result<PathBuf, GlobError>) -> bool {
|
||||
let file_extension = entry.as_ref().unwrap().extension().unwrap();
|
||||
let file_name = entry.as_ref().unwrap().file_stem().unwrap();
|
||||
|
||||
if IGNORE_EXTENSIONS.iter().any(|val| val == &file_extension) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if IGNORE_FILES.iter().any(|val| val == &file_name) {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn collect_route(&mut self, path_buf: Result<PathBuf, GlobError>) {
|
||||
let entry = path_buf.unwrap();
|
||||
let base_path_str = self.base_path.to_str().unwrap();
|
||||
let path = entry
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.replace(&format!("{base_path_str}/src/routes"), "")
|
||||
.replace(".rs", "")
|
||||
.replace(".tsx", "");
|
||||
|
||||
if entry.extension().unwrap() == "rs" {
|
||||
if let Entry::Vacant(route_map) = self.route_map.entry(path.clone()) {
|
||||
let mut route = Route::new(path);
|
||||
route.update_axum_info();
|
||||
route_map.insert(route);
|
||||
} else {
|
||||
let route = self.route_map.get_mut(&path).unwrap();
|
||||
route.update_axum_info();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Entry::Vacant(route_map) = self.route_map.entry(path.clone()) {
|
||||
let route = Route::new(path);
|
||||
route_map.insert(route);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_dynamic_routes(&self) -> bool {
|
||||
self.route_map.iter().any(|(_, route)| route.is_dynamic)
|
||||
}
|
||||
|
||||
pub fn build_react_prod(&self) {
|
||||
Command::new("./node_modules/.bin/tuono-build-prod")
|
||||
.output()
|
||||
.expect("Failed to build the react source");
|
||||
}
|
||||
|
||||
pub fn run_rust_server(&self) -> Child {
|
||||
Command::new("cargo")
|
||||
.arg("run")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("Failed to run the rust server")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn should_collect_routes() {
|
||||
let mut app = App::new();
|
||||
app.base_path = "/home/user/Documents/tuono".into();
|
||||
|
||||
let routes = [
|
||||
"/home/user/Documents/tuono/src/routes/about.rs",
|
||||
"/home/user/Documents/tuono/src/routes/index.rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/index.rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/[post].rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/UPPERCASE.rs",
|
||||
];
|
||||
|
||||
routes
|
||||
.into_iter()
|
||||
.for_each(|route| app.collect_route(Ok(PathBuf::from(route))));
|
||||
|
||||
let results = [
|
||||
("/index", "index"),
|
||||
("/about", "about"),
|
||||
("/posts/index", "posts_index"),
|
||||
("/posts/[post]", "posts_dyn_post"),
|
||||
("/posts/UPPERCASE", "posts_uppercase"),
|
||||
];
|
||||
|
||||
results.into_iter().for_each(|(path, module_import)| {
|
||||
assert_eq!(
|
||||
app.route_map
|
||||
.get(path)
|
||||
.unwrap()
|
||||
.axum_info
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.module_import,
|
||||
String::from(module_import)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_create_multi_level_axum_paths() {
|
||||
let mut app = App::new();
|
||||
app.base_path = "/home/user/Documents/tuono".into();
|
||||
|
||||
let routes = [
|
||||
"/home/user/Documents/tuono/src/routes/about.rs",
|
||||
"/home/user/Documents/tuono/src/routes/index.rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/index.rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/any-post.rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/[post].rs",
|
||||
];
|
||||
|
||||
routes
|
||||
.into_iter()
|
||||
.for_each(|route| app.collect_route(Ok(PathBuf::from(route))));
|
||||
|
||||
let results = [
|
||||
("/index", "/"),
|
||||
("/about", "/about"),
|
||||
("/posts/index", "/posts"),
|
||||
("/posts/any-post", "/posts/any-post"),
|
||||
("/posts/[post]", "/posts/:post"),
|
||||
];
|
||||
|
||||
results.into_iter().for_each(|(path, expected_path)| {
|
||||
assert_eq!(
|
||||
app.route_map
|
||||
.get(path)
|
||||
.unwrap()
|
||||
.axum_info
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.axum_route,
|
||||
String::from(expected_path)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_ignore_whitelisted_extensions() {
|
||||
let mut app = App::new();
|
||||
app.base_path = "/home/user/Documents/tuono".into();
|
||||
|
||||
let routes = [
|
||||
"/home/user/Documents/tuono/src/routes/about.css",
|
||||
"/home/user/Documents/tuono/src/routes/index.scss",
|
||||
"/home/user/Documents/tuono/src/routes/posts/index.sass",
|
||||
];
|
||||
|
||||
routes.into_iter().for_each(|route| {
|
||||
if app.should_collect_route(&Ok(PathBuf::from(route))) {
|
||||
app.collect_route(Ok(PathBuf::from(route)))
|
||||
}
|
||||
});
|
||||
|
||||
assert!(app.route_map.is_empty())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_ignore_whitelisted_files() {
|
||||
let mut app = App::new();
|
||||
app.base_path = "/home/user/Documents/tuono".into();
|
||||
|
||||
let routes = [
|
||||
"/home/user/Documents/tuono/src/routes/__root.tsx",
|
||||
"/home/user/Documents/tuono/src/routes/posts/__root.tsx",
|
||||
];
|
||||
|
||||
routes.into_iter().for_each(|route| {
|
||||
if app.should_collect_route(&Ok(PathBuf::from(route))) {
|
||||
app.collect_route(Ok(PathBuf::from(route)))
|
||||
}
|
||||
});
|
||||
|
||||
assert!(app.route_map.is_empty())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_correctly_parse_routes_with_server_handler() {
|
||||
let mut app = App::new();
|
||||
app.base_path = "/home/user/Documents/tuono".into();
|
||||
|
||||
let routes = [
|
||||
"/home/user/Documents/tuono/src/routes/about.rs",
|
||||
"/home/user/Documents/tuono/src/routes/about.tsx",
|
||||
"/home/user/Documents/tuono/src/routes/index.tsx",
|
||||
];
|
||||
|
||||
routes
|
||||
.into_iter()
|
||||
.for_each(|route| app.collect_route(Ok(PathBuf::from(route))));
|
||||
|
||||
let results = [("/about", true), ("/index", false)];
|
||||
|
||||
results
|
||||
.into_iter()
|
||||
.for_each(|(path, expected_has_server_handler)| {
|
||||
if expected_has_server_handler {
|
||||
assert!(app.route_map.get(path).unwrap().axum_info.is_some())
|
||||
} else {
|
||||
assert!(app.route_map.get(path).unwrap().axum_info.is_none())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_dynamic_routes_works() {
|
||||
let mut app = App::new();
|
||||
app.base_path = "/home/user/Documents/tuono".into();
|
||||
|
||||
let routes = [
|
||||
"/home/user/Documents/tuono/src/routes/index.rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/[post].rs",
|
||||
];
|
||||
|
||||
routes
|
||||
.into_iter()
|
||||
.for_each(|route| app.collect_route(Ok(PathBuf::from(route))));
|
||||
|
||||
assert!(app.has_dynamic_routes());
|
||||
|
||||
let mut app2 = App::new();
|
||||
app2.base_path = "/home/user/Documents/tuono".into();
|
||||
|
||||
let routes = [
|
||||
"/home/user/Documents/tuono/src/routes/[post].rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/[post].rs",
|
||||
];
|
||||
|
||||
routes
|
||||
.into_iter()
|
||||
.for_each(|route| app2.collect_route(Ok(PathBuf::from(route))));
|
||||
|
||||
assert!(app2.has_dynamic_routes());
|
||||
|
||||
let mut app3 = App::new();
|
||||
app3.base_path = "/home/user/Documents/tuono".into();
|
||||
|
||||
let routes = [
|
||||
"/home/user/Documents/tuono/src/routes/index.rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/index.rs",
|
||||
];
|
||||
|
||||
routes
|
||||
.into_iter()
|
||||
.for_each(|route| app3.collect_route(Ok(PathBuf::from(route))));
|
||||
|
||||
assert!(!app3.has_dynamic_routes())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
use fs_extra::dir::{copy, CopyOptions};
|
||||
use std::path::PathBuf;
|
||||
use std::thread::sleep;
|
||||
use std::time::Duration;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
use crate::app::App;
|
||||
use crate::mode::Mode;
|
||||
use crate::scaffold_project;
|
||||
use crate::source_builder::{bundle_axum_source, check_tuono_folder, create_client_entry_files};
|
||||
use crate::watch;
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum Actions {
|
||||
/// Start the development environment
|
||||
Dev,
|
||||
/// Build the production assets
|
||||
Build {
|
||||
#[arg(short, long = "static")]
|
||||
/// Statically generate the website HTML
|
||||
ssg: bool,
|
||||
},
|
||||
/// Scaffold a new project
|
||||
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)]
|
||||
#[command(version, about = "The react/rust fullstack framework")]
|
||||
struct Args {
|
||||
#[command(subcommand)]
|
||||
action: Actions,
|
||||
}
|
||||
|
||||
fn init_tuono_folder(mode: Mode) -> std::io::Result<()> {
|
||||
check_tuono_folder()?;
|
||||
bundle_axum_source(mode)?;
|
||||
create_client_entry_files()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn app() -> std::io::Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
match args.action {
|
||||
Actions::Dev => {
|
||||
init_tuono_folder(Mode::Dev)?;
|
||||
watch::watch().unwrap();
|
||||
}
|
||||
Actions::Build { ssg } => {
|
||||
init_tuono_folder(Mode::Prod)?;
|
||||
let app = App::new();
|
||||
|
||||
if ssg && app.has_dynamic_routes() {
|
||||
// TODO: allow dynamic routes static generation
|
||||
println!("Cannot statically build dynamic routes");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
app.build_react_prod();
|
||||
|
||||
if ssg {
|
||||
println!("SSG: generation started");
|
||||
|
||||
let static_dir = PathBuf::from("out/static");
|
||||
|
||||
if static_dir.is_dir() {
|
||||
std::fs::remove_dir_all(&static_dir)
|
||||
.expect("Failed to clear the out/static folder");
|
||||
}
|
||||
|
||||
std::fs::create_dir(&static_dir).expect("Failed to create static output dir");
|
||||
|
||||
copy(
|
||||
"./out/client",
|
||||
static_dir,
|
||||
&CopyOptions::new().overwrite(true).content_only(true),
|
||||
)
|
||||
.expect("Failed to clone assets into static output folder");
|
||||
|
||||
let mut rust_server = app.run_rust_server();
|
||||
|
||||
let reqwest = reqwest::blocking::Client::builder()
|
||||
.user_agent("")
|
||||
.build()
|
||||
.expect("Failed to build reqwest client");
|
||||
|
||||
// Wait for server
|
||||
let mut is_server_ready = false;
|
||||
|
||||
while !is_server_ready {
|
||||
if reqwest.get("http://localhost:3000").send().is_ok() {
|
||||
is_server_ready = true
|
||||
}
|
||||
// TODO: add maximum tries
|
||||
sleep(Duration::from_secs(1))
|
||||
}
|
||||
|
||||
app.route_map
|
||||
.iter()
|
||||
.for_each(|(_, route)| route.save_ssg_html(&reqwest));
|
||||
|
||||
// Close server
|
||||
let _ = rust_server.kill();
|
||||
};
|
||||
|
||||
println!("Build successfully finished");
|
||||
}
|
||||
Actions::New {
|
||||
folder_name,
|
||||
template,
|
||||
} => {
|
||||
scaffold_project::create_new_project(folder_name, template);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+5
-64
@@ -1,66 +1,7 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::process::Command;
|
||||
|
||||
mod source_builder;
|
||||
use source_builder::{bundle_axum_source, create_client_entry_files};
|
||||
|
||||
use crate::source_builder::{check_tuono_folder, Mode};
|
||||
|
||||
mod app;
|
||||
pub mod cli;
|
||||
mod mode;
|
||||
mod route;
|
||||
mod scaffold_project;
|
||||
mod source_builder;
|
||||
mod watch;
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum Actions {
|
||||
/// Start the development environment
|
||||
Dev,
|
||||
/// Build the production assets
|
||||
Build,
|
||||
/// Scaffold a new project
|
||||
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)]
|
||||
#[command(version, about = "The react/rust fullstack framework")]
|
||||
struct Args {
|
||||
#[command(subcommand)]
|
||||
action: Actions,
|
||||
}
|
||||
|
||||
fn init_tuono_folder(mode: Mode) -> std::io::Result<()> {
|
||||
check_tuono_folder()?;
|
||||
bundle_axum_source(mode)?;
|
||||
create_client_entry_files()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cli() -> std::io::Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
match args.action {
|
||||
Actions::Dev => {
|
||||
init_tuono_folder(Mode::Dev)?;
|
||||
watch::watch().unwrap();
|
||||
}
|
||||
Actions::Build => {
|
||||
init_tuono_folder(Mode::Prod)?;
|
||||
let mut vite_build = Command::new("./node_modules/.bin/tuono-build-prod");
|
||||
let _ = &vite_build.output()?;
|
||||
}
|
||||
Actions::New {
|
||||
folder_name,
|
||||
template,
|
||||
} => {
|
||||
scaffold_project::create_new_project(folder_name, template);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use tuono::cli;
|
||||
use tuono::cli::app;
|
||||
|
||||
fn main() {
|
||||
cli().unwrap();
|
||||
app().expect("Failed to start the CLI")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
#[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"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
use fs_extra::dir::create_all;
|
||||
use regex::Regex;
|
||||
use reqwest::blocking::Client;
|
||||
use reqwest::Url;
|
||||
use std::fs::File;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn has_dynamic_path(route: &str) -> bool {
|
||||
let regex = Regex::new(r"\[(.*?)\]").expect("Failed to create the regex");
|
||||
regex.is_match(route)
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct AxumInfo {
|
||||
// Path for importing the module
|
||||
pub module_import: String,
|
||||
// path for the the axum router
|
||||
pub axum_route: String,
|
||||
}
|
||||
|
||||
impl AxumInfo {
|
||||
pub fn new(path: String) -> Self {
|
||||
// Remove first slash
|
||||
let mut module = path.chars();
|
||||
module.next();
|
||||
|
||||
let axum_route = path.replace("/index", "");
|
||||
|
||||
if axum_route.is_empty() {
|
||||
return AxumInfo {
|
||||
module_import: module.as_str().to_string().replace('/', "_"),
|
||||
axum_route: "/".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
if has_dynamic_path(&path) {
|
||||
return AxumInfo {
|
||||
module_import: module
|
||||
.as_str()
|
||||
.to_string()
|
||||
.replace('/', "_")
|
||||
.replace('[', "dyn_")
|
||||
.replace(']', ""),
|
||||
axum_route: axum_route.replace('[', ":").replace(']', ""),
|
||||
};
|
||||
}
|
||||
|
||||
AxumInfo {
|
||||
module_import: module.as_str().to_string().replace('/', "_").to_lowercase(),
|
||||
axum_route,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Route {
|
||||
path: String,
|
||||
pub is_dynamic: bool,
|
||||
pub axum_info: Option<AxumInfo>,
|
||||
}
|
||||
|
||||
impl Route {
|
||||
pub fn new(cleaned_path: String) -> Self {
|
||||
Route {
|
||||
path: cleaned_path.clone(),
|
||||
axum_info: None,
|
||||
is_dynamic: has_dynamic_path(&cleaned_path),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_axum_info(&mut self) {
|
||||
self.axum_info = Some(AxumInfo::new(self.path.clone()))
|
||||
}
|
||||
|
||||
pub fn save_ssg_html(&self, reqwest: &Client) {
|
||||
let path = &self.path.replace("index", "");
|
||||
|
||||
let mut response = reqwest
|
||||
.get(format!("http://localhost:3000{path}"))
|
||||
.send()
|
||||
.unwrap();
|
||||
|
||||
let file_path = PathBuf::from(format!("out/static{}.html", &self.path));
|
||||
|
||||
let parent_dir = file_path.parent().unwrap();
|
||||
|
||||
if !parent_dir.is_dir() {
|
||||
create_all(parent_dir, false).expect("Failed to create parent directories");
|
||||
}
|
||||
|
||||
let mut file = File::create(file_path).expect("Failed to create the HTML file");
|
||||
|
||||
io::copy(&mut response, &mut file).expect("Failed to write the HTML on the file");
|
||||
|
||||
// Saving also the server response
|
||||
if self.axum_info.is_some() {
|
||||
let data_file_path =
|
||||
PathBuf::from(&format!("out/static/__tuono/data{}/data.json", path));
|
||||
|
||||
let data_parent_dir = data_file_path.parent().unwrap();
|
||||
|
||||
if !data_parent_dir.is_dir() {
|
||||
create_all(data_parent_dir, false)
|
||||
.expect("Failed to create data parent directories");
|
||||
}
|
||||
|
||||
let base = Url::parse("http://localhost:3000/__tuono/data/data.json").unwrap();
|
||||
|
||||
let path = if path == "/" { "" } else { path };
|
||||
|
||||
let pathname = &format!("/__tuono/data{path}/data.json");
|
||||
|
||||
let url = base
|
||||
.join(pathname)
|
||||
.expect("Failed to build the reqwest URL");
|
||||
|
||||
let mut response = reqwest.get(url).send().unwrap();
|
||||
|
||||
let mut data_file =
|
||||
File::create(data_file_path).expect("Failed to create the JSON file");
|
||||
|
||||
io::copy(&mut response, &mut data_file).expect("Failed to write the JSON on the file");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
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),
|
||||
(
|
||||
"/home/user/Documents/tuono/src/routes/posts/index.rs",
|
||||
false,
|
||||
),
|
||||
(
|
||||
"/home/user/Documents/tuono/src/routes/posts/[post].rs",
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
||||
routes
|
||||
.into_iter()
|
||||
.for_each(|route| assert_eq!(has_dynamic_path(route.0), route.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_correctly_create_the_axum_infos() {
|
||||
let info = AxumInfo::new("/index".to_string());
|
||||
|
||||
assert_eq!(info.axum_route, "/");
|
||||
assert_eq!(info.module_import, "index");
|
||||
|
||||
let dyn_info = AxumInfo::new("/[posts]".to_string());
|
||||
|
||||
assert_eq!(dyn_info.axum_route, "/:posts");
|
||||
assert_eq!(dyn_info.module_import, "dyn_posts");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -1,27 +1,13 @@
|
||||
use glob::glob;
|
||||
use glob::GlobError;
|
||||
use regex::Regex;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
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"
|
||||
}
|
||||
}
|
||||
use crate::app::App;
|
||||
use crate::mode::Mode;
|
||||
use crate::route::AxumInfo;
|
||||
use crate::route::Route;
|
||||
|
||||
pub const SERVER_ENTRY_DATA: &str = "// File automatically generated by tuono
|
||||
// Do not manually update this file
|
||||
@@ -47,13 +33,7 @@ pub const AXUM_ENTRY_POINT: &str = r##"
|
||||
// File automatically generated
|
||||
// Do not manually change it
|
||||
|
||||
use axum::extract::{Path, Request};
|
||||
use axum::response::Html;
|
||||
use axum::{routing::get, Router};
|
||||
use tower_http::services::ServeDir;
|
||||
use std::collections::HashMap;
|
||||
use tuono_lib::{ssr, Ssr, Mode, GLOBAL_MODE, manifest::load_manifest};
|
||||
use reqwest::Client;
|
||||
use tuono_lib::{tokio, Mode, Server, axum::Router, axum::routing::get};
|
||||
|
||||
const MODE: Mode = /*MODE*/;
|
||||
|
||||
@@ -61,140 +41,17 @@ const MODE: Mode = /*MODE*/;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
Ssr::create_platform();
|
||||
|
||||
let fetch = Client::new();
|
||||
|
||||
GLOBAL_MODE.set(MODE).unwrap();
|
||||
|
||||
if MODE == Mode::Prod {
|
||||
load_manifest()
|
||||
}
|
||||
|
||||
let app = Router::new()
|
||||
let router = Router::new()
|
||||
// ROUTE_BUILDER
|
||||
.fallback_service(
|
||||
ServeDir::new("public"))
|
||||
.fallback_service(ServeDir::new("out/client")
|
||||
.fallback(get(catch_all)))
|
||||
.with_state(fetch);
|
||||
;
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
async fn catch_all(Path(params): Path<HashMap<String, String>>, request: Request) -> Html<String> {
|
||||
let pathname = &request.uri();
|
||||
let headers = &request.headers();
|
||||
|
||||
let req = tuono_lib::Request::new(pathname, headers, params);
|
||||
|
||||
|
||||
// TODO: remove unwrap
|
||||
let payload = tuono_lib::Payload::new(&req, &"")
|
||||
.client_payload()
|
||||
.unwrap();
|
||||
|
||||
let result = ssr::Js::SSR.with(|ssr| ssr.borrow_mut().render_to_string(Some(&payload)));
|
||||
|
||||
match result {
|
||||
Ok(html) => Html(html),
|
||||
_ => Html("500 internal server error".to_string()),
|
||||
}
|
||||
Server::init(router, MODE).start().await
|
||||
}
|
||||
"##;
|
||||
|
||||
const ROOT_FOLDER: &str = "src/routes";
|
||||
const ROUTE_FOLDER: &str = "src/routes";
|
||||
const DEV_FOLDER: &str = ".tuono";
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct Route {
|
||||
/// Every module import is the path with a _ instead of /
|
||||
pub module_import: String,
|
||||
pub axum_route: String,
|
||||
}
|
||||
|
||||
fn has_dynamic_path(route: &str) -> bool {
|
||||
let regex = Regex::new(r"\[(.*?)\]").expect("Failed to create the regex");
|
||||
regex.is_match(route)
|
||||
}
|
||||
|
||||
impl Route {
|
||||
pub fn new(path: &str) -> Self {
|
||||
let route_name = path.replace(".rs", "");
|
||||
// Remove first slash
|
||||
let mut module = route_name.as_str().chars();
|
||||
module.next();
|
||||
|
||||
let axum_route = path.replace("/index.rs", "").replace(".rs", "");
|
||||
|
||||
if axum_route.is_empty() {
|
||||
return Route {
|
||||
module_import: module.as_str().to_string().replace('/', "_"),
|
||||
axum_route: "/".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
if has_dynamic_path(&route_name) {
|
||||
return Route {
|
||||
module_import: module
|
||||
.as_str()
|
||||
.to_string()
|
||||
.replace('/', "_")
|
||||
.replace('[', "dyn_")
|
||||
.replace(']', ""),
|
||||
axum_route: axum_route.replace('[', ":").replace(']', ""),
|
||||
};
|
||||
}
|
||||
|
||||
Route {
|
||||
module_import: module.as_str().to_string().replace('/', "_"),
|
||||
axum_route,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SourceBuilder {
|
||||
route_map: HashMap<PathBuf, Route>,
|
||||
base_path: PathBuf,
|
||||
}
|
||||
|
||||
impl SourceBuilder {
|
||||
pub fn new() -> Self {
|
||||
let base_path = std::env::current_dir().unwrap();
|
||||
|
||||
SourceBuilder {
|
||||
route_map: HashMap::new(),
|
||||
base_path,
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_routes(&mut self) {
|
||||
glob(self.base_path.join("src/routes/**/*.rs").to_str().unwrap())
|
||||
.unwrap()
|
||||
.for_each(|entry| self.collect_route(entry))
|
||||
}
|
||||
|
||||
fn collect_route(&mut self, path_buf: Result<PathBuf, GlobError>) {
|
||||
let entry = path_buf.unwrap();
|
||||
let base_path_str = self.base_path.to_str().unwrap();
|
||||
let path = entry
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.replace(&format!("{base_path_str}/src/routes"), "");
|
||||
|
||||
let route = Route::new(&path);
|
||||
|
||||
self.route_map.insert(PathBuf::from(path), route);
|
||||
}
|
||||
}
|
||||
|
||||
fn create_main_file(base_path: &Path, bundled_file: &String) {
|
||||
let mut data_file =
|
||||
fs::File::create(base_path.join(".tuono/main.rs")).expect("creation failed");
|
||||
@@ -204,37 +61,44 @@ fn create_main_file(base_path: &Path, bundled_file: &String) {
|
||||
.expect("write failed");
|
||||
}
|
||||
|
||||
fn create_routes_declaration(routes: &HashMap<PathBuf, Route>) -> String {
|
||||
fn create_routes_declaration(routes: &HashMap<String, Route>) -> String {
|
||||
let mut route_declarations = String::from("// ROUTE_BUILDER\n");
|
||||
|
||||
for (_, route) in routes.iter() {
|
||||
let Route {
|
||||
axum_route,
|
||||
module_import,
|
||||
} = &route;
|
||||
let Route { axum_info, .. } = &route;
|
||||
|
||||
route_declarations.push_str(&format!(
|
||||
r#".route("{axum_route}", get({module_import}::route))"#
|
||||
));
|
||||
route_declarations.push_str(&format!(
|
||||
r#".route("/__tuono/data{axum_route}", get({module_import}::api))"#
|
||||
));
|
||||
if axum_info.is_some() {
|
||||
let AxumInfo {
|
||||
axum_route,
|
||||
module_import,
|
||||
} = axum_info.as_ref().unwrap();
|
||||
|
||||
route_declarations.push_str(&format!(
|
||||
r#".route("{axum_route}", get({module_import}::route))"#
|
||||
));
|
||||
let slash = if axum_route.ends_with('/') { "" } else { "/" };
|
||||
route_declarations.push_str(&format!(
|
||||
r#".route("/__tuono/data{axum_route}{slash}data.json", get({module_import}::api))"#
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
route_declarations
|
||||
}
|
||||
|
||||
fn create_modules_declaration(routes: &HashMap<PathBuf, Route>) -> String {
|
||||
fn create_modules_declaration(routes: &HashMap<String, Route>) -> String {
|
||||
let mut route_declarations = String::from("// MODULE_IMPORTS\n");
|
||||
|
||||
for (path, route) in routes.iter() {
|
||||
let module_name = &route.module_import;
|
||||
let path_str = path.to_str().unwrap();
|
||||
route_declarations.push_str(&format!(
|
||||
r#"#[path="../{ROOT_FOLDER}{path_str}"]
|
||||
mod {module_name};
|
||||
"#
|
||||
))
|
||||
if route.axum_info.is_some() {
|
||||
let AxumInfo { module_import, .. } = route.axum_info.as_ref().unwrap();
|
||||
|
||||
route_declarations.push_str(&format!(
|
||||
r#"#[path="../{ROUTE_FOLDER}{path}.rs"]
|
||||
mod {module_import};
|
||||
"#
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
route_declarations
|
||||
@@ -243,18 +107,16 @@ mod {module_name};
|
||||
pub fn bundle_axum_source(mode: Mode) -> io::Result<()> {
|
||||
let base_path = std::env::current_dir().unwrap();
|
||||
|
||||
let mut source_builder = SourceBuilder::new();
|
||||
let app = App::new();
|
||||
|
||||
source_builder.collect_routes();
|
||||
|
||||
let bundled_file = generate_axum_source(&source_builder, mode);
|
||||
let bundled_file = generate_axum_source(&app, mode);
|
||||
|
||||
create_main_file(&base_path, &bundled_file);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_axum_source(source_builder: &SourceBuilder, mode: Mode) -> String {
|
||||
fn generate_axum_source(source_builder: &App, mode: Mode) -> String {
|
||||
AXUM_ENTRY_POINT
|
||||
.replace(
|
||||
"// ROUTE_BUILDER\n",
|
||||
@@ -293,101 +155,9 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
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),
|
||||
(
|
||||
"/home/user/Documents/tuono/src/routes/posts/index.rs",
|
||||
false,
|
||||
),
|
||||
(
|
||||
"/home/user/Documents/tuono/src/routes/posts/[post].rs",
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
||||
routes
|
||||
.into_iter()
|
||||
.for_each(|route| assert_eq!(has_dynamic_path(route.0), route.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_collect_routes() {
|
||||
let mut source_builder = SourceBuilder::new();
|
||||
source_builder.base_path = "/home/user/Documents/tuono".into();
|
||||
|
||||
let routes = [
|
||||
"/home/user/Documents/tuono/src/routes/about.rs",
|
||||
"/home/user/Documents/tuono/src/routes/index.rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/index.rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/[post].rs",
|
||||
];
|
||||
|
||||
routes
|
||||
.into_iter()
|
||||
.for_each(|route| source_builder.collect_route(Ok(PathBuf::from(route))));
|
||||
|
||||
let results = [
|
||||
("/index.rs", "index"),
|
||||
("/about.rs", "about"),
|
||||
("/posts/index.rs", "posts_index"),
|
||||
("/posts/[post].rs", "posts_dyn_post"),
|
||||
];
|
||||
|
||||
results.into_iter().for_each(|(path, module_import)| {
|
||||
assert_eq!(
|
||||
source_builder
|
||||
.route_map
|
||||
.get(&PathBuf::from(path))
|
||||
.unwrap()
|
||||
.module_import,
|
||||
String::from(module_import)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_create_multi_level_axum_paths() {
|
||||
let mut source_builder = SourceBuilder::new();
|
||||
source_builder.base_path = "/home/user/Documents/tuono".into();
|
||||
|
||||
let routes = [
|
||||
"/home/user/Documents/tuono/src/routes/about.rs",
|
||||
"/home/user/Documents/tuono/src/routes/index.rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/index.rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/any-post.rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/[post].rs",
|
||||
];
|
||||
|
||||
routes
|
||||
.into_iter()
|
||||
.for_each(|route| source_builder.collect_route(Ok(PathBuf::from(route))));
|
||||
|
||||
let results = [
|
||||
("/index.rs", "/"),
|
||||
("/about.rs", "/about"),
|
||||
("/posts/index.rs", "/posts"),
|
||||
("/posts/any-post.rs", "/posts/any-post"),
|
||||
("/posts/[post].rs", "/posts/:post"),
|
||||
];
|
||||
|
||||
results.into_iter().for_each(|(path, expected_path)| {
|
||||
assert_eq!(
|
||||
source_builder
|
||||
.route_map
|
||||
.get(&PathBuf::from(path))
|
||||
.unwrap()
|
||||
.axum_route,
|
||||
String::from(expected_path)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_set_the_correct_mode() {
|
||||
let source_builder = SourceBuilder::new();
|
||||
let source_builder = App::new();
|
||||
|
||||
let dev_bundle = generate_axum_source(&source_builder, Mode::Dev);
|
||||
assert!(dev_bundle.contains("const MODE: Mode = Mode::Dev;"));
|
||||
@@ -396,12 +166,4 @@ mod tests {
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
+17
-11
@@ -6,7 +6,8 @@ use watchexec::Watchexec;
|
||||
use watchexec_signals::Signal;
|
||||
use watchexec_supervisor::job::{start_job, Job};
|
||||
|
||||
use crate::source_builder::{bundle_axum_source, Mode};
|
||||
use crate::mode::Mode;
|
||||
use crate::source_builder::bundle_axum_source;
|
||||
|
||||
fn watch_react_src() -> Job {
|
||||
start_job(Arc::new(Command {
|
||||
@@ -57,27 +58,32 @@ pub async fn watch() -> Result<()> {
|
||||
build_ssr_bundle.to_wait().await;
|
||||
|
||||
let wx = Watchexec::new(move |mut action| {
|
||||
let mut should_reload = false;
|
||||
let mut should_reload_ssr_bundle = false;
|
||||
let mut should_reload_rust_server = 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")
|
||||
{
|
||||
should_reload = true
|
||||
if path.0.to_string_lossy().ends_with(".rs") {
|
||||
should_reload_rust_server = true
|
||||
}
|
||||
|
||||
// Either tsx and jsx
|
||||
if path.0.to_string_lossy().ends_with("sx") {
|
||||
should_reload_ssr_bundle = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if should_reload {
|
||||
if should_reload_rust_server {
|
||||
println!("Reloading...");
|
||||
run_server.stop();
|
||||
build_ssr_bundle.stop();
|
||||
build_ssr_bundle.start();
|
||||
bundle_axum_source(Mode::Dev).expect("Failed to bunlde rust source");
|
||||
run_server.start();
|
||||
println!("Ready!");
|
||||
}
|
||||
|
||||
if should_reload_ssr_bundle {
|
||||
build_ssr_bundle.stop();
|
||||
build_ssr_bundle.start();
|
||||
}
|
||||
|
||||
// if Ctrl-C is received, quit
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
[package]
|
||||
name = "tuono_lib"
|
||||
version = "0.2.2"
|
||||
version = "0.6.0"
|
||||
edition = "2021"
|
||||
authors = ["V. Ageno <valerioageno@yahoo.it>"]
|
||||
description = "The react/rust fullstack framework"
|
||||
keywords = [ "react", "typescript", "fullstack", "web", "ssr"]
|
||||
repository = "https://github.com/Valerioageno/tuono"
|
||||
readme = "../../README.md"
|
||||
license-file = "../../LICENSE.md"
|
||||
categories = ["web-programming", "reactjs", "typescript", "framework", "fullstack"]
|
||||
categories = ["web-programming"]
|
||||
include = [
|
||||
"src/*.rs",
|
||||
"Cargo.toml"
|
||||
@@ -18,14 +19,22 @@ name = "tuono_lib"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
ssr_rs = "0.5.4"
|
||||
axum = "0.7.5"
|
||||
ssr_rs = "0.5.5"
|
||||
axum = {version = "0.7.5", features = ["json", "ws"]}
|
||||
tokio = { version = "1.37.0", features = ["full"] }
|
||||
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.2.2"}
|
||||
serde_urlencoded = "0.7.1"
|
||||
reqwest = {version = "0.12.4", features = ["json", "stream"]}
|
||||
once_cell = "1.19.0"
|
||||
lazy_static = "1.5.0"
|
||||
regex = "1.10.5"
|
||||
either = "1.13.0"
|
||||
tower-http = {version = "0.5.2", features = ["fs"]}
|
||||
|
||||
tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.6.0"}
|
||||
# Match the same version used by axum
|
||||
tokio-tungstenite = "0.21.0"
|
||||
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
|
||||
tungstenite = "0.23.0"
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
use crate::{ssr::Js, Payload};
|
||||
use axum::extract::{Path, Request};
|
||||
use axum::response::Html;
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub async fn catch_all(
|
||||
Path(params): Path<HashMap<String, String>>,
|
||||
request: Request,
|
||||
) -> Html<String> {
|
||||
let pathname = request.uri();
|
||||
let headers = request.headers();
|
||||
|
||||
let req = crate::Request::new(pathname.to_owned(), headers.to_owned(), params);
|
||||
|
||||
// TODO: remove unwrap
|
||||
let payload = Payload::new(&req, &"").client_payload().unwrap();
|
||||
|
||||
let result = Js::render_to_string(Some(&payload));
|
||||
|
||||
match result {
|
||||
Ok(html) => Html(html),
|
||||
_ => Html("500 internal server error".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,22 @@
|
||||
pub mod manifest;
|
||||
mod catch_all;
|
||||
mod manifest;
|
||||
mod mode;
|
||||
mod payload;
|
||||
mod request;
|
||||
mod response;
|
||||
mod server;
|
||||
mod ssr;
|
||||
mod vite_reverse_proxy;
|
||||
mod vite_websocket_proxy;
|
||||
|
||||
pub mod ssr;
|
||||
|
||||
pub use mode::{Mode, GLOBAL_MODE};
|
||||
pub use mode::Mode;
|
||||
pub use payload::Payload;
|
||||
pub use request::Request;
|
||||
pub use response::{Props, Response};
|
||||
pub use ssr_rs::Ssr;
|
||||
pub use server::Server;
|
||||
pub use tuono_lib_macros::handler;
|
||||
|
||||
// Re-exports
|
||||
pub use axum;
|
||||
pub use reqwest;
|
||||
pub use tokio;
|
||||
|
||||
@@ -7,12 +7,19 @@ use std::path::PathBuf;
|
||||
|
||||
const VITE_MANIFEST_PATH: &str = "./out/client/.vite/manifest.json";
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BundleInfo {
|
||||
/// TODO: Add also the import field and load the dynamic
|
||||
/// values in the payload bundles.
|
||||
pub file: String,
|
||||
#[serde(default = "default_css_vector")]
|
||||
pub css: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_css_vector() -> Vec<String> {
|
||||
Vec::with_capacity(0)
|
||||
}
|
||||
|
||||
/// Manifest is the mapping between the vite output bundled files
|
||||
/// and the originals.
|
||||
/// Vite doc: https://vitejs.dev/config/build-options.html#build-manifest
|
||||
@@ -45,6 +52,73 @@ fn remap_manifest_keys(manifest: HashMap<String, BundleInfo>) -> HashMap<String,
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn correctly_parse_the_manifest_json() {
|
||||
let manifest_example = r#"{
|
||||
"../src/routes/index.tsx": {
|
||||
"file": "assets/index.js",
|
||||
"name": "index",
|
||||
"src": "../src/routes/index.tsx",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"client-main.tsx"
|
||||
],
|
||||
"css": [
|
||||
"assets/index.css"
|
||||
]
|
||||
},
|
||||
"meta-tags-lib": {
|
||||
"file": "assets/meta-lib.js",
|
||||
"name": "meta-tags-lib",
|
||||
"imports": [
|
||||
"client-main.tsx"
|
||||
]
|
||||
},
|
||||
"client-main.tsx": {
|
||||
"file": "assets/client-main.js",
|
||||
"name": "client-main",
|
||||
"src": "client-main.tsx",
|
||||
"isEntry": true,
|
||||
"dynamicImports": [
|
||||
"../src/routes/index.tsx",
|
||||
"../src/routes/pokemons/[pokemon].tsx"
|
||||
],
|
||||
"css": [
|
||||
"assets/client-main.css"
|
||||
]
|
||||
}
|
||||
}"#;
|
||||
|
||||
let parsed_manifest =
|
||||
serde_json::from_str::<HashMap<String, BundleInfo>>(manifest_example).unwrap();
|
||||
|
||||
let mut result = HashMap::new();
|
||||
result.insert(
|
||||
"../src/routes/index.tsx".to_string(),
|
||||
BundleInfo {
|
||||
file: "assets/index.js".to_string(),
|
||||
css: vec!["assets/index.css".to_string()],
|
||||
},
|
||||
);
|
||||
result.insert(
|
||||
"client-main.tsx".to_string(),
|
||||
BundleInfo {
|
||||
file: "assets/client-main.js".to_string(),
|
||||
css: vec!["assets/client-main.css".to_string()],
|
||||
},
|
||||
);
|
||||
|
||||
result.insert(
|
||||
"meta-tags-lib".to_string(),
|
||||
BundleInfo {
|
||||
file: "assets/meta-lib.js".to_string(),
|
||||
css: Vec::new(),
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(parsed_manifest, result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_correctly_remap_the_manifest() {
|
||||
let mut parsed_manifest: HashMap<String, BundleInfo> = HashMap::new();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::{manifest::MANIFEST, Mode, GLOBAL_MODE};
|
||||
use crate::manifest::MANIFEST;
|
||||
use crate::mode::{Mode, GLOBAL_MODE};
|
||||
use erased_serde::Serialize;
|
||||
use regex::Regex;
|
||||
use serde::Serialize as SerdeSerialize;
|
||||
@@ -23,7 +24,7 @@ pub struct Payload<'a> {
|
||||
}
|
||||
|
||||
impl<'a> Payload<'a> {
|
||||
pub fn new(req: &Request<'a>, props: &'a dyn Serialize) -> Payload<'a> {
|
||||
pub fn new(req: &'a Request, props: &'a dyn Serialize) -> Payload<'a> {
|
||||
Payload {
|
||||
router: req.location(),
|
||||
props,
|
||||
@@ -134,7 +135,7 @@ mod tests {
|
||||
|
||||
use crate::manifest::BundleInfo;
|
||||
|
||||
fn prepare_payload(uri: Option<&str>, mode: Mode) -> Payload<'static> {
|
||||
fn prepare_payload<'a>(uri: Option<&'a str>, mode: Mode) -> Payload<'a> {
|
||||
let mut manifest_mock = HashMap::new();
|
||||
manifest_mock.insert(
|
||||
"client-main".to_string(),
|
||||
@@ -184,11 +185,13 @@ mod tests {
|
||||
},
|
||||
);
|
||||
MANIFEST.get_or_init(|| manifest_mock);
|
||||
let location = Location::from(
|
||||
&uri.unwrap_or("http://localhost:3000/")
|
||||
.parse::<Uri>()
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
let uri = uri
|
||||
.unwrap_or("http://localhost:3000/")
|
||||
.parse::<Uri>()
|
||||
.unwrap();
|
||||
|
||||
let location = Location::from(uri);
|
||||
|
||||
Payload {
|
||||
router: location,
|
||||
|
||||
@@ -8,9 +8,9 @@ use axum::http::{HeaderMap, Uri};
|
||||
pub struct Location {
|
||||
href: String,
|
||||
pathname: String,
|
||||
search: HashMap<String, String>,
|
||||
#[serde(rename(serialize = "searchStr"))]
|
||||
search_str: String,
|
||||
hash: String,
|
||||
search: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Location {
|
||||
@@ -19,32 +19,28 @@ impl Location {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a Uri> for Location {
|
||||
fn from(uri: &Uri) -> Self {
|
||||
impl From<Uri> for Location {
|
||||
fn from(uri: Uri) -> Self {
|
||||
let query = uri.query().unwrap_or("");
|
||||
Location {
|
||||
// TODO: build correct href
|
||||
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(),
|
||||
search_str: query.to_string(),
|
||||
search: serde_urlencoded::from_str(query).unwrap_or(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Request<'a> {
|
||||
uri: &'a Uri,
|
||||
pub headers: &'a HeaderMap,
|
||||
pub struct Request {
|
||||
uri: Uri,
|
||||
pub headers: HeaderMap,
|
||||
pub params: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl<'a> Request<'a> {
|
||||
pub fn new(
|
||||
uri: &'a Uri,
|
||||
headers: &'a HeaderMap,
|
||||
params: HashMap<String, String>,
|
||||
) -> Request<'a> {
|
||||
impl Request {
|
||||
pub fn new(uri: Uri, headers: HeaderMap, params: HashMap<String, String>) -> Request {
|
||||
Request {
|
||||
uri,
|
||||
headers,
|
||||
@@ -53,6 +49,6 @@ impl<'a> Request<'a> {
|
||||
}
|
||||
|
||||
pub fn location(&self) -> Location {
|
||||
Location::from(self.uri)
|
||||
Location::from(self.uri.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::Request;
|
||||
use crate::{ssr::Js, Payload};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{Html, IntoResponse};
|
||||
use axum::response::{Html, IntoResponse, Redirect, Response as AxumResponse};
|
||||
use axum::Json;
|
||||
use erased_serde::Serialize;
|
||||
|
||||
@@ -15,6 +15,41 @@ pub enum Response {
|
||||
Props(Props),
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct JsonResponseInfo {
|
||||
redirect_destination: Option<String>,
|
||||
}
|
||||
|
||||
impl JsonResponseInfo {
|
||||
fn new(redirect_destination: Option<String>) -> 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),
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -32,25 +67,32 @@ impl Props {
|
||||
}
|
||||
|
||||
impl Response {
|
||||
pub fn render_to_string(&self, req: Request) -> impl IntoResponse {
|
||||
pub fn render_to_string(&self, req: Request) -> AxumResponse {
|
||||
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())),
|
||||
match Js::render_to_string(Some(&payload)) {
|
||||
Ok(html) => (*http_code, Html(html)).into_response(),
|
||||
Err(_) => {
|
||||
(*http_code, Html("500 Internal server error".to_string())).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO: Handle here other enum arms
|
||||
_ => todo!(),
|
||||
Self::Redirect(to) => Redirect::permanent(to).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
Self::Props(Props { data, http_code }) => {
|
||||
(*http_code, Json(JsonResponse::new(data))).into_response()
|
||||
}
|
||||
Self::Redirect(destination) => (
|
||||
StatusCode::PERMANENT_REDIRECT,
|
||||
Json(JsonResponse::new_redirect(destination.to_string())),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
use crate::mode::{Mode, GLOBAL_MODE};
|
||||
|
||||
use crate::manifest::load_manifest;
|
||||
use axum::routing::{get, Router};
|
||||
use ssr_rs::Ssr;
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
use crate::{
|
||||
catch_all::catch_all, vite_reverse_proxy::vite_reverse_proxy,
|
||||
vite_websocket_proxy::vite_websocket_proxy,
|
||||
};
|
||||
|
||||
const DEV_PUBLIC_DIR: &str = "public";
|
||||
const PROD_PUBLIC_DIR: &str = "out/client";
|
||||
|
||||
pub struct Server {
|
||||
router: Router<reqwest::Client>,
|
||||
mode: Mode,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub fn init(router: Router<reqwest::Client>, mode: Mode) -> Server {
|
||||
Ssr::create_platform();
|
||||
|
||||
GLOBAL_MODE.set(mode).unwrap();
|
||||
|
||||
if mode == Mode::Prod {
|
||||
load_manifest()
|
||||
}
|
||||
|
||||
Server { router, mode }
|
||||
}
|
||||
|
||||
pub async fn start(&self) {
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
|
||||
|
||||
let fetch = reqwest::Client::new();
|
||||
|
||||
if self.mode == Mode::Dev {
|
||||
println!("\nDevelopment app ready at http://localhost:3000/");
|
||||
let router = self
|
||||
.router
|
||||
.to_owned()
|
||||
.route("/vite-server/", get(vite_websocket_proxy))
|
||||
.route("/vite-server/*path", get(vite_reverse_proxy))
|
||||
.fallback_service(ServeDir::new(DEV_PUBLIC_DIR).fallback(get(catch_all)))
|
||||
.with_state(fetch);
|
||||
|
||||
axum::serve(listener, router)
|
||||
.await
|
||||
.expect("Failed to serve development server");
|
||||
} else {
|
||||
println!("\nProduction app ready at http://localhost:3000/");
|
||||
let router = self
|
||||
.router
|
||||
.to_owned()
|
||||
.fallback_service(ServeDir::new(PROD_PUBLIC_DIR).fallback(get(catch_all)))
|
||||
.with_state(fetch);
|
||||
|
||||
axum::serve(listener, router)
|
||||
.await
|
||||
.expect("Failed to serve production server");
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
-12
@@ -1,27 +1,48 @@
|
||||
use crate::{Mode, GLOBAL_MODE};
|
||||
use lazy_static::lazy_static;
|
||||
use crate::mode::{Mode, GLOBAL_MODE};
|
||||
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"
|
||||
};
|
||||
}
|
||||
|
||||
/// For the server side rendering we need to split the implementation between dev and prod.
|
||||
/// This completely remove the multi-thread optimization on dev but allow the dev server to
|
||||
/// update the SSR result without reloading the whole server.
|
||||
pub struct Js;
|
||||
|
||||
impl Js {
|
||||
pub fn render_to_string(payload: Option<&str>) -> Result<String, &'static str> {
|
||||
let mode = GLOBAL_MODE.get().expect("Failed to get GLOBAL_MODE");
|
||||
|
||||
if *mode == Mode::Dev {
|
||||
DevJs::render_to_string(payload)
|
||||
} else {
|
||||
ProdJs::SSR.with(|ssr| ssr.borrow_mut().render_to_string(payload))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProdJs;
|
||||
|
||||
impl ProdJs {
|
||||
thread_local! {
|
||||
pub static SSR: RefCell<Ssr<'static, 'static>> = RefCell::new(
|
||||
Ssr::from(
|
||||
read_to_string(PathBuf::from(*BUNDLE_PATH)).expect("Server bundle not found"), ""
|
||||
read_to_string(PathBuf::from("./out/server/prod-server.js")).expect("Server bundle not found"), ""
|
||||
).unwrap()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct DevJs;
|
||||
|
||||
impl DevJs {
|
||||
pub fn render_to_string(params: Option<&str>) -> Result<String, &'static str> {
|
||||
Ssr::from(
|
||||
read_to_string(PathBuf::from("./.tuono/server/dev-server.js"))
|
||||
.expect("Server bundle not found"),
|
||||
"",
|
||||
)
|
||||
.unwrap()
|
||||
.render_to_string(params)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Path, State};
|
||||
|
||||
use axum::http::{HeaderName, HeaderValue};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use reqwest::Client;
|
||||
|
||||
const VITE_URL: &str = "http://localhost:3001/vite-server";
|
||||
|
||||
pub async fn vite_reverse_proxy(
|
||||
State(client): State<Client>,
|
||||
Path(path): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
match client.get(format!("{VITE_URL}/{path}")).send().await {
|
||||
Ok(res) => {
|
||||
let mut response_builder = Response::builder().status(res.status().as_u16());
|
||||
|
||||
{
|
||||
let headers = response_builder.headers_mut().unwrap();
|
||||
res.headers().into_iter().for_each(|(name, value)| {
|
||||
let name = HeaderName::from_bytes(name.as_ref()).unwrap();
|
||||
let value = HeaderValue::from_bytes(value.as_ref()).unwrap();
|
||||
headers.insert(name, value);
|
||||
});
|
||||
}
|
||||
|
||||
response_builder
|
||||
.body(Body::from_stream(res.bytes_stream()))
|
||||
.unwrap()
|
||||
}
|
||||
Err(_) => todo!(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
use axum::extract::ws::{self, WebSocket, WebSocketUpgrade};
|
||||
use axum::response::IntoResponse;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio_tungstenite::connect_async;
|
||||
use tokio_tungstenite::tungstenite::{Error, Message};
|
||||
use tungstenite::client::IntoClientRequest;
|
||||
use tungstenite::ClientRequestBuilder;
|
||||
|
||||
const VITE_WS: &str = "ws://localhost:3001/vite-server/";
|
||||
const VITE_WS_PROTOCOL: &str = "vite-hmr";
|
||||
|
||||
/// This is the entry point to proxy the vite's WebSocket.
|
||||
/// The proxy is needed for allowing all the development requests to point
|
||||
/// to localhost:3000/*. This enabled the framework to be developed in a Docker
|
||||
/// environment with just the 3000 port exposed.
|
||||
pub async fn vite_websocket_proxy(ws: WebSocketUpgrade) -> impl IntoResponse {
|
||||
ws.protocols([VITE_WS_PROTOCOL]).on_upgrade(handle_socket)
|
||||
}
|
||||
|
||||
async fn handle_socket(mut tuono_socket: WebSocket) {
|
||||
// Send back a message to confirm connection
|
||||
if tuono_socket
|
||||
.send(ws::Message::Ping("tuono connected".into()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
// If is error close the connection
|
||||
return;
|
||||
}
|
||||
|
||||
let vite_ws_request = ClientRequestBuilder::new(VITE_WS.parse().unwrap())
|
||||
.with_sub_protocol(VITE_WS_PROTOCOL)
|
||||
.into_client_request()
|
||||
.expect("Failed to create vite WS request");
|
||||
|
||||
let vite_socket = match connect_async(vite_ws_request).await {
|
||||
Ok((stream, _)) => {
|
||||
// Connected to vite's WebSocket
|
||||
stream
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to connect to vite's WebSocket. Error: {e}");
|
||||
// As fallback vite automatically connect to port 3001.
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let (mut vite_sender, mut vite_receiver) = vite_socket.split();
|
||||
let (mut tuono_sender, mut tuono_receiver) = tuono_socket.split();
|
||||
|
||||
// Handle browser messages.
|
||||
// Every message gets forwarded to the vite WebSocket
|
||||
tokio::spawn(async move {
|
||||
while let Some(msg) = tuono_receiver.next().await {
|
||||
if let Ok(msg) = msg {
|
||||
let msg_to_vite = match msg.clone() {
|
||||
ws::Message::Text(str) => Message::Text(str),
|
||||
ws::Message::Pong(payload) => Message::Pong(payload),
|
||||
ws::Message::Ping(payload) => Message::Ping(payload),
|
||||
ws::Message::Binary(payload) => Message::Binary(payload),
|
||||
// Hard to match axum and tungstenite close payload.
|
||||
// Not a priority
|
||||
ws::Message::Close(_) => Message::Close(None),
|
||||
};
|
||||
|
||||
vite_sender
|
||||
.send(msg_to_vite)
|
||||
.await
|
||||
.expect("Failed to tunnel msg to vite's WebSocket");
|
||||
|
||||
msg
|
||||
} else {
|
||||
// Close browser's WebSocket connection.
|
||||
return;
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Handle vite messages.
|
||||
// Every message gets forwarded to the browser.
|
||||
tokio::spawn(async move {
|
||||
while let Some(Ok(msg)) = vite_receiver.next().await {
|
||||
let msg_to_browser = match msg {
|
||||
Message::Text(str) => ws::Message::Text(str),
|
||||
Message::Ping(payload) => ws::Message::Ping(payload),
|
||||
Message::Pong(payload) => ws::Message::Pong(payload),
|
||||
Message::Binary(payload) => ws::Message::Binary(payload),
|
||||
// Hard to match axum and tungstenite close payload.
|
||||
// Not a priority
|
||||
Message::Close(_) => ws::Message::Close(None),
|
||||
_ => {
|
||||
eprintln!("Unexpected message from the vite WebSocket to the browser: {msg:?}");
|
||||
ws::Message::Text("Unhandled".to_string())
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = tuono_sender.send(msg_to_browser).await {
|
||||
if err.to_string() != Error::AlreadyClosed.to_string() {
|
||||
eprintln!("Failed to send back message from vite to browser: {err}")
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
[package]
|
||||
name = "tuono_lib_macros"
|
||||
version = "0.2.2"
|
||||
version = "0.6.0"
|
||||
edition = "2021"
|
||||
description = "The react/rust fullstack framework"
|
||||
keywords = [ "react", "typescript", "fullstack", "web", "ssr"]
|
||||
repository = "https://github.com/Valerioageno/tuono"
|
||||
readme = "../../README.md"
|
||||
license-file = "../../LICENSE.md"
|
||||
categories = ["web-programming", "reactjs", "typescript", "framework", "fullstack"]
|
||||
categories = ["web-programming"]
|
||||
include = [
|
||||
"src/*.rs",
|
||||
"Cargo.toml"
|
||||
|
||||
@@ -8,35 +8,34 @@ pub fn handler_core(_args: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let fn_name = item.clone().sig.ident;
|
||||
|
||||
quote! {
|
||||
use axum::response::IntoResponse;
|
||||
use tuono_lib::axum::response::IntoResponse;
|
||||
use std::collections::HashMap;
|
||||
use axum::extract::{State, Path};
|
||||
use reqwest::Client;
|
||||
use tuono_lib::axum::extract::{State, Path};
|
||||
|
||||
#item
|
||||
|
||||
pub async fn route(
|
||||
Path(params): Path<HashMap<String, String>>,
|
||||
State(client): State<Client>,
|
||||
request: axum::extract::Request
|
||||
State(client): State<tuono_lib::reqwest::Client>,
|
||||
request: tuono_lib::axum::extract::Request
|
||||
) -> impl IntoResponse {
|
||||
let pathname = &request.uri();
|
||||
let headers = &request.headers();
|
||||
let pathname = request.uri();
|
||||
let headers = request.headers();
|
||||
|
||||
let req = tuono_lib::Request::new(pathname, headers, params);
|
||||
let req = tuono_lib::Request::new(pathname.to_owned(), headers.to_owned(), params);
|
||||
|
||||
#fn_name(req.clone(), client).await.render_to_string(req)
|
||||
}
|
||||
|
||||
pub async fn api(
|
||||
Path(params): Path<HashMap<String, String>>,
|
||||
State(client): State<Client>,
|
||||
request: axum::extract::Request
|
||||
State(client): State<tuono_lib::reqwest::Client>,
|
||||
request: tuono_lib::axum::extract::Request
|
||||
) -> impl IntoResponse{
|
||||
let pathname = &request.uri();
|
||||
let headers = &request.headers();
|
||||
let pathname = request.uri();
|
||||
let headers = request.headers();
|
||||
|
||||
let req = tuono_lib::Request::new(pathname, headers, params);
|
||||
let req = tuono_lib::Request::new(pathname.to_owned(), headers.to_owned(), params);
|
||||
|
||||
#fn_name(req.clone(), client).await.json()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Documentation
|
||||
|
||||
This folder contains the documentation related to the `tuono` framework.
|
||||
|
||||
The framework expects to enable SSG and MDX support in the next months.
|
||||
At that time a standalone website will be built with tuono to hold the whole documentation.
|
||||
|
||||
The following docs are currently actively maintained:
|
||||
- [tutorial](https://github.com/Valerioageno/tuono/blob/main/docs/tutorial.md)
|
||||
|
||||
## Contributions
|
||||
Any help or suggestion will be strongly appreciated.
|
||||
+132
-9
@@ -23,6 +23,8 @@ Typescript and Rust knowledge is not a requirement though!
|
||||
* [Create a stand-alone component](#create-a-stand-alone-component)
|
||||
* [Create the /pokemons/[pokemon] route](#create-the-pokemonspokemon-route)
|
||||
* [Error handling](#error-handling)
|
||||
* [Seo and meta tags](#seo-and-meta-tags)
|
||||
* [Handle redirections](#handle-redirections)
|
||||
* [Building for production](#building-for-production)
|
||||
* [Conclusion](#conclusion)
|
||||
|
||||
@@ -53,7 +55,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.
|
||||
@@ -98,7 +100,7 @@ The file `index.rs` represents the server side capabilities for the index route.
|
||||
|
||||
- Passing server side props
|
||||
- Changing http status code
|
||||
- Redirect/Rewrite to a different route (Available soon)
|
||||
- Redirecting to a different route
|
||||
|
||||
## Tutorial introduction
|
||||
|
||||
@@ -118,6 +120,7 @@ Clear the `index.rs` file and paste:
|
||||
```rust
|
||||
// src/routes/index.rs
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tuono_lib::reqwest::Client;
|
||||
use tuono_lib::{Props, Request, Response};
|
||||
|
||||
const ALL_POKEMON: &str = "https://pokeapi.co/api/v2/pokemon?limit=151";
|
||||
@@ -134,7 +137,7 @@ struct Pokemon {
|
||||
}
|
||||
|
||||
#[tuono_lib::handler]
|
||||
async fn get_all_pokemons(_req: Request<'_>, fetch: reqwest::Client) -> Response {
|
||||
async fn get_all_pokemons(_req: Request, fetch: Client) -> Response {
|
||||
return match fetch.get(ALL_POKEMON).send().await {
|
||||
Ok(res) => {
|
||||
let data = res.json::<Pokemons>().await.unwrap();
|
||||
@@ -283,7 +286,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:
|
||||
|
||||
@@ -327,6 +330,7 @@ Let’s first work on the server side file. Paste into the new `[pokemon].rs` fi
|
||||
```rust
|
||||
// src/routes/pokemons/[pokemon].rs
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tuono_lib::reqwest::Client;
|
||||
use tuono_lib::{Props, Request, Response};
|
||||
|
||||
const POKEMON_API: &str = "https://pokeapi.co/api/v2/pokemon";
|
||||
@@ -340,7 +344,7 @@ struct Pokemon {
|
||||
}
|
||||
|
||||
#[tuono_lib::handler]
|
||||
async fn get_pokemon(req: Request<'_>, fetch: reqwest::Client) -> Response {
|
||||
async fn get_pokemon(req: Request, fetch: Client) -> Response {
|
||||
// The param `pokemon` is defined in the route filename [pokemon].rs
|
||||
let pokemon = req.params.get("pokemon").unwrap();
|
||||
|
||||
@@ -467,8 +471,9 @@ Let's see how it works!
|
||||
|
||||
```diff
|
||||
// src/routes/pokemons/[pokemon].rs
|
||||
++ use reqwest::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
-- use tuono_lib::reqwest::Client;
|
||||
++ use tuono_lib::reqwest::{Client, StatusCode};
|
||||
use tuono_lib::{Props, Request, Response};
|
||||
|
||||
const POKEMON_API: &str = "https://pokeapi.co/api/v2/pokemon";
|
||||
@@ -482,7 +487,7 @@ struct Pokemon {
|
||||
}
|
||||
|
||||
#[tuono_lib::handler]
|
||||
async fn get_pokemon(req: Request<'_>, fetch: reqwest::Client) -> Response {
|
||||
async fn get_pokemon(req: Request, fetch: Client) -> Response {
|
||||
// The param `pokemon` is defined in the route filename [pokemon].rs
|
||||
let pokemon = req.params.get("pokemon").unwrap();
|
||||
|
||||
@@ -506,8 +511,9 @@ async fn get_pokemon(req: Request<'_>, fetch: reqwest::Client) -> Response {
|
||||
|
||||
```diff
|
||||
// src/routes/index.rs
|
||||
++ use reqwest::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
-- use tuono_lib::reqwest::Client;
|
||||
++ use tuono_lib::reqwest::{Client, StatusCode};
|
||||
use tuono_lib::{Props, Request, Response};
|
||||
|
||||
const ALL_POKEMON: &str = "https://pokeapi.co/api/v2/pokemon?limit=151";
|
||||
@@ -524,7 +530,7 @@ struct Pokemon {
|
||||
}
|
||||
|
||||
#[tuono_lib::handler]
|
||||
async fn get_all_pokemons(_req: Request<'_>, fetch: reqwest::Client) -> Response {
|
||||
async fn get_all_pokemons(_req: Request, fetch: Client) -> Response {
|
||||
return match fetch.get(ALL_POKEMON).send().await {
|
||||
Ok(res) => {
|
||||
let data = res.json::<Pokemons>().await.unwrap();
|
||||
@@ -542,6 +548,123 @@ 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.
|
||||
|
||||
## Seo and meta tags
|
||||
|
||||
The website now works and the http errors are meaningful but we should also take care to be meaningful
|
||||
for the web crawlers. The best way to do it is to enrich the meta tags like the `<title>` and the
|
||||
`<description>`.
|
||||
|
||||
To do so `tuono` exposes also the `<Head />` component useful exactly for handling this scenario. Let's update the `/` and the
|
||||
`/pokemons/[pokemon]` routes with this.
|
||||
|
||||
```diff
|
||||
// src/routes/index.tsx
|
||||
import type { TuonoProps } from "tuono";
|
||||
++ import { Head } from "tuono"
|
||||
|
||||
interface Pokemon {
|
||||
name: string
|
||||
}
|
||||
|
||||
interface IndexProps {
|
||||
results: Pokemon[]
|
||||
}
|
||||
|
||||
export default function IndexPage({
|
||||
data,
|
||||
}: TuonoProps<IndexProps>): JSX.Element {
|
||||
if (!data?.results) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
++ <Head>
|
||||
++ <title>Tuono tutorial</title>
|
||||
++ </Head>
|
||||
<header className="header">
|
||||
<a href="https://crates.io/crates/tuono" target="_blank">
|
||||
Crates
|
||||
</a>
|
||||
<a href="https://www.npmjs.com/package/tuono" target="_blank">
|
||||
Npm
|
||||
</a>
|
||||
</header>
|
||||
<div className="title-wrap">
|
||||
<h1 className="title">
|
||||
TU<span>O</span>NO
|
||||
</h1>
|
||||
<div className="logo">
|
||||
<img src="rust.svg" className="rust" />
|
||||
<img src="react.svg" className="react" />
|
||||
</div>
|
||||
</div>
|
||||
<ul style={{ flexWrap: "wrap", display: "flex", gap: 10 }}>
|
||||
{data.results.map((pokemon) => {
|
||||
return pokemon.name;
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```diff
|
||||
// src/routes/pokemons/[pokemon].tsx
|
||||
-- import { TuonoProps } from "tuono";
|
||||
++ import { TuonoProps, Head } from "tuono";
|
||||
import PokemonView from "../../components/PokemonView";
|
||||
|
||||
export default function Pokemon({ data }: TuonoProps): JSX.Element {
|
||||
-- return <PokemonView pokemon={data} />;
|
||||
++ return (
|
||||
++ <>
|
||||
++ <Head>
|
||||
++ <title>Pokemon: ${data?.name}</title>
|
||||
++ </Head>
|
||||
++ <PokemonView pokemon={data} />
|
||||
++ </>
|
||||
++ )
|
||||
}
|
||||
```
|
||||
|
||||
The `Head` component takes as children any valid HTML meta tag.
|
||||
|
||||
## Handle redirections
|
||||
|
||||
What if there is a pokemon among all of them that should be considered the GOAT? What
|
||||
we are going to do right now is creating a new route `/pokemons/GOAT` that points to the best
|
||||
pokemon of the first generation.
|
||||
|
||||
First let's create a new route by just creating an new file `/pokemons/GOAT.rs` and pasting the following code:
|
||||
|
||||
```rs
|
||||
// src/routes/pokemons/GOAT.rs
|
||||
use tuono_lib::{reqwest::Client, Request, Response};
|
||||
|
||||
#[tuono_lib::handler]
|
||||
async fn redirect_to_goat(_: Request, _: Client) -> Response {
|
||||
// Of course the GOAT is mewtwo - feel free to select your favourite 😉
|
||||
Response::Redirect("/pokemons/mewtwo".to_string())
|
||||
}
|
||||
```
|
||||
|
||||
Now let's create the button in the home page to actually point to it!
|
||||
|
||||
```diff
|
||||
// src/routes/index.tsx
|
||||
|
||||
<ul style={{ flexWrap: 'wrap', display: 'flex', gap: 10 }}>
|
||||
++ <PokemonLink pokemon={{ name: 'GOAT' }} id={0} />
|
||||
{data.results.map((pokemon, i) => {
|
||||
return <PokemonLink pokemon={pokemon} id={i + 1} key={i} />
|
||||
})}
|
||||
</ul>
|
||||
```
|
||||
|
||||
Now at [http://localhost:3000/](http:/localhost:3000/) you will find a new link at the beginning of the list.
|
||||
Click on it and see the application automatically redirecting you to your favourite pokemon's route!
|
||||
|
||||
## Building for production
|
||||
|
||||
The source now is ready to be released. Both server and client have been managed in a unoptimized way
|
||||
|
||||
@@ -8,11 +8,6 @@ name = "tuono"
|
||||
path = ".tuono/main.rs"
|
||||
|
||||
[dependencies]
|
||||
axum = {version = "0.7.5", features = ["json"]}
|
||||
tokio = { version = "1.37.0", features = ["full"] }
|
||||
tower-http = {version = "0.5.2", features = ["fs"]}
|
||||
serde = { version = "1.0.202", features = ["derive"] }
|
||||
tuono_lib = { path = "../../crates/tuono_lib/"}
|
||||
serde_json = "1.0"
|
||||
reqwest = {version = "0.12.4", features = ["json"]}
|
||||
serde = { version = "1.0.202", features = ["derive"] }
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use serde::Serialize;
|
||||
use tuono_lib::reqwest;
|
||||
use tuono_lib::{Props, Request, Response};
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -7,7 +8,7 @@ struct MyResponse<'a> {
|
||||
}
|
||||
|
||||
#[tuono_lib::handler]
|
||||
async fn get_server_side_props(_req: Request<'_>, _fetch: reqwest::Client) -> Response {
|
||||
async fn get_server_side_props(_req: Request, _fetch: reqwest::Client) -> Response {
|
||||
Response::Props(Props::new(MyResponse {
|
||||
subtitle: "The react / rust fullstack framework",
|
||||
}))
|
||||
|
||||
@@ -8,11 +8,6 @@ name = "tuono"
|
||||
path = ".tuono/main.rs"
|
||||
|
||||
[dependencies]
|
||||
axum = {version = "0.7.5", features = ["json"]}
|
||||
tokio = { version = "1.37.0", features = ["full"] }
|
||||
tower-http = {version = "0.5.2", features = ["fs"]}
|
||||
serde = { version = "1.0.202", features = ["derive"] }
|
||||
tuono_lib = { path = "../../crates/tuono_lib/"}
|
||||
serde_json = "1.0"
|
||||
reqwest = {version = "0.12.4", features = ["json"]}
|
||||
serde = { version = "1.0.202", features = ["derive"] }
|
||||
|
||||
|
||||
@@ -13,7 +13,11 @@ export default function PokemonLink({
|
||||
id: number
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<Link className={styles.link} href={`/pokemons/${pokemon.name}`}>
|
||||
<Link
|
||||
className={styles.link}
|
||||
href={`/pokemons/${pokemon.name}`}
|
||||
id={pokemon.name}
|
||||
>
|
||||
{pokemon.name}
|
||||
<img
|
||||
src={`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${id}.png`}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/routes/index.rs
|
||||
use reqwest::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tuono_lib::reqwest::{Client, StatusCode};
|
||||
use tuono_lib::{Props, Request, Response};
|
||||
|
||||
const ALL_POKEMON: &str = "https://pokeapi.co/api/v2/pokemon?limit=151";
|
||||
@@ -17,8 +17,8 @@ struct Pokemon {
|
||||
}
|
||||
|
||||
#[tuono_lib::handler]
|
||||
async fn get_all_pokemons(_req: Request<'_>, fetch: reqwest::Client) -> Response {
|
||||
return match fetch.get(ALL_POKEMON).send().await {
|
||||
async fn get_all_pokemons(_req: Request, fetch: Client) -> Response {
|
||||
match fetch.get(ALL_POKEMON).send().await {
|
||||
Ok(res) => {
|
||||
let data = res.json::<Pokemons>().await.unwrap();
|
||||
Response::Props(Props::new(data))
|
||||
@@ -27,5 +27,5 @@ async fn get_all_pokemons(_req: Request<'_>, fetch: reqwest::Client) -> Response
|
||||
"{}", // Return empty JSON
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/routes/index.tsx
|
||||
import type { TuonoProps } from 'tuono'
|
||||
import { Head, type TuonoProps } from 'tuono'
|
||||
import PokemonLink from '../components/PokemonLink'
|
||||
|
||||
interface Pokemon {
|
||||
@@ -19,6 +19,9 @@ export default function IndexPage({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Tuono tutorial</title>
|
||||
</Head>
|
||||
<header className="header">
|
||||
<a href="https://crates.io/crates/tuono" target="_blank">
|
||||
Crates
|
||||
@@ -37,6 +40,7 @@ export default function IndexPage({
|
||||
</div>
|
||||
</div>
|
||||
<ul style={{ flexWrap: 'wrap', display: 'flex', gap: 10 }}>
|
||||
<PokemonLink pokemon={{ name: 'GOAT' }} id={0} />
|
||||
{data.results.map((pokemon, i) => {
|
||||
return <PokemonLink pokemon={pokemon} id={i + 1} key={i} />
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// src/routes/pokemons/GOAT.rs
|
||||
use tuono_lib::{reqwest::Client, Request, Response};
|
||||
|
||||
#[tuono_lib::handler]
|
||||
async fn redirect_to_goat(_: Request, _: Client) -> Response {
|
||||
Response::Redirect("/pokemons/mewtwo".to_string())
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/routes/pokemons/[pokemon].rs
|
||||
use reqwest::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tuono_lib::reqwest::{Client, StatusCode};
|
||||
use tuono_lib::{Props, Request, Response};
|
||||
|
||||
const POKEMON_API: &str = "https://pokeapi.co/api/v2/pokemon";
|
||||
@@ -14,11 +14,11 @@ struct Pokemon {
|
||||
}
|
||||
|
||||
#[tuono_lib::handler]
|
||||
async fn get_pokemon(req: Request<'_>, fetch: reqwest::Client) -> Response {
|
||||
async fn get_pokemon(req: Request, fetch: 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 {
|
||||
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));
|
||||
@@ -30,5 +30,5 @@ async fn get_pokemon(req: Request<'_>, fetch: reqwest::Client) -> Response {
|
||||
"{}",
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TuonoProps } from 'tuono'
|
||||
import { Head, type TuonoProps } from 'tuono'
|
||||
import PokemonView from '../../components/PokemonView'
|
||||
|
||||
interface Pokemon {
|
||||
@@ -11,5 +11,12 @@ interface Pokemon {
|
||||
export default function PokemonPage({
|
||||
data,
|
||||
}: TuonoProps<Pokemon>): JSX.Element {
|
||||
return <PokemonView pokemon={data} />
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Pokemon: {data?.name}</title>
|
||||
</Head>
|
||||
<PokemonView pokemon={data} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+9
-8
@@ -3,17 +3,18 @@
|
||||
"packageManager": "pnpm@9.1.1",
|
||||
"scripts": {
|
||||
"dev": "turbo dev --filter tuono",
|
||||
"build": "turbo build",
|
||||
"lint": "turbo lint",
|
||||
"format": "turbo format",
|
||||
"format:check": "turbo format:check",
|
||||
"types": "turbo types",
|
||||
"test": "turbo test",
|
||||
"test:watch": "turbo test:watch"
|
||||
"build": "turbo build --filter tuono",
|
||||
"lint": "turbo lint --filter tuono",
|
||||
"format": "turbo format --filter tuono",
|
||||
"format:check": "turbo format:check --filter tuono",
|
||||
"types": "turbo types --filter tuono",
|
||||
"test": "turbo test --filter tuono",
|
||||
"test:watch": "turbo test:watch --filter tuono"
|
||||
},
|
||||
"workspaces": [
|
||||
"tuono",
|
||||
"tuono-lazy-fn-vite-plugin"
|
||||
"tuono-lazy-fn-vite-plugin",
|
||||
"tuono-fs-router-vite-plugin"
|
||||
],
|
||||
"author": "Valerio Ageno",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# tuono-fs-router-vite-plugin
|
||||
|
||||
This is a vite plugin for [tuono](https://github.com/Valerioageno/tuono).
|
||||
|
||||
This package specifically handles the file system based routing.
|
||||
|
||||
Check [tuono](https://github.com/Valerioageno/tuono) for more.
|
||||
|
||||
## Credits
|
||||
|
||||
This plugin is strongly inspired by the [@tanstack/router](https://tanstack.com/router/latest)
|
||||
route generator plugin.
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "tuono-fs-router-vite-plugin",
|
||||
"version": "0.6.0",
|
||||
"description": "Plugin for the tuono's file system router. Tuono is the react/rust fullstack framework",
|
||||
"scripts": {
|
||||
"dev": "vite build --watch",
|
||||
"build": "vite build",
|
||||
"lint": "eslint --ext .ts,.tsx ./src -c ../../.eslintrc",
|
||||
"format": "prettier -u --write --ignore-unknown '**/*'",
|
||||
"format:check": "prettier --check --ignore-unknown '**/*'",
|
||||
"types": "tsc --noEmit",
|
||||
"test:watch": "vitest",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Valerio Ageno",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"types": "dist/esm/index.d.ts",
|
||||
"main": "dist/cjs/index.cjs",
|
||||
"module": "dist/esm/index.js",
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/esm/index.d.ts",
|
||||
"default": "./dist/esm/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/cjs/index.d.cts",
|
||||
"default": "./dist/cjs/index.cjs"
|
||||
}
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.24.4",
|
||||
"@babel/types": "^7.24.0",
|
||||
"prettier": "^3.2.4",
|
||||
"vite": "^5.2.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/config": "^0.7.11",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"vitest": "^1.5.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const ROUTES_FOLDER = './src/routes/'
|
||||
export const ROOT_PATH_ID = '__root'
|
||||
export const GENERATED_ROUTE_TREE = './.tuono/routeTree.gen.ts'
|
||||
+2
-12
@@ -1,4 +1,3 @@
|
||||
import * as fs from 'fs'
|
||||
import * as fsp from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
@@ -15,14 +14,11 @@ import {
|
||||
removeLayoutSegments,
|
||||
} from './utils'
|
||||
|
||||
import type { Config, RouteNode, RouteSubNode } from './types'
|
||||
import type { Config, RouteNode } from './types'
|
||||
import { ROUTES_FOLDER, ROOT_PATH_ID, GENERATED_ROUTE_TREE } from './constants'
|
||||
|
||||
import { format } from 'prettier'
|
||||
|
||||
const ROUTES_FOLDER = './src/routes/'
|
||||
const ROOT_PATH_ID = '__root'
|
||||
const GENERATED_ROUTE_TREE = './.tuono/routeTree.gen.ts'
|
||||
|
||||
let latestTask = 0
|
||||
|
||||
const defaultConfig: Config = {
|
||||
@@ -168,8 +164,6 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
|
||||
(d): string => d.routePath,
|
||||
]).filter((d) => ![`/${ROOT_PATH_ID}`].includes(d.routePath || ''))
|
||||
|
||||
const routePiecesByPath: Record<string, RouteSubNode> = {}
|
||||
|
||||
// Loop over the flat list of routeNodes and
|
||||
// build up a tree based on the routeNodes' routePath
|
||||
const routeNodes: RouteNode[] = []
|
||||
@@ -202,10 +196,6 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
if (node.isLayout && !node.children?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const route = `${node.variableName}Route`
|
||||
|
||||
if (node.children?.length) {
|
||||
@@ -0,0 +1,47 @@
|
||||
import { routeGenerator } from './generator'
|
||||
|
||||
import { normalize } from 'path'
|
||||
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
const ROUTES_DIRECTORY_PATH = './src/routes'
|
||||
|
||||
let lock = false
|
||||
|
||||
export default function RouterGenerator(): Plugin {
|
||||
const generate = async (): Promise<void> => {
|
||||
if (lock) return
|
||||
lock = true
|
||||
|
||||
try {
|
||||
await routeGenerator()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
lock = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleFile = async (file: string): Promise<void> => {
|
||||
const filePath = normalize(file)
|
||||
|
||||
if (filePath.startsWith(ROUTES_DIRECTORY_PATH)) {
|
||||
await generate()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'vite-plugin-tuono-fs-router',
|
||||
configResolved: async (): Promise<void> => {
|
||||
await generate()
|
||||
},
|
||||
watchChange: async (
|
||||
file: string,
|
||||
context: { event: string },
|
||||
): Promise<void> => {
|
||||
if (['create', 'update', 'delete'].includes(context.event)) {
|
||||
await handleFile(file)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
-11
@@ -11,17 +11,6 @@ export interface RouteNode {
|
||||
variableName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export interface RouteSubNode {
|
||||
component?: RouteNode
|
||||
errorComponent?: RouteNode
|
||||
pendingComponent?: RouteNode
|
||||
loader?: RouteNode
|
||||
lazy?: RouteNode
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
folderName: string
|
||||
generatedRouteTree: string
|
||||
@@ -0,0 +1,41 @@
|
||||
import fs from 'fs/promises'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { routeGenerator } from '../src/generator'
|
||||
|
||||
function makeFolderDir(folder: string) {
|
||||
return process.cwd() + `/tests/generator/${folder}`
|
||||
}
|
||||
|
||||
async function getRouteTreeFileText(folder: string) {
|
||||
const dir = makeFolderDir(folder)
|
||||
return await fs.readFile(dir + '/routeTree.gen.ts', 'utf-8')
|
||||
}
|
||||
|
||||
async function getExpectedRouteTreeFileText(folder: string) {
|
||||
const dir = makeFolderDir(folder)
|
||||
const location = dir + '/routeTree.expected.ts'
|
||||
return await fs.readFile(location, 'utf-8')
|
||||
}
|
||||
|
||||
describe('generator works', async () => {
|
||||
const folderNames = await fs.readdir(process.cwd() + '/tests/generator')
|
||||
|
||||
it.each(folderNames.map((folder) => [folder]))(
|
||||
'should wire-up the routes for a "%s" tree',
|
||||
async (folderName) => {
|
||||
const currentFolder = `${process.cwd()}/tests/generator/${folderName}`
|
||||
|
||||
await routeGenerator({
|
||||
folderName: `${currentFolder}/routes`,
|
||||
generatedRouteTree: `${currentFolder}/routeTree.gen.ts`,
|
||||
})
|
||||
|
||||
const [expectedRouteTree, generatedRouteTree] = await Promise.all([
|
||||
getExpectedRouteTreeFileText(folderName),
|
||||
getRouteTreeFileText(folderName),
|
||||
])
|
||||
|
||||
expect(generatedRouteTree).equal(expectedRouteTree)
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
// This file is auto-generated by Tuono
|
||||
|
||||
import { createRoute, dynamic } from 'tuono'
|
||||
|
||||
import RootImport from './routes/__root'
|
||||
|
||||
const AboutImport = dynamic(() => import('./routes/about'))
|
||||
const IndexImport = dynamic(() => import('./routes/index'))
|
||||
const PostsMyPostImport = dynamic(() => import('./routes/posts/my-post'))
|
||||
|
||||
const rootRoute = createRoute({ isRoot: true, component: RootImport })
|
||||
|
||||
const About = createRoute({ component: AboutImport })
|
||||
const Index = createRoute({ component: IndexImport })
|
||||
const PostsMyPost = createRoute({ component: PostsMyPostImport })
|
||||
|
||||
// Create/Update Routes
|
||||
|
||||
const AboutRoute = About.update({
|
||||
path: '/about',
|
||||
getParentRoute: () => rootRoute,
|
||||
})
|
||||
|
||||
const IndexRoute = Index.update({
|
||||
path: '/',
|
||||
getParentRoute: () => rootRoute,
|
||||
})
|
||||
|
||||
const PostsMyPostRoute = PostsMyPost.update({
|
||||
path: '/posts/my-post',
|
||||
getParentRoute: () => rootRoute,
|
||||
})
|
||||
|
||||
// Create and export the route tree
|
||||
|
||||
export const routeTree = rootRoute.addChildren([
|
||||
IndexRoute,
|
||||
AboutRoute,
|
||||
PostsMyPostRoute,
|
||||
])
|
||||
@@ -0,0 +1 @@
|
||||
/** */
|
||||
@@ -0,0 +1 @@
|
||||
/** */
|
||||
@@ -0,0 +1 @@
|
||||
/** */
|
||||
@@ -0,0 +1 @@
|
||||
/** */
|
||||
@@ -0,0 +1,29 @@
|
||||
// This file is auto-generated by Tuono
|
||||
|
||||
import { createRoute, dynamic } from 'tuono'
|
||||
|
||||
import RootImport from './routes/__root'
|
||||
|
||||
const AboutImport = dynamic(() => import('./routes/about'))
|
||||
const IndexImport = dynamic(() => import('./routes/index'))
|
||||
|
||||
const rootRoute = createRoute({ isRoot: true, component: RootImport })
|
||||
|
||||
const About = createRoute({ component: AboutImport })
|
||||
const Index = createRoute({ component: IndexImport })
|
||||
|
||||
// Create/Update Routes
|
||||
|
||||
const AboutRoute = About.update({
|
||||
path: '/about',
|
||||
getParentRoute: () => rootRoute,
|
||||
})
|
||||
|
||||
const IndexRoute = Index.update({
|
||||
path: '/',
|
||||
getParentRoute: () => rootRoute,
|
||||
})
|
||||
|
||||
// Create and export the route tree
|
||||
|
||||
export const routeTree = rootRoute.addChildren([IndexRoute, AboutRoute])
|
||||
@@ -0,0 +1 @@
|
||||
/** */
|
||||
@@ -0,0 +1 @@
|
||||
/** */
|
||||
@@ -0,0 +1 @@
|
||||
/** */
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig, mergeConfig } from 'vitest/config'
|
||||
import { tanstackBuildConfig } from '@tanstack/config/build'
|
||||
|
||||
const config = defineConfig({})
|
||||
|
||||
export default mergeConfig(
|
||||
config,
|
||||
tanstackBuildConfig({
|
||||
entry: './src/index.ts',
|
||||
srcDir: './src',
|
||||
}),
|
||||
)
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tuono-lazy-fn-vite-plugin",
|
||||
"version": "0.2.2",
|
||||
"version": "0.6.0",
|
||||
"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.2.2",
|
||||
"version": "0.6.0",
|
||||
"description": "The react/rust fullstack framework",
|
||||
"scripts": {
|
||||
"dev": "vite build --watch",
|
||||
@@ -88,7 +88,8 @@
|
||||
"@types/node": "^20.12.7",
|
||||
"@vitejs/plugin-react-swc": "^3.7.0",
|
||||
"fast-text-encoding": "^1.0.6",
|
||||
"prettier": "^3.2.4",
|
||||
"react-meta-tags": "^1.0.1",
|
||||
"tuono-fs-router-vite-plugin": "workspace:*",
|
||||
"tuono-lazy-fn-vite-plugin": "workspace:*",
|
||||
"vite": "^5.2.11",
|
||||
"zustand": "4.4.7"
|
||||
@@ -101,6 +102,7 @@
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"jsdom": "^24.0.0",
|
||||
"prettier": "^3.2.4",
|
||||
"vitest": "^1.5.2"
|
||||
},
|
||||
"sideEffects": false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { build, createServer, InlineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import { ViteFsRouter } from './tuono-vite-plugin'
|
||||
import ViteFsRouter from 'tuono-fs-router-vite-plugin'
|
||||
import { LazyLoadingPlugin } from 'tuono-lazy-fn-vite-plugin'
|
||||
|
||||
const BASE_CONFIG: InlineConfig = {
|
||||
@@ -12,6 +12,8 @@ const BASE_CONFIG: InlineConfig = {
|
||||
plugins: [react(), ViteFsRouter(), LazyLoadingPlugin()],
|
||||
}
|
||||
|
||||
const VITE_PORT = 3001
|
||||
|
||||
export function developmentSSRBundle() {
|
||||
;(async () => {
|
||||
await build({
|
||||
@@ -43,8 +45,10 @@ export function developmentCSRWatch() {
|
||||
;(async () => {
|
||||
const server = await createServer({
|
||||
...BASE_CONFIG,
|
||||
// Entry point for the development vite proxy
|
||||
base: '/vite-server/',
|
||||
server: {
|
||||
port: 3001,
|
||||
port: VITE_PORT,
|
||||
strictPort: true,
|
||||
},
|
||||
build: {
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* Overview:
|
||||
*
|
||||
* src/routes = Routes entry point
|
||||
* src/routes/layout.tsx = Shared layout
|
||||
* src/routes/index.tsx = xyz.com
|
||||
* src/routes/about.tsx = xyz.com/about
|
||||
* src/routes/about-loading.tsx = xyz.com/about - while loading data
|
||||
* src/routes/about/index.tsx = xyz.com/about
|
||||
* src/routes/posts/[slug].tsx = xyz.com/posts/my-lovely-post
|
||||
* src/routes/posts/[...params].tsx = xyz.com/posts/my-lovely-post/commend-id-304
|
||||
* src/routes/404.tsx = Not found
|
||||
*
|
||||
* public/ = Public files
|
||||
*
|
||||
* All the routes are lazy loaded!
|
||||
*/
|
||||
|
||||
import { routeGenerator } from './generator'
|
||||
|
||||
export { routeGenerator }
|
||||
@@ -1,282 +0,0 @@
|
||||
import * as babel from '@babel/core'
|
||||
import * as t from '@babel/types'
|
||||
import { eliminateUnreferencedIdentifiers } from './eliminateUnreferencedIdentifiers'
|
||||
import { SPLIT_PREFIX } from './constants'
|
||||
import type { BabelFileResult } from '@babel/core'
|
||||
|
||||
type SplitModulesById = Record<
|
||||
string,
|
||||
{ id: string; node: t.FunctionExpression }
|
||||
>
|
||||
|
||||
interface State {
|
||||
filename: string
|
||||
opts: {
|
||||
minify: boolean
|
||||
root: string
|
||||
}
|
||||
imported: Record<string, boolean>
|
||||
refs: Set<any>
|
||||
serverIndex: number
|
||||
splitIndex: number
|
||||
splitModulesById: SplitModulesById
|
||||
}
|
||||
|
||||
interface MakeCompileFnArgs {
|
||||
root: string
|
||||
}
|
||||
|
||||
interface MakeCompileFnReturnArgs {
|
||||
code: string
|
||||
filename: string
|
||||
getBabelConfig: () => { plugins: any[] }
|
||||
}
|
||||
|
||||
interface CompileOutput {
|
||||
code: string
|
||||
map: BabelFileResult['map']
|
||||
}
|
||||
|
||||
type SplitNodeType = (typeof splitNodeTypes)[number]
|
||||
|
||||
export const makeCompile = ({
|
||||
root,
|
||||
}: MakeCompileFnArgs): ((args: MakeCompileFnReturnArgs) => CompileOutput) => {
|
||||
return ({ code, getBabelConfig, filename }) => {
|
||||
const res = babel.transformSync(code, {
|
||||
plugins: [
|
||||
['@babel/plugin-syntax-jsx', {}],
|
||||
['@babel/plugin-syntax-typescript', { isTSX: true }],
|
||||
...getBabelConfig().plugins,
|
||||
],
|
||||
root,
|
||||
filename,
|
||||
sourceMaps: true,
|
||||
})
|
||||
|
||||
if (res?.code) {
|
||||
return {
|
||||
code: res.code,
|
||||
map: res.map,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
code,
|
||||
map: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface SplitFileFnArgs {
|
||||
code: string
|
||||
compile: (args: MakeCompileFnReturnArgs) => CompileOutput
|
||||
filename: string
|
||||
}
|
||||
|
||||
// NOTE: We don't need the loader
|
||||
const splitNodeTypes = ['component', 'loader'] as const
|
||||
|
||||
// Reusable function to get literal value or resolve variable to literal
|
||||
function resolveIdentifier(path: any, node: any) {
|
||||
if (t.isIdentifier(node)) {
|
||||
const binding = path.scope.getBinding(node.name)
|
||||
if (binding) {
|
||||
const declarator = binding.path.node
|
||||
if (t.isObjectExpression(declarator.init)) {
|
||||
return declarator.init
|
||||
} else if (t.isFunctionDeclaration(declarator.init)) {
|
||||
return declarator.init
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
export const splitFile = async ({
|
||||
code,
|
||||
compile,
|
||||
filename,
|
||||
}: SplitFileFnArgs): Promise<CompileOutput> => {
|
||||
return compile({
|
||||
code,
|
||||
filename,
|
||||
getBabelConfig: () => ({
|
||||
plugins: [
|
||||
[
|
||||
{
|
||||
visitor: {
|
||||
Program: {
|
||||
enter(
|
||||
programPath: babel.NodePath<t.Program>,
|
||||
state: State,
|
||||
): void {
|
||||
const splitNodesByType: Record<
|
||||
SplitNodeType,
|
||||
t.Node | undefined
|
||||
> = {
|
||||
component: undefined,
|
||||
loader: undefined,
|
||||
}
|
||||
|
||||
// Find the node
|
||||
// NOTE: I'd use a more NextJS approach.
|
||||
// All the "default exported" fns are the page.
|
||||
programPath.traverse(
|
||||
{
|
||||
CallExpression: (path) => {
|
||||
if (path.node.callee.type === 'Identifier') {
|
||||
// TODO: Update this to support just "default exported" fns
|
||||
if (path.node.callee.name === 'createFileRoute') {
|
||||
if (
|
||||
path.parentPath.node.type === 'CallExpression'
|
||||
) {
|
||||
const options = resolveIdentifier(
|
||||
path,
|
||||
path.parentPath.node.arguments[0],
|
||||
)
|
||||
|
||||
if (t.isObjectExpression(options)) {
|
||||
options.properties.forEach((prop) => {
|
||||
if (t.isObjectProperty(prop)) {
|
||||
splitNodeTypes.forEach((type) => {
|
||||
if (t.isIdentifier(prop.key)) {
|
||||
if (prop.key.name === 'type') {
|
||||
splitNodesByType[type] = prop.value
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Remove all of the options
|
||||
options.properties = []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
state,
|
||||
)
|
||||
|
||||
// TODO: We don't need to iterate since we don't support the loader
|
||||
splitNodeTypes.forEach((splitType) => {
|
||||
let splitNode = splitNodesByType[splitType]
|
||||
|
||||
if (!splitNode) {
|
||||
return
|
||||
}
|
||||
|
||||
while (t.isIdentifier(splitNode)) {
|
||||
const binding = programPath.scope.getBinding(
|
||||
splitNode.name,
|
||||
)
|
||||
splitNode = binding?.path.node
|
||||
}
|
||||
|
||||
// Add the node to the program
|
||||
if (splitNode) {
|
||||
if (t.isFunctionDeclaration(splitNode)) {
|
||||
programPath.pushContainer(
|
||||
'body',
|
||||
t.variableDeclaration('const', [
|
||||
t.variableDeclarator(
|
||||
t.identifier(splitType),
|
||||
t.functionExpression(
|
||||
splitNode.id || null,
|
||||
splitNode.params,
|
||||
splitNode.body,
|
||||
splitNode.generator,
|
||||
splitNode.async,
|
||||
),
|
||||
),
|
||||
]),
|
||||
)
|
||||
} else if (
|
||||
t.isFunctionExpression(splitNode) ||
|
||||
t.isArrowFunctionExpression(splitNode)
|
||||
) {
|
||||
programPath.pushContainer(
|
||||
'body',
|
||||
t.variableDeclaration('const', [
|
||||
t.variableDeclarator(
|
||||
t.identifier(splitType),
|
||||
splitType as any,
|
||||
),
|
||||
]),
|
||||
)
|
||||
} else if (t.isImportSpecifier(splitNode)) {
|
||||
programPath.pushContainer(
|
||||
'body',
|
||||
t.variableDeclaration('const', [
|
||||
t.variableDeclarator(
|
||||
t.identifier(splitType),
|
||||
splitNode.local,
|
||||
),
|
||||
]),
|
||||
)
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unexpected splitNode type ${splitNode.type}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// If the splitNode exists at the top of the program
|
||||
// then we need to remove that copy
|
||||
programPath.node.body = programPath.node.body.filter(
|
||||
(node) => {
|
||||
return node !== splitNode
|
||||
},
|
||||
)
|
||||
|
||||
// Export the node
|
||||
programPath.pushContainer('body', [
|
||||
t.exportNamedDeclaration(null, [
|
||||
t.exportSpecifier(
|
||||
t.identifier(splitType),
|
||||
t.identifier(splitType),
|
||||
),
|
||||
]),
|
||||
])
|
||||
})
|
||||
|
||||
// Convert exports to imports from the original file
|
||||
programPath.traverse({
|
||||
ExportNamedDeclaration(path) {
|
||||
// e.g. export const x = 1 or export { x }
|
||||
// becomes
|
||||
// import { x } from '${opts.id}'
|
||||
if (path.node.declaration) {
|
||||
if (t.isVariableDeclaration(path.node.declaration)) {
|
||||
path.replaceWith(
|
||||
t.importDeclaration(
|
||||
path.node.declaration.declarations.map((decl) =>
|
||||
t.importSpecifier(
|
||||
t.identifier((decl.id as any).name),
|
||||
t.identifier((decl.id as any).name),
|
||||
),
|
||||
),
|
||||
t.stringLiteral(
|
||||
filename.split(`?${SPLIT_PREFIX}`)[0] as string,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
eliminateUnreferencedIdentifiers(programPath)
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export const SPLIT_PREFIX = 'sp-'
|
||||
@@ -1,298 +0,0 @@
|
||||
// Copied from https://github.com/pcattori/vite-env-only/blob/main/src/dce.ts
|
||||
// Adapted with some minor changes for the purpose of this project
|
||||
|
||||
import * as t from '@babel/types'
|
||||
import type { types as BabelTypes } from '@babel/core'
|
||||
import type { NodePath } from '@babel/traverse'
|
||||
|
||||
type IdentifierPath = NodePath<BabelTypes.Identifier>
|
||||
|
||||
// export function findReferencedIdentifiers(
|
||||
// programPath: NodePath<BabelTypes.Program>,
|
||||
// ): Set<IdentifierPath> {
|
||||
// const refs = new Set<IdentifierPath>()
|
||||
|
||||
// function markFunction(
|
||||
// path: NodePath<
|
||||
// | BabelTypes.FunctionDeclaration
|
||||
// | BabelTypes.FunctionExpression
|
||||
// | BabelTypes.ArrowFunctionExpression
|
||||
// >,
|
||||
// ) {
|
||||
// const ident = getIdentifier(path)
|
||||
// if (ident?.node && isIdentifierReferenced(ident)) {
|
||||
// refs.add(ident)
|
||||
// }
|
||||
// }
|
||||
|
||||
// function markImport(
|
||||
// path: NodePath<
|
||||
// | BabelTypes.ImportSpecifier
|
||||
// | BabelTypes.ImportDefaultSpecifier
|
||||
// | BabelTypes.ImportNamespaceSpecifier
|
||||
// >,
|
||||
// ) {
|
||||
// const local = path.get('local')
|
||||
// if (isIdentifierReferenced(local)) {
|
||||
// refs.add(local)
|
||||
// }
|
||||
// }
|
||||
|
||||
// programPath.traverse({
|
||||
// VariableDeclarator(path) {
|
||||
// if (path.node.id.type === 'Identifier') {
|
||||
// const local = path.get('id') as NodePath<BabelTypes.Identifier>
|
||||
// if (isIdentifierReferenced(local)) {
|
||||
// refs.add(local)
|
||||
// }
|
||||
// } else if (path.node.id.type === 'ObjectPattern') {
|
||||
// const pattern = path.get('id') as NodePath<BabelTypes.ObjectPattern>
|
||||
|
||||
// const properties = pattern.get('properties')
|
||||
// properties.forEach((p) => {
|
||||
// const local = p.get(
|
||||
// p.node.type === 'ObjectProperty'
|
||||
// ? 'value'
|
||||
// : p.node.type === 'RestElement'
|
||||
// ? 'argument'
|
||||
// : (function () {
|
||||
// throw new Error('invariant')
|
||||
// })(),
|
||||
// ) as NodePath<BabelTypes.Identifier>
|
||||
// if (isIdentifierReferenced(local)) {
|
||||
// refs.add(local)
|
||||
// }
|
||||
// })
|
||||
// } else if (path.node.id.type === 'ArrayPattern') {
|
||||
// const pattern = path.get('id') as NodePath<BabelTypes.ArrayPattern>
|
||||
|
||||
// const elements = pattern.get('elements')
|
||||
// elements.forEach((e) => {
|
||||
// let local: NodePath<BabelTypes.Identifier>
|
||||
// if (e.node?.type === 'Identifier') {
|
||||
// local = e as NodePath<BabelTypes.Identifier>
|
||||
// } else if (e.node?.type === 'RestElement') {
|
||||
// local = e.get('argument') as NodePath<BabelTypes.Identifier>
|
||||
// } else {
|
||||
// return
|
||||
// }
|
||||
|
||||
// if (isIdentifierReferenced(local)) {
|
||||
// refs.add(local)
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
// },
|
||||
|
||||
// FunctionDeclaration: markFunction,
|
||||
// FunctionExpression: markFunction,
|
||||
// ArrowFunctionExpression: markFunction,
|
||||
// ImportSpecifier: markImport,
|
||||
// ImportDefaultSpecifier: markImport,
|
||||
// ImportNamespaceSpecifier: markImport,
|
||||
// })
|
||||
// return refs
|
||||
// }
|
||||
|
||||
/**
|
||||
* @param refs - If provided, only these identifiers will be considered for removal.
|
||||
*/
|
||||
export const eliminateUnreferencedIdentifiers = (
|
||||
programPath: NodePath<BabelTypes.Program>,
|
||||
refs?: Set<IdentifierPath>,
|
||||
): void => {
|
||||
let referencesRemovedInThisPass: number
|
||||
|
||||
const shouldBeRemoved = (ident: IdentifierPath): boolean => {
|
||||
if (isIdentifierReferenced(ident)) return false
|
||||
if (!refs) return true
|
||||
return refs.has(ident)
|
||||
}
|
||||
|
||||
const sweepFunction = (
|
||||
path: NodePath<
|
||||
| BabelTypes.FunctionDeclaration
|
||||
| BabelTypes.FunctionExpression
|
||||
| BabelTypes.ArrowFunctionExpression
|
||||
>,
|
||||
): void => {
|
||||
const identifier = getIdentifier(path)
|
||||
if (identifier?.node && shouldBeRemoved(identifier)) {
|
||||
++referencesRemovedInThisPass
|
||||
|
||||
if (
|
||||
t.isAssignmentExpression(path.parentPath.node) ||
|
||||
t.isVariableDeclarator(path.parentPath.node)
|
||||
) {
|
||||
path.parentPath.remove()
|
||||
} else {
|
||||
path.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sweepImport = (
|
||||
path: NodePath<
|
||||
| BabelTypes.ImportSpecifier
|
||||
| BabelTypes.ImportDefaultSpecifier
|
||||
| BabelTypes.ImportNamespaceSpecifier
|
||||
>,
|
||||
): void => {
|
||||
const local = path.get('local')
|
||||
if (shouldBeRemoved(local)) {
|
||||
++referencesRemovedInThisPass
|
||||
path.remove()
|
||||
if (
|
||||
(path.parent as BabelTypes.ImportDeclaration).specifiers.length === 0
|
||||
) {
|
||||
path.parentPath.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Traverse again to remove unused references. This happens at least once,
|
||||
// then repeats until no more references are removed.
|
||||
do {
|
||||
referencesRemovedInThisPass = 0
|
||||
|
||||
programPath.scope.crawl()
|
||||
|
||||
programPath.traverse({
|
||||
VariableDeclarator(path: any) {
|
||||
if (path.node.id.type === 'Identifier') {
|
||||
const local = path.get('id') as NodePath<BabelTypes.Identifier>
|
||||
if (shouldBeRemoved(local)) {
|
||||
++referencesRemovedInThisPass
|
||||
path.remove()
|
||||
}
|
||||
} else if (path.node.id.type === 'ObjectPattern') {
|
||||
const pattern = path.get('id') as NodePath<BabelTypes.ObjectPattern>
|
||||
|
||||
const beforeCount = referencesRemovedInThisPass
|
||||
const properties = pattern.get('properties')
|
||||
properties.forEach((property) => {
|
||||
const local = property.get(
|
||||
property.node.type === 'ObjectProperty'
|
||||
? 'value'
|
||||
: // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
property.node.type === 'RestElement'
|
||||
? 'argument'
|
||||
: (function () {
|
||||
throw new Error('invariant')
|
||||
})(),
|
||||
) as NodePath<BabelTypes.Identifier>
|
||||
|
||||
if (shouldBeRemoved(local)) {
|
||||
++referencesRemovedInThisPass
|
||||
property.remove()
|
||||
}
|
||||
})
|
||||
|
||||
if (
|
||||
beforeCount !== referencesRemovedInThisPass &&
|
||||
pattern.get('properties').length < 1
|
||||
) {
|
||||
path.remove()
|
||||
}
|
||||
} else if (path.node.id.type === 'ArrayPattern') {
|
||||
const pattern = path.get('id') as NodePath<BabelTypes.ArrayPattern>
|
||||
|
||||
let hasRemoved = false as boolean
|
||||
|
||||
pattern.get('elements').forEach((element, index) => {
|
||||
// if (!element) return // Skip holes in the pattern
|
||||
|
||||
let identifierPath: NodePath<BabelTypes.Identifier>
|
||||
|
||||
if (t.isIdentifier(element.node)) {
|
||||
identifierPath = element as NodePath<BabelTypes.Identifier>
|
||||
} else if (t.isRestElement(element.node)) {
|
||||
identifierPath = element.get(
|
||||
'argument',
|
||||
) as NodePath<BabelTypes.Identifier>
|
||||
} else {
|
||||
// For now, ignore other types like AssignmentPattern
|
||||
return
|
||||
}
|
||||
|
||||
if (shouldBeRemoved(identifierPath)) {
|
||||
hasRemoved = true
|
||||
pattern.node.elements[index] = null // Remove the element by setting it to null
|
||||
}
|
||||
})
|
||||
|
||||
// If any elements were removed and no elements are left, remove the entire declaration
|
||||
if (
|
||||
hasRemoved &&
|
||||
pattern.node.elements.every((element) => element === null)
|
||||
) {
|
||||
path.remove()
|
||||
++referencesRemovedInThisPass
|
||||
}
|
||||
}
|
||||
},
|
||||
FunctionDeclaration: sweepFunction,
|
||||
FunctionExpression: sweepFunction,
|
||||
ArrowFunctionExpression: sweepFunction,
|
||||
ImportSpecifier: sweepImport,
|
||||
ImportDefaultSpecifier: sweepImport,
|
||||
ImportNamespaceSpecifier: sweepImport,
|
||||
})
|
||||
} while (referencesRemovedInThisPass)
|
||||
}
|
||||
|
||||
function getIdentifier(
|
||||
path: NodePath<
|
||||
| BabelTypes.FunctionDeclaration
|
||||
| BabelTypes.FunctionExpression
|
||||
| BabelTypes.ArrowFunctionExpression
|
||||
>,
|
||||
): NodePath<BabelTypes.Identifier> | null {
|
||||
const parentPath = path.parentPath
|
||||
if (parentPath.type === 'VariableDeclarator') {
|
||||
const variablePath = parentPath as NodePath<BabelTypes.VariableDeclarator>
|
||||
const name = variablePath.get('id')
|
||||
return name.node.type === 'Identifier'
|
||||
? (name as NodePath<BabelTypes.Identifier>)
|
||||
: null
|
||||
}
|
||||
|
||||
if (parentPath.type === 'AssignmentExpression') {
|
||||
const variablePath = parentPath as NodePath<BabelTypes.AssignmentExpression>
|
||||
const name = variablePath.get('left')
|
||||
return name.node.type === 'Identifier'
|
||||
? (name as NodePath<BabelTypes.Identifier>)
|
||||
: null
|
||||
}
|
||||
|
||||
if (path.node.type === 'ArrowFunctionExpression') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (path.node.type === 'FunctionExpression') {
|
||||
return null
|
||||
}
|
||||
|
||||
return path.node.id && path.node.id.type === 'Identifier'
|
||||
? (path.get('id') as NodePath<BabelTypes.Identifier>)
|
||||
: null
|
||||
}
|
||||
|
||||
function isIdentifierReferenced(
|
||||
ident: NodePath<BabelTypes.Identifier>,
|
||||
): boolean {
|
||||
const binding = ident.scope.getBinding(ident.node.name)
|
||||
if (binding?.referenced) {
|
||||
// Functions can reference themselves, so we need to check if there's a
|
||||
// binding outside the function scope or not.
|
||||
if (binding.path.type === 'FunctionDeclaration') {
|
||||
return !binding.constantViolations
|
||||
.concat(binding.referencePaths)
|
||||
// Check that every reference is contained within the function:
|
||||
.every((ref) => ref.findParent((parent) => parent === binding.path))
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { fileURLToPath, pathToFileURL } from 'url'
|
||||
import { routeGenerator } from '../routes-generator'
|
||||
|
||||
import { normalize } from 'path'
|
||||
// eslint-disable-next-line sort-imports
|
||||
import { makeCompile, splitFile } from './compiler'
|
||||
import { SPLIT_PREFIX } from './constants'
|
||||
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
const ROUTES_DIRECTORY_PATH = './src/routes'
|
||||
|
||||
let lock = false
|
||||
|
||||
export function RouterGenerator(): Plugin {
|
||||
const generate = async (): Promise<void> => {
|
||||
if (lock) return
|
||||
lock = true
|
||||
|
||||
try {
|
||||
// TODO: generator function
|
||||
await routeGenerator()
|
||||
} catch (err) {
|
||||
} finally {
|
||||
lock = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleFile = async (file: string): Promise<void> => {
|
||||
const filePath = normalize(file)
|
||||
|
||||
if (filePath.startsWith(ROUTES_DIRECTORY_PATH)) {
|
||||
// TODO: generator function
|
||||
await generate()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'vite-plugin-tuono-fs-router-generator',
|
||||
configResolved: async (): Promise<void> => {
|
||||
await generate()
|
||||
},
|
||||
watchChange: async (
|
||||
file: string,
|
||||
context: { event: string },
|
||||
): Promise<void> => {
|
||||
if (['create', 'update', 'delete'].includes(context.event)) {
|
||||
await handleFile(file)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function RouterCodeSplitter(): Plugin {
|
||||
const ROOT: string = process.cwd()
|
||||
|
||||
return {
|
||||
name: 'vite-plugin-tuono-fs-router-code-splitter',
|
||||
enforce: 'pre',
|
||||
resolveId(source): string | null {
|
||||
if (source.startsWith(SPLIT_PREFIX + ':')) {
|
||||
return source.replace(SPLIT_PREFIX + ':', '')
|
||||
}
|
||||
return null
|
||||
},
|
||||
async transform(code, id): Promise<any> {
|
||||
const url = pathToFileURL(id)
|
||||
url.searchParams.delete('v')
|
||||
id = fileURLToPath(url).replace(/\\/g, '/')
|
||||
|
||||
const compile = makeCompile({ root: ROOT })
|
||||
|
||||
if (id.includes(SPLIT_PREFIX)) {
|
||||
const compiled = await splitFile({
|
||||
code,
|
||||
compile,
|
||||
filename: id,
|
||||
})
|
||||
|
||||
return compiled
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function ViteFsRouter(): Plugin[] {
|
||||
return [RouterGenerator(), RouterCodeSplitter()]
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import Head from 'react-meta-tags'
|
||||
import {
|
||||
createRoute,
|
||||
createRootRoute,
|
||||
@@ -5,6 +6,7 @@ import {
|
||||
Link,
|
||||
RouterProvider,
|
||||
dynamic,
|
||||
useRouter,
|
||||
} from './router'
|
||||
|
||||
export type { TuonoProps } from './types'
|
||||
@@ -16,4 +18,6 @@ export {
|
||||
Link,
|
||||
RouterProvider,
|
||||
dynamic,
|
||||
useRouter,
|
||||
Head,
|
||||
}
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
declare module 'react-meta-tags'
|
||||
declare module 'react-meta-tags/server'
|
||||
@@ -1,25 +1,18 @@
|
||||
import * as React from 'react'
|
||||
import { useRouterStore } from '../hooks/useRouterStore'
|
||||
import { useRouter } from '../hooks/useRouter'
|
||||
import type { AnchorHTMLAttributes, MouseEvent } from 'react'
|
||||
|
||||
export default function Link(
|
||||
props: AnchorHTMLAttributes<HTMLAnchorElement>,
|
||||
): JSX.Element {
|
||||
const router = useRouter()
|
||||
|
||||
const handleTransition = (e: MouseEvent<HTMLAnchorElement>): void => {
|
||||
e.preventDefault()
|
||||
props.onClick?.(e)
|
||||
useRouterStore.setState({
|
||||
// TODO: Refine store update
|
||||
location: {
|
||||
href: props.href || '',
|
||||
pathname: props.href || '',
|
||||
search: undefined,
|
||||
searchStr: '',
|
||||
hash: '',
|
||||
},
|
||||
})
|
||||
history.pushState(props.href, '', props.href)
|
||||
router.push(props.href || '')
|
||||
}
|
||||
|
||||
return (
|
||||
<a {...props} onClick={handleTransition}>
|
||||
{props.children}
|
||||
|
||||
@@ -8,8 +8,8 @@ describe('Test getRouteByPathname fn', () => {
|
||||
})
|
||||
|
||||
test('match routes by ids', () => {
|
||||
vi.mock('../hooks/useRouter.tsx', () => ({
|
||||
useRouter: (): { routesById: Record<string, any> } => {
|
||||
vi.mock('../hooks/useInternalRouter.tsx', () => ({
|
||||
useInternalRouter: (): { routesById: Record<string, any> } => {
|
||||
return {
|
||||
routesById: {
|
||||
'/': { id: '/' },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react'
|
||||
import { useRouter } from '../hooks/useRouter'
|
||||
import { useInternalRouter } from '../hooks/useInternalRouter'
|
||||
import { useRouterStore } from '../hooks/useRouterStore'
|
||||
import type { Route } from '../route'
|
||||
import { RouteMatch } from './RouteMatch'
|
||||
@@ -21,7 +21,7 @@ const DYNAMIC_PATH_REGEX = /\[(.*?)\]/
|
||||
* Optimizations should occour on both
|
||||
*/
|
||||
export function getRouteByPathname(pathname: string): Route | undefined {
|
||||
const { routesById } = useRouter()
|
||||
const { routesById } = useInternalRouter()
|
||||
|
||||
if (routesById[pathname]) return routesById[pathname]
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import * as React from 'react'
|
||||
import { useRouter } from '../hooks/useRouter'
|
||||
import { RouteMatch } from './RouteMatch'
|
||||
import Link from './Link'
|
||||
import { useInternalRouter } from '../hooks/useInternalRouter'
|
||||
|
||||
export default function NotFound(): JSX.Element {
|
||||
const router = useRouter()
|
||||
const router = useInternalRouter()
|
||||
|
||||
const custom404Route = router.routesById['/404']
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { getRouterContext } from './RouterContext'
|
||||
import { Matches } from './Matches'
|
||||
import { useRouterStore } from '../hooks/useRouterStore'
|
||||
import React, { useEffect, useLayoutEffect, type ReactNode } from 'react'
|
||||
import { useListenBrowserUrlUpdates } from '../hooks/useListenBrowserUrlUpdates'
|
||||
import React, { type ReactNode } from 'react'
|
||||
import { initRouterStore } from '../hooks/useRouterStore'
|
||||
import type { ServerProps } from '../types'
|
||||
|
||||
type Router = any
|
||||
|
||||
@@ -15,11 +17,6 @@ interface RouterProviderProps {
|
||||
serverProps?: ServerProps
|
||||
}
|
||||
|
||||
interface ServerProps {
|
||||
router: Location
|
||||
props: any
|
||||
}
|
||||
|
||||
function RouterContextProvider({
|
||||
router,
|
||||
children,
|
||||
@@ -47,58 +44,13 @@ function RouterContextProvider({
|
||||
)
|
||||
}
|
||||
|
||||
const initRouterStore = (props?: ServerProps): void => {
|
||||
const updateLocation = useRouterStore((st) => st.updateLocation)
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
updateLocation({
|
||||
pathname: props?.router.pathname || '',
|
||||
hash: '',
|
||||
href: '',
|
||||
searchStr: '',
|
||||
})
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const { pathname, hash, href, search } = window.location
|
||||
updateLocation({
|
||||
pathname,
|
||||
hash,
|
||||
href,
|
||||
searchStr: search,
|
||||
search: new URLSearchParams(search),
|
||||
})
|
||||
}, [])
|
||||
}
|
||||
|
||||
const useListenUrlUpdates = (): void => {
|
||||
const updateLocation = useRouterStore((st) => st.updateLocation)
|
||||
|
||||
const updateLocationOnPopStateChange = ({ target }: any): void => {
|
||||
const { pathname, hash, href, search } = target.location
|
||||
updateLocation({
|
||||
pathname,
|
||||
hash,
|
||||
href,
|
||||
searchStr: search,
|
||||
search: new URLSearchParams(search),
|
||||
})
|
||||
}
|
||||
useEffect(() => {
|
||||
window.addEventListener('popstate', updateLocationOnPopStateChange)
|
||||
return (): void => {
|
||||
window.removeEventListener('popstate', updateLocationOnPopStateChange)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
||||
export function RouterProvider({
|
||||
router,
|
||||
serverProps,
|
||||
}: RouterProviderProps): JSX.Element {
|
||||
initRouterStore(serverProps)
|
||||
|
||||
useListenUrlUpdates()
|
||||
useListenBrowserUrlUpdates()
|
||||
|
||||
return (
|
||||
<RouterContextProvider router={router}>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import * as React from 'react'
|
||||
import { getRouterContext } from '../components/RouterContext'
|
||||
import type { Router } from '../router'
|
||||
|
||||
export function useInternalRouter(): Router {
|
||||
return React.useContext(getRouterContext())
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useRouterStore } from './useRouterStore'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* This hook is meant to handle just browser related location updates
|
||||
* like the back and forward buttons.
|
||||
*/
|
||||
export const useListenBrowserUrlUpdates = (): void => {
|
||||
const updateLocation = useRouterStore((st) => st.updateLocation)
|
||||
|
||||
const updateLocationOnPopStateChange = ({ target }: any): void => {
|
||||
const { pathname, hash, href, search } = target.location
|
||||
updateLocation({
|
||||
pathname,
|
||||
hash,
|
||||
href,
|
||||
searchStr: search,
|
||||
search: Object.fromEntries(new URLSearchParams(search)),
|
||||
})
|
||||
}
|
||||
useEffect(() => {
|
||||
window.addEventListener('popstate', updateLocationOnPopStateChange)
|
||||
return (): void => {
|
||||
window.removeEventListener('popstate', updateLocationOnPopStateChange)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
@@ -1,7 +1,44 @@
|
||||
import * as React from 'react'
|
||||
import { getRouterContext } from '../components/RouterContext'
|
||||
import type { Router } from '../router'
|
||||
import { useRouterStore } from './useRouterStore'
|
||||
|
||||
export function useRouter(): Router {
|
||||
return React.useContext(getRouterContext())
|
||||
interface UseRouterHook {
|
||||
/**
|
||||
* Redirects to the path passed as argument updating the browser history.
|
||||
*/
|
||||
push: (path: string) => void
|
||||
|
||||
/**
|
||||
* This object contains all the query params of the current route
|
||||
*/
|
||||
query: Record<string, any>
|
||||
|
||||
/**
|
||||
* Returns the current pathname
|
||||
*/
|
||||
pathname: string
|
||||
}
|
||||
|
||||
export const useRouter = (): UseRouterHook => {
|
||||
const [location, updateLocation] = useRouterStore((st) => [
|
||||
st.location,
|
||||
st.updateLocation,
|
||||
])
|
||||
|
||||
const push = (path: string): void => {
|
||||
const url = new URL(path, window.location.origin)
|
||||
|
||||
updateLocation({
|
||||
href: url.href,
|
||||
pathname: url.pathname,
|
||||
search: Object.fromEntries(url.searchParams),
|
||||
searchStr: url.search,
|
||||
hash: url.hash,
|
||||
})
|
||||
history.pushState(path, '', path)
|
||||
}
|
||||
|
||||
return {
|
||||
push,
|
||||
query: location.search,
|
||||
pathname: location.pathname,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { create } from 'zustand'
|
||||
import { useLayoutEffect } from 'react'
|
||||
|
||||
import type { ServerProps } from '../types'
|
||||
|
||||
export interface ParsedLocation {
|
||||
href: string
|
||||
pathname: string
|
||||
search?: URLSearchParams
|
||||
search: Record<string, string>
|
||||
searchStr: string
|
||||
hash: string
|
||||
}
|
||||
@@ -20,6 +23,35 @@ interface RouterState {
|
||||
updateLocation: (loc: ParsedLocation) => void
|
||||
}
|
||||
|
||||
export const initRouterStore = (props?: ServerProps): void => {
|
||||
const updateLocation = useRouterStore((st) => st.updateLocation)
|
||||
|
||||
// Init the store in the server in order to correctly
|
||||
// SSR the components that depend on the router.
|
||||
if (typeof window === 'undefined') {
|
||||
updateLocation({
|
||||
pathname: props?.router.pathname || '',
|
||||
hash: '',
|
||||
href: props?.router.href || '',
|
||||
searchStr: props?.router.searchStr || '',
|
||||
search: {},
|
||||
})
|
||||
}
|
||||
|
||||
// Update the store on the client side before the first
|
||||
// rendering
|
||||
useLayoutEffect(() => {
|
||||
const { pathname, hash, href, search } = window.location
|
||||
updateLocation({
|
||||
pathname,
|
||||
hash,
|
||||
href,
|
||||
searchStr: search,
|
||||
search: Object.fromEntries(new URLSearchParams(search)),
|
||||
})
|
||||
}, [])
|
||||
}
|
||||
|
||||
export const useRouterStore = create<RouterState>()((set) => ({
|
||||
isLoading: false,
|
||||
isTransitioning: false,
|
||||
@@ -27,7 +59,7 @@ export const useRouterStore = create<RouterState>()((set) => ({
|
||||
location: {
|
||||
href: '',
|
||||
pathname: '',
|
||||
search: undefined,
|
||||
search: {},
|
||||
searchStr: '',
|
||||
hash: '',
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import type { Route } from '../route'
|
||||
import { useRouterStore } from './useRouterStore'
|
||||
import { fromUrlToParsedLocation } from '../utils/from-url-to-parsed-location'
|
||||
|
||||
const isServer = typeof document === 'undefined'
|
||||
|
||||
@@ -15,6 +16,20 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
interface TuonoApi {
|
||||
data?: any
|
||||
info: {
|
||||
redirect_destination?: string
|
||||
}
|
||||
}
|
||||
|
||||
const fetchClientSideData = async (): Promise<TuonoApi> => {
|
||||
const slash = location.pathname.endsWith('/') ? '' : '/'
|
||||
const res = await fetch(`/__tuono/data${location.pathname}${slash}data.json`)
|
||||
const data: TuonoApi = await res.json()
|
||||
return data
|
||||
}
|
||||
|
||||
/*
|
||||
* Use the props provided by the SSR and dehydrate the
|
||||
* props for client side usage.
|
||||
@@ -27,7 +42,10 @@ export function useServerSideProps<T>(
|
||||
serverSideProps: T,
|
||||
): UseServerSidePropsReturn {
|
||||
const isFirstRendering = useRef<boolean>(true)
|
||||
const location = useRouterStore((st) => st.location)
|
||||
const [location, updateLocation] = useRouterStore((st) => [
|
||||
st.location,
|
||||
st.updateLocation,
|
||||
])
|
||||
const [isLoading, setIsLoading] = useState<boolean>(
|
||||
// Force loading if has handler
|
||||
route.options.hasHandler &&
|
||||
@@ -53,8 +71,22 @@ export function useServerSideProps<T>(
|
||||
;(async (): Promise<void> => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/__tuono/data${location.pathname}`)
|
||||
setData(await res.json())
|
||||
const response = await fetchClientSideData()
|
||||
if (response.info.redirect_destination) {
|
||||
const parsedLocation = fromUrlToParsedLocation(
|
||||
response.info.redirect_destination,
|
||||
)
|
||||
|
||||
history.pushState(
|
||||
parsedLocation.pathname,
|
||||
'',
|
||||
parsedLocation.pathname,
|
||||
)
|
||||
|
||||
updateLocation(parsedLocation)
|
||||
return
|
||||
}
|
||||
setData(response.data)
|
||||
} catch (error) {
|
||||
throw Error('Failed loading Server Side Data', { cause: error })
|
||||
} finally {
|
||||
|
||||
@@ -3,3 +3,4 @@ export { default as Link } from './components/Link'
|
||||
export { createRouter } from './router'
|
||||
export { createRoute, createRootRoute } from './route'
|
||||
export { dynamic } from './dynamic'
|
||||
export { useRouter } from './hooks/useRouter'
|
||||
|
||||
@@ -2,3 +2,12 @@ export interface Segment {
|
||||
type: 'pathname' | 'param' | 'wildcard'
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface ServerProps {
|
||||
router: {
|
||||
href: string
|
||||
pathname: string
|
||||
searchStr: string
|
||||
}
|
||||
props: any
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { ParsedLocation } from '../hooks/useRouterStore'
|
||||
|
||||
export function fromUrlToParsedLocation(href: string): ParsedLocation {
|
||||
const location = new URL(href, window.location.origin)
|
||||
return {
|
||||
href: location.href,
|
||||
pathname: location.pathname,
|
||||
search: Object.fromEntries(location.searchParams),
|
||||
searchStr: location.search,
|
||||
hash: location.hash,
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,25 @@
|
||||
import 'fast-text-encoding' // Mandatory for React18
|
||||
import * as React from 'react'
|
||||
import { renderToString, renderToStaticMarkup } from 'react-dom/server'
|
||||
import { RouterProvider } from '../router'
|
||||
import { createRouter } from '../router'
|
||||
import MetaTagsServer from 'react-meta-tags/server'
|
||||
import { MetaTagsContext } from 'react-meta-tags'
|
||||
import { RouterProvider, createRouter } from '../router'
|
||||
|
||||
type RouteTree = any
|
||||
type Mode = 'Dev' | 'Prod'
|
||||
|
||||
const TUONO_DEV_SERVER_PORT = 3000
|
||||
const VITE_PROXY_PATH = '/vite-server'
|
||||
|
||||
const VITE_DEV_AND_HMR = `<script type="module">
|
||||
import RefreshRuntime from 'http://localhost:3001/@react-refresh'
|
||||
import RefreshRuntime from 'http://localhost:${TUONO_DEV_SERVER_PORT}${VITE_PROXY_PATH}/@react-refresh'
|
||||
RefreshRuntime.injectIntoGlobalHook(window)
|
||||
window.$RefreshReg$ = () => {}
|
||||
window.$RefreshSig$ = () => (type) => type
|
||||
window.__vite_plugin_react_preamble_installed__ = true
|
||||
</script>
|
||||
<script type="module" src="http://localhost:3001/@vite/client"></script>
|
||||
<script type="module" src="http://localhost:3001/client-main.tsx"></script>`
|
||||
<script type="module" src="http://localhost:${TUONO_DEV_SERVER_PORT}${VITE_PROXY_PATH}/@vite/client"></script>
|
||||
<script type="module" src="http://localhost:${TUONO_DEV_SERVER_PORT}${VITE_PROXY_PATH}/client-main.tsx"></script>`
|
||||
|
||||
function generateCssLinks(cssBundles: string[], mode: Mode): string {
|
||||
if (mode === 'Dev') return ''
|
||||
@@ -40,16 +44,22 @@ export function serverSideRendering(routeTree: RouteTree) {
|
||||
const cssBundles = props.cssBundles as string[]
|
||||
const router = createRouter({ routeTree }) // Render the app
|
||||
|
||||
const metaTagsInstance = MetaTagsServer()
|
||||
|
||||
const app = renderToString(
|
||||
<RouterProvider router={router} serverProps={props} />,
|
||||
<MetaTagsContext extract={metaTagsInstance.extract}>
|
||||
<RouterProvider router={router} serverProps={props} />
|
||||
</MetaTagsContext>,
|
||||
)
|
||||
|
||||
const metaTags = metaTagsInstance.renderToString()
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Playground</title>
|
||||
${metaTags}
|
||||
${generateCssLinks(cssBundles, mode)}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user