Compare commits

...

47 Commits

Author SHA1 Message Date
renovate[bot] 32d7d83d79 fix(deps): update dependency vite to v6.3.5 (#813)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-23 08:34:11 +02:00
renovate[bot] 0a2ee1ab02 chore(deps): update dependency @vitejs/plugin-react-swc to v3.11.0 (#797)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-22 08:48:20 +02:00
renovate[bot] 77c9e1f7c8 chore(deps): update dependency oxc-transform to v0.77.3 (#825)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-21 08:48:14 +02:00
Valerio Ageno 183f1c498d chore: dedupe upgraded dependencies (#824) 2025-07-20 18:18:41 +02:00
renovate[bot] 3e07d57956 chore(deps): update node.js to v22.17.1 (#808)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-20 18:09:16 +02:00
renovate[bot] d5c0dd4365 chore(deps): update dependency happy-dom to v18 (#817) 2025-07-20 08:50:00 +02:00
renovate[bot] 4d187e506f chore(deps): update dependency typescript to v5.8.3 (#796) 2025-07-19 19:49:28 +02:00
renovate[bot] 71d3440d23 chore(deps): update dependency vitest to v3.2.4 (#806)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-19 18:42:27 +02:00
renovate[bot] 4856af992b fix(deps): update rust crate tungstenite to 0.27.0 (#816)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-19 18:42:16 +02:00
renovate[bot] 6ccc91c4a8 fix(deps): update rust crate tokio-tungstenite to 0.27.0 (#815)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-19 18:35:56 +02:00
renovate[bot] 468bc48a0c chore(deps): update pnpm to v10.13.1 (#809)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-19 18:34:37 +02:00
renovate[bot] 1ef6a9dc90 fix(deps): update dependency prettier to v3.6.2 (#812)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-19 18:33:25 +02:00
renovate[bot] 27c546d8e3 fix(deps): update rust crate colored to v3 (#819)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-19 18:32:56 +02:00
renovate[bot] fbaf411992 fix(deps): update rust crate console to 0.16.0 (#814)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-19 18:31:53 +02:00
renovate[bot] 403d548a07 fix(deps): update babel monorepo (#810)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-19 18:30:56 +02:00
renovate[bot] be48dbda09 fix(deps): update dependency @vitejs/plugin-react-swc to v3.11.0 (#811)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-19 18:30:44 +02:00
renovate[bot] 038d9e4a46 chore(deps): update dependency oxc-transform to v0.77.2 (#802)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-19 18:29:57 +02:00
renovate[bot] a5dd7cb537 chore(deps): update dependency turbo to v2.5.5 (#799)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-19 18:24:30 +02:00
renovate[bot] 8ac2132a2e chore(deps): update react monorepo (#794)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-19 18:21:39 +02:00
renovate[bot] bc49ec3193 chore(deps): update dependency @playwright/test to v1.54.1 (#800)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-19 18:19:42 +02:00
renovate[bot] 022b548af1 chore(deps): update dependency happy-dom to v17.6.3 (#792)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-19 18:08:18 +02:00
Valerio Ageno a1f5811eda fix: lifetime notation (#798) 2025-07-19 17:49:49 +02:00
renovate[bot] 5990cf3d27 chore(deps): update dependency @vitejs/plugin-react-swc to v3.10.1 (#787) 2025-06-03 14:22:54 +02:00
renovate[bot] c0719c6edb chore(deps): update react monorepo (#782) 2025-06-03 07:33:36 +02:00
renovate[bot] 9404946269 chore(deps): update dependency oxc-transform to v0.72.2 (#789) 2025-06-03 07:32:12 +02:00
renovate[bot] eab8d67203 chore(deps): update dependency turbo to v2.5.4 (#790) 2025-06-03 07:32:04 +02:00
renovate[bot] a9a76f8b09 chore(deps): update dependency happy-dom to v17.6.1 (#791) 2025-06-03 07:31:55 +02:00
renovate[bot] f7ba80c5c4 chore(deps): update dependency @vitest/eslint-plugin to v1.2.1 (#774)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-27 08:56:09 +02:00
renovate[bot] a4667c4c2c chore(deps): update dependency @playwright/test to v1.52.0 (#786)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-27 08:55:57 +02:00
renovate[bot] 998a3191b4 chore(deps): update dependency oxc-transform to v0.72.0 (#788)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-27 08:55:45 +02:00
Valerio Ageno 89ad91a055 chore: bump version (v0.19.7) (#784) 2025-05-25 12:09:01 +02:00
Valerio Ageno d810047ddd feat: improve manifest data collection (#785) 2025-05-25 12:08:47 +02:00
renovate[bot] 19c73f7747 chore(deps): update dependency vitest to v3.1.4 (#781)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-25 11:33:52 +02:00
renovate[bot] 2f2d772882 chore(deps): update dependency turbo to v2.5.3 (#776)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-24 18:34:39 +02:00
renovate[bot] 32e300f730 chore(deps): update dependency happy-dom to v17.4.7 (#775)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-24 18:34:14 +02:00
Valerio Ageno 2f31d14194 feat: parse manifest on server start (#783) 2025-05-24 18:34:02 +02:00
Aaron Griffin 980aa0d07f fix(crates/tuono): Fix HTTP method handling for mixed-case input (#780) 2025-05-19 18:56:09 +02:00
Valerio Ageno 07351587f3 chore: bump version (0.19.6) (#771) 2025-05-11 19:11:40 +02:00
Valerio Ageno 3027b828a8 fix: improve critical css generation (#770) 2025-05-11 19:11:30 +02:00
Valerio Ageno eef2cd26e0 chore: bump ssr_rs (0.8.3) (#769) 2025-05-10 20:15:13 +02:00
Valerio Ageno 8190915e3a chore: bump version (0.19.5) (#768) 2025-05-09 17:02:38 +02:00
Valerio Ageno dd157d6ef9 feat: load critical CSS on development (#765) 2025-05-09 17:02:18 +02:00
Valerio Ageno 58f4ecb260 fix: tailwindcss template (#767) 2025-05-08 20:52:42 +02:00
Valerio Ageno ab441d1fa7 chore: dedupe pnpm dependencies (#766) 2025-05-07 20:47:26 +02:00
renovate[bot] 7538031261 chore(deps): update dependency typescript to v5.8.3 (#751)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-06 10:23:37 +02:00
renovate[bot] 18f66ff79b chore(deps): update react monorepo to v19.1.3 (#746)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-06 10:23:24 +02:00
Valerio Ageno b48f7244b7 refactor: turn tuono-fs-router-vite-plugin into tuono-react-vite-plugin (#756) 2025-04-28 20:54:40 +02:00
86 changed files with 2056 additions and 1339 deletions
+1 -1
View File
@@ -1 +1 @@
22.14.0
22.17.1
+5 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "tuono"
version = "0.19.4"
version = "0.19.7"
edition = "2024"
authors = ["V. Ageno <valerioageno@yahoo.it>"]
description = "Superfast React fullstack framework"
@@ -23,7 +23,8 @@ tracing = "0.1.41"
tracing-subscriber = {version = "0.3.19", features = ["env-filter"]}
miette = "7.2.0"
colored = "2.1.0"
colored = "3.0.0"
once_cell = "1.19.0"
watchexec = "5.0.0"
watchexec-signals = "4.0.0"
watchexec-events = "4.0.0"
@@ -36,9 +37,9 @@ reqwest = { version = "0.12.4", features = ["blocking", "json"] }
serde_json = "1.0"
fs_extra = "1.3.0"
http = "1.1.0"
tuono_internal = {path = "../tuono_internal", version = "0.19.4"}
tuono_internal = {path = "../tuono_internal", version = "0.19.7"}
spinners = "4.1.1"
console = "0.15.10"
console = "0.16.0"
convert_case = "0.8.0"
[dev-dependencies]
+2 -4
View File
@@ -164,10 +164,9 @@ impl App {
std::net::TcpListener::bind(format!("{}:{}", config.server.host, vite_port));
if let Err(_e) = vite_listener {
eprintln!("Error: Failed to bind to port {}", vite_port);
eprintln!("Error: Failed to bind to port {vite_port}");
eprintln!(
"Please ensure that port {} is not already in use by another process or application.",
vite_port
"Please ensure that port {vite_port} is not already in use by another process or application."
);
std::process::exit(1);
}
@@ -253,7 +252,6 @@ impl App {
#[cfg(test)]
mod tests {
use super::*;
#[test]
+15 -1
View File
@@ -1,3 +1,6 @@
use once_cell::sync::Lazy;
use regex::Regex;
use std::borrow::Cow;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
@@ -19,6 +22,12 @@ use crate::source_builder::SourceBuilder;
use console::Term;
use spinners::{Spinner, Spinners};
fn is_css_module(file_name: Cow<str>) -> bool {
static RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^.*\.module\.(css|scss|sass|less|styl|stylus)$").unwrap());
RE.is_match(&file_name)
}
fn ssr_reload_needed(path: &Path) -> bool {
let file_name_starts_with_env = path
.file_name()
@@ -27,7 +36,12 @@ fn ssr_reload_needed(path: &Path) -> bool {
let file_path = path.to_string_lossy();
file_name_starts_with_env || file_path.ends_with("sx") || file_path.ends_with("mdx")
file_name_starts_with_env
|| file_path.ends_with("sx")
|| file_path.ends_with("mdx")
// When a CSS module is modified
// also the class names get modified.
|| is_css_module(file_path)
}
#[allow(
+8 -10
View File
@@ -33,7 +33,7 @@ struct GithubTreeResponse<T> {
}
fn exit_with_error(message: &str) -> ! {
eprintln!("{}", message);
eprintln!("{message}");
std::process::exit(1);
}
@@ -125,7 +125,7 @@ pub fn create_new_project(
let folder_path = current_dir.join(folder_name);
create_directories(&new_project_files, &folder_path, &template)
.unwrap_or_else(|err| exit_with_error(&format!("Failed to create directories: {}", err)));
.unwrap_or_else(|err| exit_with_error(&format!("Failed to create directories: {err}")));
for GithubFile {
element_type, path, ..
@@ -231,19 +231,17 @@ fn update_package_json_version(folder_path: &Path) -> io::Result<()> {
let v = crate_version!();
let package_json_path = folder_path.join(PathBuf::from("package.json"));
let package_json = fs::read_to_string(&package_json_path)
.unwrap_or_else(|err| exit_with_error(&format!("Failed to read package.json: {}", err)));
.unwrap_or_else(|err| exit_with_error(&format!("Failed to read package.json: {err}")));
let package_json = package_json.replace("link:../../packages/tuono", v);
let mut file = OpenOptions::new()
.write(true)
.truncate(true)
.open(package_json_path)
.unwrap_or_else(|err| exit_with_error(&format!("Failed to open package.json: {}", err)));
.unwrap_or_else(|err| exit_with_error(&format!("Failed to open package.json: {err}")));
file.write_all(package_json.as_bytes())
.unwrap_or_else(|err| {
exit_with_error(&format!("Failed to write to package.json: {}", err))
});
.unwrap_or_else(|err| exit_with_error(&format!("Failed to write to package.json: {err}")));
Ok(())
}
@@ -252,7 +250,7 @@ fn update_cargo_toml_version(folder_path: &Path) -> io::Result<()> {
let v = crate_version!();
let cargo_toml_path = folder_path.join(PathBuf::from("Cargo.toml"));
let cargo_toml = fs::read_to_string(&cargo_toml_path)
.unwrap_or_else(|err| exit_with_error(&format!("Failed to read Cargo.toml: {}", err)));
.unwrap_or_else(|err| exit_with_error(&format!("Failed to read Cargo.toml: {err}")));
let cargo_toml = cargo_toml.replace(
"{ path = \"../../crates/tuono_lib/\" }",
&format!("\"{v}\""),
@@ -262,10 +260,10 @@ fn update_cargo_toml_version(folder_path: &Path) -> io::Result<()> {
.write(true)
.truncate(true)
.open(cargo_toml_path)
.unwrap_or_else(|err| exit_with_error(&format!("Failed to open Cargo.toml: {}", err)));
.unwrap_or_else(|err| exit_with_error(&format!("Failed to open Cargo.toml: {err}")));
file.write_all(cargo_toml.as_bytes())
.unwrap_or_else(|err| exit_with_error(&format!("Failed to write to Cargo.toml: {}", err)));
.unwrap_or_else(|err| exit_with_error(&format!("Failed to write to Cargo.toml: {err}")));
Ok(())
}
+1 -1
View File
@@ -94,7 +94,7 @@ impl ProcessManager {
let server_address = format!("{}:{}", config.server.host, config.server.port);
// Format the server address as a valid URL so that it becomes clickable in the CLI
// @see https://github.com/tuono-labs/tuono/issues/460
let server_base_url = format!("http://{}", server_address);
let server_base_url = format!("http://{server_address}");
println!();
tuono_println!("⚡ Tuono v{}", crate_version!());
+11 -19
View File
@@ -102,7 +102,7 @@ fn read_http_methods_from_file(path: &String) -> Vec<Method> {
// Extract just the element surrounded by the phrantesist.
.replace("tuono_lib::api(", "")
.replace(")]", "");
Method::from_str(http_method.as_str()).unwrap_or(Method::GET)
Method::from_str(http_method.to_uppercase().as_str()).unwrap_or(Method::GET)
})
.collect::<Vec<Method>>()
}
@@ -169,7 +169,7 @@ impl Route {
trace!("Requesting the page: {}", url);
let mut response = match reqwest.get(format!("http://localhost:3000{path}")).send() {
Ok(response) => response,
Err(_) => return Err(format!("Failed to get the response: {}", url)),
Err(_) => return Err(format!("Failed to get the response: {url}")),
};
let file_path = self.output_file_path();
@@ -177,18 +177,14 @@ impl Route {
let parent_dir = match file_path.parent() {
Some(parent_dir) => parent_dir,
None => {
return Err(format!(
"Failed to get the parent directory {:?}",
file_path
));
return Err(format!("Failed to get the parent directory {file_path:?}"));
}
};
if !parent_dir.is_dir() {
if let Err(err) = create_all(parent_dir, false) {
return Err(format!(
"Failed to create the parent directory {:?}\nError: {err}",
parent_dir
"Failed to create the parent directory {parent_dir:?}\nError: {err}"
));
}
}
@@ -197,11 +193,11 @@ impl Route {
let mut file = match File::create(&file_path) {
Ok(file) => file,
Err(_) => return Err(format!("Failed to create the file: {:?}", file_path)),
Err(_) => return Err(format!("Failed to create the file: {file_path:?}")),
};
if io::copy(&mut response, &mut file).is_err() {
return Err(format!("Failed to write the file: {:?}", file_path));
return Err(format!("Failed to write the file: {file_path:?}"));
}
// Saving also the server response
@@ -214,8 +210,7 @@ impl Route {
Some(parent_dir) => parent_dir,
None => {
return Err(format!(
"Failed to get the parent directory {:?}",
data_file_path
"Failed to get the parent directory {data_file_path:?}"
));
}
};
@@ -223,8 +218,7 @@ impl Route {
if !data_parent_dir.is_dir() {
if let Err(err) = create_all(data_parent_dir, false) {
return Err(format!(
"Failed to create the parent directory {:?}\n Error: {err}",
data_parent_dir
"Failed to create the parent directory {data_parent_dir:?}\n Error: {err}"
));
}
}
@@ -253,8 +247,7 @@ impl Route {
Ok(file) => file,
Err(_) => {
return Err(format!(
"Failed to create the JSON file: {:?}",
data_file_path
"Failed to create the JSON file: {data_file_path:?}"
));
}
};
@@ -274,16 +267,15 @@ impl Route {
.iter()
.any(|extension| self.path.ends_with(extension))
{
return PathBuf::from(format!("out/static{}", cleaned_path));
return PathBuf::from(format!("out/static{cleaned_path}"));
}
PathBuf::from(format!("out/static{}/index.html", cleaned_path))
PathBuf::from(format!("out/static{cleaned_path}/index.html"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
@@ -22,7 +22,7 @@ pub fn parse_enum(element: &syn::ItemEnum) -> (String, String) {
match &variant.fields {
syn::Fields::Named(field) => {
parsed_variant = format!("{{\"{}\": {{ ", parsed_variant);
parsed_variant = format!("{{\"{parsed_variant}\": {{ ");
let mut variant_fields: Vec<String> = Vec::new();
for field in &field.named {
@@ -79,7 +79,7 @@ mod tests {
}
"#;
let parsed_enum = syn::parse_str::<syn::ItemEnum>(&enum_str).unwrap();
let parsed_enum = syn::parse_str::<syn::ItemEnum>(enum_str).unwrap();
let (enum_name, typescript_definition) = parse_enum(&parsed_enum);
assert_eq!(enum_name, "MyEnum");
@@ -101,7 +101,7 @@ mod tests {
}
"#;
let parsed_enum = syn::parse_str::<syn::ItemEnum>(&enum_str).unwrap();
let parsed_enum = syn::parse_str::<syn::ItemEnum>(enum_str).unwrap();
let (enum_name, typescript_definition) = parse_enum(&parsed_enum);
assert_eq!(enum_name, "MyEnum");
@@ -125,7 +125,7 @@ mod tests {
}
"#;
let parsed_enum = syn::parse_str::<syn::ItemEnum>(&enum_str).unwrap();
let parsed_enum = syn::parse_str::<syn::ItemEnum>(enum_str).unwrap();
let (_, typescript_definition) = parse_enum(&parsed_enum);
assert_eq!(
@@ -147,7 +147,7 @@ mod tests {
}
"#;
let parsed_enum = syn::parse_str::<syn::ItemEnum>(&enum_str).unwrap();
let parsed_enum = syn::parse_str::<syn::ItemEnum>(enum_str).unwrap();
let (_, typescript_definition) = parse_enum(&parsed_enum);
assert_eq!(
@@ -167,7 +167,7 @@ mod tests {
}
"#;
let parsed_enum = syn::parse_str::<syn::ItemEnum>(&enum_str).unwrap();
let parsed_enum = syn::parse_str::<syn::ItemEnum>(enum_str).unwrap();
let (_, typescript_definition) = parse_enum(&parsed_enum);
assert_eq!(
@@ -187,7 +187,7 @@ mod tests {
}
"#;
let parsed_enum = syn::parse_str::<syn::ItemEnum>(&enum_str).unwrap();
let parsed_enum = syn::parse_str::<syn::ItemEnum>(enum_str).unwrap();
let (_, typescript_definition) = parse_enum(&parsed_enum);
assert_eq!(
@@ -209,7 +209,7 @@ mod tests {
}
"#;
let parsed_enum = syn::parse_str::<syn::ItemEnum>(&enum_str).unwrap();
let parsed_enum = syn::parse_str::<syn::ItemEnum>(enum_str).unwrap();
let (enum_name, typescript_definition) = parse_enum(&parsed_enum);
assert_eq!(enum_name, "MyEnum");
@@ -229,7 +229,7 @@ mod tests {
}
"#;
let parsed_enum = syn::parse_str::<syn::ItemEnum>(&enum_str).unwrap();
let parsed_enum = syn::parse_str::<syn::ItemEnum>(enum_str).unwrap();
let (_, typescript_definition) = parse_enum(&parsed_enum);
assert_eq!(
+35
View File
@@ -107,6 +107,41 @@ fn it_successfully_create_multiple_api_for_the_same_file() {
);
}
#[test]
#[serial]
fn it_successfully_import_mixed_case_routes() {
let temp_tuono_project = TempTuonoProject::new();
for method in ["get", "post", "put", "delete", "patch"] {
temp_tuono_project.add_file_with_content(
&format!("./src/routes/api/{method}_lower.rs"),
&format!(r"#[tuono_lib::api({method})]"),
);
temp_tuono_project.add_file_with_content(
&format!("./src/routes/api/{method}_upper.rs"),
&format!(r"#[tuono_lib::api({})]", method.to_uppercase()),
);
}
let mut test_tuono_build = Command::cargo_bin("tuono").unwrap();
test_tuono_build
.arg("build")
.arg("--no-js-emit")
.assert()
.success();
let temp_main_rs_path = temp_tuono_project.path().join(".tuono/main.rs");
let temp_main_rs_content =
fs::read_to_string(&temp_main_rs_path).expect("Failed to read '.tuono/main.rs' content.");
for method in ["get", "post", "put", "delete", "patch"] {
let expected = format!(r#"use tuono_lib::axum::routing::{method};"#);
let imports = temp_main_rs_content.match_indices(&expected);
assert_eq!(imports.count(), 1);
}
}
#[test]
#[serial]
fn it_successfully_create_catch_all_routes() {
@@ -24,9 +24,8 @@ impl GitHubServerMock {
);
Mock::given(method("GET"))
.and(path(&format!(
"repos/tuono-labs/tuono/git/ref/tags/v{}",
tuono_version
.and(path(format!(
"repos/tuono-labs/tuono/git/ref/tags/v{tuono_version}"
)))
.respond_with(sha_response_template)
.mount(&server)
@@ -57,7 +56,7 @@ impl GitHubServerMock {
);
Mock::given(method("GET"))
.and(path(&format!("repos/tuono-labs/tuono/git/trees/{}", sha)))
.and(path(format!("repos/tuono-labs/tuono/git/trees/{sha}")))
.respond_with(tree_response_template)
.mount(&server)
.await;
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "tuono_internal"
version = "0.19.4"
version = "0.19.7"
edition = "2024"
authors = ["V. Ageno <valerioageno@yahoo.it>"]
description = "Superfast React fullstack framework"
+6
View File
@@ -13,6 +13,12 @@ pub struct TempTuonoProject {
temp_dir: TempDir,
}
impl Default for TempTuonoProject {
fn default() -> Self {
Self::new()
}
}
impl TempTuonoProject {
pub fn new() -> Self {
let original_dir = env::current_dir().expect("Failed to read current_dir");
+6 -6
View File
@@ -1,6 +1,6 @@
[package]
name = "tuono_lib"
version = "0.19.4"
version = "0.19.7"
edition = "2024"
authors = ["V. Ageno <valerioageno@yahoo.it>"]
description = "Superfast React fullstack framework"
@@ -17,7 +17,7 @@ include = [
[dependencies]
ssr_rs = "0.8.2"
ssr_rs = "0.8.3"
axum = {version = "0.8.1", features = ["json", "ws"]}
axum-extra = {version = "0.10.0", features = ["cookie"]}
tokio = { version = "1.37.0", features = ["full"] }
@@ -32,12 +32,12 @@ either = "1.13.0"
tower-http = {version = "0.6.0", features = ["fs"]}
colored = "3.0.0"
tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.19.4"}
tuono_internal = {path = "../tuono_internal", version = "0.19.4"}
tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.19.7"}
tuono_internal = {path = "../tuono_internal", version = "0.19.7"}
# Match the same version used by axum
tokio-tungstenite = "0.26.0"
tokio-tungstenite = "0.27.0"
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
tungstenite = "0.26.0"
tungstenite = "0.27.0"
http = "1.1.0"
pin-project = "1.1.7"
tower = "0.5.1"
+2 -2
View File
@@ -16,9 +16,9 @@ pub unsafe fn load_env_vars(mode: Mode) {
Mode::Prod => "production",
};
env_files.push(format!(".env.{}", mode_name));
env_files.push(format!(".env.{mode_name}"));
env_files.push(String::from(".env.local"));
env_files.push(format!(".env.{}.local", mode_name));
env_files.push(format!(".env.{mode_name}.local"));
let system_env_names: HashSet<String> = env::vars().map(|(k, _)| k).collect();
+478 -130
View File
@@ -1,4 +1,6 @@
use once_cell::sync::Lazy;
use once_cell::sync::OnceCell;
use regex::Regex;
use serde::Deserialize;
use std::collections::HashMap;
use std::fs::File;
@@ -7,167 +9,513 @@ use std::path::PathBuf;
const VITE_MANIFEST_PATH: &str = "./out/client/.vite/manifest.json";
#[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 has_dynamic_path(pathname: &str) -> bool {
static RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\[(.*?)\]").expect("Invalid regex for dynamic path detection"));
RE.is_match(pathname)
}
fn default_css_vector() -> Vec<String> {
/// ViteManifest is the mapping between the vite output bundled files
/// and the originals.
/// Vite doc: https://vitejs.dev/config/build-options.html#build-manifest
pub type ViteManifest = HashMap<String, BundleInfo>;
fn empty_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
pub static MANIFEST: OnceCell<HashMap<String, BundleInfo>> = OnceCell::new();
pub fn load_manifest() {
let file = File::open(PathBuf::from(VITE_MANIFEST_PATH)).unwrap();
let reader = BufReader::new(file);
let manifest = serde_json::from_reader(reader).unwrap();
let _ = MANIFEST.set(remap_manifest_keys(manifest));
/// Interface representing the bundle information
/// as they are in the vite manifest.json.
///
/// Used for deserialization
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct BundleInfo {
pub file: String,
#[serde(default = "empty_vector")]
pub css: Vec<String>,
#[serde(default = "empty_vector")]
pub imports: Vec<String>,
// TODO: Add also dynamic imports
}
fn remap_manifest_keys(manifest: HashMap<String, BundleInfo>) -> HashMap<String, BundleInfo> {
let mut new_hashmap = HashMap::new();
#[derive(Debug, Default, Clone)]
pub struct RouteBundle {
pub css_files: Vec<String>,
pub js_files: Vec<String>,
}
manifest.keys().for_each(|key| {
let new_key = key
.replace("../src/routes", "")
.replace(".tsx", "")
.replace(".jsx", "")
.replace("index", "");
#[derive(Debug)]
pub struct Manifest {
/// The mapping between the route and the bundle
bundles: HashMap<String, RouteBundle>,
}
new_hashmap.insert(new_key, manifest.get(key).unwrap().clone());
});
fn clean_route_path(path: String) -> String {
let path = path
.replace("../src/routes", "")
.replace(".tsx", "")
.replace(".mdx", "")
.replace(".md", "")
.replace(".jsx", "");
new_hashmap
if path == "/index" {
return "/".to_string();
}
path.replace("/index", "")
}
impl From<ViteManifest> for Manifest {
fn from(manifest: ViteManifest) -> Self {
let mut bundles = HashMap::new();
let client_main = manifest
.get("client-main.tsx")
// client-main.tsx is the entry point and always exists
.expect("client-main.tsx not found in the manifest")
.clone();
for (key, bundle) in &manifest {
if key.contains("__layout") {
continue;
}
if key == "client-main.tsx" {
bundles.insert(
"client-main".to_string(),
RouteBundle {
css_files: bundle.css.clone(),
js_files: vec![bundle.file.clone()],
},
);
continue;
}
let route = clean_route_path(key.clone());
// Skip components/utils files
if !route.starts_with("/") {
continue;
}
let css_files = [bundle.css.clone(), client_main.css.clone()].concat();
let js_files = vec![bundle.file.clone(), client_main.file.clone()];
let mut route_bundle = RouteBundle {
css_files,
js_files,
};
// the imports bundle always contains at least the client-main
if bundle.imports.len() > 1 {
for import in &bundle.imports {
if import == "client-main.tsx" {
continue;
}
if let Some(import_bundle) = manifest.get(import) {
route_bundle.js_files.push(import_bundle.file.clone());
route_bundle.css_files.extend(import_bundle.css.clone());
}
}
}
bundles.insert(route, route_bundle);
}
// Add __layout imports
for (key, layout_bundle) in &manifest {
let route = clean_route_path(key.clone());
if route.contains("__layout") {
let path_included_in_layout = route.replace("__layout", "");
let mut layout_css_files: Vec<String> = Vec::new();
let mut layout_js_files: Vec<String> = Vec::new();
for import in &layout_bundle.imports {
if import == "client-main.tsx" {
continue;
}
if let Some(import_bundle) = manifest.get(import) {
layout_js_files.push(import_bundle.file.clone());
layout_css_files.extend(import_bundle.css.clone());
}
}
for (key, route_bundles) in &mut bundles {
if key.starts_with(path_included_in_layout.as_str()) {
route_bundles.js_files.push(layout_bundle.file.clone());
route_bundles.css_files.extend(layout_bundle.css.clone());
route_bundles.js_files.extend(layout_js_files.clone());
route_bundles.css_files.extend(layout_css_files.clone());
}
}
}
}
Manifest { bundles }
}
}
impl Manifest {
/// This method adds the route specific bundles to the server
/// side rendered HTML.
///
/// The same matching algorithm is implemented on the client side in
/// this file (packages/tuono/src/router/components/Matches.ts).
///
/// Optimizations should occour on both.
pub fn get_bundle_from_pathname(&self, pathname: &str) -> RouteBundle {
// Exact match
if let Some(bundle) = self.bundles.get(pathname) {
return bundle.clone();
}
let dynamic_routes = self
.bundles
.keys()
.filter(|path| has_dynamic_path(path))
.collect::<Vec<&String>>();
if !dynamic_routes.is_empty() {
let path_segments = pathname
.split('/')
.filter(|path| !path.is_empty())
.collect::<Vec<&str>>();
'_dynamic_routes_loop: for dyn_route in dynamic_routes.iter() {
let dyn_route_segments = dyn_route
.split('/')
.filter(|path| !path.is_empty())
.collect::<Vec<&str>>();
let mut route_segments_collector: Vec<&str> = Vec::new();
for i in 0..dyn_route_segments.len() {
// Catch all dynamic route
if dyn_route_segments[i].starts_with("[...") {
route_segments_collector.push(dyn_route_segments[i]);
let manifest_key = route_segments_collector.join("/");
let route_data = self.bundles.get(&format!("/{manifest_key}"));
if let Some(data) = route_data {
return data.clone();
}
break '_dynamic_routes_loop;
}
if path_segments.len() == i {
break;
}
if dyn_route_segments[i] == path_segments[i]
|| has_dynamic_path(dyn_route_segments[i])
{
route_segments_collector.push(dyn_route_segments[i])
} else {
break;
}
}
if route_segments_collector.len() == path_segments.len() {
let manifest_key = route_segments_collector.join("/");
let route_data = self.bundles.get(&format!("/{manifest_key}"));
if let Some(data) = route_data {
return data.clone();
}
break;
}
}
}
// No dynamic routes, return the client main bundle
if let Some(bundle) = self.bundles.get("client-main") {
return bundle.clone();
}
// This should never happen because client-main always exists
RouteBundle::default()
}
}
pub static MANIFEST: OnceCell<Manifest> = OnceCell::new();
/// Load the vite manifest from the file system and set it in the MANIFEST
/// static variable.
pub fn load_manifest() -> std::io::Result<()> {
let file = File::open(PathBuf::from(VITE_MANIFEST_PATH))?;
let reader = BufReader::new(file);
let manifest: ViteManifest = serde_json::from_reader(reader)?;
MANIFEST
.set(Manifest::from(manifest))
.map_err(|_| std::io::Error::other("Failed to set the manifest"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
// This manifest is an example of a complex vite manifest.json
// It includes dynamic routes, static routes, catch all routes, nested
// __layout and shared components.
const MANIFEST_EXAMPLE: &str = r#"{
"../src/routes/about.tsx": {
"file": "assets/about-C3UqHfGb.js",
"name": "about",
"src": "../src/routes/about.tsx",
"isDynamicEntry": true,
"imports": [
"client-main.tsx",
"_FileWithCssOnly.js"
],
"css": [
"assets/about-DUhMJ_Ze.css"
]
},
"_FileWithCssOnly.js": {
"file": "assets/FileWithCssOnly.js",
"name": "FileWithCssOnly",
"imports": [
"client-main.tsx"
],
"css": [
"assets/FileWithCssOnly.css"
]
},
"../src/routes/catch_all/[...slug].tsx": {
"file": "assets/_...slug_-CpJyPnPj.js",
"name": "_...slug_",
"src": "../src/routes/catch_all/[...slug].tsx",
"isDynamicEntry": true,
"imports": [
"client-main.tsx"
],
"css": [
"assets/_..-CipbPoTl.css"
]
},
"../src/routes/index.tsx": {
"file": "assets/index-B3tnHOzi.js",
"name": "index",
"src": "../src/routes/index.tsx",
"isDynamicEntry": true,
"imports": [
"client-main.tsx"
],
"css": [
"assets/index-CynfArjF.css"
]
},
"../src/routes/pokemons/[pokemon]/[type].tsx": {
"file": "assets/_type_-B-sJOcVJ.js",
"name": "_type_",
"src": "../src/routes/pokemons/[pokemon]/[type].tsx",
"isDynamicEntry": true,
"imports": [
"client-main.tsx",
"_PokemonView-jNGFFO0j.js"
],
"css": [
"assets/_type_-B8vgxybx.css"
]
},
"../src/routes/pokemons/[pokemon]/index.tsx": {
"file": "assets/index-ByRBj7WK.js",
"name": "index",
"src": "../src/routes/pokemons/[pokemon]/index.tsx",
"isDynamicEntry": true,
"imports": [
"client-main.tsx",
"_PokemonView-jNGFFO0j.js"
],
"css": [
"assets/index-CM86zKWq.css"
]
},
"../src/routes/pokemons/__layout.tsx": {
"file": "assets/__layout-2v3JiSeL.js",
"name": "__layout",
"src": "../src/routes/pokemons/__layout.tsx",
"isDynamicEntry": true,
"imports": [
"client-main.tsx"
],
"css": [
"assets/__layout-CXGGqNw5.css"
]
},
"_PokemonView-BcJZaQaO.css": {
"file": "assets/PokemonView-BcJZaQaO.css",
"src": "_PokemonView-BcJZaQaO.css"
},
"_PokemonView-jNGFFO0j.js": {
"file": "assets/PokemonView-jNGFFO0j.js",
"name": "PokemonView",
"imports": [
"client-main.tsx"
],
"css": [
"assets/PokemonView-BcJZaQaO.css"
]
},
"client-main.tsx": {
"file": "assets/client-main-DOdr9gvl.js",
"name": "client-main",
"src": "client-main.tsx",
"isEntry": true,
"dynamicImports": [
"../src/routes/pokemons/__layout.tsx",
"../src/routes/about.tsx",
"../src/routes/index.tsx",
"../src/routes/catch_all/[...slug].tsx",
"../src/routes/pokemons/[pokemon]/[type].tsx",
"../src/routes/pokemons/[pokemon]/index.tsx"
],
"css": [
"assets/client-main-BS7N-NIa.css"
]
}
}"#;
#[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"
]
}
}"#;
fn it_correctly_cleans_the_route_path() {
let cleaned_path = clean_route_path("../src/routes/index.tsx".to_string());
assert_eq!(cleaned_path, "/");
let parsed_manifest =
serde_json::from_str::<HashMap<String, BundleInfo>>(manifest_example).unwrap();
let cleaned_path =
clean_route_path("../src/routes/pokemons/[pokemon]/index.tsx".to_string());
assert_eq!(cleaned_path, "/pokemons/[pokemon]");
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()],
},
);
let cleaned_path = clean_route_path("../src/routes/pokemons/__layout.tsx".to_string());
assert_eq!(cleaned_path, "/pokemons/__layout");
result.insert(
"meta-tags-lib".to_string(),
BundleInfo {
file: "assets/meta-lib.js".to_string(),
css: Vec::new(),
},
);
let cleaned_path =
clean_route_path("../src/routes/pokemons/[pokemon]/[type].mdx".to_string());
assert_eq!(cleaned_path, "/pokemons/[pokemon]/[type]");
assert_eq!(parsed_manifest, result);
let cleaned_path = clean_route_path("../src/routes/about.md".to_string());
assert_eq!(cleaned_path, "/about");
}
#[test]
fn should_correctly_remap_the_manifest() {
let mut parsed_manifest: HashMap<String, BundleInfo> = HashMap::new();
parsed_manifest.insert(
"../src/routes/index.tsx".to_string(),
BundleInfo {
file: "assets/index.js".to_string(),
css: vec!["assets/index.css".to_string()],
},
);
parsed_manifest.insert(
"../src/routes/about.jsx".to_string(),
BundleInfo {
file: "assets/about.js".to_string(),
css: vec!["assets/about.css".to_string()],
},
);
parsed_manifest.insert(
"../src/routes/posts/[post].tsx".to_string(),
BundleInfo {
file: "assets/posts/[post].js".to_string(),
css: vec!["assets/posts/[post].css".to_string()],
},
);
parsed_manifest.insert(
"client-main.tsx".to_string(),
BundleInfo {
file: "assets/main.js".to_string(),
css: vec!["assets/main.css".to_string()],
},
);
fn correctly_parse_the_manifest_json() {
let parsed_manifest = serde_json::from_str::<ViteManifest>(MANIFEST_EXAMPLE).unwrap();
let remapped = remap_manifest_keys(parsed_manifest);
let manifest = Manifest::from(parsed_manifest);
assert_eq!(manifest.bundles.len(), 6);
let index_route = manifest.get_bundle_from_pathname("/");
assert_eq!(
remapped.get("/").unwrap().file,
"assets/index.js".to_string()
index_route.css_files,
vec![
"assets/index-CynfArjF.css",
"assets/client-main-BS7N-NIa.css"
]
);
assert_eq!(
index_route.js_files,
vec!["assets/index-B3tnHOzi.js", "assets/client-main-DOdr9gvl.js"]
);
}
#[test]
fn should_load_the_correct_single_dyn_path() {
let parsed_manifest = serde_json::from_str::<ViteManifest>(MANIFEST_EXAMPLE).unwrap();
let manifest = Manifest::from(parsed_manifest);
let nested_route = manifest.get_bundle_from_pathname("/pokemons/ditto");
assert_eq!(
nested_route.css_files,
vec![
"assets/index-CM86zKWq.css",
"assets/client-main-BS7N-NIa.css",
"assets/PokemonView-BcJZaQaO.css",
"assets/__layout-CXGGqNw5.css"
]
);
assert_eq!(
remapped.get("/about").unwrap().file,
"assets/about.js".to_string()
nested_route.js_files,
vec![
"assets/index-ByRBj7WK.js",
"assets/client-main-DOdr9gvl.js",
"assets/PokemonView-jNGFFO0j.js",
"assets/__layout-2v3JiSeL.js"
]
);
}
#[test]
fn should_load_the_correct_nested_dyn_path_bundles() {
let parsed_manifest = serde_json::from_str::<ViteManifest>(MANIFEST_EXAMPLE).unwrap();
let manifest = Manifest::from(parsed_manifest);
let route = manifest.get_bundle_from_pathname("/pokemons/charizard/fire");
assert_eq!(
route.css_files,
vec![
"assets/_type_-B8vgxybx.css",
"assets/client-main-BS7N-NIa.css",
"assets/PokemonView-BcJZaQaO.css",
"assets/__layout-CXGGqNw5.css"
]
);
assert_eq!(
route.js_files,
vec![
"assets/_type_-B-sJOcVJ.js",
"assets/client-main-DOdr9gvl.js",
"assets/PokemonView-jNGFFO0j.js",
"assets/__layout-2v3JiSeL.js"
]
);
}
#[test]
fn should_load_the_correct_catch_all_bundles() {
let parsed_manifest = serde_json::from_str::<ViteManifest>(MANIFEST_EXAMPLE).unwrap();
let manifest = Manifest::from(parsed_manifest);
let route = manifest.get_bundle_from_pathname("/catch_all/some/random/path");
assert_eq!(
route.css_files,
vec!["assets/_..-CipbPoTl.css", "assets/client-main-BS7N-NIa.css"]
);
assert_eq!(
route.js_files,
vec![
"assets/_...slug_-CpJyPnPj.js",
"assets/client-main-DOdr9gvl.js"
]
);
}
#[test]
fn should_load_the_defined_path_bundles() {
let parsed_manifest = serde_json::from_str::<ViteManifest>(MANIFEST_EXAMPLE).unwrap();
let manifest = Manifest::from(parsed_manifest);
let route = manifest.get_bundle_from_pathname("/about");
assert_eq!(
route.css_files,
vec![
"assets/about-DUhMJ_Ze.css",
"assets/client-main-BS7N-NIa.css",
"assets/FileWithCssOnly.css"
]
);
assert_eq!(
remapped.get("/posts/[post]").unwrap().file,
"assets/posts/[post].js".to_string()
);
assert_eq!(
remapped.get("client-main").unwrap().file,
"assets/main.js".to_string()
route.js_files,
vec![
"assets/about-C3UqHfGb.js",
"assets/client-main-DOdr9gvl.js",
"assets/FileWithCssOnly.js"
]
);
}
}
+67 -252
View File
@@ -2,17 +2,11 @@ use crate::config::GLOBAL_CONFIG;
use crate::manifest::MANIFEST;
use crate::mode::{GLOBAL_MODE, Mode};
use erased_serde::Serialize;
use regex::Regex;
use serde::Serialize as SerdeSerialize;
use tuono_internal::config::ServerConfig;
use crate::request::{Location, Request};
fn has_dynamic_path(route: &str) -> bool {
let regex = Regex::new(r"\[(.*?)\]").expect("Failed to create the regex");
regex.is_match(route)
}
#[derive(SerdeSerialize)]
/// This is the payload sent to the client for hydration
pub struct Payload<'a> {
@@ -20,9 +14,9 @@ pub struct Payload<'a> {
data: &'a dyn Serialize,
mode: Mode,
#[serde(rename(serialize = "jsBundles"))]
js_bundles: Option<Vec<&'a String>>,
js_bundles: Option<Vec<String>>,
#[serde(rename(serialize = "cssBundles"))]
css_bundles: Option<Vec<&'a String>>,
css_bundles: Option<Vec<String>>,
#[serde(rename(serialize = "devServerConfig"))]
dev_server_config: Option<&'a ServerConfig>,
}
@@ -58,102 +52,11 @@ impl<'a> Payload<'a> {
serde_json::to_string(&self)
}
/// This method adds the route specific bundles to the server
/// side rendered HTML.
///
/// The same matching algorithm is implemented on the client side in
/// this file (packages/tuono/src/router/components/Matches.ts).
///
/// Optimizations should occour on both.
fn add_bundle_sources(&mut self) {
// Manifest should always be loaded. The load happen before starting
// the server.
let manifest = MANIFEST.get().expect("Failed to load manifest");
// The main bundle should always exist.
// The extension should be tsx even with JS only projects.
let main_bundle = manifest
.get("client-main")
.expect("Failed to get client-main bundle");
let mut js_bundles_sources = vec![&main_bundle.file];
let mut css_bundles_sources = main_bundle.css.iter().collect::<Vec<&String>>();
let pathname = &self.location.pathname();
let bundle_data = manifest.get(*pathname);
if let Some(data) = bundle_data {
js_bundles_sources.push(&data.file);
data.css
.iter()
.for_each(|source| css_bundles_sources.push(source))
} else {
let dynamic_routes = manifest
.keys()
.filter(|path| has_dynamic_path(path))
.collect::<Vec<&String>>();
if !dynamic_routes.is_empty() {
let path_segments = pathname
.split('/')
.filter(|path| !path.is_empty())
.collect::<Vec<&str>>();
'_dynamic_routes_loop: for dyn_route in dynamic_routes.iter() {
let dyn_route_segments = dyn_route
.split('/')
.filter(|path| !path.is_empty())
.collect::<Vec<&str>>();
let mut route_segments_collector: Vec<&str> = Vec::new();
for i in 0..dyn_route_segments.len() {
if dyn_route_segments[i].starts_with("[...") {
route_segments_collector.push(dyn_route_segments[i]);
let manifest_key = route_segments_collector.join("/");
let route_data = manifest.get(&format!("/{manifest_key}"));
if let Some(data) = route_data {
js_bundles_sources.push(&data.file);
data.css
.iter()
.for_each(|source| css_bundles_sources.push(source))
}
break '_dynamic_routes_loop;
}
if path_segments.len() == i {
break;
}
if dyn_route_segments[i] == path_segments[i]
|| has_dynamic_path(dyn_route_segments[i])
{
route_segments_collector.push(dyn_route_segments[i])
} else {
break;
}
}
if route_segments_collector.len() == path_segments.len() {
let manifest_key = route_segments_collector.join("/");
let route_data = manifest.get(&format!("/{manifest_key}"));
if let Some(data) = route_data {
js_bundles_sources.push(&data.file);
data.css
.iter()
.for_each(|source| css_bundles_sources.push(source))
}
break;
}
}
}
}
self.js_bundles = Some(js_bundles_sources);
self.css_bundles = Some(css_bundles_sources);
let manifest = MANIFEST.get().expect("Manifest not loaded");
let bundles = manifest.get_bundle_from_pathname(self.location.pathname());
self.js_bundles = Some(bundles.js_files);
self.css_bundles = Some(bundles.css_files);
}
}
@@ -161,69 +64,66 @@ impl<'a> Payload<'a> {
mod tests {
use super::*;
use crate::manifest::ViteManifest;
use axum::http::Uri;
use std::collections::HashMap;
use crate::manifest::BundleInfo;
const MANIFEST_EXAMPLE: &str = r#"{
"../src/routes/index.tsx": {
"file": "assets/index-D-yFyCZo.js",
"name": "index",
"src": "../src/routes/index.tsx",
"isDynamicEntry": true,
"imports": [
"client-main.tsx"
],
"css": [
"assets/index-CynfArjF.css"
]
},
"../src/routes/pokemons/[pokemon].tsx": {
"file": "assets/_pokemon_-DlFInatQ.js",
"name": "_pokemon_",
"src": "../src/routes/pokemons/[pokemon].tsx",
"isDynamicEntry": true,
"imports": [
"client-main.tsx"
],
"css": [
"assets/_pokemon_-BcJZaQaO.css"
]
},
"../src/routes/pokemons/__layout.tsx": {
"file": "assets/__layout-BFnT3M7X.js",
"name": "__layout",
"src": "../src/routes/pokemons/__layout.tsx",
"isDynamicEntry": true,
"imports": [
"client-main.tsx"
],
"css": [
"assets/__layout-CXGGqNw5.css"
]
},
"client-main.tsx": {
"file": "assets/client-main-B9g1NVV7.js",
"name": "client-main",
"src": "client-main.tsx",
"isEntry": true,
"dynamicImports": [
"../src/routes/pokemons/__layout.tsx",
"../src/routes/index.tsx",
"../src/routes/pokemons/[pokemon].tsx"
],
"css": [
"assets/client-main-BS7N-NIa.css"
]
}
}"#;
fn prepare_payload(uri: Option<&str>, mode: Mode) -> Payload {
let mut manifest_mock = HashMap::new();
manifest_mock.insert(
"client-main".to_string(),
BundleInfo {
file: "assets/bundled-file.js".to_string(),
css: vec!["assets/bundled-file.css".to_string()],
},
);
manifest_mock.insert(
"/".to_string(),
BundleInfo {
file: "assets/index.js".to_string(),
css: vec!["assets/index.css".to_string()],
},
);
manifest_mock.insert(
"/posts/[post]".to_string(),
BundleInfo {
file: "assets/posts/[post].js".to_string(),
css: vec!["assets/posts/[post].css".to_string()],
},
);
manifest_mock.insert(
"/posts/[post]/[comment]".to_string(),
BundleInfo {
file: "assets/posts/[post]/[comment].js".to_string(),
css: vec!["assets/posts/[post]/[comment].css".to_string()],
},
);
manifest_mock.insert(
"/pokemons/[...catch_all]".to_string(),
BundleInfo {
file: "assets/catch_all.js".to_string(),
css: vec!["assets/catch_all.css".to_string()],
},
);
manifest_mock.insert(
"/posts/custom-post".to_string(),
BundleInfo {
file: "assets/custom-post.js".to_string(),
css: vec!["assets/custom-post.css".to_string()],
},
);
manifest_mock.insert(
"/about".to_string(),
BundleInfo {
file: "assets/about.js".to_string(),
css: vec!["assets/about.css".to_string()],
},
);
MANIFEST.get_or_init(|| manifest_mock);
fn prepare_payload(uri: Option<&str>, mode: Mode) -> Payload<'_> {
let manifest_mock = serde_json::from_str::<ViteManifest>(MANIFEST_EXAMPLE)
.expect("Failed to parse the manifest example");
MANIFEST.get_or_init(|| manifest_mock.into());
let uri = uri
.unwrap_or("http://localhost:3000/")
@@ -250,15 +150,15 @@ mod tests {
assert_eq!(
payload.js_bundles,
Some(vec![
&"assets/bundled-file.js".to_string(),
&"assets/index.js".to_string()
"assets/index-D-yFyCZo.js".to_string(),
"assets/client-main-B9g1NVV7.js".to_string()
])
);
assert_eq!(
payload.css_bundles,
Some(vec![
&"assets/bundled-file.css".to_string(),
&"assets/index.css".to_string()
"assets/index-CynfArjF.css".to_string(),
"assets/client-main-BS7N-NIa.css".to_string()
])
);
}
@@ -270,89 +170,4 @@ mod tests {
assert!(payload.js_bundles.is_none());
assert!(payload.css_bundles.is_none());
}
#[test]
fn should_load_the_correct_single_dyn_path_bundles() {
let mut payload = prepare_payload(Some("http://localhost:3000/posts/a-post"), Mode::Prod);
let _ = payload.client_payload();
assert_eq!(
payload.js_bundles,
Some(vec![
&"assets/bundled-file.js".to_string(),
&"assets/posts/[post].js".to_string()
])
);
assert_eq!(
payload.css_bundles,
Some(vec![
&"assets/bundled-file.css".to_string(),
&"assets/posts/[post].css".to_string()
])
);
}
#[test]
fn should_load_the_correct_nested_dyn_path_bundles() {
let mut payload = prepare_payload(
Some("http://localhost:3000/posts/a-post/a-comment"),
Mode::Prod,
);
let _ = payload.client_payload();
assert_eq!(
payload.js_bundles,
Some(vec![
&"assets/bundled-file.js".to_string(),
&"assets/posts/[post]/[comment].js".to_string()
])
);
assert_eq!(
payload.css_bundles,
Some(vec![
&"assets/bundled-file.css".to_string(),
&"assets/posts/[post]/[comment].css".to_string()
])
);
}
#[test]
fn should_load_the_correct_catch_all_bundles() {
let mut payload = prepare_payload(
Some("http://localhost:3000/pokemons/a-poke/a-poke"),
Mode::Prod,
);
let _ = payload.client_payload();
assert_eq!(
payload.js_bundles,
Some(vec![
&"assets/bundled-file.js".to_string(),
&"assets/catch_all.js".to_string()
])
);
assert_eq!(
payload.css_bundles,
Some(vec![
&"assets/bundled-file.css".to_string(),
&"assets/catch_all.css".to_string()
])
);
}
#[test]
fn should_load_the_defined_path_bundles() {
let mut payload = prepare_payload(Some("http://localhost:3000/about"), Mode::Prod);
let _ = payload.client_payload();
assert_eq!(
payload.js_bundles,
Some(vec![
&"assets/bundled-file.js".to_string(),
&"assets/about.js".to_string()
])
);
assert_eq!(
payload.css_bundles,
Some(vec![
&"assets/bundled-file.css".to_string(),
&"assets/about.css".to_string()
])
);
}
}
+3 -1
View File
@@ -55,7 +55,9 @@ impl Server {
let _ = GLOBAL_CONFIG.set(config.clone());
if mode == Mode::Prod {
load_manifest()
if let Err(err) = load_manifest() {
tuono_println!("Failed to load vite manifest: {}", err.to_string().red());
}
}
let server_address = format!("{}:{}", config.server.host, config.server.port);
+24 -3
View File
@@ -1,12 +1,16 @@
use crate::config::GLOBAL_CONFIG;
use axum::body::Body;
use axum::extract::Path;
use axum::extract::{Path, Query};
use std::collections::HashMap;
use axum::http::{HeaderName, HeaderValue};
use axum::response::{IntoResponse, Response};
use reqwest::Client;
pub async fn vite_reverse_proxy(Path(path): Path<String>) -> impl IntoResponse {
pub async fn vite_reverse_proxy(
Path(path): Path<String>,
query: Query<HashMap<String, String>>,
) -> impl IntoResponse {
let client = Client::new();
let config = GLOBAL_CONFIG
@@ -19,7 +23,24 @@ pub async fn vite_reverse_proxy(Path(path): Path<String>) -> impl IntoResponse {
config.server.port + 1
);
match client.get(format!("{vite_url}/{path}")).send().await {
let query_string = query
.0
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join("&");
let query_string = if query_string.is_empty() {
String::new()
} else {
format!("?{query_string}")
};
match client
.get(format!("{vite_url}/{path}{query_string}"))
.send()
.await
{
Ok(res) => {
let mut response_builder = Response::builder().status(res.status().as_u16());
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "tuono_lib_macros"
version = "0.19.4"
version = "0.19.7"
edition = "2024"
description = "Superfast React fullstack framework"
homepage = "https://tuono.dev"
+1 -1
View File
@@ -15,7 +15,7 @@ pub fn api_core(attrs: TokenStream, item: TokenStream) -> TokenStream {
.to_lowercase();
let api_fn_name = Ident::new(
&format!("{}_tuono_internal_api", http_method),
&format!("{http_method}_tuono_internal_api"),
Span::call_site().into(),
);
+1 -1
View File
@@ -15,7 +15,7 @@
"types": "tsc --noEmit"
},
"devDependencies": {
"oxc-transform": "0.63.0",
"oxc-transform": "0.77.3",
"rollup-plugin-preserve-directives": "0.4.0",
"unplugin-isolated-decl": "0.13.6",
"vite": "6.1.3",
+1 -2
View File
@@ -31,7 +31,7 @@ const tuonoEslintConfig = tseslint.config(
// #endregion shared
// #region package-specific
'packages/tuono-fs-router-vite-plugin/tests/generator/**',
'packages/tuono-react-vite-plugin/tests/generator/**',
'packages/tuono-lazy-fn-vite-plugin/tests/sources/**',
@@ -69,7 +69,6 @@ const tuonoEslintConfig = tseslint.config(
{
files: [REACT_FILES_MATCH],
plugins: {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
'react-hooks': eslintPluginReactHooks,
},
rules: {
+1 -1
View File
@@ -1 +1 @@
@import 'tailwindcss';
@import 'tailwindcss' source('../..');
+6 -6
View File
@@ -2,7 +2,7 @@
"name": "workspace",
"private": true,
"type": "module",
"packageManager": "pnpm@10.7.1+sha512.2d92c86b7928dc8284f53494fb4201f983da65f0fb4f0d40baafa5cf628fa31dae3e5968f12466f17df7e97310e30f343a648baea1b9b350685dafafffdf5808",
"packageManager": "pnpm@10.13.1+sha512.37ebf1a5c7a30d5fabe0c5df44ee8da4c965ca0c5af3dbab28c3a1681b70a256218d05c81c9c0dcf767ef6b8551eb5b960042b9ed4300c59242336377e01cfad",
"scripts": {
"dev": "turbo dev --filter=./devtools/* --filter=./packages/*",
"build": "turbo build --filter=./devtools/* --filter=./packages/*",
@@ -26,17 +26,17 @@
"license": "MIT",
"devDependencies": {
"@eslint/js": "9.24.0",
"@playwright/test": "1.51.1",
"@types/node": "22.14.1",
"@vitest/eslint-plugin": "1.1.42",
"@playwright/test": "1.54.1",
"@types/node": "22.16.5",
"@vitest/eslint-plugin": "1.2.1",
"eslint": "9.24.0",
"eslint-import-resolver-typescript": "4.3.2",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "5.2.0",
"prettier": "3.5.3",
"turbo": "2.5.0",
"typescript": "5.7.3",
"turbo": "2.5.5",
"typescript": "5.8.3",
"typescript-eslint": "8.29.1"
}
}
@@ -1,47 +0,0 @@
import { normalize } from 'node:path'
import type { Plugin } from 'vite'
import { routeGenerator } from './generator'
const ROUTES_DIRECTORY_PATH = './src/routes'
let lock = false
export function TuonoFsRouterPlugin(): 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)
}
},
}
}
@@ -1,6 +1,6 @@
{
"name": "tuono-fs-router-vite-plugin",
"version": "0.19.4",
"name": "tuono-react-vite-plugin",
"version": "0.19.7",
"description": "Plugin for the tuono's file system router. Tuono is the react/rust fullstack framework",
"homepage": "https://tuono.dev",
"scripts": {
@@ -16,7 +16,7 @@
"repository": {
"type": "git",
"url": "git+https://github.com/tuono-labs/tuono.git",
"directory": "packages/tuono-fs-router-vite-plugin"
"directory": "packages/tuono-react-vite-plugin"
},
"keywords": [],
"author": "Valerio Ageno",
@@ -46,6 +46,6 @@
"devDependencies": {
"@types/babel__core": "7.20.5",
"vite-config": "workspace:*",
"vitest": "3.1.1"
"vitest": "3.2.4"
}
}
@@ -1,5 +1,6 @@
import type { RouteNode } from '../types'
import { spaces } from './utils'
import type { RouteNode } from './types'
export function buildRouteConfig(nodes: Array<RouteNode>, depth = 1): string {
const children = nodes.map((node) => {
@@ -3,6 +3,8 @@ import path from 'path'
import { format } from 'prettier'
import type { Config, RouteNode } from '../types'
import { buildRouteConfig } from './build-route-config'
import { hasParentRoute } from './has-parent-route'
import {
@@ -17,7 +19,6 @@ import {
removeUnderscores,
} from './utils'
import type { Config, RouteNode } from './types'
import {
ROUTES_FOLDER,
LAYOUT_PATH_ID,
@@ -26,7 +27,7 @@ import {
} from './constants'
import { sortRouteNodes } from './sort-route-nodes'
import isDefaultExported from './utils/is-default-exported'
import isDefaultExported from './is-default-exported'
let latestTask = 0
@@ -206,6 +207,7 @@ export async function routeGenerator(
rustHandlersNodes.includes(node.path || '')
? 'hasHandler: true'
: '',
`filePath: '${node.path || '/'}'`,
]
.filter(Boolean)
.join(',')}
@@ -1,6 +1,7 @@
import type { RouteNode } from '../types'
import { LAYOUT_PATH_ID } from './constants'
import { multiSortBy } from './utils'
import type { RouteNode } from './types'
export function hasParentRoute(
routes: Array<RouteNode>,
@@ -1,4 +1,5 @@
import type { RouteNode } from './types'
import type { RouteNode } from '../types'
import { multiSortBy } from './utils'
import { LAYOUT_PATH_ID } from './constants'
@@ -1,4 +1,4 @@
import type { RouteNode } from './types'
import type { RouteNode } from '../types'
export function removeExt(d: string, keepExtension: boolean = false): string {
return keepExtension ? d : d.substring(0, d.lastIndexOf('.')) || d
@@ -0,0 +1 @@
export { TuonoReactPlugin } from './plugin'
@@ -0,0 +1,83 @@
import { normalize } from 'node:path'
import type { Plugin, ViteDevServer } from 'vite'
import { routeGenerator } from './fs-routing/generator'
import { getStylesForComponentId, isCssModulesFile } from './styles'
const CRITICAL_CSS_PATH = '/vite-server/tuono_internal__critical_css'
const ROUTES_DIRECTORY_PATH = './src/routes'
let lock = false
export function TuonoReactPlugin(): 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()
}
}
// This manifest is used to store the CSS modules contents in dev mode
// { [filePath]: cssContent }
const cssModulesManifest: Record<string, string> = {}
return {
name: 'vite-plugin-tuono-react',
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)
}
},
transform: (code, id): void => {
if (isCssModulesFile(id)) {
cssModulesManifest[id] = code
}
},
configureServer: (server: ViteDevServer): void => {
// Using middlewares in order to take advantage of async requests out of
// the box
// eslint-disable-next-line @typescript-eslint/no-misused-promises
server.middlewares.use(async (req, res, next): Promise<void> => {
const url = new URL(req.url || '', `http://${req.headers.host || ''}`)
// Give the request handler access to the critical CSS in dev to avoid a
// flash of unstyled content since Vite injects CSS file contents via JS
if (url.pathname === CRITICAL_CSS_PATH) {
const componentId = url.searchParams.get('componentId')
const css = await getStylesForComponentId(
server,
componentId,
cssModulesManifest,
)
res.writeHead(200, { 'Content-Type': 'text/css' })
res.end(css)
return
}
next()
})
},
}
}
@@ -0,0 +1,215 @@
/**
* This module is strongly inspired by the remix project.
*
* source: https://github.com/remix-run/remix/blob/main/packages/remix-dev/vite/styles.ts
*/
import path from 'path'
import type { ModuleNode, ViteDevServer } from 'vite'
const isCssFile = (file: string): boolean => cssFileRegExp.test(file)
const cssFileRegExp =
/\.(css|less|sass|scss|styl|stylus|pcss|postcss|sss)(?:$|\?)/
const cssModulesRegExp = new RegExp(`\\.module${cssFileRegExp.source}`)
const routesFolder = path.relative(process.cwd(), 'src/routes')
const injectQuery = (url: string, query: string): string =>
url.includes('?') ? url.replace('?', `?${query}&`) : `${url}?${query}`
export const isCssModulesFile = (file: string): boolean =>
cssModulesRegExp.test(file)
const cssUrlParamsWithoutSideEffects = ['url', 'inline', 'raw', 'inline-css']
const isCssUrlWithoutSideEffects = (url: string): boolean => {
const queryString = url.split('?')[1]
if (!queryString) {
return false
}
const params = new URLSearchParams(queryString)
for (const paramWithoutSideEffects of cssUrlParamsWithoutSideEffects) {
if (
// Parameter is blank and not explicitly set, i.e. "?url", not "?url="
params.get(paramWithoutSideEffects) === '' &&
!url.includes(`?${paramWithoutSideEffects}=`) &&
!url.includes(`&${paramWithoutSideEffects}=`)
) {
return true
}
}
return false
}
const normalizePath = (modulePath: string): string => {
return modulePath.startsWith('node_modules')
? path.join(process.cwd(), modulePath)
: modulePath
}
export const getStylesForModule = async (
viteDevServer: ViteDevServer,
moduleUrl: string,
/**
* All the CSS modules are preloaded and saved in this manifest
*/
cssModulesManifest: Record<string, string>,
): Promise<string | undefined> => {
const styles: Record<string, string> = {}
const deps: Set<ModuleNode> = new Set()
const moduleFilePath = normalizePath(moduleUrl)
try {
let node: ModuleNode | undefined =
await viteDevServer.moduleGraph.getModuleByUrl(moduleFilePath)
// If the module is only present in the client module graph, the module
// won't have been found on the first request to the server. If so, we
// request the module so it's in the module graph, then try again.
if (!node) {
try {
await viteDevServer.transformRequest(moduleFilePath)
} catch (err) {
console.error(err)
}
node = await viteDevServer.moduleGraph.getModuleByUrl(moduleFilePath)
}
if (!node) {
console.error(`Could not resolve module for file: ${moduleFilePath}`)
return
}
await findNodeDependencies(viteDevServer, node, deps)
} catch (error) {
console.error(error)
}
for (const dep of deps) {
if (
dep.file &&
isCssFile(dep.file) &&
!isCssUrlWithoutSideEffects(dep.url) // Ignore styles that resolved as URLs, inline or raw. These shouldn't get injected.
) {
try {
const css = isCssModulesFile(dep.file)
? cssModulesManifest[dep.file]
: ((
await viteDevServer.ssrLoadModule(
// We need the ?inline query in Vite v6 when loading CSS in SSR
// since it does not expose the default export for CSS in a
// server environment.
injectQuery(normalizePath(dep.file), 'inline'),
)
).default as string)
if (css === undefined) {
throw new Error()
}
styles[dep.url] = css
} catch {
// this can happen with dynamically imported modules
console.warn(`Could not load ${dep.file}`)
}
}
}
return (
Object.entries(styles)
.map(([fileName, css]) => [
`\n/* ${fileName
// Escape comment syntax in file paths
.replace(/\/\*/g, '/\\*')
.replace(/\*\//g, '*\\/')} */`,
css,
])
.flat()
.join('\n') || undefined
)
}
/**
* This function transform the componentId into a file path.
* File extension is not required for the vite.moduleGraph URL search.
*/
function findFileFromComponentId(id: string): string {
if (id.endsWith('/')) {
return id + 'index'
}
if (id.includes('__root__')) {
return id.replaceAll('__root__', '__layout')
}
return id
}
export const getStylesForComponentId = async (
viteDevServer: ViteDevServer,
/**
* The route name (should match tuono-router specs)
*/
componentId: string | null,
/**
* All the CSS modules are preloaded and saved in this manifest
*/
cssModulesManifest: Record<string, string>,
): Promise<string | undefined> => {
const relativeFilePath = path.join(
routesFolder,
findFileFromComponentId(componentId || ''),
)
const fileUrl = path.join(process.cwd(), relativeFilePath)
return await getStylesForModule(viteDevServer, fileUrl, cssModulesManifest)
}
/**
* This function is used to find all the dependencies of a module node.
* The starting node is always a route.
*/
const findNodeDependencies = async (
vite: ViteDevServer,
node: ModuleNode,
deps: Set<ModuleNode>,
): Promise<void> => {
// since `ssrTransformResult.deps` contains URLs instead of `ModuleNode`s, this process is asynchronous.
// instead of using `await`, we resolve all branches in parallel.
const branches: Array<Promise<void>> = []
async function addFromNode(innerNode: ModuleNode): Promise<void> {
if (!deps.has(innerNode)) {
deps.add(innerNode)
await findNodeDependencies(vite, innerNode, deps)
}
}
async function addFromUrl(url: string): Promise<void> {
const innerNode = await vite.moduleGraph.getModuleByUrl(url)
if (innerNode) {
await addFromNode(innerNode)
}
}
if (node.ssrTransformResult) {
if (node.ssrTransformResult.deps) {
node.ssrTransformResult.deps.forEach((url) =>
branches.push(addFromUrl(url)),
)
}
} else {
node.importedModules.forEach((innerNode: ModuleNode) =>
branches.push(addFromNode(innerNode)),
)
}
await Promise.all(branches)
}
@@ -2,7 +2,7 @@ import fs from 'fs/promises'
import { describe, it, expect } from 'vitest'
import { routeGenerator } from '../src/generator'
import { routeGenerator } from '../src/fs-routing/generator'
describe('generator works', async () => {
const folderNames = await fs.readdir(`${process.cwd()}/tests/generator`)
@@ -21,12 +21,14 @@ const Postscatchall = createRoute({ component: PostscatchallImport })
const IndexRoute = Index.update({
path: '/',
getParentRoute: () => rootRoute,
filePath: '/',
})
const PostscatchallRoute = Postscatchall.update({
path: '/posts/[...catch_all]',
getParentRoute: () => rootRoute,
hasHandler: true,
filePath: '/posts/[...catch_all]',
})
// Create and export the route tree
@@ -21,11 +21,13 @@ const Index = createRoute({ component: IndexImport })
const AboutRoute = About.update({
path: '/about',
getParentRoute: () => rootRoute,
filePath: '/about',
})
const IndexRoute = Index.update({
path: '/',
getParentRoute: () => rootRoute,
filePath: '/',
})
// Create and export the route tree
@@ -36,31 +36,37 @@ const PostsMyPost = createRoute({ component: PostsMyPostImport })
const PostslayoutRoute = Postslayout.update({
getParentRoute: () => rootRoute,
filePath: '/posts/__layout',
})
const AboutRoute = About.update({
path: '/about',
getParentRoute: () => rootRoute,
filePath: '/about',
})
const IndexRoute = Index.update({
path: '/',
getParentRoute: () => rootRoute,
filePath: '/',
})
const PostspostRoute = Postspost.update({
path: '/posts/[post]',
getParentRoute: () => PostslayoutRoute,
filePath: '/posts/[post]',
})
const PostsIndexRoute = PostsIndex.update({
path: '/posts',
getParentRoute: () => PostslayoutRoute,
filePath: '/posts/',
})
const PostsMyPostRoute = PostsMyPost.update({
path: '/posts/my-post',
getParentRoute: () => PostslayoutRoute,
filePath: '/posts/my-post',
})
// Create and export the route tree
@@ -25,16 +25,19 @@ const PostsMyPost = createRoute({ component: PostsMyPostImport })
const AboutRoute = About.update({
path: '/about',
getParentRoute: () => rootRoute,
filePath: '/about',
})
const IndexRoute = Index.update({
path: '/',
getParentRoute: () => rootRoute,
filePath: '/',
})
const PostsMyPostRoute = PostsMyPost.update({
path: '/posts/my-post',
getParentRoute: () => rootRoute,
filePath: '/posts/my-post',
})
// Create and export the route tree
@@ -21,11 +21,13 @@ const Index = createRoute({ component: IndexImport })
const AboutRoute = About.update({
path: '/about',
getParentRoute: () => rootRoute,
filePath: '/about',
})
const IndexRoute = Index.update({
path: '/',
getParentRoute: () => rootRoute,
filePath: '/',
})
// Create and export the route tree
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "tuono-router",
"version": "0.19.4",
"version": "0.19.7",
"description": "React routing component for the framework tuono. Tuono is the react/rust fullstack framework",
"homepage": "https://tuono.dev",
"scripts": {
@@ -45,12 +45,12 @@
},
"devDependencies": {
"@testing-library/react": "16.3.0",
"@types/react": "19.1.1",
"@vitejs/plugin-react-swc": "3.8.1",
"happy-dom": "17.4.4",
"@types/react": "19.1.8",
"@vitejs/plugin-react-swc": "3.11.0",
"happy-dom": "18.0.1",
"react": "19.1.0",
"vite": "6.1.3",
"vite-config": "workspace:*",
"vitest": "3.1.1"
"vitest": "3.2.4"
}
}
@@ -0,0 +1,33 @@
import type { JSX } from 'react'
import type { Mode } from '../types'
const VITE_PROXY_PATH = '/vite-server'
const CRITICAL_CSS_PATH = VITE_PROXY_PATH + '/tuono_internal__critical_css'
interface CriticalCssProps {
routeFilePath?: string
mode?: Mode
}
/**
* Returns the critical CSS for the given route
* This is required in order to avoid FOUC during development
* since vite does not support CSS injection without JS waterfall
*/
export function CriticalCss({
routeFilePath,
mode,
}: CriticalCssProps): JSX.Element | null {
if (!routeFilePath || mode !== 'Dev') {
return null
}
return (
<link
href={`${CRITICAL_CSS_PATH}?componentId=${routeFilePath}`}
precedence="high"
rel="stylesheet"
/>
)
}
@@ -2,6 +2,8 @@ import type { JSX } from 'react'
import { useRoute } from '../hooks/useRoute'
import type { Mode } from '../types'
import { RouteMatch } from './RouteMatch'
import { NotFound } from './NotFound'
import { useRouterContext } from './RouterContext'
@@ -9,16 +11,26 @@ import { useRouterContext } from './RouterContext'
interface MatchesProps<TServerPayloadData = unknown> {
// user defined props
serverInitialData: TServerPayloadData
mode?: Mode
}
export function Matches({ serverInitialData }: MatchesProps): JSX.Element {
export function Matches({
serverInitialData,
mode,
}: MatchesProps): JSX.Element {
const { location } = useRouterContext()
const route = useRoute(location.pathname)
if (!route) {
return <NotFound />
return <NotFound mode={mode} />
}
return <RouteMatch route={route} serverInitialData={serverInitialData} />
return (
<RouteMatch
route={route}
mode={mode}
serverInitialData={serverInitialData}
/>
)
}
@@ -3,17 +3,25 @@ import type { JSX } from 'react'
import { useRouterContext } from '../components/RouterContext'
import { ROOT_ROUTE_ID } from '../route'
import type { Mode } from '../types'
import { RouteMatch } from './RouteMatch'
import { NotFoundDefaultContent } from './NotFoundDefaultContent'
import { CriticalCss } from './CriticalCss'
export function NotFound(): JSX.Element | null {
export function NotFound({ mode }: { mode?: Mode }): JSX.Element | null {
const { router } = useRouterContext()
const custom404Route = router.routesById['/404']
// Check if exists a custom 404 error page
if (custom404Route) {
return <RouteMatch route={custom404Route} serverInitialData={{}} />
return (
<>
<CriticalCss routeFilePath={custom404Route.filePath} mode={mode} />
<RouteMatch route={custom404Route} mode={mode} serverInitialData={{}} />
</>
)
}
const RootLayout = router.routesById[ROOT_ROUTE_ID]?.component
@@ -22,6 +30,7 @@ export function NotFound(): JSX.Element | null {
return (
<RootLayout data={null} isLoading={false}>
<CriticalCss routeFilePath="__root__" mode={mode} />
<NotFoundDefaultContent />
</RootLayout>
)
@@ -1,16 +1,19 @@
import type { JSX } from 'react'
import { memo, Suspense, useMemo } from 'react'
import type { Mode } from '../types'
import type { Route } from '../route'
import { useServerPayloadData } from '../hooks/useServerPayloadData'
import { useRouterContext } from './RouterContext'
import { CriticalCss } from './CriticalCss'
interface RouteMatchProps<TServerPayloadData = unknown> {
route: Route
// User defined server side props
serverInitialData: TServerPayloadData
mode?: Mode
}
/**
@@ -21,6 +24,7 @@ interface RouteMatchProps<TServerPayloadData = unknown> {
export const RouteMatch = ({
route,
serverInitialData,
mode,
}: RouteMatchProps): JSX.Element => {
const { data } = useServerPayloadData(route, serverInitialData)
const { isTransitioning } = useRouterContext()
@@ -35,8 +39,10 @@ export const RouteMatch = ({
routes={routes}
data={routeData}
isLoading={isTransitioning}
mode={mode}
>
<Suspense>
<CriticalCss routeFilePath={route.filePath} mode={mode} />
<route.component data={routeData} isLoading={isTransitioning} />
</Suspense>
</TraverseRootComponents>
@@ -49,6 +55,7 @@ interface TraverseRootComponentsProps<TData = unknown> {
isLoading: boolean
children?: React.ReactNode
index?: number
mode?: Mode
}
/**
@@ -63,18 +70,26 @@ const TraverseRootComponents = memo(
data,
isLoading,
index = 0,
mode,
children,
}: TraverseRootComponentsProps): React.JSX.Element => {
if (routes.length > index) {
const Parent = (routes[index] as Route).component
const route = routes[index] as Route
const Parent = route.component
// Fallback to the route id if the filePath is not defined
// as is the case for the root route
const routeFilePath = route.filePath || route.id
return (
<Parent data={data} isLoading={isLoading}>
<CriticalCss routeFilePath={routeFilePath} mode={mode} />
<TraverseRootComponents
routes={routes}
data={data}
isLoading={isLoading}
index={index + 1}
mode={mode}
>
{children}
</TraverseRootComponents>
@@ -1,6 +1,6 @@
import type { JSX } from 'react'
import type { ServerInitialLocation } from '../types'
import type { ServerInitialLocation, Mode } from '../types'
import type { Router } from '../router'
import { RouterContextProvider } from './RouterContext'
@@ -10,19 +10,21 @@ interface RouterProviderProps {
router: Router
serverInitialLocation: ServerInitialLocation
serverInitialData: unknown
mode?: Mode
}
export function RouterProvider({
router,
serverInitialLocation,
serverInitialData,
mode,
}: RouterProviderProps): JSX.Element {
return (
<RouterContextProvider
router={router}
serverInitialLocation={serverInitialLocation}
>
<Matches serverInitialData={serverInitialData} />
<Matches serverInitialData={serverInitialData} mode={mode} />
</RouterContextProvider>
)
}
+3 -1
View File
@@ -16,7 +16,9 @@ export function sanitizePathname(pathname: string): string {
return pathname
}
/*
/**
* Returns the route that matches the given pathname
*
* This hook is also implemented on server side to match the bundle
* file to load at the first rendering.
*
+20 -3
View File
@@ -6,6 +6,7 @@ interface RouteOptions {
isRoot?: boolean
getParentRoute?: () => Route
path?: string
filePath?: string
component: RouteComponent
hasHandler?: boolean
}
@@ -19,11 +20,28 @@ export const ROOT_ROUTE_ID = '__root__'
export class Route {
options: RouteOptions
/**
* The route id is used to identify the route in the router
* and is used to match the route with the URL.
*
* For now is the `path`
*/
id?: string
isRoot: boolean
/**
* Used for identify the route by matching the URL
*/
path?: string
fullPath!: string
/**
* Utility to identify the route in the file system
* Used i.e. for finding the criticalCss to load
*
* The path does not include the file extension
*/
filePath?: string
children?: Array<Route>
parentRoute?: Route
originalIndex?: number
@@ -70,11 +88,10 @@ export class Route {
id = joinPaths(['/', id])
}
const fullPath = id === ROOT_ROUTE_ID ? '/' : path
this.filePath = this.options.filePath
this.path = path
this.id = id
this.fullPath = fullPath || ''
this.fullPath = path || ''
}
addChildren(routes: Array<Route>): this {
+2
View File
@@ -1,5 +1,7 @@
import type { ReactNode, ComponentType } from 'react'
export type Mode = 'Dev' | 'Prod'
export interface Segment {
type: 'pathname' | 'param' | 'wildcard'
value: string
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "tuono",
"version": "0.19.4",
"version": "0.19.7",
"description": "Superfast React fullstack framework",
"homepage": "https://tuono.dev",
"scripts": {
@@ -71,7 +71,7 @@
"@rollup/plugin-inject": "^5.0.5",
"@vitejs/plugin-react-swc": "^3.8.0",
"fast-text-encoding": "^1.0.6",
"tuono-fs-router-vite-plugin": "workspace:*",
"tuono-react-vite-plugin": "workspace:*",
"tuono-router": "workspace:*",
"url-search-params-polyfill": "^8.2.5",
"vite": "^6.1.1",
@@ -80,13 +80,13 @@
"devDependencies": {
"@types/babel__core": "7.20.5",
"@types/babel__traverse": "7.20.7",
"@types/node": "22.14.1",
"@types/react": "19.1.1",
"@types/react-dom": "19.1.2",
"@types/node": "22.16.5",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"react": "19.1.0",
"react-dom": "19.1.0",
"vite-config": "workspace:*",
"vitest": "3.1.1"
"vitest": "3.2.4"
},
"sideEffects": false,
"keywords": [
+2 -2
View File
@@ -2,7 +2,7 @@ import type { InlineConfig, Plugin } from 'vite'
import { build, createServer, mergeConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
import inject from '@rollup/plugin-inject'
import { TuonoFsRouterPlugin } from 'tuono-fs-router-vite-plugin'
import { TuonoReactPlugin } from 'tuono-react-vite-plugin'
import type { TuonoConfig } from '../config'
@@ -71,7 +71,7 @@ function createBaseViteConfigFromTuonoConfig(
// @ts-expect-error see above comment
react({ include: pluginFilesInclude }),
TuonoFsRouterPlugin(),
TuonoReactPlugin(),
],
}
+1 -1
View File
@@ -2,8 +2,8 @@ import type { JSX } from 'react'
import type { TuonoConfigServer } from '../config'
const VITE_PROXY_PATH = '/vite-server'
const DEFAULT_SERVER_CONFIG = { host: 'localhost', origin: null, port: 3000 }
const VITE_PROXY_PATH = '/vite-server'
interface DevResourcesProps {
devServerConfig?: TuonoConfigServer
@@ -2,10 +2,13 @@ import type { JSX } from 'react'
import { RouterProvider } from 'tuono-router'
import type { RouterInstanceType } from 'tuono-router'
import type { Mode } from '../types'
import { useTuonoContextServerPayload } from './TuonoContext'
interface RouterContextProviderWrapperProps {
router: RouterInstanceType
mode?: Mode
}
/**
@@ -17,6 +20,7 @@ interface RouterContextProviderWrapperProps {
*/
export function RouterContextProviderWrapper({
router,
mode,
}: RouterContextProviderWrapperProps): JSX.Element {
const serverPayload = useTuonoContextServerPayload()
@@ -25,6 +29,7 @@ export function RouterContextProviderWrapper({
router={router}
serverInitialLocation={serverPayload.location}
serverInitialData={serverPayload.data}
mode={mode}
/>
)
}
@@ -19,7 +19,10 @@ export function TuonoEntryPoint({
return (
<StrictMode>
<TuonoContextProvider serverPayload={serverPayload}>
<RouterContextProviderWrapper router={router} />
<RouterContextProviderWrapper
router={router}
mode={serverPayload?.mode}
/>
</TuonoContextProvider>
</StrictMode>
)
-1
View File
@@ -1 +0,0 @@
export type Mode = 'Dev' | 'Prod'
+2
View File
@@ -2,6 +2,8 @@ import type { ReactNode } from 'react'
import type { TuonoConfigServer } from './config'
export type Mode = 'Dev' | 'Prod'
/**
* Provided by the rust server and used in the ssr env
* @see tuono-router {@link ServerInitialLocation}
+909 -793
View File
File diff suppressed because it is too large Load Diff