Compare commits

...

24 Commits

Author SHA1 Message Date
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
Valerio Ageno 22844a37fa chore: bump version (v0.19.4) (#748) 2025-04-20 11:05:30 +02:00
Valerio Ageno bc444929ed feat: add automatic types generation (#703) 2025-04-20 11:04:48 +02:00
renovate[bot] 0813c14e15 fix(deps): update babel monorepo (#750)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-14 15:25:40 +02:00
renovate[bot] 5331cc0c8c chore(deps): update dependency typescript to v5.8.3 (#749)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-14 15:11:38 +02:00
renovate[bot] ba89a7e388 fix(deps): update babel monorepo (#747)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-14 14:59:19 +02:00
renovate[bot] 90a4d20436 chore(deps): update dependency oxc-transform to v0.63.0 (#744)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-14 12:01:15 +02:00
renovate[bot] d938e82998 chore(deps): update dependency typescript to v5.8.3 (#745)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-14 12:01:02 +02:00
renovate[bot] fc15c01c90 chore(deps): update dependency typescript-eslint to v8.29.1 (#742)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-14 09:50:09 +02:00
renovate[bot] ffdf0f91ac chore(deps): update react monorepo (#743)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-14 09:42:45 +02:00
renovate[bot] fa6b2f4c89 chore(deps): update dependency @vitest/eslint-plugin to v1.1.42 (#741)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-14 09:18:20 +02:00
renovate[bot] 245b5c535b chore(deps): update dependency @types/node to v22.14.1 (#740)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-14 09:04:36 +02:00
Valerio Ageno 2bb0bdb2b0 chore: bump version (v0.19.3) (#732) 2025-04-10 21:50:59 +02:00
Valerio Ageno e368cc5e23 fix: old data kept on new route during transition (#730)
Co-authored-by: Marco Pasqualetti <marco.pasqualetti@live.com>
2025-04-10 21:50:16 +02:00
Valerio Ageno eb4966e88e chore: dedupe dependencies (#734) 2025-04-09 10:05:36 +02:00
99 changed files with 2170 additions and 470 deletions
+4 -20
View File
@@ -43,7 +43,7 @@ jobs:
NAMES=$(find . -mindepth 1 -maxdepth 1 -type d -exec basename {} \;)
echo "names=$(echo "$NAMES" | jq -R -s -c 'split("\n")[:-1]')" >> "$GITHUB_OUTPUT"
check_rust:
check_example:
needs:
- example_list
@@ -87,26 +87,11 @@ jobs:
working-directory: ./examples/${{ matrix.example_name }}
run: ../../target/debug/tuono build
check_typescript:
name: Check typescript
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install NodeJS Dependencies
uses: ./.github/actions/install-node-dependencies
- name: Build typescript
run: pnpm run build
- name: Check formatting
run: pnpm run examples:format
run: pnpm run format
- name: Typecheck
run: pnpm run examples:typecheck
run: pnpm run typecheck
ci_ok:
name: Examples CI OK
@@ -115,8 +100,7 @@ jobs:
if: always()
needs:
- example_list
- check_rust
- check_typescript
- check_example
steps:
- name: Exit with error if some jobs are not successful
run: exit 1
+5 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "tuono"
version = "0.19.2"
version = "0.19.6"
edition = "2024"
authors = ["V. Ageno <valerioageno@yahoo.it>"]
description = "Superfast React fullstack framework"
@@ -18,11 +18,13 @@ path = "src/lib.rs"
[dependencies]
clap = { version = "4.5.4", features = ["derive", "cargo"] }
syn = { version = "2.0.100", features = ["full"] }
tracing = "0.1.41"
tracing-subscriber = {version = "0.3.19", features = ["env-filter"]}
miette = "7.2.0"
colored = "2.1.0"
once_cell = "1.19.0"
watchexec = "5.0.0"
watchexec-signals = "4.0.0"
watchexec-events = "4.0.0"
@@ -35,9 +37,10 @@ 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.2"}
tuono_internal = {path = "../tuono_internal", version = "0.19.6"}
spinners = "4.1.1"
console = "0.15.10"
convert_case = "0.8.0"
[dev-dependencies]
wiremock = "0.6.2"
+1 -2
View File
@@ -1,7 +1,6 @@
use crate::mode::Mode;
use crate::route::Route;
use glob::GlobError;
use glob::glob;
use glob::{GlobError, glob};
use http::Method;
use std::collections::hash_set::HashSet;
use std::collections::{HashMap, hash_map::Entry};
+62 -20
View File
@@ -1,8 +1,13 @@
use once_cell::sync::Lazy;
use regex::Regex;
use std::borrow::Cow;
use std::collections::HashSet;
use std::fs;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::RwLock;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tracing::error;
use tuono_internal::tuono_println;
use watchexec_events::Tag;
use watchexec_events::filekind::FileEventKind;
@@ -17,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()
@@ -25,14 +36,19 @@ 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(
clippy::await_holding_lock,
reason = "At this point there is no other thread waiting for the lock"
)]
async fn start_all_processes(process_manager: Arc<Mutex<ProcessManager>>) {
async unsafe fn start_all_processes(process_manager: Arc<Mutex<ProcessManager>>) {
if let Ok(mut pm) = process_manager.lock() {
pm.start_dev_processes().await
}
@@ -59,7 +75,12 @@ pub async fn watch(source_builder: SourceBuilder) -> Result<()> {
let env_files = detect_existing_env_files();
start_all_processes(process_manager.clone()).await;
unsafe {
// It is safe to call this function because here
// only one thread is running and the lock is not
// needed by any other thread.
start_all_processes(process_manager.clone()).await;
}
// Remove the spinner
sp.stop();
@@ -94,38 +115,59 @@ pub async fn watch(source_builder: SourceBuilder) -> Result<()> {
let mut should_reload_rust_server = false;
let mut should_refresh_axum_source = false;
// Using HashSet to avoid duplicates
let mut paths_to_refresh_types: HashSet<PathBuf> = HashSet::new();
let mut removed_files_from_types: HashSet<PathBuf> = HashSet::new();
for event in action.events.iter() {
for event_type in event.tags.iter() {
if let Tag::FileEventKind(kind) = event_type {
match kind {
FileEventKind::Remove(_) => {
if event.paths().any(|(path, _)| {
path.extension().is_some_and(|ext| ext == "rs") ||
// APIs might define new HTTP methods that requires
// a refresh of the axum source
path.to_str().unwrap_or("").contains("api")
}) {
FileEventKind::Remove(_) => event.paths().for_each(|(path, _)| {
if path.extension().is_some_and(|ext| ext == "rs") {
removed_files_from_types.insert(path.to_path_buf());
should_refresh_axum_source = true;
}
}
FileEventKind::Modify(_) => {
if event
.paths()
.any(|(path, _)| path.extension().is_some_and(|ext| ext == "rs"))
{
}),
FileEventKind::Modify(_) => event.paths().for_each(|(path, _)| {
if path.extension().is_some_and(|ext| ext == "rs") {
should_reload_rust_server = true;
paths_to_refresh_types.insert(path.to_path_buf());
}
if event.paths().any(|(path, _)| ssr_reload_needed(path)) {
if ssr_reload_needed(path) {
should_reload_ssr_bundle = true;
}
}
}),
_ => {}
}
}
}
}
if !paths_to_refresh_types.is_empty() {
if let Ok(mut builder) = source_builder.write() {
for path in paths_to_refresh_types {
// There is no need to check here if the `Type` trait is
// derived since it will be checked later by the TypeJar struct.
builder.refresh_typescript_file(path)
}
if builder.generate_typescript_file().is_err() {
error!("Failed to generate typescript file");
};
}
}
if !removed_files_from_types.is_empty() {
if let Ok(mut builder) = source_builder.write() {
for path in removed_files_from_types {
builder.remove_typescript_file(path);
}
if builder.generate_typescript_file().is_err() {
error!("Failed to generate typescript file");
};
}
}
if should_reload_rust_server || should_refresh_axum_source {
tuono_println!("Reloading...");
if should_refresh_axum_source {
+2
View File
@@ -10,3 +10,5 @@ mod mode;
mod process_manager;
mod route;
mod source_builder;
mod symbols;
mod typescript;
+2 -2
View File
@@ -48,7 +48,7 @@ impl ProcessManager {
let mut processes = HashMap::new();
processes.insert(
ProcessId::WatchReactSrc,
start_supervisor_job(DEV_WATCH_BIN_SRC, vec![]),
start_supervisor_job(DEV_WATCH_BIN_SRC, Vec::new()),
);
processes.insert(
@@ -63,7 +63,7 @@ impl ProcessManager {
processes.insert(
ProcessId::BuildReactSSRSrc,
start_supervisor_job(DEV_SSR_BIN_SRC, vec![]),
start_supervisor_job(DEV_SSR_BIN_SRC, Vec::new()),
);
Self { processes }
+24 -2
View File
@@ -11,6 +11,7 @@ use crate::app::App;
use crate::mode::Mode;
use crate::route::AxumInfo;
use crate::route::Route;
use crate::typescript::TypesJar;
#[cfg(not(target_os = "windows"))]
const FALLBACK_HTML: &str = include_str!("../templates/fallback.html");
@@ -59,6 +60,7 @@ pub struct SourceBuilder {
pub app: App,
mode: Mode,
base_path: PathBuf,
types_jar: TypesJar,
}
impl SourceBuilder {
@@ -79,20 +81,23 @@ impl SourceBuilder {
Ok(Self {
app,
mode,
types_jar: TypesJar::from(&base_path),
base_path,
})
}
// Build the source code needed for both build and dev
pub fn base_build(&mut self) -> io::Result<()> {
let Self { mode, .. } = &self;
let mode = self.mode.clone();
self.refresh_axum_source()?;
let dev_folder = Path::new(DEV_FOLDER);
self.create_file(dev_folder.join("server-main.tsx"), SERVER_ENTRY_DATA)?;
self.create_file(dev_folder.join("client-main.tsx"), CLIENT_ENTRY_DATA)?;
if mode == &Mode::Dev {
self.types_jar.generate_typescript_file(&self.base_path)?;
if mode == Mode::Dev {
self.app.build_tuono_config()?;
let fallback_html = self.build_html_fallback();
self.create_file(PathBuf::from(FALLBACK_HTML_PATH), &fallback_html)?;
@@ -165,6 +170,18 @@ impl SourceBuilder {
Ok(())
}
pub fn refresh_typescript_file(&mut self, path: PathBuf) {
self.types_jar.refresh_file(path);
}
pub fn remove_typescript_file(&mut self, path: PathBuf) {
self.types_jar.remove_file(path);
}
pub fn generate_typescript_file(&mut self) -> io::Result<()> {
self.types_jar.generate_typescript_file(&self.base_path)
}
fn create_routes_declaration(&self) -> String {
let routes = &self.app.route_map;
let mut route_declarations = String::from("// ROUTE_BUILDER\n");
@@ -244,6 +261,7 @@ mod tests {
app: App::new(),
mode: Mode::Dev,
base_path: PathBuf::new(),
types_jar: TypesJar::default(),
}
.generate_axum_source();
@@ -251,6 +269,7 @@ mod tests {
app: App::new(),
mode: Mode::Prod,
base_path: PathBuf::new(),
types_jar: TypesJar::default(),
}
.generate_axum_source();
@@ -264,6 +283,7 @@ mod tests {
app: App::new(),
mode: Mode::Dev,
base_path: PathBuf::new(),
types_jar: TypesJar::default(),
}
.generate_axum_source();
@@ -276,6 +296,7 @@ mod tests {
app: App::new(),
mode: Mode::Dev,
base_path: PathBuf::new(),
types_jar: TypesJar::default(),
};
let mut route = Route::new(String::from("index.tsx"));
@@ -300,6 +321,7 @@ mod tests {
app,
mode: Mode::Dev,
base_path: PathBuf::new(),
types_jar: TypesJar::default(),
};
let fallback_html = source_builder.build_html_fallback();
+29
View File
@@ -0,0 +1,29 @@
use std::ops::Deref;
use syn::Ident;
pub struct Symbol(&'static str);
// The trait name that allow automatic struct/enum/type conversion
// to typescript
pub const TYPE_TRAIT: Symbol = Symbol("Type");
impl PartialEq<Symbol> for Ident {
fn eq(&self, word: &Symbol) -> bool {
self == word.0
}
}
impl PartialEq<Symbol> for &Ident {
fn eq(&self, word: &Symbol) -> bool {
*self == word.0
}
}
impl Deref for Symbol {
type Target = &'static str;
fn deref(&self) -> &Self::Target {
&self.0
}
}
+99
View File
@@ -0,0 +1,99 @@
use super::utils::has_derive_type;
use crate::typescript::parser::{parse_enum, parse_struct};
use std::error::Error;
use std::path::PathBuf;
use tracing::trace;
/// Represents all the valid typescript types found in a file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileTypes {
/// Rust file when the type was found
pub file_path: PathBuf,
/// All the types found in the file
/// ready to be printed in the typescript file
pub types_as_string: String,
/// The types found in the file.
/// Used to check that the types are not duplicated across files.
pub types: Vec<String>,
}
impl TryFrom<(PathBuf, String)> for FileTypes {
type Error = Box<dyn Error>;
fn try_from((file_path, file_str): (PathBuf, String)) -> Result<Self, Self::Error> {
trace!("Parsing file: {:?}", &file_path);
let file = syn::parse_file(&file_str)?;
let mut types_as_string = String::new();
let mut types = Vec::new();
for item in file.items {
match item {
syn::Item::Struct(element) => {
if !has_derive_type(&element.attrs) {
continue;
}
trace!("Found struct in file: {:?}", &file_path);
let (struct_name, typescript_definition) = parse_struct(&element);
types_as_string.push_str(&typescript_definition);
types.push(struct_name);
}
syn::Item::Enum(element) => {
if !has_derive_type(&element.attrs) {
continue;
}
trace!("Found enum in file: {:?}", &file_path);
let (enum_name, typescript_definition) = parse_enum(&element);
types_as_string.push_str(&typescript_definition);
types.push(enum_name);
}
_ => {}
}
}
if types_as_string.is_empty() {
return Err("No types found in the file".into());
}
Ok(Self {
file_path,
types_as_string,
types,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_correctly_creates_type_from_pathbuf_and_string() {
let file_path = PathBuf::from("src/types.rs");
let file_str = r#"
#[derive(Type)]
struct MyStruct {
field1: String,
field2: i32,
}
#[derive(Type)]
enum MyEnum {
Variant1,
Variant2,
}
"#
.to_string();
let ttype = FileTypes::try_from((file_path.clone(), file_str)).unwrap();
assert_eq!(ttype.file_path, file_path);
assert_eq!(
ttype.types_as_string,
"export interface MyStruct {\n field1: string;\n field2: number;\n}\nexport type MyEnum = \"Variant1\" | \"Variant2\";\n"
);
assert_eq!(ttype.types, vec!["MyStruct", "MyEnum"]);
}
}
+8
View File
@@ -0,0 +1,8 @@
mod file_types;
pub mod parser;
mod types_jar;
pub mod utils;
pub use file_types::*;
pub use types_jar::*;
@@ -0,0 +1,6 @@
mod parse_enum;
mod parse_struct;
pub mod utils;
pub use parse_enum::*;
pub use parse_struct::*;
@@ -0,0 +1,240 @@
use crate::typescript::parser::utils::{
RenameSerdeOptions, get_field_name, parse_generics_to_typescript_string, parse_serde_attribute,
rust_to_typescript_type, should_skip_element,
};
/// Parse a rust enum and returns a tuple of the enum name and the
/// enum compiled to a typescript type
pub fn parse_enum(element: &syn::ItemEnum) -> (String, String) {
let enum_name = element.ident.to_string();
let generics = parse_generics_to_typescript_string(element.generics.clone().params);
let mut enum_variants: Vec<String> = Vec::new();
let rename_option: RenameSerdeOptions = parse_serde_attribute(&element.attrs, "rename_all");
for variant in &element.variants {
if should_skip_element(&variant.attrs) {
continue;
}
let mut parsed_variant = rename_option.transform(variant.ident.to_string());
match &variant.fields {
syn::Fields::Named(field) => {
parsed_variant = format!("{{\"{}\": {{ ", parsed_variant);
let mut variant_fields: Vec<String> = Vec::new();
for field in &field.named {
if should_skip_element(&field.attrs) {
continue;
}
let field_name = rename_option.transform(get_field_name(field));
let field_type = rust_to_typescript_type(&field.ty);
variant_fields.push(format!("{field_name}: {field_type}"));
}
enum_variants.push(format!(
"{parsed_variant}{} }}}}",
variant_fields.join(", ")
));
}
syn::Fields::Unnamed(field) => {
let mut variant_fields: Vec<String> = Vec::new();
for field in &field.unnamed {
let field_type = rust_to_typescript_type(&field.ty);
variant_fields.push(field_type);
}
enum_variants.push(format!(
"{{\"{parsed_variant}\": [{}]}}",
variant_fields.join(", ")
));
}
syn::Fields::Unit => {
enum_variants.push(format!("\"{parsed_variant}\""));
}
}
}
let enum_type = format!(
"export type {enum_name}{generics} = {};\n",
enum_variants.join(" | ")
);
(enum_name, enum_type)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_correctly_parse_a_simple_enum() {
let enum_str = r#"
#[derive(Type)]
enum MyEnum {
Variant1,
Variant2,
Variant3
}
"#;
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");
assert_eq!(
typescript_definition,
"export type MyEnum = \"Variant1\" | \"Variant2\" | \"Variant3\";\n"
);
}
#[test]
fn it_correctly_apply_rename_all_modifier() {
let enum_str = r#"
#[derive(Type)]
#[serde(rename_all = "lowercase")]
enum MyEnum {
Id,
Name,
UserAge
}
"#;
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");
assert_eq!(
typescript_definition,
"export type MyEnum = \"id\" | \"name\" | \"userage\";\n"
);
}
#[test]
fn it_correctly_skips_a_variant() {
let enum_str = r#"
#[derive(Type)]
#[serde(rename_all = "lowercase")]
enum MyEnum {
Id,
Name,
UserAge,
#[serde(skip)]
SkipMe
}
"#;
let parsed_enum = syn::parse_str::<syn::ItemEnum>(&enum_str).unwrap();
let (_, typescript_definition) = parse_enum(&parsed_enum);
assert_eq!(
typescript_definition,
"export type MyEnum = \"id\" | \"name\" | \"userage\";\n"
);
}
#[test]
fn it_correctly_parse_struct_variant() {
let enum_str = r#"
#[derive(Type)]
enum MyEnum {
Id,
User {
name: String,
age: u32,
},
}
"#;
let parsed_enum = syn::parse_str::<syn::ItemEnum>(&enum_str).unwrap();
let (_, typescript_definition) = parse_enum(&parsed_enum);
assert_eq!(
typescript_definition,
"export type MyEnum = \"Id\" | {\"User\": { name: string, age: number }};\n"
);
let enum_str = r#"
#[derive(Type)]
enum MyEnum {
Id,
User {
name: String,
#[serde(skip)]
age: u32,
},
}
"#;
let parsed_enum = syn::parse_str::<syn::ItemEnum>(&enum_str).unwrap();
let (_, typescript_definition) = parse_enum(&parsed_enum);
assert_eq!(
typescript_definition,
"export type MyEnum = \"Id\" | {\"User\": { name: string }};\n"
);
let enum_str = r#"
#[derive(Type)]
enum MyEnum {
Request {
body: Bytes
},
Response{
payload: Bytes
},
}
"#;
let parsed_enum = syn::parse_str::<syn::ItemEnum>(&enum_str).unwrap();
let (_, typescript_definition) = parse_enum(&parsed_enum);
assert_eq!(
typescript_definition,
"export type MyEnum = {\"Request\": { body: Bytes }} | {\"Response\": { payload: Bytes }};\n"
);
}
#[test]
fn it_correctly_parse_the_generic_type() {
let enum_str = r#"
#[derive(Type)]
enum MyEnum<T> {
Id,
User {
name: T,
age: u32
},
}
"#;
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");
assert_eq!(
typescript_definition,
"export type MyEnum<T> = \"Id\" | {\"User\": { name: T, age: number }};\n"
);
}
#[test]
fn it_correctly_parse_the_tuple_variant() {
let enum_str = r#"
#[derive(Type)]
enum MyEnum {
Id,
User(String, u32),
}
"#;
let parsed_enum = syn::parse_str::<syn::ItemEnum>(&enum_str).unwrap();
let (_, typescript_definition) = parse_enum(&parsed_enum);
assert_eq!(
typescript_definition,
"export type MyEnum = \"Id\" | {\"User\": [string, number]};\n"
);
}
}
@@ -0,0 +1,214 @@
use crate::typescript::parser::utils::{
RenameSerdeOptions, get_field_name, parse_generics_to_typescript_string, parse_serde_attribute,
rust_to_typescript_type,
};
use super::utils::should_skip_element;
/// Parse a rust struct and returns a tuple of the struct name and the
/// struct compiled to a typescript interface
pub fn parse_struct(element: &syn::ItemStruct) -> (String, String) {
let struct_name = element.ident.to_string();
let generics = parse_generics_to_typescript_string(element.generics.clone().params);
let mut fields_as_string = format!("export interface {struct_name}{generics} {{\n");
let rename_option: RenameSerdeOptions = parse_serde_attribute(&element.attrs, "rename_all");
for field in &element.fields {
if should_skip_element(&field.attrs) {
continue;
}
let field_name = get_field_name(field);
let field_type = rust_to_typescript_type(&field.ty);
let field_name = rename_option.transform(field_name);
fields_as_string.push_str(&format!(" {field_name}: {field_type};\n"));
}
fields_as_string.push_str("}\n");
(struct_name, fields_as_string)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_correctly_parses_struct() {
let struct_str = r#"
#[derive(Type)]
struct MyStruct<'a, T, R>{
field_1: &str,
field2: i32,
field3: Option<String>,
field4: Vec<i32>,
record: HashMap<&'a str, i32>,
user: User,
generic: T,
generic2: HashMap<&mut str, R>,
btree: BTreeMap<String, i32>,
}
"#;
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
let (struct_name, typescript_definition) = parse_struct(&parsed_struct);
assert_eq!(struct_name, "MyStruct");
assert_eq!(
typescript_definition,
"export interface MyStruct<T, R> {\n field_1: string;\n field2: number;\n field3: string | null;\n field4: number[];\n record: Record<string, number>;\n user: User;\n generic: T;\n generic2: Record<string, R>;\n btree: Record<string, number>;\n}\n"
);
}
#[test]
fn it_correctly_parse_tuple_fields() {
let struct_str = r#"
#[derive(Type)]
struct MyStruct {
tuple: (i32, i32, String, User),
}"#;
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
let (_, typescript_definition) = parse_struct(&parsed_struct);
assert_eq!(
typescript_definition,
"export interface MyStruct {\n tuple: [number, number, string, User];\n}\n"
);
}
#[test]
fn it_correctly_parses_struct_with_no_generics() {
let struct_str = r#"
#[derive(Type)]
struct MyStruct {
field_1: &str,
}
"#;
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
let (struct_name, typescript_definition) = parse_struct(&parsed_struct);
assert_eq!(struct_name, "MyStruct");
assert_eq!(
typescript_definition,
"export interface MyStruct {\n field_1: string;\n}\n"
);
}
#[test]
fn it_correctly_turn_the_keys_camel_case() {
let struct_str = r#"
#[derive(Type)]
#[serde(rename_all = "camelCase")]
struct MyStruct {
field_one: &str,
field_two: i32,
}
"#;
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
let (struct_name, typescript_definition) = parse_struct(&parsed_struct);
assert_eq!(struct_name, "MyStruct");
assert_eq!(
typescript_definition,
"export interface MyStruct {\n fieldOne: string;\n fieldTwo: number;\n}\n"
);
}
#[test]
fn it_correctly_turn_the_keys_pascal_case() {
let struct_str = r#"
#[derive(Type)]
#[serde(rename_all = "PascalCase")]
struct MyStruct {
field_one: &str,
field_two: i32,
}
"#;
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
let (struct_name, typescript_definition) = parse_struct(&parsed_struct);
assert_eq!(struct_name, "MyStruct");
assert_eq!(
typescript_definition,
"export interface MyStruct {\n FieldOne: string;\n FieldTwo: number;\n}\n"
);
}
#[test]
fn it_correctly_retrieve_the_serde_rename_option() {
let struct_str = r#"
#[derive(Type)]
#[serde(rename_all = "PascalCase")]
struct MyStruct {
field_one: &str,
}
"#;
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
let rename_option: RenameSerdeOptions =
parse_serde_attribute(&parsed_struct.attrs, "rename_all");
assert_eq!(rename_option, RenameSerdeOptions::PascalCase);
let struct_str = r#"
#[derive(Type)]
#[serde(rename_all = "camelCase")]
struct MyStruct {
field_one: &str,
}"#;
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
let rename_option: RenameSerdeOptions =
parse_serde_attribute(&parsed_struct.attrs, "rename_all");
assert_eq!(rename_option, RenameSerdeOptions::CamelCase);
}
#[test]
fn it_correctly_identifies_skip_fields() {
let struct_str = r#"
#[derive(Type)]
struct MyStruct {
#[serde(skip)]
field_one: &str,
field_two: i32,
#[serde(skip_serializing)]
field_three: i32,
}
"#;
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
let (_, typescript_definition) = parse_struct(&parsed_struct);
assert_eq!(
typescript_definition,
"export interface MyStruct {\n field_two: number;\n}\n"
);
}
#[test]
fn it_correctly_override_a_field_name() {
let struct_str = r#"
#[derive(Type)]
struct MyStruct {
#[serde(rename = "field_one")]
field_two: i32,
}
"#;
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
let (_, typescript_definition) = parse_struct(&parsed_struct);
assert_eq!(
typescript_definition,
"export interface MyStruct {\n field_one: number;\n}\n"
);
}
}
+217
View File
@@ -0,0 +1,217 @@
use convert_case::{Case, Casing};
use std::str::FromStr;
use syn::punctuated::Punctuated;
use syn::token::Comma;
use syn::{GenericArgument, GenericParam, PathArguments};
fn type_to_typescript(type_name: &str) -> &str {
match type_name {
"i8" | "i16" | "i32" | "i64" | "i128" | "u8" | "u16" | "u32" | "u64" | "f32" | "f64"
| "isize" | "usize" => "number",
"str" | "String" | "char" => "string",
"bool" => "boolean",
_ => type_name,
}
}
pub fn get_field_name(field: &syn::Field) -> String {
let field_name: String = parse_serde_attribute(&field.attrs, "rename");
if !field_name.is_empty() {
return field_name;
}
if let Some(field) = field.ident.as_ref() {
field.to_string()
} else {
String::from("unknown")
}
}
/// Parse the struct generics and return them collected into a "<...>" string.
/// If no generics are present, return an empty string.
pub fn parse_generics_to_typescript_string(generics: Punctuated<GenericParam, Comma>) -> String {
let generics = generics
.iter()
.map(|param| {
if let syn::GenericParam::Type(type_param) = param {
type_param.ident.to_string()
} else {
String::new()
}
})
.filter(|name| !name.is_empty())
.collect::<Vec<String>>();
if !generics.is_empty() {
return format!("<{}>", generics.join(", "));
}
String::new()
}
/// Parse any "assign" `#[serde(... = "...")]` attribute and return the value of the specified
/// attribute.
pub fn parse_serde_attribute<T>(attrs: &[syn::Attribute], attribute_name: &str) -> T
where
T: Default + FromStr,
{
for attr in attrs {
if attr.path().is_ident("serde") {
if let Ok(meta) = attr.parse_args::<syn::Expr>() {
match meta {
syn::Expr::Assign(assign) => {
if let syn::Expr::Path(path) = *assign.left {
if !path.path.is_ident(attribute_name) {
return T::default();
}
}
if let syn::Expr::Lit(syn::ExprLit {
lit: syn::Lit::Str(lit_str),
..
}) = *assign.right
{
return T::from_str(&lit_str.value()).unwrap_or_default();
}
}
_ => return T::default(),
}
}
}
}
T::default()
}
/// Check if the element should be skipped based on the presence of
/// `#[serde(skip)]` or `#[serde(skip_serializing)]` attributes.
pub fn should_skip_element(attrs: &[syn::Attribute]) -> bool {
for attr in attrs {
if attr.path().is_ident("serde") {
if let Ok(meta) = attr.parse_args::<syn::Ident>() {
if meta == "skip" || meta == "skip_serializing" {
return true;
}
}
}
}
false
}
pub fn rust_to_typescript_type(ty: &syn::Type) -> String {
match ty {
syn::Type::Tuple(tuple) => {
let inner_types: Vec<String> =
tuple.elems.iter().map(rust_to_typescript_type).collect();
format!("[{}]", inner_types.join(", "))
}
syn::Type::Path(type_path) => {
if let Some(last_segment) = type_path.path.segments.last() {
let outer_type = last_segment.ident.to_string();
if let PathArguments::AngleBracketed(args) = &last_segment.arguments {
let inner_types: Vec<String> = args
.args
.iter()
.filter_map(|arg| {
if let GenericArgument::Type(inner_type) = arg {
match inner_type {
syn::Type::Path(inner_type_path) => {
Some(inner_type_path.path.segments[0].ident.to_string())
}
syn::Type::Reference(reference) => {
if let syn::Type::Path(inner_type_path) = &*reference.elem {
Some(inner_type_path.path.segments[0].ident.to_string())
} else {
Some("unknown".to_string())
}
}
_ => Some("unknown".to_string()),
}
} else {
None
}
})
.collect();
match outer_type.as_str() {
"Option" => {
format!("{} | null", type_to_typescript(&inner_types[0]))
}
"Vec" => {
format!("{}[]", type_to_typescript(&inner_types[0]))
}
"HashMap" | "BTreeMap" => {
format!(
"Record<{}, {}>",
type_to_typescript(&inner_types[0]),
type_to_typescript(&inner_types[1])
)
}
_ => "unknown".to_string(),
}
} else {
type_to_typescript(&outer_type).to_string()
}
} else {
"unknown".to_string()
}
}
syn::Type::Reference(reference) => {
// Ignore lifetimes and treat references as their base type
if let syn::Type::Path(type_path) = &*reference.elem {
if let Some(base_type) = type_path.path.segments.last() {
type_to_typescript(&base_type.ident.to_string()).to_string()
} else {
"unknown".to_string()
}
} else {
"unknown".to_string()
}
}
_ => "unknown".to_string(),
}
}
// This enum matches serde's RenameRule enum
#[derive(Debug, Eq, PartialEq, Default)]
pub enum RenameSerdeOptions {
#[default]
None,
LowerCase,
UpperCase,
PascalCase,
CamelCase,
SnakeCase,
ScreamingSnakeCase,
KebabCase,
ScreamingKebabCase,
}
impl FromStr for RenameSerdeOptions {
type Err = ();
fn from_str(input: &str) -> Result<Self, Self::Err> {
match input {
"lowercase" => Ok(Self::LowerCase),
"UPPERCASE" => Ok(Self::UpperCase),
"PascalCase" => Ok(Self::PascalCase),
"camelCase" => Ok(Self::CamelCase),
"snake_case" => Ok(Self::SnakeCase),
"SCREAMING_SNAKE_CASE" => Ok(Self::ScreamingSnakeCase),
"kebab-case" => Ok(Self::KebabCase),
"SCREAMING-KEBAB-CASE" => Ok(Self::ScreamingKebabCase),
_ => Err(()),
}
}
}
impl RenameSerdeOptions {
pub fn transform(&self, input: String) -> String {
match self {
Self::LowerCase => input.to_lowercase(),
Self::UpperCase => input.to_uppercase(),
Self::CamelCase => input.to_case(Case::Camel),
Self::PascalCase => input.to_case(Case::Pascal),
_ => input,
}
}
}
+259
View File
@@ -0,0 +1,259 @@
use crate::symbols::TYPE_TRAIT;
use crate::typescript::FileTypes;
use glob::glob;
use std::collections::HashMap;
use std::env;
use std::fs::read_to_string;
use std::path::{Path, PathBuf};
use tracing::{error, trace};
use tuono_internal::tuono_println;
#[derive(Debug, Clone, Default)]
pub struct TypesJar {
types: Vec<FileTypes>,
should_generate_typescript_file: bool,
}
impl TypesJar {
pub fn new() -> Self {
Self {
types: Vec::new(),
should_generate_typescript_file: true,
}
}
}
impl TypesJar {
/// Removes all types from the jar that are
/// present in the provided `file_path`.
/// This function is triggered when a file is deleted
pub fn remove_file(&mut self, file_path: PathBuf) {
self.should_generate_typescript_file = true;
self.types.retain(|ttype| ttype.file_path != file_path);
}
pub fn refresh_file(&mut self, path: PathBuf) {
if let Ok(file_str) = read_to_string(&path) {
if file_str.contains(*TYPE_TRAIT) {
if let Ok(ttype) = FileTypes::try_from((path.clone(), file_str)) {
if Some(&ttype) == self.types.iter().find(|t| t.file_path == path) {
// The new file exactly matches the old one
trace!("File already exists in jar: {:?}", path);
return;
}
trace!("Refreshing: {:?} type", ttype.types);
self.should_generate_typescript_file = true;
self.remove_file(path);
self.types.push(ttype);
} else {
error!("Failed to parse file: {:?}", path);
}
} else {
// Check if the file exist. In case it is it means that the user
// removed the "Type" derived trait. Hence we have to remove it from
// the jar
self.remove_file(path.clone());
}
} else {
error!("Failed to read file: {:?}", path);
}
}
pub fn check_duplicate_types(&self) -> HashMap<String, (PathBuf, PathBuf)> {
trace!("Checking for duplicated types");
let mut duplicates: HashMap<String, (PathBuf, PathBuf)> = HashMap::new();
let mut paths: Vec<&PathBuf> = Vec::new();
let mut types: Vec<&Vec<String>> = Vec::new();
for file_types in self.types.iter() {
paths.push(&file_types.file_path);
types.push(&file_types.types);
}
for i in 0..types.len() {
for j in (i + 1)..types.len() {
let types_i = types[i];
let types_j = types[j];
for type_i in types_i {
if types_j.contains(type_i) {
duplicates.insert(type_i.clone(), (paths[j].clone(), paths[i].clone()));
}
}
}
}
duplicates
}
fn log_duplicated_types(&self) {
let duplicates = self.check_duplicate_types();
let base_path = env::current_dir().unwrap_or_default();
let base_path_str = base_path.to_string_lossy();
for (type_name, file_paths) in duplicates.iter() {
let first_file_path = file_paths
.0
.to_string_lossy()
.replace(&base_path_str.to_string(), "");
let second_file_path = file_paths
.1
.to_string_lossy()
.replace(&base_path_str.to_string(), "");
tuono_println!("Duplicate \"{}\" type found in files:\n", type_name);
tuono_println!("- {}", first_file_path);
tuono_println!("- {}\n", second_file_path);
}
}
/// Generate the string containing all the typescript types
/// found in the jar.
fn generate_typescript(&self) -> String {
self.log_duplicated_types();
let mut typescript = String::from("declare module \"tuono/types\" {\n");
for ttype in &self.types {
typescript.push_str(&format!(
"// START [{}]\n",
ttype.file_path.to_string_lossy()
));
typescript.push_str(&ttype.types_as_string);
typescript.push_str(&format!("// END [{}]\n", ttype.file_path.to_string_lossy()));
}
typescript.push_str("}\n");
typescript
}
pub fn generate_typescript_file(&mut self, base_path: &Path) -> std::io::Result<()> {
if !self.should_generate_typescript_file {
trace!("No need to create typescript module file");
return Ok(());
}
self.should_generate_typescript_file = false;
trace!("Creating typescript module file");
let typescript = self.generate_typescript();
let typescript_file_path = base_path.join(".tuono").join("types.ts");
std::fs::write(typescript_file_path, typescript)?;
trace!("Typescript module file created");
Ok(())
}
}
impl From<&PathBuf> for TypesJar {
/// Fill the TypesJar with all the Rust files found within
/// the provided `base_path`.
fn from(base_path: &PathBuf) -> Self {
let mut jar = Self::new();
if let Some(path) = base_path.join("src/**/*.rs").to_str() {
if let Ok(files) = glob(path) {
files.for_each(|path| {
let file_path = path.unwrap_or_default();
if let Ok(file_str) = read_to_string(&file_path) {
if !file_str.contains(*TYPE_TRAIT) {
return;
}
if let Ok(ttype) = FileTypes::try_from((file_path.clone(), file_str)) {
jar.types.push(ttype);
} else {
error!("Failed to parse file: {:?}", file_path);
}
} else {
error!("Failed to read file: {:?}", file_path);
}
});
} else {
error!("Failed to read glob pattern: {:?}", path);
}
}
jar
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_correctly_finds_duplicate_types() {
let file_type1 = FileTypes {
file_path: PathBuf::from("src/types1.rs"),
types_as_string: String::from("type1"),
types: vec![
String::from("Type1"),
String::from("Type2"),
String::from("Type3"),
],
};
let file_type2 = FileTypes {
file_path: PathBuf::from("src/types2.rs"),
types_as_string: String::from("type1"),
types: vec![
String::from("Type1"),
String::from("Type2"),
String::from("Type4"),
],
};
let file_type3 = FileTypes {
file_path: PathBuf::from("src/types2.rs"),
types_as_string: String::from("type1"),
types: vec![String::from("Type3")],
};
let mut jar = TypesJar::new();
jar.types.push(file_type1);
jar.types.push(file_type2);
jar.types.push(file_type3);
let result = jar.check_duplicate_types();
assert!(
result
.keys()
.collect::<Vec<&String>>()
.contains(&&"Type1".to_string())
);
assert!(
result
.keys()
.collect::<Vec<&String>>()
.contains(&&"Type2".to_string())
);
assert!(
result
.keys()
.collect::<Vec<&String>>()
.contains(&&"Type3".to_string())
);
assert!(
!result
.keys()
.collect::<Vec<&String>>()
.contains(&&"Type4".to_string())
);
}
#[test]
fn it_correctly_removes_types() {
let mut jar = TypesJar::new();
let file_path = PathBuf::from("src/types.rs");
let file_type = FileTypes {
file_path: file_path.clone(),
types_as_string: String::from("type1"),
types: vec![String::from("Type1")],
};
assert!(jar.should_generate_typescript_file);
//Force file generation to false
jar.should_generate_typescript_file = false;
jar.types.push(file_type);
jar.remove_file(file_path.clone());
assert!(jar.should_generate_typescript_file);
assert_eq!(jar.types.len(), 0);
}
}
+71
View File
@@ -0,0 +1,71 @@
use crate::symbols::TYPE_TRAIT;
use syn::{Attribute, Meta};
pub fn has_derive_type(attrs: &[Attribute]) -> bool {
for attr in attrs {
if let Meta::List(meta_list) = &attr.meta {
if meta_list.path.is_ident("derive") {
for nested_meta in meta_list
.parse_args_with(
syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated,
)
.unwrap_or_default()
{
if let Meta::Path(path) = nested_meta {
if path.is_ident(&TYPE_TRAIT) {
return true;
}
}
}
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use syn::{ItemEnum, ItemStruct, ItemType, parse_quote};
#[test]
fn it_correctly_checks_if_derive_type_is_present() {
let test_struct: ItemStruct = parse_quote! {
#[derive(Type)]
struct MyStruct;
};
assert!(has_derive_type(&test_struct.attrs));
let test_type: ItemType = parse_quote! {
#[derive(Type)]
type MyType = i32;
};
assert!(has_derive_type(&test_type.attrs));
let test_enum: ItemEnum = parse_quote! {
#[derive(Type)]
enum MyEnunType {
Variant1,
Variant2,
}
};
assert!(has_derive_type(&test_enum.attrs));
let test_struct_without_type: ItemStruct = parse_quote! {
struct MyStruct;
};
assert!(!has_derive_type(&test_struct_without_type.attrs));
let multi_derived_trait: ItemStruct = parse_quote! {
#[derive(Type, Serialize, Debug)]
struct MyStruct;
};
assert!(has_derive_type(&multi_derived_trait.attrs));
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "tuono_internal"
version = "0.19.2"
version = "0.19.6"
edition = "2024"
authors = ["V. Ageno <valerioageno@yahoo.it>"]
description = "Superfast React fullstack framework"
+4 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "tuono_lib"
version = "0.19.2"
version = "0.19.6"
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,8 +32,8 @@ 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.2"}
tuono_internal = {path = "../tuono_internal", version = "0.19.2"}
tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.19.6"}
tuono_internal = {path = "../tuono_internal", version = "0.19.6"}
# Match the same version used by axum
tokio-tungstenite = "0.26.0"
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
+1 -1
View File
@@ -22,7 +22,7 @@ pub use payload::Payload;
pub use request::Request;
pub use response::{Props, Response};
pub use server::{Server, tuono_internal_init_v8_platform};
pub use tuono_lib_macros::{api, handler};
pub use tuono_lib_macros::{Type, api, handler};
// Re-exports
pub use axum;
+1 -1
View File
@@ -107,7 +107,7 @@ impl<'a> Payload<'a> {
.filter(|path| !path.is_empty())
.collect::<Vec<&str>>();
let mut route_segments_collector: Vec<&str> = vec![];
let mut route_segments_collector: Vec<&str> = Vec::new();
for i in 0..dyn_route_segments.len() {
if dyn_route_segments[i].starts_with("[...") {
+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.2"
version = "0.19.6"
edition = "2024"
description = "Superfast React fullstack framework"
homepage = "https://tuono.dev"
+10
View File
@@ -19,3 +19,13 @@ pub fn handler(args: TokenStream, item: TokenStream) -> TokenStream {
pub fn api(args: TokenStream, item: TokenStream) -> TokenStream {
api::api_core(args, item)
}
/// Automatically generate typescript's types
/// from Rust's structs, types and enums.
///
/// The types will be exported on the client side
/// and it will be available from the `"tuono/types"` module.
#[proc_macro_derive(Type)]
pub fn derive_typescript_type(_: TokenStream) -> TokenStream {
TokenStream::new()
}
+1 -1
View File
@@ -15,7 +15,7 @@
"types": "tsc --noEmit"
},
"devDependencies": {
"oxc-transform": "0.62.0",
"oxc-transform": "0.63.0",
"rollup-plugin-preserve-directives": "0.4.0",
"unplugin-isolated-decl": "0.13.6",
"vite": "6.1.3",
+2 -2
View File
@@ -1,7 +1,7 @@
use serde::Serialize;
use tuono_lib::{Props, Request, Response};
use tuono_lib::{Props, Request, Response, Type};
#[derive(Serialize)]
#[derive(Serialize, Type)]
struct MyResponse<'a> {
subtitle: &'a str,
}
+2 -5
View File
@@ -1,15 +1,12 @@
import type { JSX } from 'react'
import type { TuonoRouteProps } from 'tuono'
import { Link } from 'tuono'
interface IndexProps {
subtitle: string
}
import type { MyResponse } from 'tuono/types'
export default function IndexPage({
data,
isLoading,
}: TuonoRouteProps<IndexProps>): JSX.Element {
}: TuonoRouteProps<MyResponse>): JSX.Element {
if (isLoading) {
return <h1>Loading...</h1>
}
+1 -1
View File
@@ -20,5 +20,5 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "tuono.config.ts"]
"include": ["src", "tuono.config.ts", "./.tuono/types.ts"]
}
+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
@@ -28,5 +28,5 @@
// Completeness
"skipLibCheck": true
},
"include": ["src", "tuono.config.ts"]
"include": ["src", "tuono.config.ts", "./.tuono/types.ts"]
}
+1 -1
View File
@@ -28,5 +28,5 @@
// Completeness
"skipLibCheck": true
},
"include": ["src", "tuono.config.ts"]
"include": ["src", "tuono.config.ts", "./.tuono/types.ts"]
}
+1 -1
View File
@@ -28,5 +28,5 @@
// Completeness
"skipLibCheck": true
},
"include": ["src", "tuono.config.ts"]
"include": ["src", "tuono.config.ts", "./.tuono/types.ts"]
}
+1 -1
View File
@@ -1 +1 @@
@import 'tailwindcss';
@import 'tailwindcss' source('../..');
+1 -1
View File
@@ -28,5 +28,5 @@
// Completeness
"skipLibCheck": true
},
"include": ["src", "tuono.config.ts"]
"include": ["src", "tuono.config.ts", "./.tuono/types.ts"]
}
+3 -3
View File
@@ -27,8 +27,8 @@
"devDependencies": {
"@eslint/js": "9.24.0",
"@playwright/test": "1.51.1",
"@types/node": "22.14.0",
"@vitest/eslint-plugin": "1.1.39",
"@types/node": "22.14.1",
"@vitest/eslint-plugin": "1.1.42",
"eslint": "9.24.0",
"eslint-import-resolver-typescript": "4.3.2",
"eslint-plugin-import": "2.31.0",
@@ -37,6 +37,6 @@
"prettier": "3.5.3",
"turbo": "2.5.0",
"typescript": "5.7.3",
"typescript-eslint": "8.29.0"
"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.2",
"name": "tuono-react-vite-plugin",
"version": "0.19.6",
"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",
@@ -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
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "tuono-router",
"version": "0.19.2",
"version": "0.19.6",
"description": "React routing component for the framework tuono. Tuono is the react/rust fullstack framework",
"homepage": "https://tuono.dev",
"scripts": {
@@ -45,7 +45,7 @@
},
"devDependencies": {
"@testing-library/react": "16.3.0",
"@types/react": "19.1.0",
"@types/react": "19.1.3",
"@vitejs/plugin-react-swc": "3.8.1",
"happy-dom": "17.4.4",
"react": "19.1.0",
@@ -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>
)
@@ -4,6 +4,7 @@ import { cleanup, render, screen } from '@testing-library/react'
import { Route } from '../route'
import type { RouteComponent, RouteProps } from '../types'
import { useServerPayloadData } from '../hooks/useServerPayloadData'
import { useRouterContext } from '../components/RouterContext'
import { RouteMatch } from './RouteMatch'
@@ -47,16 +48,21 @@ vi.mock('../hooks/useServerPayloadData', () => ({
useServerPayloadData: vi.fn(),
}))
vi.mock('../components/RouterContext', () => ({
useRouterContext: vi.fn(),
}))
const useServerPayloadDataMock = vi.mocked(useServerPayloadData)
const useRouterContextMock = vi.mocked(useRouterContext)
describe('<RouteMatch />', () => {
afterEach(cleanup)
it('should correctly render nested routes', () => {
useServerPayloadDataMock.mockReturnValue({
data: { some: 'data' },
isLoading: false,
})
useServerPayloadDataMock.mockReturnValue({ data: { some: 'data' } })
// @ts-expect-error only isTransitioning is used by RouteMatch
useRouterContextMock.mockReturnValue({ isTransitioning: false })
render(<RouteMatch route={route} serverInitialData={{}} />)
@@ -81,13 +87,15 @@ describe('<RouteMatch />', () => {
)
})
it('should return null data when while loading', () => {
useServerPayloadDataMock.mockReturnValue({
data: { some: 'data' },
isLoading: true,
})
it('should correctly handle loading transition', () => {
useServerPayloadDataMock.mockReturnValue({ data: { some: 'data' } })
render(<RouteMatch route={route} serverInitialData={{}} />)
// @ts-expect-error only isTransitioning is used by RouteMatch
useRouterContextMock.mockReturnValue({ isTransitioning: true })
const { rerender } = render(
<RouteMatch route={route} serverInitialData={{}} />,
)
expect(screen.getByTestId('root')).toMatchInlineSnapshot(
`
@@ -106,5 +114,30 @@ describe('<RouteMatch />', () => {
</div>
`,
)
// @ts-expect-error only isTransitioning is used by RouteMatch
useRouterContextMock.mockReturnValue({ isTransitioning: false })
rerender(<RouteMatch route={route} serverInitialData={{}} />)
expect(screen.getByTestId('root')).toMatchInlineSnapshot(
`
<div
data-testid="root"
>
root route
<div
data-testid="parent"
>
parent route
<div
data-testid="current"
>
{"some":"data"}
</div>
</div>
</div>
`,
)
})
})
@@ -1,13 +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
}
/**
@@ -18,22 +24,26 @@ interface RouteMatchProps<TServerPayloadData = unknown> {
export const RouteMatch = ({
route,
serverInitialData,
mode,
}: RouteMatchProps): JSX.Element => {
const { data, isLoading } = useServerPayloadData(route, serverInitialData)
const { data } = useServerPayloadData(route, serverInitialData)
const { isTransitioning } = useRouterContext()
// eslint-disable-next-line react-hooks/exhaustive-deps
const routes = useMemo(() => loadParentComponents(route), [route.id])
const routeData = isLoading ? null : data
const routeData = isTransitioning ? null : data
return (
<TraverseRootComponents
routes={routes}
data={routeData}
isLoading={isLoading}
isLoading={isTransitioning}
mode={mode}
>
<Suspense>
<route.component data={routeData} isLoading={isLoading} />
<CriticalCss routeFilePath={route.filePath} mode={mode} />
<route.component data={routeData} isLoading={isTransitioning} />
</Suspense>
</TraverseRootComponents>
)
@@ -45,6 +55,7 @@ interface TraverseRootComponentsProps<TData = unknown> {
isLoading: boolean
children?: React.ReactNode
index?: number
mode?: Mode
}
/**
@@ -59,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,4 +1,11 @@
import { createContext, useState, useEffect, useContext, useMemo } from 'react'
import {
createContext,
useState,
useEffect,
useContext,
useMemo,
useCallback,
} from 'react'
import type { ReactNode } from 'react'
import type { Router } from '../router'
@@ -17,7 +24,9 @@ export interface ParsedLocation {
interface RouterContextValue {
router: Router
location: ParsedLocation
isTransitioning: boolean
updateLocation: (loc: ParsedLocation) => void
stopTransitioning: () => void
}
const RouterContext = createContext({} as RouterContextValue)
@@ -64,6 +73,9 @@ export function RouterContextProvider({
const [location, setLocation] = useState<ParsedLocation>(() =>
getInitialLocation(serverInitialLocation),
)
// Global state to track whether a page transition is in progress.
// Set to `false` once the page is fully loaded, including server-side data.
const [isTransitioning, setIsTransitioning] = useState<boolean>(false)
/**
* Listen browser navigation events
@@ -91,13 +103,24 @@ export function RouterContextProvider({
}
}, [])
const updateLocation = useCallback((newLocation: ParsedLocation): void => {
setIsTransitioning(true)
setLocation(newLocation)
}, [])
const stopTransitioning = useCallback((): void => {
setIsTransitioning(false)
}, [])
const contextValue: RouterContextValue = useMemo(
() => ({
router,
location,
updateLocation: setLocation,
isTransitioning,
updateLocation,
stopTransitioning,
}),
[location, router],
[location, router, isTransitioning, updateLocation, stopTransitioning],
)
return (
@@ -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.
*
@@ -5,8 +5,6 @@ import { fromUrlToParsedLocation } from '../utils/from-url-to-parsed-location'
import { useRouterContext } from '../components/RouterContext'
const isServer = typeof document === 'undefined'
interface TuonoApi {
data?: unknown
info: {
@@ -22,7 +20,6 @@ const fetchClientSideData = async (): Promise<TuonoApi> => {
interface UseServerPayloadDataResult<TData> {
data: TData
isLoading: boolean
}
/*
@@ -37,15 +34,7 @@ export function useServerPayloadData<TServerPayloadData>(
serverInitialData: TServerPayloadData,
): UseServerPayloadDataResult<TServerPayloadData> {
const isFirstRendering = useRef<boolean>(true)
const { location, updateLocation } = useRouterContext()
const [isLoading, setIsLoading] = useState<boolean>(
// Force loading if has handler
!!route.options.hasHandler &&
// Avoid loading on the server
!isServer &&
// Avoid loading if first rendering
!isFirstRendering.current,
)
const { location, updateLocation, stopTransitioning } = useRouterContext()
const [data, setData] = useState<TServerPayloadData | undefined>(
serverInitialData,
@@ -56,6 +45,7 @@ export function useServerPayloadData<TServerPayloadData>(
// props are already bundled by the SSR
if (isFirstRendering.current) {
isFirstRendering.current = false
stopTransitioning()
return
}
// After client side routing load again the remote data
@@ -63,7 +53,6 @@ export function useServerPayloadData<TServerPayloadData>(
// The error management is already handled inside the IIFE
// eslint-disable-next-line @typescript-eslint/no-floating-promises
;(async (): Promise<void> => {
setIsLoading(true)
try {
const response = await fetchClientSideData()
if (response.info.redirect_destination) {
@@ -84,16 +73,23 @@ export function useServerPayloadData<TServerPayloadData>(
} catch (error) {
throw Error('Failed loading Server Side Data', { cause: error })
} finally {
setIsLoading(false)
stopTransitioning()
}
})()
} else {
stopTransitioning()
}
// Clean up the data when changing route
return (): void => {
setData(undefined)
}
}, [location.pathname, route.options.hasHandler, updateLocation])
}, [
location.pathname,
route.options.hasHandler,
updateLocation,
stopTransitioning,
])
return { isLoading, data: data as TServerPayloadData }
return { data: data as TServerPayloadData }
}
+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
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "tuono",
"version": "0.19.2",
"version": "0.19.6",
"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,9 +80,9 @@
"devDependencies": {
"@types/babel__core": "7.20.5",
"@types/babel__traverse": "7.20.7",
"@types/node": "22.14.0",
"@types/react": "19.1.0",
"@types/react-dom": "19.1.1",
"@types/node": "22.14.1",
"@types/react": "19.1.3",
"@types/react-dom": "19.1.3",
"react": "19.1.0",
"react-dom": "19.1.0",
"vite-config": "workspace:*",
+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}
+327 -278
View File
File diff suppressed because it is too large Load Diff