Compare commits

...

25 Commits

Author SHA1 Message Date
Valerio Ageno 5f6bad637c chore: update version to v0.17.9 (#608) 2025-02-27 08:35:42 +01:00
Marco Pasqualetti 3063763667 feat(env): temporary revert frontend implementation (#609) 2025-02-27 08:35:06 +01:00
Valerio Ageno caf1a58a0b feat: update axum to v0.8.1 (#595) 2025-02-26 19:01:10 +01:00
Marco Pasqualetti 059f3d6476 chore(deps): update dependency prettier to v3.5.2 (#602) 2025-02-24 20:30:12 +01:00
renovate[bot] 303a177344 chore(deps): update dependency vitest to v3.0.7 (#601)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-24 20:21:16 +01:00
renovate[bot] d26918fc2f chore(deps): update dependency happy-dom to v17.1.4 (#598)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-24 20:15:27 +01:00
renovate[bot] 31f0a167e9 chore(deps): update dependency @types/node to v22.13.5 (#597)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-24 20:04:09 +01:00
Jovan Milosevic d71185c9cb feat(packages/tuono-router): add replace method to router and replace prop to Link (#596) 2025-02-24 19:53:59 +01:00
Valerio Ageno 798f8f1bf8 feat: allow custom route with .txt extension (#594) 2025-02-24 12:18:14 +01:00
Marco Pasqualetti 06612f07b0 refactor(examples): uniform tsconfig.json files (#591) 2025-02-23 15:36:04 +01:00
Marco Pasqualetti 067535c2f6 test(packages/tuono): create-json-config test are not clearing writeFileSpy mock (#593) 2025-02-23 15:31:56 +01:00
Jacob Marshall d679982949 feat(env): setup env prefix and typescript for client-side (#584) 2025-02-23 14:17:23 +01:00
Jacob Marshall de5ac53cc3 feat: more detailed errors for Failed to read tuono.config.ts (#579) 2025-02-23 12:15:12 +01:00
Robert Lawson ca4a7b3466 test: add full integration test for tuono new CLI command (#411)
Co-authored-by: Valerio Ageno <valerioageno@yahoo.it>
2025-02-23 11:06:06 +01:00
Marco Pasqualetti 0a4d1e2995 feat: update vite to v6 and vitest v3 (#576) 2025-02-22 21:06:38 +01:00
Jacob Marshall d6fc18e2f6 chore(examples/with-tailwind): update tailwind to v4.0.6 (#577) 2025-02-17 20:58:42 +01:00
Valerio Ageno 8e04abbe42 feat: setup default tracing subscriber (#564) 2025-02-17 20:49:51 +01:00
Marco Pasqualetti 2905cfae3d chore: add devtools to labeler.yml (#575) 2025-02-17 18:07:56 +01:00
Jacob Marshall b632cf43ba refactor: apply RustRover lint suggestions (#569) 2025-02-17 08:40:03 +01:00
Jacob Marshall 06bc3700b7 fix: use snake_case for handler names (#565) 2025-02-16 18:53:40 +01:00
Marco Pasqualetti d4648afe01 chore: correct missing types task reference (#572) 2025-02-16 16:15:59 +01:00
renovate[bot] 0653349c08 chore(deps): update dependency turbo to v2.4.2 (#496)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-16 15:49:29 +01:00
Marco Pasqualetti 12e0937174 chore: add internal vite config (#568) 2025-02-16 15:39:25 +01:00
Marco Pasqualetti d37b655add feat(crates/tuono): feat: add spinner to tuono build (#560) 2025-02-14 19:20:22 +01:00
Valerio Ageno e3b1c0e013 feat: create body extractor function (#561) 2025-02-14 17:39:37 +01:00
60 changed files with 1889 additions and 2057 deletions
+3
View File
@@ -5,3 +5,6 @@ end_of_line = lf
indent_style = space
indent_size = 2
max_line_length = 80
[*.rs]
indent_size = 4
+2
View File
@@ -8,6 +8,7 @@ typescript:
- changed-files:
- any-glob-to-any-file:
[
'devtools/**',
'packages/**',
'package.json',
'pnpm-*.yaml',
@@ -23,6 +24,7 @@ typescript:
- changed-files:
- any-glob-to-any-file:
[
'devtools/**',
'.npmrc',
'.nvmrc',
'.prettierrc',
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
uses: ./.github/actions/install-node-dependencies
- name: Test project
run: pnpm repo:root:format:check
run: pnpm repo:root:format
ci_ok:
name: Repo root CI OK
+4 -4
View File
@@ -51,13 +51,13 @@ jobs:
uses: ./.github/actions/install-node-dependencies
- name: Check formatting
run: pnpm format:check
run: pnpm run format
- name: Lint
run: pnpm lint
run: pnpm run lint
- name: Types
run: pnpm types
- name: Typecheck
run: pnpm run typecheck
ci_ok:
name: Typescript CI OK
+2
View File
@@ -6,3 +6,5 @@ dist
vite.config.ts.timestamp-*
packages/tuono-lazy-fn-vite-plugin/tests/sources/*
e2e/fixtures/*/test-results
+6 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "tuono"
version = "0.17.8"
version = "0.17.9"
edition = "2021"
authors = ["V. Ageno <valerioageno@yahoo.it>"]
description = "Superfast React fullstack framework"
@@ -19,6 +19,8 @@ path = "src/lib.rs"
[dependencies]
clap = { version = "4.5.4", features = ["derive", "cargo"] }
watchexec = "5.0.0"
tracing = "0.1.41"
tracing-subscriber = {version = "0.3.19", features = ["env-filter"]}
miette = "7.2.0"
watchexec-signals = "4.0.0"
tokio = { version = "1", features = ["full"] }
@@ -30,11 +32,13 @@ 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.17.8"}
tuono_internal = {path = "../tuono_internal", version = "0.17.9"}
spinners = "4.1.1"
console = "0.15.10"
[dev-dependencies]
wiremock = "0.6.2"
tempfile = "3.14.0"
assert_cmd = "2.0.16"
serial_test = "0.10.0"
+26 -13
View File
@@ -5,6 +5,7 @@ use http::Method;
use std::collections::hash_set::HashSet;
use std::collections::{hash_map::Entry, HashMap};
use std::fs::File;
use std::io;
use std::io::prelude::*;
use std::io::BufReader;
use std::path::Path;
@@ -12,6 +13,7 @@ use std::path::PathBuf;
use std::process::Child;
use std::process::Command;
use std::process::Stdio;
use tracing::error;
use tuono_internal::config::Config;
use crate::route::Route;
@@ -176,15 +178,18 @@ impl App {
pub fn build_react_prod(&self) {
if !Path::new(BUILD_JS_SCRIPT).exists() {
eprintln!("Failed to find the build script. Please run `npm install`");
error!("Failed to find the build script. Please run `npm install`");
std::process::exit(1);
}
let output = Command::new(BUILD_JS_SCRIPT)
.output()
.expect("Failed to build the react source");
let output = Command::new(BUILD_JS_SCRIPT).output().unwrap_or_else(|_| {
error!("Failed to build the react source");
std::process::exit(1);
});
if !output.status.success() {
eprintln!("Failed to build the react source");
eprintln!("Error: {}", String::from_utf8_lossy(&output.stderr));
error!("Failed to build the react source");
error!("Error: {}", String::from_utf8_lossy(&output.stderr));
std::process::exit(1);
}
}
@@ -204,18 +209,26 @@ impl App {
eprintln!("Failed to find the build script. Please run `npm install`");
std::process::exit(1);
}
let config_build_command = Command::new(BUILD_TUONO_CONFIG)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output();
if let Ok(config) = Config::get() {
self.config = Some(config);
} else {
eprintln!("[CLI] Failed to read tuono.config.ts");
std::process::exit(1);
};
match Config::get() {
Ok(config) => self.config = Some(config),
Err(error) => {
match error.kind() {
io::ErrorKind::NotFound => eprintln!("Failed to read config. Please run `npm install` to generate automatically."),
_ => {
error!("Failed to read config with the following error:");
error!("{}", error);
}
}
std::process::exit(1);
}
}
config_build_command
}
@@ -349,7 +362,7 @@ mod tests {
("/about", "/about"),
("/posts/index", "/posts"),
("/posts/any-post", "/posts/any-post"),
("/posts/[post]", "/posts/:post"),
("/posts/[post]", "/posts/{post}"),
];
results.into_iter().for_each(|(path, expected_path)| {
+112
View File
@@ -0,0 +1,112 @@
use fs_extra::dir::{copy, CopyOptions};
use spinners::{Spinner, Spinners};
use std::path::PathBuf;
use std::thread::sleep;
use std::time::Duration;
use tracing::{error, trace};
use crate::app::App;
use crate::mode::Mode;
fn exit_gracefully_with_error(msg: &str) -> ! {
error!(msg);
std::process::exit(1);
}
pub fn build(mut app: App, ssg: bool, no_js_emit: bool) {
if no_js_emit {
println!("Rust build successfully finished");
std::process::exit(0);
}
if ssg && app.has_dynamic_routes() {
// TODO: allow dynamic routes static generation
println!("Cannot statically build dynamic routes");
std::process::exit(1);
}
app.build_tuono_config()
.unwrap_or_else(|_| exit_gracefully_with_error("Failed to build tuono.config.ts"));
let mut app_build_spinner = Spinner::new(Spinners::Dots, "Building app...".into());
app.check_server_availability(Mode::Prod);
app.build_react_prod();
// Remove the spinner
app_build_spinner.stop_with_message("\u{2705}Build completed".into());
if ssg {
let mut app_build_static_spinner =
Spinner::new(Spinners::Dots, "Static site generation".into());
let mut exit_gracefully_with_error = |msg: &str| -> ! {
app_build_static_spinner.stop_with_message("\u{274C} Build failed\n".into());
exit_gracefully_with_error(msg)
};
let static_dir = PathBuf::from("out/static");
if static_dir.is_dir() {
std::fs::remove_dir_all(&static_dir).unwrap_or_else(|_| {
exit_gracefully_with_error("Failed to clear the out/static folder")
});
}
std::fs::create_dir(&static_dir)
.unwrap_or_else(|_| exit_gracefully_with_error("Failed to create static output dir"));
copy(
"./out/client",
static_dir,
&CopyOptions::new().overwrite(true).content_only(true),
)
.unwrap_or_else(|_| {
exit_gracefully_with_error("Failed to clone assets into static output folder")
});
// Start the server
#[allow(clippy::zombie_processes)]
let mut rust_server = app.run_rust_server();
let mut exit_and_shut_server = |msg: &str| -> ! {
_ = rust_server.kill();
exit_gracefully_with_error(msg)
};
let reqwest_client = reqwest::blocking::Client::builder()
.user_agent("")
.build()
.unwrap_or_else(|_| exit_and_shut_server("Failed to build reqwest client"));
// Wait for server
let mut is_server_ready = false;
let config = app.config.as_ref().unwrap();
while !is_server_ready {
trace!("Checking server availability");
let server_url = format!("http://{}:{}", config.server.host, config.server.port);
if reqwest_client.get(&server_url).send().is_ok() {
is_server_ready = true;
} else {
trace!("Server not ready yet. Sleeping for 1 second");
}
sleep(Duration::from_secs(1));
}
trace!("Server is ready, starting static site generation");
for (_, route) in app.route_map {
if let Err(msg) = route.save_ssg_file(&reqwest_client) {
exit_and_shut_server(&msg);
}
}
// Close server
let _ = rust_server.kill();
app_build_static_spinner
.stop_with_message("\u{2705}Static site generation completed".into());
}
}
+21 -72
View File
@@ -1,11 +1,11 @@
use fs_extra::dir::{copy, CopyOptions};
use std::path::PathBuf;
use std::thread::sleep;
use std::time::Duration;
use clap::{Parser, Subcommand};
use tracing::{span, Level};
use tracing_subscriber::EnvFilter;
use crate::app::App;
use crate::build;
use crate::mode::Mode;
use crate::scaffold_project;
use crate::source_builder::{bundle_axum_source, check_tuono_folder, create_client_entry_files};
@@ -63,10 +63,20 @@ fn init_tuono_folder(mode: Mode) -> std::io::Result<App> {
}
pub fn app() -> std::io::Result<()> {
tracing_subscriber::fmt()
// Not need for the time since the code is synchronous
.without_time()
.with_env_filter(EnvFilter::from_default_env())
.init();
let args = Args::parse();
match args.action {
Actions::Dev => {
let span = span!(Level::TRACE, "DEV");
let _guard = span.enter();
let mut app = init_tuono_folder(Mode::Dev)?;
app.build_tuono_config()
@@ -77,84 +87,23 @@ pub fn app() -> std::io::Result<()> {
watch::watch().unwrap();
}
Actions::Build { ssg, no_js_emit } => {
let mut app = init_tuono_folder(Mode::Prod)?;
let span = span!(Level::TRACE, "BUILD");
if no_js_emit {
println!("Rust build successfully finished");
return Ok(());
}
let _guard = span.enter();
if ssg && app.has_dynamic_routes() {
// TODO: allow dynamic routes static generation
println!("Cannot statically build dynamic routes");
return Ok(());
}
let app = init_tuono_folder(Mode::Prod)?;
app.build_tuono_config()
.expect("Failed to build tuono.config.ts");
app.check_server_availability(Mode::Prod);
app.build_react_prod();
if ssg {
println!("SSG: generation started");
let static_dir = PathBuf::from("out/static");
if static_dir.is_dir() {
std::fs::remove_dir_all(&static_dir)
.expect("Failed to clear the out/static folder");
}
std::fs::create_dir(&static_dir).expect("Failed to create static output dir");
copy(
"./out/client",
static_dir,
&CopyOptions::new().overwrite(true).content_only(true),
)
.expect("Failed to clone assets into static output folder");
// the process is killed below so we don't really need to use wait() function
#[allow(clippy::zombie_processes)]
let mut rust_server = app.run_rust_server();
let reqwest = reqwest::blocking::Client::builder()
.user_agent("")
.build()
.expect("Failed to build reqwest client");
// Wait for server
let mut is_server_ready = false;
let config = app.config.as_ref().unwrap();
while !is_server_ready {
let server_url =
format!("http://{}:{}", config.server.host, config.server.port);
if reqwest.get(server_url).send().is_ok() {
is_server_ready = true
}
// TODO: add maximum tries
sleep(Duration::from_secs(1))
}
for (_, route) in app.route_map {
route.save_ssg_file(&reqwest)
}
// Close server
let _ = rust_server.kill();
};
println!("Build successfully finished");
build::build(app, ssg, no_js_emit);
}
Actions::New {
folder_name,
template,
head,
} => {
let span = span!(Level::TRACE, "NEW");
let _guard = span.enter();
scaffold_project::create_new_project(folder_name, template, head);
}
}
+1
View File
@@ -4,6 +4,7 @@
//! You can find the full documentation at [tuono.dev](https://tuono.dev/)
mod app;
mod build;
pub mod cli;
mod mode;
mod route;
+94 -25
View File
@@ -7,6 +7,7 @@ use std::fs::File;
use std::io;
use std::path::PathBuf;
use std::str::FromStr;
use tracing::trace;
fn has_dynamic_path(route: &str) -> bool {
let regex = Regex::new(r"\[(.*?)\]").expect("Failed to create the regex");
@@ -45,6 +46,20 @@ impl AxumInfo {
}
if route.is_dynamic {
let dyn_re = Regex::new(r"\[(.*?)\]").expect("Failed to create dyn regex");
let catch_all_re =
Regex::new(r"\{\.\.\.(.*?)\}").expect("Failed to create catch all regex");
let dyn_result = dyn_re.replace_all(&axum_route, |caps: &regex::Captures| {
format!("{{{}}}", &caps[1])
});
let axum_route = catch_all_re
.replace_all(&dyn_result, |caps: &regex::Captures| {
format!("{{*{}}}", &caps[1])
})
.to_string();
return AxumInfo {
module_import: module
.as_str()
@@ -54,10 +69,7 @@ impl AxumInfo {
.replace('[', "dyn_")
.replace("...", "catch_all_")
.replace(']', ""),
axum_route: axum_route
.replace("[...", "*")
.replace('[', ":")
.replace(']', ""),
axum_route,
};
}
@@ -69,7 +81,7 @@ impl AxumInfo {
}
// TODO: to be extended with common scenarios
const NO_HTML_EXTENSIONS: [&str; 1] = ["xml"];
const NO_HTML_EXTENSIONS: [&str; 2] = ["xml", "txt"];
// TODO: Refine this function to catch
// if the methods are commented.
@@ -141,35 +153,72 @@ impl Route {
self.axum_info = Some(AxumInfo::new(self))
}
pub fn save_ssg_file(&self, reqwest: &Client) {
pub fn save_ssg_file(&self, reqwest: &Client) -> Result<(), String> {
if self.is_api() {
return Ok(());
}
let path = &self.path.replace("index", "");
let mut response = reqwest
.get(format!("http://localhost:3000{path}"))
.send()
.unwrap();
let url = format!("http://localhost:3000{path}");
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)),
};
let file_path = self.output_file_path();
let parent_dir = file_path.parent().unwrap();
let parent_dir = match file_path.parent() {
Some(parent_dir) => parent_dir,
None => {
return Err(format!(
"Failed to get the parent directory {:?}",
file_path
))
}
};
if !parent_dir.is_dir() {
create_all(parent_dir, false).expect("Failed to create parent directories");
if !parent_dir.is_dir() || create_all(parent_dir, false).is_err() {
return Err(format!(
"Failed to create the parent directory {:?}",
parent_dir
));
}
let mut file = File::create(file_path).expect("Failed to create the HTML file");
trace!("Saving the HTML file: {:?}", file_path);
io::copy(&mut response, &mut file).expect("Failed to write the HTML on the file");
let mut file = match File::create(&file_path) {
Ok(file) => file,
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));
}
// Saving also the server response
if self.axum_info.is_some() {
let data_file_path = PathBuf::from(&format!("out/static/__tuono/data{path}"));
trace!("The route is an axum route, saving the JSON file");
let data_parent_dir = data_file_path.parent().unwrap();
let data_file_path = PathBuf::from(&format!("out/static/__tuono/data{path}.json"));
if !data_parent_dir.is_dir() {
create_all(data_parent_dir, false)
.expect("Failed to create data parent directories");
let data_parent_dir = match data_file_path.parent() {
Some(parent_dir) => parent_dir,
None => {
return Err(format!(
"Failed to get the parent directory {:?}",
data_file_path
))
}
};
if !data_parent_dir.is_dir() && create_all(data_parent_dir, false).is_err() {
return Err(format!(
"Failed to create the parent directory {:?}",
data_parent_dir
));
}
let base = Url::parse("http://localhost:3000/__tuono/data").unwrap();
@@ -182,13 +231,32 @@ impl Route {
.join(pathname)
.expect("Failed to build the reqwest URL");
let mut response = reqwest.get(url).send().unwrap();
trace!("Requesting the JSON file: {}", url);
let mut data_file =
File::create(data_file_path).expect("Failed to create the JSON file");
let mut response = match reqwest.get(url.clone()).send() {
Ok(response) => {
trace!("Successfully got the response for: {url}");
response
}
Err(_) => return Err(format!("Failed to get the response: {url}")),
};
io::copy(&mut response, &mut data_file).expect("Failed to write the JSON on the file");
let mut data_file = match File::create(&data_file_path) {
Ok(file) => file,
Err(_) => {
return Err(format!(
"Failed to create the JSON file: {:?}",
data_file_path
))
}
};
if io::copy(&mut response, &mut data_file).is_err() {
return Err("Failed to write the JSON file".to_string());
}
}
Ok(())
}
fn output_file_path(&self) -> PathBuf {
@@ -239,7 +307,7 @@ mod tests {
let dyn_info = AxumInfo::new(&Route::new("/[posts]".to_string()));
assert_eq!(dyn_info.axum_route, "/:posts");
assert_eq!(dyn_info.axum_route, "/{posts}");
assert_eq!(dyn_info.module_import, "dyn_posts");
}
@@ -249,6 +317,7 @@ mod tests {
("/index", "out/static/index.html"),
("/documentation", "out/static/documentation/index.html"),
("/sitemap.xml", "out/static/sitemap.xml"),
("/robot.txt", "out/static/robot.txt"),
(
"/documentation/routing",
"out/static/documentation/routing/index.html",
+41 -23
View File
@@ -7,13 +7,6 @@ use std::fs::{self, create_dir, File, OpenOptions};
use std::io::{self, prelude::*};
use std::path::{Path, PathBuf};
const GITHUB_TUONO_TAGS_URL: &str = "https://api.github.com/repos/tuono-labs/tuono/git/ref/tags/";
const GITHUB_TUONO_TAG_COMMIT_TREES_URL: &str =
"https://api.github.com/repos/tuono-labs/tuono/git/trees/";
const GITHUB_RAW_CONTENT_URL: &str = "https://raw.githubusercontent.com/tuono-labs/tuono";
#[derive(Deserialize, Debug)]
enum GithubFileType {
#[serde(rename = "blob")]
@@ -69,6 +62,12 @@ pub fn create_new_project(
) {
let folder = folder_name.unwrap_or(".".to_string());
let github_api_base_url =
env::var("__INTERNAL_TUONO_TEST").unwrap_or("https://api.github.com".to_string());
let github_raw_base_url = env::var("__INTERNAL_TUONO_TEST")
.unwrap_or("https://raw.githubusercontent.com".to_string());
// In case of missing select the tuono example
let template = template.unwrap_or("tuono-app".to_string());
let client = blocking::Client::builder()
@@ -79,7 +78,8 @@ pub fn create_new_project(
// This string does not include the "v" version prefix
let cli_version: &str = crate_version!();
let tree_url: String = generate_tree_url(select_head, &client, cli_version);
let tree_url: String =
generate_tree_url(select_head, &client, cli_version, &github_api_base_url);
let res_tree = client
.get(tree_url)
@@ -126,7 +126,8 @@ pub fn create_new_project(
} in new_project_files.iter()
{
if let GithubFileType::Blob = element_type {
let url = generate_raw_content_url(select_head, cli_version, path);
let url =
generate_raw_content_url(select_head, cli_version, path, &github_raw_base_url);
let file_content = client
.get(url)
@@ -144,7 +145,7 @@ pub fn create_new_project(
let file_path = folder_path.join(&path);
if let Err(err) = create_file(file_path, file_content) {
exit_with_error(&format!("Failed to create file: {}", err));
exit_with_error(&format!("Failed to create file: {err}"));
}
}
}
@@ -154,22 +155,34 @@ pub fn create_new_project(
outro(folder);
}
fn generate_raw_content_url(select_head: Option<bool>, cli_version: &str, path: &String) -> String {
fn generate_raw_content_url(
select_head: Option<bool>,
cli_version: &str,
path: &String,
url: &str,
) -> String {
let tag = if select_head.unwrap_or(false) {
"main"
"/main"
} else {
&format!("v{cli_version}")
&format!("/tuono-labs/tuono/v{cli_version}")
};
format!("{}/{}/{}", GITHUB_RAW_CONTENT_URL, tag, path)
format!("{url}{tag}/{path}")
}
fn generate_tree_url(select_head: Option<bool>, client: &Client, cli_version: &str) -> String {
fn generate_tree_url(
select_head: Option<bool>,
client: &Client,
cli_version: &str,
url: &str,
) -> String {
if select_head.unwrap_or(false) {
format!("{}main?recursive=1", GITHUB_TUONO_TAG_COMMIT_TREES_URL)
format!("{url}/repos/tuono-labs/tuono/git/trees/main?recursive=1")
} else {
// This string does not include the "v" version prefix
let res_tag = client
.get(format!("{}v{}", GITHUB_TUONO_TAGS_URL, cli_version))
.get(format!(
"{url}/repos/tuono-labs/tuono/git/ref/tags/v{cli_version}"
))
.send()
.unwrap_or_else(|_| {
exit_with_error("Failed to call the tag github API for v{cli_version}")
@@ -178,8 +191,8 @@ fn generate_tree_url(select_head: Option<bool>, client: &Client, cli_version: &s
.unwrap_or_else(|_| exit_with_error("Failed to parse the tag response"));
format!(
"{}{}?recursive=1",
GITHUB_TUONO_TAG_COMMIT_TREES_URL, res_tag.object.sha
"{url}/repos/tuono-labs/tuono/git/trees/{}?recursive=1",
res_tag.object.sha
)
}
}
@@ -197,7 +210,10 @@ fn create_directories(
let path = PathBuf::from(&path.replace(&format!("examples/{template}/"), ""));
let dir_path = folder_path.join(&path);
create_dir(&dir_path).unwrap();
if let Err(e) = create_dir(&dir_path) {
eprintln!("Failed to create directory {}: {}", dir_path.display(), e);
std::process::exit(1);
}
}
}
Ok(())
@@ -267,12 +283,13 @@ mod tests {
fn generate_valid_content_url_from_head() {
let expected = format!(
"{}/{}/{}",
GITHUB_RAW_CONTENT_URL, "main", "examples/tuono-app"
"http://localhost:3000", "main", "examples/tuono-app"
);
let generated = generate_raw_content_url(
Some(true),
crate_version!(),
&String::from("examples/tuono-app"),
"http://localhost:3000",
);
assert_eq!(expected, generated)
}
@@ -281,14 +298,15 @@ mod tests {
fn generate_valid_content_url_from_cli_version() {
let expected = format!(
"{}/{}/{}",
GITHUB_RAW_CONTENT_URL,
&format!("v{}", crate_version!()),
"http://localhost:3000",
&format!("tuono-labs/tuono/v{}", crate_version!()),
"examples/tuono-app"
);
let generated = generate_raw_content_url(
Some(false),
crate_version!(),
&String::from("examples/tuono-app"),
"http://localhost:3000",
);
assert_eq!(expected, generated)
}
+3 -5
View File
@@ -85,17 +85,17 @@ fn create_routes_declaration(routes: &HashMap<String, Route>) -> String {
if !route.is_api() {
route_declarations.push_str(&format!(
r#".route("{axum_route}", get({module_import}::tuono__internal__route))"#
r#".route("{axum_route}", get({module_import}::tuono_internal_route))"#
));
route_declarations.push_str(&format!(
r#".route("/__tuono/data{axum_route}", get({module_import}::tuono__internal__api))"#
r#".route("/__tuono/data{axum_route}", get({module_import}::tuono_internal_api))"#
));
} else {
for method in route.api_data.as_ref().unwrap().methods.clone() {
let method = method.to_string().to_lowercase();
route_declarations.push_str(&format!(
r#".route("{axum_route}", {method}({module_import}::{method}__tuono_internal_api))"#
r#".route("{axum_route}", {method}({module_import}::{method}_tuono_internal_api))"#
));
}
}
@@ -127,9 +127,7 @@ pub fn bundle_axum_source(mode: Mode) -> io::Result<App> {
let base_path = std::env::current_dir().unwrap();
let app = App::new();
let bundled_file = generate_axum_source(&app, mode);
create_main_file(&base_path, &bundled_file);
Ok(app)
+16 -16
View File
@@ -1,8 +1,9 @@
mod utils;
use assert_cmd::Command;
use serial_test::serial;
use std::fs;
use utils::TempTuonoProject;
mod utils;
use utils::temp_tuono_project::TempTuonoProject;
const POST_API_FILE: &str = r"#[tuono_lib::api(POST)]";
const GET_API_FILE: &str = r"#[tuono_lib::api(GET)]";
@@ -36,7 +37,7 @@ fn it_successfully_create_the_index_route() {
assert!(temp_main_rs_content.contains("mod index;"));
assert!(temp_main_rs_content
.contains(r#".route("/", get(index::tuono__internal__route)).route("/__tuono/data/", get(index::tuono__internal__api))"#));
.contains(r#".route("/", get(index::tuono_internal_route)).route("/__tuono/data/", get(index::tuono_internal_api))"#));
}
#[test]
@@ -58,13 +59,11 @@ fn it_successfully_create_an_api_route() {
let temp_main_rs_content =
fs::read_to_string(&temp_main_rs_path).expect("Failed to read '.tuono/main.rs' content.");
dbg!(&temp_main_rs_content);
assert!(temp_main_rs_content.contains(r#"#[path="../src/routes/api/health_check.rs"]"#));
assert!(temp_main_rs_content.contains("mod api_health_check;"));
assert!(temp_main_rs_content.contains(
r#".route("/api/health_check", post(api_health_check::post__tuono_internal_api))"#
r#".route("/api/health_check", post(api_health_check::post_tuono_internal_api))"#
));
}
@@ -94,11 +93,10 @@ fn it_successfully_create_multiple_api_for_the_same_file() {
assert!(temp_main_rs_content.contains("mod api_health_check;"));
assert!(temp_main_rs_content.contains(
r#".route("/api/health_check", post(api_health_check::post__tuono_internal_api))"#
));
assert!(temp_main_rs_content.contains(
r#".route("/api/health_check", get(api_health_check::get__tuono_internal_api))"#
r#".route("/api/health_check", post(api_health_check::post_tuono_internal_api))"#
));
assert!(temp_main_rs_content
.contains(r#".route("/api/health_check", get(api_health_check::get_tuono_internal_api))"#));
}
#[test]
@@ -129,19 +127,20 @@ fn it_successfully_create_catch_all_routes() {
assert!(temp_main_rs_content.contains("mod dyn_catch_all_all_routes;"));
assert!(temp_main_rs_content.contains(
r#".route("/api/*all_apis", post(api_dyn_catch_all_all_apis::post__tuono_internal_api))"#
r#".route("/api/{*all_apis}", post(api_dyn_catch_all_all_apis::post_tuono_internal_api))"#
));
assert!(temp_main_rs_content.contains(
r#".route("/*all_routes", get(dyn_catch_all_all_routes::tuono__internal__route))"#
r#".route("/{*all_routes}", get(dyn_catch_all_all_routes::tuono_internal_route))"#
));
assert!(temp_main_rs_content.contains(
r#".route("/*all_routes", get(dyn_catch_all_all_routes::tuono__internal__route))"#
r#".route("/{*all_routes}", get(dyn_catch_all_all_routes::tuono_internal_route))"#
));
assert!(temp_main_rs_content
.contains(r#".route("/__tuono/data/*all_routes", get(dyn_catch_all_all_routes::tuono__internal__api))"#));
assert!(temp_main_rs_content.contains(
r#".route("/__tuono/data/{*all_routes}", get(dyn_catch_all_all_routes::tuono_internal_api))"#
));
}
#[test]
@@ -168,11 +167,12 @@ fn it_fails_without_installed_build_script() {
.assert()
.success();
let mut test_tuono_build = Command::cargo_bin("tuono").unwrap();
test_tuono_build
.arg("build")
.assert()
.failure()
.stderr("[CLI] Failed to read tuono.config.ts\n");
.stderr("Failed to read config. Please run `npm install` to generate automatically.\n");
}
#[test]
+61
View File
@@ -0,0 +1,61 @@
use assert_cmd::Command;
use clap::crate_version;
use serial_test::serial;
use std::fs;
mod utils;
use utils::mock_github_endpoint::GitHubServerMock;
use utils::temp_tuono_project::TempTuonoProject;
#[tokio::test]
#[serial]
async fn it_creates_a_new_project_and_replace_the_versions_in_the_manifest() {
let GitHubServerMock { env_vars, .. } = GitHubServerMock::new().await;
let temp_project = TempTuonoProject::new();
let project_folder = "my-project";
let mut cmd = Command::cargo_bin("tuono").unwrap();
cmd.arg("new")
.arg(project_folder)
.envs(env_vars)
.assert()
.success();
let main_rs_path = temp_project
.path()
.join(project_folder)
.join("src")
.join("main.rs");
let cargo_toml_path = temp_project.path().join(project_folder).join("Cargo.toml");
let package_json_path = temp_project
.path()
.join(project_folder)
.join("package.json");
let main_rs_content = fs::read_to_string(main_rs_path).unwrap();
let cargo_toml_content = fs::read_to_string(cargo_toml_path).unwrap();
let package_json_content = fs::read_to_string(package_json_path).unwrap();
assert_eq!(
main_rs_content,
"fn main() { println!(\"Hello, world!\"); }"
);
let version = crate_version!();
let expected_cargo_toml_content =
format!("[package] name = \"tuono-tutorial\" [dependencies] tuono_lib = \"{version}\"");
assert_eq!(cargo_toml_content, expected_cargo_toml_content);
let expected_package_json_content =
format!("{{\"name\": \"tuono-app\", \"dependencies\": {{ \"tuono\": \"{version}\" }}}}");
assert_eq!(package_json_content, expected_package_json_content);
}
@@ -0,0 +1,101 @@
use clap::crate_version;
use std::env;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[allow(dead_code)]
pub struct GitHubServerMock {
pub server: MockServer,
pub env_vars: Vec<(String, String)>,
}
#[allow(dead_code)]
impl GitHubServerMock {
pub async fn new() -> Self {
let server = MockServer::start().await;
let tuono_version = crate_version!();
let sha = "1234567890abcdef";
let sha_response_template = ResponseTemplate::new(200).set_body_raw(
format!("{{ \"object\": {{ \"sha\": \"{sha}\" }} }}"),
"application/json",
);
Mock::given(method("GET"))
.and(path(&format!(
"repos/tuono-labs/tuono/git/ref/tags/v{}",
tuono_version
)))
.respond_with(sha_response_template)
.mount(&server)
.await;
let tree_response_template = ResponseTemplate::new(200).set_body_raw(
r#"{
"tree": [
{
"path": "examples/tuono-app/src",
"type": "tree"
},
{
"path": "examples/tuono-app/src/main.rs",
"type": "blob"
},
{
"path": "examples/tuono-app/Cargo.toml",
"type": "blob"
},
{
"path": "examples/tuono-app/package.json",
"type": "blob"
}
]
}"#,
"application/json",
);
Mock::given(method("GET"))
.and(path(&format!("repos/tuono-labs/tuono/git/trees/{}", sha)))
.respond_with(tree_response_template)
.mount(&server)
.await;
let file_response_template =
|content: &str| ResponseTemplate::new(200).set_body_raw(content, "text/plain");
Mock::given(method("GET"))
.and(path(format!(
"tuono-labs/tuono/v{tuono_version}/examples/tuono-app/src/main.rs"
)))
.respond_with(file_response_template(
"fn main() { println!(\"Hello, world!\"); }",
))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(format!(
"tuono-labs/tuono/v{tuono_version}/examples/tuono-app/Cargo.toml"
)))
.respond_with(file_response_template(
"[package] name = \"tuono-tutorial\" [dependencies] tuono_lib = { path = \"../../crates/tuono_lib/\" }"
))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(format!(
"tuono-labs/tuono/v{tuono_version}/examples/tuono-app/package.json"
)))
.respond_with(file_response_template(
r#"{"name": "tuono-app", "dependencies": { "tuono": "link:../../packages/tuono" }}"#,
))
.mount(&server)
.await;
let env_vars = vec![("__INTERNAL_TUONO_TEST".to_string(), server.uri())];
GitHubServerMock { server, env_vars }
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod mock_github_endpoint;
pub mod temp_tuono_project;
@@ -11,6 +11,7 @@ pub struct TempTuonoProject {
temp_dir: TempDir,
}
#[allow(dead_code)]
impl TempTuonoProject {
pub fn new() -> Self {
let project = TempTuonoProject::new_with_no_config();
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "tuono_internal"
version = "0.17.8"
version = "0.17.9"
edition = "2021"
authors = ["V. Ageno <valerioageno@yahoo.it>"]
description = "Superfast React fullstack framework"
+1 -1
View File
@@ -43,7 +43,7 @@ impl TempTuonoProject {
impl Drop for TempTuonoProject {
fn drop(&mut self) {
// Set back the current dir in the previous state
env::set_current_dir(self.original_dir.to_owned())
env::set_current_dir(&self.original_dir)
.expect("Failed to restore the original directory.");
}
}
+7 -7
View File
@@ -1,6 +1,6 @@
[package]
name = "tuono_lib"
version = "0.17.8"
version = "0.17.9"
edition = "2021"
authors = ["V. Ageno <valerioageno@yahoo.it>"]
description = "Superfast React fullstack framework"
@@ -18,8 +18,8 @@ include = [
[dependencies]
ssr_rs = "0.8.1"
axum = {version = "0.7.5", features = ["json", "ws"]}
axum-extra = {version = "0.9.6", features = ["cookie"]}
axum = {version = "0.8.1", features = ["json", "ws"]}
axum-extra = {version = "0.10.0", features = ["cookie"]}
tokio = { version = "1.37.0", features = ["full"] }
serde = { version = "1.0.202", features = ["derive"] }
erased-serde = "0.4.5"
@@ -32,12 +32,12 @@ either = "1.13.0"
tower-http = {version = "0.6.0", features = ["fs"]}
colored = "2.1.0"
tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.17.8"}
tuono_internal = {path = "../tuono_internal", version = "0.17.8"}
tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.17.9"}
tuono_internal = {path = "../tuono_internal", version = "0.17.9"}
# Match the same version used by axum
tokio-tungstenite = "0.24.0"
tokio-tungstenite = "0.26.0"
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
tungstenite = "0.24.0"
tungstenite = "0.26.0"
http = "1.1.0"
pin-project = "1.1.7"
tower = "0.5.1"
+1 -1
View File
@@ -166,7 +166,7 @@ mod tests {
use crate::manifest::BundleInfo;
fn prepare_payload<'a>(uri: Option<&'a str>, mode: Mode) -> Payload<'a> {
fn prepare_payload(uri: Option<&str>, mode: Mode) -> Payload {
let mut manifest_mock = HashMap::new();
manifest_mock.insert(
"client-main".to_string(),
+91 -1
View File
@@ -1,4 +1,4 @@
use serde::Serialize;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use axum::http::{HeaderMap, Uri};
@@ -51,4 +51,94 @@ impl Request {
pub fn location(&self) -> Location {
Location::from(self.uri.to_owned())
}
pub fn body<'de, T: Deserialize<'de>>(&'de self) -> Result<T, BodyParseError> {
if let Some(body) = self.headers.get("body") {
if let Ok(body) = body.to_str() {
let body = serde_json::from_str::<T>(body)?;
return Ok(body);
}
return Err(BodyParseError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Failed to read body",
)));
}
Err(BodyParseError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"No body found",
)))
}
}
#[derive(Debug)]
pub enum BodyParseError {
Io(std::io::Error),
Serde(serde_json::Error),
}
impl From<serde_json::Error> for BodyParseError {
fn from(err: serde_json::Error) -> BodyParseError {
BodyParseError::Serde(err)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, Deserialize)]
struct FakeBody {
field1: bool,
field2: String,
}
#[test]
fn it_correctly_parse_the_body() {
let mut request = Request::new(
Uri::from_static("http://localhost:3000"),
HeaderMap::new(),
HashMap::new(),
);
request.headers.insert(
"body",
r#"{"field1": true, "field2": "hello"}"#.parse().unwrap(),
);
let body: FakeBody = request.body().expect("Failed to parse body");
assert!(body.field1);
assert_eq!(body.field2, "hello".to_string());
}
#[test]
fn it_should_trigger_an_error_when_no_body_is_found() {
let request = Request::new(
Uri::from_static("http://localhost:3000"),
HeaderMap::new(),
HashMap::new(),
);
let body: Result<FakeBody, BodyParseError> = request.body();
assert!(body.is_err());
}
#[test]
fn it_should_trigger_an_error_when_body_is_invalid() {
let mut request = Request::new(
Uri::from_static("http://localhost:3000"),
HeaderMap::new(),
HashMap::new(),
);
request
.headers
.insert("body", r#"{"field1": true"#.parse().unwrap());
let body: Result<FakeBody, BodyParseError> = request.body();
assert!(body.is_err());
}
}
+1 -1
View File
@@ -64,7 +64,7 @@ impl Server {
.to_owned()
.layer(LoggerLayer::new())
.route("/vite-server/", get(vite_websocket_proxy))
.route("/vite-server/*path", get(vite_reverse_proxy))
.route("/vite-server/{*path}", get(vite_reverse_proxy))
.fallback_service(
ServeDir::new(DEV_PUBLIC_DIR)
.fallback(get(catch_all).layer(LoggerLayer::new())),
+4 -4
View File
@@ -1,5 +1,5 @@
use crate::config::GLOBAL_CONFIG;
use axum::extract::ws::{self, WebSocket, WebSocketUpgrade};
use axum::extract::ws::{self, Utf8Bytes as AxumUtf8Bytes, WebSocket, WebSocketUpgrade};
use axum::response::IntoResponse;
use futures_util::{SinkExt, StreamExt};
use tokio_tungstenite::connect_async;
@@ -64,7 +64,7 @@ async fn handle_socket(mut tuono_socket: WebSocket) {
while let Some(msg) = tuono_receiver.next().await {
if let Ok(msg) = msg {
let msg_to_vite = match msg.clone() {
ws::Message::Text(str) => Message::Text(str),
ws::Message::Text(str) => Message::Text(str.to_string().into()),
ws::Message::Pong(payload) => Message::Pong(payload),
ws::Message::Ping(payload) => Message::Ping(payload),
ws::Message::Binary(payload) => Message::Binary(payload),
@@ -91,7 +91,7 @@ async fn handle_socket(mut tuono_socket: WebSocket) {
tokio::spawn(async move {
while let Some(Ok(msg)) = vite_receiver.next().await {
let msg_to_browser = match msg {
Message::Text(str) => ws::Message::Text(str),
Message::Text(str) => ws::Message::Text(AxumUtf8Bytes::from(str.to_string())),
Message::Ping(payload) => ws::Message::Ping(payload),
Message::Pong(payload) => ws::Message::Pong(payload),
Message::Binary(payload) => ws::Message::Binary(payload),
@@ -100,7 +100,7 @@ async fn handle_socket(mut tuono_socket: WebSocket) {
Message::Close(_) => ws::Message::Close(None),
_ => {
eprintln!("Unexpected message from the vite WebSocket to the browser: {msg:?}");
ws::Message::Text("Unhandled".to_string())
ws::Message::Text("Unhandled".to_string().into())
}
};
+49 -5
View File
@@ -1,5 +1,5 @@
mod utils;
use crate::utils::utils::MockTuonoServer;
use crate::utils::mock_server::MockTuonoServer;
use serial_test::serial;
#[tokio::test]
@@ -15,7 +15,7 @@ async fn api_endpoint_work() {
let server_url = format!("http://{}:{}", &app.address, &app.port);
let response = client
.get(&format!("{server_url}/health_check"))
.get(format!("{server_url}/health_check"))
.send()
.await
.expect("Failed to execute request.");
@@ -38,7 +38,7 @@ async fn not_found_route() {
let server_url = format!("http://{}:{}", &app.address, &app.port);
let response = client
.get(&format!("{server_url}/not-found"))
.get(format!("{server_url}/not-found"))
.send()
.await
.expect("Failed to execute request.");
@@ -64,7 +64,7 @@ async fn index_html_route() {
let server_url = format!("http://{}:{}", &app.address, &app.port);
let response = client
.get(&format!("{server_url}/"))
.get(format!("{server_url}/"))
.send()
.await
.expect("Failed to execute request.");
@@ -91,7 +91,7 @@ async fn api_route_route() {
let server_url = format!("http://{}:{}", &app.address, &app.port);
let response = client
.get(&format!("{server_url}/tuono/data"))
.get(format!("{server_url}/tuono/data"))
.send()
.await
.expect("Failed to execute request.");
@@ -103,3 +103,47 @@ async fn api_route_route() {
"{\"data\":\"{}\",\"info\":{\"redirect_destination\":null}}"
);
}
#[tokio::test]
#[serial]
async fn it_reads_the_catch_all_path_parameter() {
let app = MockTuonoServer::spawn().await;
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
let server_url = format!("http://{}:{}", &app.address, &app.port);
let response = client
.get(format!("{server_url}/catch_all/url_parameter"))
.send()
.await
.expect("Failed to execute request.");
assert!(response.status().is_success());
assert_eq!(response.text().await.unwrap(), "url_parameter");
}
#[tokio::test]
#[serial]
async fn it_reads_the_path_parameter() {
let app = MockTuonoServer::spawn().await;
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
let server_url = format!("http://{}:{}", &app.address, &app.port);
let response = client
.get(format!("{server_url}/dynamic/url_parameter"))
.send()
.await
.expect("Failed to execute request.");
assert!(response.status().is_success());
assert_eq!(response.text().await.unwrap(), "url_parameter");
}
+11
View File
@@ -0,0 +1,11 @@
use tuono_lib::Request;
#[tuono_lib::api(GET)]
async fn read_catch_all_parameter(req: Request) -> String {
let param = req
.params
.get("catch_all")
.expect("Failed to get the catch_all param");
param.to_string()
}
@@ -0,0 +1,11 @@
use tuono_lib::Request;
#[tuono_lib::api(GET)]
async fn read_dynamic_parameter(req: Request) -> String {
let param = req
.params
.get("parameter")
.expect("Failed to get the catch_all param");
param.to_string()
}
+109
View File
@@ -0,0 +1,109 @@
use fs_extra::dir::create_all;
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use std::{env, fs};
use tempfile::{tempdir, TempDir};
use tuono_lib::axum::routing::get;
use tuono_lib::{axum::Router, tuono_internal_init_v8_platform, Mode, Server};
use crate::utils::catch_all::get_tuono_internal_api as catch_all;
use crate::utils::dynamic_parameter::get_tuono_internal_api as dynamic_parameter;
use crate::utils::health_check::get_tuono_internal_api as health_check;
use crate::utils::route as html_route;
use crate::utils::route::tuono_internal_api as route_api;
use std::sync::Once;
static INIT: Once = Once::new();
fn init_v8() {
INIT.call_once(|| {
tuono_internal_init_v8_platform();
})
}
fn add_file_with_content<'a>(path: &'a str, content: &'a str) {
let path = PathBuf::from(path);
create_all(
path.parent().expect("File path does not have any parent"),
false,
)
.unwrap_or_else(|_| {
panic!(
"Failed to create parent file directories: {}",
path.display()
)
});
let mut file = File::create(path).expect("Failed to create the file");
file.write_all(content.as_bytes())
.expect("Failed to write into the file");
}
#[derive(Debug)]
pub struct MockTuonoServer {
pub address: String,
pub port: u16,
original_dir: PathBuf,
#[allow(dead_code)]
// Required for dropping the temp_dir when this struct drops
temp_dir: TempDir,
}
impl MockTuonoServer {
pub async fn spawn() -> Self {
init_v8();
let original_dir = env::current_dir().expect("Failed to read current_dir");
let temp_dir = tempdir().expect("Failed to create temp_dir");
let react_prod_build = fs::read_to_string("./tests/assets/fake_react_build.js")
.expect("Failed to read fake_react_build.js");
env::set_current_dir(temp_dir.path()).expect("Failed to change current dir into temp_dir");
add_file_with_content(
"./.tuono/config/config.json",
r#"{"server": {"host": "127.0.0.1", "port": 0}}"#,
);
add_file_with_content("./out/server/prod-server.js", react_prod_build.as_str());
add_file_with_content(
"./out/client/.vite/manifest.json",
r#"{"client-main.tsx": { "file": "assets/index.js", "name": "index", "src": "index.tsx", "isEntry": true,"dynamicImports": [],"css": []}}"#,
);
let router = Router::new()
.route("/", get(html_route::tuono_internal_route))
.route("/tuono/data", get(html_route::tuono_internal_api))
.route("/health_check", get(health_check))
.route("/route-api", get(route_api))
.route("/catch_all/{*catch_all}", get(catch_all))
.route("/dynamic/{parameter}", get(dynamic_parameter));
let server = Server::init(router, Mode::Prod).await;
let socket = server
.listener
.local_addr()
.expect("Failed to extract test server socket");
_ = tokio::spawn(server.start());
MockTuonoServer {
address: socket.ip().to_string(),
port: socket.port(),
original_dir,
temp_dir,
}
}
}
impl Drop for MockTuonoServer {
fn drop(&mut self) {
// Set back the current dir in the previous state
env::set_current_dir(&self.original_dir)
.expect("Failed to restore the original directory.");
}
}
+3 -1
View File
@@ -1,3 +1,5 @@
pub mod catch_all;
pub mod dynamic_parameter;
pub mod health_check;
pub mod mock_server;
pub mod route;
pub mod utils;
-106
View File
@@ -1,106 +0,0 @@
use fs_extra::dir::create_all;
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use std::{env, fs};
use tempfile::{tempdir, TempDir};
use tuono_lib::axum::routing::get;
use tuono_lib::{axum::Router, tuono_internal_init_v8_platform, Mode, Server};
use crate::utils::health_check::get__tuono_internal_api as health_check;
use crate::utils::route as html_route;
use crate::utils::route::tuono__internal__api as route_api;
use std::sync::Once;
static INIT: Once = Once::new();
fn init_v8() {
INIT.call_once(|| {
tuono_internal_init_v8_platform();
})
}
fn add_file_with_content<'a>(path: &'a str, content: &'a str) {
let path = PathBuf::from(path);
create_all(
path.parent().expect("File path does not have any parent"),
false,
)
.expect(
format!(
"Failed to create parent file directories: {}",
path.display()
)
.as_str(),
);
let mut file = File::create(path).expect("Failed to create the file");
file.write_all(content.as_bytes())
.expect("Failed to write into the file");
}
#[derive(Debug)]
pub struct MockTuonoServer {
pub address: String,
pub port: u16,
original_dir: PathBuf,
#[allow(dead_code)]
// Required for dropping the temp_dir when this struct drops
temp_dir: TempDir,
}
impl MockTuonoServer {
pub async fn spawn() -> Self {
init_v8();
let original_dir = env::current_dir().expect("Failed to read current_dir");
let temp_dir = tempdir().expect("Failed to create temp_dir");
let react_prod_build = fs::read_to_string("./tests/assets/fake_react_build.js")
.expect("Failed to read fake_react_build.js");
env::set_current_dir(temp_dir.path()).expect("Failed to change current dir into temp_dir");
add_file_with_content(
"./.tuono/config/config.json",
r#"{"server": {"host": "127.0.0.1", "port": 0}}"#,
);
add_file_with_content("./out/server/prod-server.js", react_prod_build.as_str());
add_file_with_content(
"./out/client/.vite/manifest.json",
r#"{"client-main.tsx": { "file": "assets/index.js", "name": "index", "src": "index.tsx", "isEntry": true,"dynamicImports": [],"css": []}}"#,
);
let router = Router::new()
.route("/", get(html_route::tuono__internal__route))
.route("/tuono/data", get(html_route::tuono__internal__api))
.route("/health_check", get(health_check))
.route("/route-api", get(route_api));
let server = Server::init(router, Mode::Prod).await;
let socket = server
.listener
.local_addr()
.expect("Failed to extract test server socket");
let _ = tokio::spawn(server.start());
MockTuonoServer {
address: socket.ip().to_string(),
port: socket.port(),
original_dir,
temp_dir,
}
}
}
impl Drop for MockTuonoServer {
fn drop(&mut self) {
// Set back the current dir in the previous state
env::set_current_dir(self.original_dir.to_owned())
.expect("Failed to restore the original directory.");
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "tuono_lib_macros"
version = "0.17.8"
version = "0.17.9"
edition = "2021"
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!("{}_tuono_internal_api", http_method),
Span::call_site().into(),
);
+2 -2
View File
@@ -45,7 +45,7 @@ pub fn handler_core(_args: TokenStream, item: TokenStream) -> TokenStream {
#item
pub async fn tuono__internal__route(
pub async fn tuono_internal_route(
#axum_arguments
) -> impl tuono_lib::axum::response::IntoResponse {
@@ -59,7 +59,7 @@ pub fn handler_core(_args: TokenStream, item: TokenStream) -> TokenStream {
#fn_name(req.clone(), #argument_names).await.render_to_string(req)
}
pub async fn tuono__internal__api(
pub async fn tuono_internal_api(
#axum_arguments
) -> impl tuono_lib::axum::response::IntoResponse {
+24
View File
@@ -0,0 +1,24 @@
{
"name": "vite-config",
"version": "1.0.0",
"type": "module",
"private": true,
"exports": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"scripts": {
"build": "tsc",
"lint": "eslint .",
"format": "prettier --check --ignore-unknown --ignore-path ../../.prettierignore .",
"format:fix": "prettier --write --ignore-unknown --ignore-path ../../.prettierignore .",
"types": "tsc --noEmit"
},
"devDependencies": {
"oxc-transform": "0.52.0",
"rollup-plugin-preserve-directives": "0.4.0",
"unplugin-isolated-decl": "0.11.2",
"vite": "6.1.1",
"vite-plugin-externalize-deps": "0.9.0"
}
}
+37
View File
@@ -0,0 +1,37 @@
import type { UserConfig } from 'vite'
import { preserveDirectives } from 'rollup-plugin-preserve-directives'
import { externalizeDeps } from 'vite-plugin-externalize-deps'
import UnpluginIsolatedDecl from 'unplugin-isolated-decl/vite'
interface Options {
/** Entry file, e.g. `./src/index.ts` */
entry: string | Array<string>
}
export function defineViteConfig({ entry }: Options): UserConfig {
const outDir = 'dist'
return {
build: {
outDir,
minify: false,
sourcemap: true,
lib: {
entry,
formats: ['es'],
fileName: 'esm/[name]',
},
rollupOptions: {
output: {
preserveModules: true,
entryFileNames: 'esm/[name].js',
},
},
},
plugins: [
externalizeDeps(),
preserveDirectives(),
UnpluginIsolatedDecl({ transformer: 'oxc' }),
],
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.json",
"include": ["src"],
"compilerOptions": {
// Modules
"moduleResolution": "node16",
"module": "Node16",
// Emit
"noEmit": false,
"declaration": true,
"outDir": "dist"
}
}
+14 -5
View File
@@ -6,6 +6,9 @@ import eslintPluginReact from 'eslint-plugin-react'
// @ts-expect-error no types are available for this plugin
import eslintPluginReactHooks from 'eslint-plugin-react-hooks'
const REACT_FILES_MATCH =
'packages/{tuono,tuono-router}/**/*.{js,mjs,cjs,jsx,mjsx,ts,tsx,mtsx}'
/** @type import('typescript-eslint').ConfigArray */
const tuonoEslintConfig = tseslint.config(
{
@@ -44,12 +47,17 @@ const tuonoEslintConfig = tseslint.config(
// eslint-disable-next-line import/no-named-as-default-member
tseslint.configs.strictTypeChecked,
// @ts-expect-error flat is optional but always defined on runtime
eslintPluginReact.configs.flat.recommended,
eslintPluginReact.configs.flat['jsx-runtime'],
// #region react
{
files: ['**/*.{js,mjs,cjs,jsx,mjsx,ts,tsx,mtsx}'],
files: [REACT_FILES_MATCH],
...eslintPluginReact.configs.flat.recommended,
},
{
files: [REACT_FILES_MATCH],
...eslintPluginReact.configs.flat['jsx-runtime'],
},
{
files: [REACT_FILES_MATCH],
plugins: {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
'react-hooks': eslintPluginReactHooks,
@@ -59,6 +67,7 @@ const tuonoEslintConfig = tseslint.config(
'react-hooks/exhaustive-deps': 'error',
},
},
// #endregion react
{
languageOptions: {
+23 -16
View File
@@ -1,24 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
// Typechecking
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
// Modules
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
// Interop Constraints
"isolatedModules": true,
// Language and Environment
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"useDefineForClassFields": true,
"jsx": "react-jsx",
// Emit
"noEmit": true,
// Completeness
"skipLibCheck": true
},
"include": ["src", "tuono.config.ts"]
}
+18 -17
View File
@@ -1,28 +1,29 @@
{
"compilerOptions": {
// Language and Environment
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"useDefineForClassFields": true,
// Modules
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
// Emit
"noEmit": true,
// Interop Constraints
"isolatedModules": true,
// Typechecking
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
// Modules
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
// Interop Constraints
"isolatedModules": true,
// Language and Environment
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"useDefineForClassFields": true,
"jsx": "react-jsx",
// Emit
"noEmit": true,
// Completeness
"skipLibCheck": true
},
+23 -16
View File
@@ -1,24 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
// Typechecking
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
// Modules
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
// Interop Constraints
"isolatedModules": true,
// Language and Environment
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"useDefineForClassFields": true,
"jsx": "react-jsx",
// Emit
"noEmit": true,
// Completeness
"skipLibCheck": true
},
"include": ["src", "tuono.config.ts"]
}
+2 -2
View File
@@ -3,10 +3,10 @@
"description": "Basic tuono application with tailwind",
"version": "0.0.1",
"dependencies": {
"@tailwindcss/vite": "^4.0.0-beta.9",
"@tailwindcss/vite": "^4.0.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwindcss": "^4.0.0-beta.9",
"tailwindcss": "^4.0.6",
"tuono": "link:../../packages/tuono"
},
"devDependencies": {
+23 -16
View File
@@ -1,24 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
// Typechecking
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
// Modules
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
// Interop Constraints
"isolatedModules": true,
// Language and Environment
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"useDefineForClassFields": true,
"jsx": "react-jsx",
// Emit
"noEmit": true,
// Completeness
"skipLibCheck": true
},
"include": ["src", "tuono.config.ts"]
}
+15 -15
View File
@@ -4,32 +4,32 @@
"type": "module",
"packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0",
"scripts": {
"dev": "turbo dev --filter=./packages/*",
"build": "turbo build --filter=./packages/*",
"lint": "turbo lint --filter=./packages/*",
"format": "turbo format --filter=./packages/*",
"format:check": "turbo format:check --filter=./packages/*",
"types": "turbo types --filter=./packages/*",
"test": "turbo test --filter=./packages/*",
"test:watch": "turbo test:watch --filter=./packages/*",
"dev": "turbo dev --filter=./devtools/* --filter=./packages/*",
"build": "turbo build --filter=./devtools/* --filter=./packages/*",
"lint": "turbo lint --filter=./devtools/* --filter=./packages/*",
"format:fix": "turbo format --filter=./devtools/* --filter=./packages/*",
"format": "turbo format --filter=./devtools/* --filter=./packages/*",
"typecheck": "turbo typecheck --filter=./devtools/* --filter=./packages/*",
"test": "turbo test --filter=./devtools/* --filter=./packages/*",
"test:watch": "turbo test:watch --filter=./devtools/* --filter=./packages/*",
"test:e2e": "pnpm --filter='fixture-*' run test:e2e",
"repo:root:format:check": "prettier . !./assets/** !./crates !./examples !./packages/** --check",
"repo:root:format": "prettier . !./assets/** !./crates !./examples !./packages/** --write",
"check-all": "turbo build lint format:check types --filter=!./examples"
"repo:root:format": "prettier . !./assets/** !./crates !./examples !./devtools/** !./packages/** --check",
"repo:root:format:fix": "prettier . !./assets/** !./crates !./examples !./devtools/** !./packages/** --write",
"check-all": "turbo build lint format typecheck --filter=!./examples"
},
"author": "Valerio Ageno",
"license": "MIT",
"devDependencies": {
"@eslint/js": "9.20.0",
"@types/node": "22.13.1",
"@playwright/test": "1.50.1",
"@types/node": "22.13.5",
"eslint": "9.20.0",
"@playwright/test": "^1.50.1",
"eslint-import-resolver-typescript": "3.7.0",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-react": "7.37.4",
"eslint-plugin-react-hooks": "5.1.0",
"prettier": "3.5.0",
"turbo": "2.4.0",
"prettier": "3.5.2",
"turbo": "2.4.2",
"typescript": "5.7.3",
"typescript-eslint": "8.24.0"
}
@@ -1,15 +1,15 @@
{
"name": "tuono-fs-router-vite-plugin",
"version": "0.17.8",
"version": "0.17.9",
"description": "Plugin for the tuono's file system router. Tuono is the react/rust fullstack framework",
"homepage": "https://tuono.dev",
"scripts": {
"dev": "vite build --watch",
"build": "vite build",
"lint": "eslint .",
"format": "prettier --write --ignore-unknown --ignore-path ../../.prettierignore .",
"format:check": "prettier --check --ignore-unknown --ignore-path ../../.prettierignore .",
"types": "tsc --noEmit",
"format": "prettier --check --ignore-unknown --ignore-path ../../.prettierignore .",
"format:fix": "prettier --write --ignore-unknown --ignore-path ../../.prettierignore .",
"typecheck": "tsc --noEmit",
"test:watch": "vitest",
"test": "vitest run"
},
@@ -23,7 +23,7 @@
"license": "MIT",
"type": "module",
"types": "dist/esm/index.d.ts",
"main": "dist/cjs/index.cjs",
"main": "dist/esm/index.js",
"module": "dist/esm/index.js",
"files": [
"dist",
@@ -32,14 +32,8 @@
],
"exports": {
".": {
"import": {
"types": "./dist/esm/index.d.ts",
"default": "./dist/esm/index.js"
},
"require": {
"types": "./dist/cjs/index.d.cts",
"default": "./dist/cjs/index.cjs"
}
"types": "./dist/esm/index.d.ts",
"default": "./dist/esm/index.js"
},
"./package.json": "./package.json"
},
@@ -47,11 +41,11 @@
"@babel/core": "^7.24.4",
"@babel/types": "^7.24.0",
"prettier": "^3.2.4",
"vite": "^5.4.14"
"vite": "^6.1.1"
},
"devDependencies": {
"@tanstack/config": "0.7.13",
"@types/babel__core": "7.20.5",
"vitest": "2.1.9"
"vite-config": "workspace:*",
"vitest": "3.0.7"
}
}
@@ -105,7 +105,9 @@ async function getRouteNodes(
return { routeNodes, rustHandlersNodes }
}
export async function routeGenerator(config = defaultConfig): Promise<void> {
export async function routeGenerator(
config: Config = defaultConfig,
): Promise<void> {
if (!isFirst) {
isFirst = true
} else if (skipMessage) {
@@ -1,4 +1,4 @@
import { normalize } from 'path'
import { normalize } from 'node:path'
import type { Plugin } from 'vite'
@@ -1,12 +1,5 @@
import { defineConfig, mergeConfig } from 'vitest/config'
import { tanstackBuildConfig } from '@tanstack/config/build'
import { defineViteConfig } from 'vite-config'
const config = defineConfig({})
export default mergeConfig(
config,
tanstackBuildConfig({
entry: './src/index.ts',
srcDir: './src',
}),
)
export default defineViteConfig({
entry: './src/index.ts',
})
+12 -18
View File
@@ -1,15 +1,15 @@
{
"name": "tuono-router",
"version": "0.17.8",
"version": "0.17.9",
"description": "React routing component for the framework tuono. Tuono is the react/rust fullstack framework",
"homepage": "https://tuono.dev",
"scripts": {
"dev": "vite build --watch",
"build": "vite build",
"lint": "eslint .",
"format": "prettier --write --ignore-unknown --ignore-path ../../.prettierignore .",
"format:check": "prettier --check --ignore-unknown --ignore-path ../../.prettierignore .",
"types": "tsc --noEmit",
"format": "prettier --check --ignore-unknown --ignore-path ../../.prettierignore .",
"format:fix": "prettier --write --ignore-unknown --ignore-path ../../.prettierignore .",
"typecheck": "tsc --noEmit",
"test:watch": "vitest",
"test": "vitest run"
},
@@ -23,7 +23,7 @@
"license": "MIT",
"type": "module",
"types": "dist/esm/index.d.ts",
"main": "dist/cjs/index.cjs",
"main": "dist/esm/index.js",
"module": "dist/esm/index.js",
"files": [
"dist",
@@ -32,14 +32,8 @@
],
"exports": {
".": {
"import": {
"types": "./dist/esm/index.d.ts",
"default": "./dist/esm/index.js"
},
"require": {
"types": "./dist/cjs/index.d.cts",
"default": "./dist/cjs/index.cjs"
}
"types": "./dist/esm/index.d.ts",
"default": "./dist/esm/index.js"
},
"./package.json": "./package.json"
},
@@ -50,13 +44,13 @@
"react-intersection-observer": "^9.13.0"
},
"devDependencies": {
"@tanstack/config": "0.7.13",
"@testing-library/react": "16.2.0",
"@types/react": "19.0.8",
"@vitejs/plugin-react-swc": "3.7.2",
"happy-dom": "16.8.1",
"@vitejs/plugin-react-swc": "3.8.0",
"happy-dom": "17.1.4",
"react": "19.0.0",
"vite": "5.4.14",
"vitest": "2.1.9"
"vite": "6.1.1",
"vite-config": "workspace:*",
"vitest": "3.0.7"
}
}
@@ -4,10 +4,14 @@ import { render, fireEvent, screen } from '@testing-library/react'
import Link from './Link'
const pushMock = vi.fn()
const replaceMock = vi.fn()
const preloadMock = vi.fn()
vi.mock('../hooks/useRouter', () => ({
useRouter: (): { push: typeof pushMock } => ({ push: pushMock }),
useRouter: (): { push: typeof pushMock; replace: typeof replaceMock } => ({
push: pushMock,
replace: replaceMock,
}),
}))
vi.mock('../hooks/useRoute', () => ({
@@ -51,6 +55,19 @@ describe('Link Component', () => {
expect(pushMock).toHaveBeenCalledWith('/test', { scroll: true })
})
it('calls router.replace on click when the replace prop is true', () => {
render(
<Link href="/test" replace>
Test Link
</Link>,
)
const link = screen.getByRole('link')
fireEvent.click(link)
expect(replaceMock).toHaveBeenCalledWith('/test', { scroll: true })
expect(pushMock).not.toHaveBeenCalled()
})
it('does not navigate if href starts with "#"', () => {
render(<Link href="#section">Anchor Link</Link>)
const link = screen.getByRole('link')
+10 -1
View File
@@ -16,6 +16,12 @@ interface TuonoLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
* @default true
*/
scroll?: boolean
/**
* If "true" the history entry will be replaced instead of pushed.
* @default false
*/
replace?: boolean
}
function isEventModifierKeyActiveAndTargetDifferentFromSelf(
@@ -39,6 +45,7 @@ export default function Link(
scroll = true,
children,
href,
replace,
onClick,
...rest
} = componentProps
@@ -68,7 +75,9 @@ export default function Link(
event.preventDefault()
router.push(href || '', { scroll })
const method = replace ? 'replace' : 'push'
router[method](href || '', { scroll })
}
return (
+31 -6
View File
@@ -2,7 +2,10 @@ import React from 'react'
import { useRouterContext } from '../components/RouterContext'
interface PushOptions {
type NavigationType = 'pushState' | 'replaceState'
type NavigationFn = (path: string, opts?: NavigationOptions) => void
interface NavigationOptions {
/**
* If "false" the scroll offset will be kept across page navigation. Default "true"
*/
@@ -13,7 +16,13 @@ interface UseRouterResult {
/**
* Redirects to the path passed as argument updating the browser history.
*/
push: (path: string, opt?: PushOptions) => void
push: NavigationFn
/**
* Redirects to the path passed as argument replacing the current history
* entry.
*/
replace: NavigationFn
/**
* This object contains all the query params of the current route
@@ -29,9 +38,9 @@ interface UseRouterResult {
export const useRouter = (): UseRouterResult => {
const { location, updateLocation } = useRouterContext()
const push = React.useCallback(
(path: string, opt?: PushOptions): void => {
const { scroll = true } = opt || {}
const navigate = React.useCallback(
(type: NavigationType, path: string, opts?: NavigationOptions): void => {
const { scroll = true } = opts || {}
const url = new URL(path, window.location.origin)
updateLocation({
@@ -41,7 +50,8 @@ export const useRouter = (): UseRouterResult => {
searchStr: url.search,
hash: url.hash,
})
history.pushState(path, '', path)
history[type](path, '', path)
if (scroll) {
window.scroll(0, 0)
@@ -50,8 +60,23 @@ export const useRouter = (): UseRouterResult => {
[updateLocation],
)
const push = React.useCallback(
(path: string, opts?: NavigationOptions): void => {
navigate('pushState', path, opts)
},
[navigate],
)
const replace = React.useCallback(
(path: string, opts?: NavigationOptions): void => {
navigate('replaceState', path, opts)
},
[navigate],
)
return {
push,
replace,
query: location.search,
pathname: location.pathname,
}
+1 -1
View File
@@ -28,7 +28,7 @@ export class Router {
basePath = '/'
routeTree?: RouteTree
isServer = typeof document === 'undefined'
isServer: boolean = typeof document === 'undefined'
routesById: Record<string, Route> = {}
+10 -13
View File
@@ -1,22 +1,19 @@
/// <reference types="vitest" />
/// <reference types="vite/client" />
import { defineConfig, mergeConfig } from 'vitest/config'
import { tanstackBuildConfig } from '@tanstack/config/build'
import { defineViteConfig } from 'vite-config'
import react from '@vitejs/plugin-react-swc'
const config = defineConfig({
plugins: [react()],
test: {
name: 'tuono-router',
environment: 'happy-dom',
globals: true,
},
})
export default mergeConfig(
config,
tanstackBuildConfig({
defineConfig({
plugins: [react()],
test: {
name: 'tuono-router',
environment: 'happy-dom',
globals: true,
},
}),
defineViteConfig({
entry: './src/index.ts',
srcDir: './src',
}),
)
+20 -50
View File
@@ -1,15 +1,15 @@
{
"name": "tuono",
"version": "0.17.8",
"version": "0.17.9",
"description": "Superfast React fullstack framework",
"homepage": "https://tuono.dev",
"scripts": {
"dev": "vite build --watch",
"build": "vite build",
"lint": "eslint .",
"format": "prettier --write --ignore-unknown --ignore-path ../../.prettierignore .",
"format:check": "prettier --check --ignore-unknown --ignore-path ../../.prettierignore .",
"types": "tsc --noEmit",
"format": "prettier --check --ignore-unknown --ignore-path ../../.prettierignore .",
"format:fix": "prettier --write --ignore-unknown --ignore-path ../../.prettierignore .",
"typecheck": "tsc --noEmit",
"test:watch": "vitest",
"test": "vitest run"
},
@@ -20,58 +20,28 @@
},
"type": "module",
"types": "dist/esm/index.d.ts",
"main": "dist/cjs/index.cjs",
"main": "dist/esm/index.js",
"module": "dist/esm/index.js",
"exports": {
"./build": {
"import": {
"types": "./dist/esm/build/index.d.ts",
"default": "./dist/esm/build/index.js"
},
"require": {
"types": "./dist/cjs/build/index.d.ts",
"default": "./dist/cjs/build/index.js"
}
"types": "./dist/esm/build/index.d.ts",
"default": "./dist/esm/build/index.js"
},
"./config": {
"import": {
"types": "./dist/esm/config/index.d.ts",
"default": "./dist/esm/config/index.js"
},
"require": {
"types": "./dist/cjs/config/index.d.ts",
"default": "./dist/cjs/config/index.js"
}
"types": "./dist/esm/config/index.d.ts",
"default": "./dist/esm/config/index.js"
},
"./ssr": {
"import": {
"types": "./dist/esm/ssr/index.d.ts",
"default": "./dist/esm/ssr/index.js"
},
"require": {
"types": "./dist/cjs/ssr/index.d.ts",
"default": "./dist/cjs/ssr/index.js"
}
"types": "./dist/esm/ssr/index.d.ts",
"default": "./dist/esm/ssr/index.js"
},
"./hydration": {
"import": {
"types": "./dist/esm/hydration/index.d.ts",
"default": "./dist/esm/hydration/index.js"
},
"require": {
"types": "./dist/cjs/ssr/index.d.ts",
"default": "./dist/cjs/ssr/index.js"
}
"types": "./dist/esm/hydration/index.d.ts",
"default": "./dist/esm/hydration/index.js"
},
".": {
"import": {
"types": "./dist/esm/index.d.ts",
"default": "./dist/esm/index.js"
},
"require": {
"types": "./dist/cjs/index.d.cts",
"default": "./dist/cjs/index.cjs"
}
"types": "./dist/esm/index.d.ts",
"default": "./dist/esm/index.js"
},
"./package.json": "./package.json"
},
@@ -96,24 +66,24 @@
"@babel/plugin-syntax-jsx": "^7.24.1",
"@babel/plugin-syntax-typescript": "^7.24.1",
"@rollup/plugin-inject": "^5.0.5",
"@vitejs/plugin-react-swc": "^3.7.0",
"@vitejs/plugin-react-swc": "^3.8.0",
"fast-text-encoding": "^1.0.6",
"tuono-fs-router-vite-plugin": "workspace:*",
"tuono-router": "workspace:*",
"url-search-params-polyfill": "^8.2.5",
"vite": "^5.4.14",
"vite": "^6.1.1",
"web-streams-polyfill": "^4.0.0"
},
"devDependencies": {
"@tanstack/config": "0.7.13",
"@types/babel__core": "7.20.5",
"@types/babel__traverse": "7.20.6",
"@types/node": "22.13.1",
"@types/node": "22.13.5",
"@types/react": "19.0.8",
"@types/react-dom": "19.0.3",
"react": "19.0.0",
"react-dom": "19.0.0",
"vitest": "2.1.9"
"vite-config": "workspace:*",
"vitest": "3.0.7"
},
"sideEffects": false,
"keywords": [
@@ -1,6 +1,6 @@
import fs from 'fs/promises'
import { describe, expect, it, vitest } from 'vitest'
import { beforeEach, describe, expect, it, vitest } from 'vitest'
import react from '@vitejs/plugin-react-swc'
import { createJsonConfig } from './create-json-config'
@@ -8,6 +8,10 @@ import { createJsonConfig } from './create-json-config'
const writeFileSpy = vitest.spyOn(fs, 'writeFile').mockResolvedValue(void 0)
describe('createJsonConfig', () => {
beforeEach(() => {
writeFileSpy.mockClear()
})
const sampleConfig = { server: { host: 'h', port: 1 } }
it('should process config with only server property', async () => {
+5 -8
View File
@@ -1,16 +1,14 @@
/// <reference types="vitest" />
/// <reference types="vite/client" />
import { defineConfig, mergeConfig } from 'vitest/config'
import { tanstackBuildConfig } from '@tanstack/config/build'
import { defineViteConfig } from 'vite-config'
import react from '@vitejs/plugin-react-swc'
const config = defineConfig({
plugins: [react()],
})
export default mergeConfig(
config,
tanstackBuildConfig({
defineConfig({
plugins: [react()],
}),
defineViteConfig({
entry: [
'./src/index.ts',
'./src/build/index.ts',
@@ -18,6 +16,5 @@ export default mergeConfig(
'./src/ssr/index.ts',
'./src/hydration/index.tsx',
],
srcDir: './src',
}),
)
+770 -1546
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,4 +1,5 @@
packages:
- 'devtools/*'
- 'packages/*'
- 'examples/*'
- 'e2e/fixtures/*'
+5 -3
View File
@@ -6,12 +6,14 @@
"dependsOn": ["^build"]
},
"format": {},
"format:check": {},
"types": {
"format:fix": {},
"typecheck": {
"dependsOn": ["^build"],
"outputLogs": "new-only"
},
"test": {},
"test": {
"dependsOn": ["^build"]
},
"test:watch": {},
"build": {
"outputs": ["dist/**"],