From 67de777fd1d646053857195890214b01e1b8046c Mon Sep 17 00:00:00 2001 From: Marco Pasqualetti <24919330+marcalexiei@users.noreply.github.com> Date: Sun, 9 Mar 2025 12:00:12 +0100 Subject: [PATCH] feat: Implement .env file support (#618) Co-authored-by: Jacob Marshall --- crates/tuono/src/app.rs | 3 +- crates/tuono/src/source_builder.rs | 7 +- crates/tuono/src/watch.rs | 29 +- crates/tuono_lib/src/env.rs | 268 +++++++++++++++++++ crates/tuono_lib/src/lib.rs | 1 + crates/tuono_lib/src/server.rs | 3 + crates/tuono_lib/tests/server_test.rs | 22 ++ crates/tuono_lib/tests/utils/env.rs | 7 + crates/tuono_lib/tests/utils/mock_server.rs | 6 +- crates/tuono_lib/tests/utils/mod.rs | 1 + examples/tuono-app/.gitignore | 3 + examples/tuono-app/tsconfig.json | 1 + examples/tuono-tutorial/.gitignore | 3 + examples/tuono-tutorial/src/css-modules.d.ts | 4 - examples/tuono-tutorial/tsconfig.json | 1 + examples/with-mdx/.gitignore | 3 + examples/with-mdx/tsconfig.json | 1 + examples/with-tailwind/.gitignore | 3 + examples/with-tailwind/tsconfig.json | 1 + packages/tuono/package.json | 3 + packages/tuono/src/build-client/index.ts | 1 + packages/tuono/src/build/constants.ts | 1 + packages/tuono/src/build/index.ts | 2 + packages/tuono/vite.config.ts | 1 + 24 files changed, 361 insertions(+), 14 deletions(-) create mode 100644 crates/tuono_lib/src/env.rs create mode 100644 crates/tuono_lib/tests/utils/env.rs delete mode 100644 examples/tuono-tutorial/src/css-modules.d.ts create mode 100644 packages/tuono/src/build-client/index.ts diff --git a/crates/tuono/src/app.rs b/crates/tuono/src/app.rs index b9e42128..14bf75fc 100644 --- a/crates/tuono/src/app.rs +++ b/crates/tuono/src/app.rs @@ -1,4 +1,5 @@ use crate::mode::Mode; +use crate::route::Route; use glob::glob; use glob::GlobError; use http::Method; @@ -16,8 +17,6 @@ use std::process::Stdio; use tracing::error; use tuono_internal::config::Config; -use crate::route::Route; - const IGNORE_EXTENSIONS: [&str; 3] = ["css", "scss", "sass"]; const IGNORE_FILES: [&str; 1] = ["__layout"]; diff --git a/crates/tuono/src/source_builder.rs b/crates/tuono/src/source_builder.rs index 7586c246..3f43eab5 100644 --- a/crates/tuono/src/source_builder.rs +++ b/crates/tuono/src/source_builder.rs @@ -143,7 +143,7 @@ fn create_modules_declaration(routes: &HashMap) -> String { } pub fn bundle_axum_source(mode: Mode) -> io::Result { - let base_path = std::env::current_dir().unwrap(); + let base_path = std::env::current_dir()?; let app = App::new(); let bundled_file = generate_axum_source(&app, mode); @@ -261,10 +261,9 @@ mod tests { let source_builder = App::new(); let dev_bundle = generate_axum_source(&source_builder, Mode::Dev); - assert!(dev_bundle.contains("const MODE: Mode = Mode::Dev;")); - let prod_bundle = generate_axum_source(&source_builder, Mode::Prod); + assert!(dev_bundle.contains("const MODE: Mode = Mode::Dev;")); assert!(prod_bundle.contains("const MODE: Mode = Mode::Prod;")); } @@ -273,6 +272,7 @@ mod tests { let source_builder = App::new(); let dev_bundle = generate_axum_source(&source_builder, Mode::Dev); + assert!(!dev_bundle.contains("use tuono_lib::axum::routing::get;")); } @@ -288,6 +288,7 @@ mod tests { .insert(String::from("index.rs"), route); let dev_bundle = generate_axum_source(&source_builder, Mode::Dev); + assert!(dev_bundle.contains("use tuono_lib::axum::routing::get;")); } diff --git a/crates/tuono/src/watch.rs b/crates/tuono/src/watch.rs index 59e45db7..21c2c3a7 100644 --- a/crates/tuono/src/watch.rs +++ b/crates/tuono/src/watch.rs @@ -1,3 +1,4 @@ +use std::fs; use std::path::Path; use std::sync::Arc; use watchexec_supervisor::command::{Command, Program}; @@ -74,6 +75,17 @@ fn build_react_ssr_src() -> Job { .0 } +fn ssr_reload_needed(path: &Path) -> bool { + let file_name_starts_with_env = path + .file_name() + .map(|f| f.to_string_lossy().starts_with(".env")) + .unwrap_or(false); + + let file_path = path.to_string_lossy(); + + file_name_starts_with_env || file_path.ends_with("sx") || file_path.ends_with("mdx") +} + #[tokio::main] pub async fn watch() -> Result<()> { let term = Term::stdout(); @@ -86,6 +98,13 @@ pub async fn watch() -> Result<()> { let build_ssr_bundle = build_react_ssr_src(); + let env_files = fs::read_dir("./") + .expect("Error reading env files from current directory") + .filter_map(|entry| entry.ok()) + .filter(|entry| entry.file_name().to_string_lossy().starts_with(".env")) + .map(|entry| entry.path().to_string_lossy().into_owned()) + .collect::>(); + build_ssr_bundle.start().await; build_rust_src.start().await; @@ -110,8 +129,7 @@ pub async fn watch() -> Result<()> { should_reload_rust_server = true } - // Either tsx, jsx and mdx - if file_path.ends_with("sx") || file_path.ends_with("mdx") { + if ssr_reload_needed(path.0) { should_reload_ssr_bundle = true } } @@ -137,8 +155,11 @@ pub async fn watch() -> Result<()> { action })?; - // watch the current directory - wx.config.pathset(["./src"]); + // watch the current directory and all types of .env file + let mut paths_to_watch = vec!["./src".to_string()]; + paths_to_watch.extend(env_files); + + wx.config.pathset(paths_to_watch); let _ = wx.main().await.into_diagnostic()?; Ok(()) diff --git a/crates/tuono_lib/src/env.rs b/crates/tuono_lib/src/env.rs new file mode 100644 index 00000000..f25e6df3 --- /dev/null +++ b/crates/tuono_lib/src/env.rs @@ -0,0 +1,268 @@ +use crate::mode::Mode; +use std::collections::HashSet; +use std::env; +use std::fs; + +pub fn load_env_vars(mode: Mode) { + let mut env_files = vec![String::from(".env"), String::from(".env.local")]; + + let mode_name = match mode { + Mode::Dev => "development", + Mode::Prod => "production", + }; + + env_files.push(format!(".env.{}", mode_name)); + env_files.push(String::from(".env.local")); + env_files.push(format!(".env.{}.local", mode_name)); + + let system_env_names: HashSet = env::vars().map(|(k, _)| k).collect(); + + for env_file in env_files { + if let Ok(contents) = fs::read_to_string(env_file) { + for line in contents.lines() { + if let Some((key, mut value)) = line.split_once('=') { + if value.starts_with('"') && value.ends_with('"') { + value = &value[1..value.len() - 1]; + } + + let key = key.trim().to_string(); + let value = value.trim().to_string(); + + if system_env_names.contains(&key) { + continue; // Skip if key exists in system env + } + + env::set_var(key, value); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mode::Mode; + use serial_test::serial; + use std::collections::HashMap; + use std::env; + use std::fs; + + struct MockEnv { + files: Vec, + vars: HashMap, + } + + impl MockEnv { + fn new() -> Self { + Self { + files: Vec::new(), + vars: HashMap::new(), + } + } + + fn add_system_var(&mut self, k: &str, v: &str) { + self.vars.insert(k.to_string(), v.to_string()); + env::set_var(k, v); + } + + pub fn setup_env_file(&mut self, file_name: &str, contents: &str) { + self.files.push(file_name.to_string()); + fs::write(file_name, contents).expect("Failed to write test .env file"); + } + + pub fn capture_keys(&mut self, keys: &[&str]) { + for key in keys { + if let Ok(val) = env::var(key) { + self.vars.insert(key.to_string(), val); + } + } + } + } + + impl Drop for MockEnv { + fn drop(&mut self) { + for file in self.files.iter() { + let _ = fs::remove_file(file.as_str()); + } + for key in self.vars.keys() { + env::remove_var(key); + } + } + } + + #[test] + #[serial] + fn test_system_env_var_precedence() { + let mut mock_env = MockEnv::new(); + + mock_env.add_system_var("TEST_KEY", "system_value"); + mock_env.setup_env_file(".env", "TEST_KEY=file_value"); + + load_env_vars(Mode::Dev); + + mock_env.capture_keys(&["TEST_KEY"]); + + assert_eq!(env::var("TEST_KEY").unwrap(), "system_value"); + } + + #[test] + #[serial] + fn test_mode_specific_env_var_precedence_dev() { + let mut mock_env = MockEnv::new(); + + mock_env.setup_env_file(".env", "TEST_KEY=base_value"); + mock_env.setup_env_file(".env.development", "TEST_KEY=development_value"); + + load_env_vars(Mode::Dev); + + mock_env.capture_keys(&["TEST_KEY"]); + + assert_eq!(env::var("TEST_KEY").unwrap(), "development_value"); + } + + #[test] + #[serial] + fn test_mode_specific_env_var_precedence_prod() { + let mut mock_env = MockEnv::new(); + + mock_env.setup_env_file(".env", "TEST_KEY=base_value"); + mock_env.setup_env_file(".env.production", "TEST_KEY=production_value"); + + load_env_vars(Mode::Prod); + + mock_env.capture_keys(&["TEST_KEY"]); + + assert_eq!(env::var("TEST_KEY").unwrap(), "production_value"); + } + + #[test] + #[serial] + fn test_local_env_var_precedence() { + let mut mock_env = MockEnv::new(); + + mock_env.setup_env_file(".env", "TEST_KEY=base_value"); + mock_env.setup_env_file(".env.local", "TEST_KEY=local_value"); + + load_env_vars(Mode::Dev); + + mock_env.capture_keys(&["TEST_KEY"]); + + assert_eq!(env::var("TEST_KEY").unwrap(), "local_value"); + } + + #[test] + #[serial] + fn test_mode_local_env_var_precedence_dev() { + let mut mock_env = MockEnv::new(); + + mock_env.setup_env_file(".env", "TEST_KEY=base_value"); + mock_env.setup_env_file(".env.development", "TEST_KEY=development_value"); + mock_env.setup_env_file(".env.development.local", "TEST_KEY=local_dev_value"); + + load_env_vars(Mode::Dev); + + mock_env.capture_keys(&["TEST_KEY"]); + + assert_eq!(env::var("TEST_KEY").unwrap(), "local_dev_value"); + } + + #[test] + #[serial] + fn test_mode_local_env_var_precedence_prod() { + let mut mock_env = MockEnv::new(); + + mock_env.setup_env_file(".env", "TEST_KEY=base_value"); + mock_env.setup_env_file(".env.production", "TEST_KEY=production_value"); + mock_env.setup_env_file(".env.production.local", "TEST_KEY=local_prod_value"); + + load_env_vars(Mode::Prod); + + mock_env.capture_keys(&["TEST_KEY"]); + + assert_eq!(env::var("TEST_KEY").unwrap(), "local_prod_value"); + } + + #[test] + #[serial] + fn test_ignores_files_from_other_mode() { + let mut mock_env = MockEnv::new(); + + mock_env.setup_env_file(".env.development", "TEST_KEY=development_value"); + mock_env.setup_env_file(".env.production", "TEST_KEY=production_value"); + + load_env_vars(Mode::Prod); + + mock_env.capture_keys(&["TEST_KEY"]); + + assert_eq!(env::var("TEST_KEY").unwrap(), "production_value"); + } + + #[test] + #[serial] + fn test_empty_env_file() { + let mut mock_env = MockEnv::new(); + + mock_env.setup_env_file(".env", ""); + + load_env_vars(Mode::Dev); + + assert!(env::var("NON_EXISTENT_KEY").is_err()); + } + + #[test] + #[serial] + fn test_malformed_env_entries() { + let mut mock_env = MockEnv::new(); + + mock_env.setup_env_file(".env", "INVALID_LINE\nMISSING_EQUALS_SIGN"); + + load_env_vars(Mode::Dev); + + mock_env.capture_keys(&["INVALID_LINE", "MISSING_EQUALS_SIGN"]); + + assert!(env::var("INVALID_LINE").is_err()); + assert!(env::var("MISSING_EQUALS_SIGN").is_err()); + } + + #[test] + #[serial] + fn test_quoted_values_parsing() { + let mut mock_env = MockEnv::new(); + + mock_env.setup_env_file(".env", r#"TEST_KEY="quoted_value""#); + + load_env_vars(Mode::Dev); + + mock_env.capture_keys(&["TEST_KEY"]); + + assert_eq!(env::var("TEST_KEY").unwrap(), "quoted_value"); + } + + #[test] + #[serial] + fn test_non_existent_env_file() { + let mut mock_env = MockEnv::new(); + + load_env_vars(Mode::Dev); + + mock_env.capture_keys(&["NON_EXISTENT_KEY"]); + + assert!(env::var("NON_EXISTENT_KEY").is_err()); + } + + #[test] + #[serial] + fn test_multiple_env_vars() { + let mut mock_env = MockEnv::new(); + + mock_env.setup_env_file(".env", "KEY1=value1\nKEY2=value2"); + + load_env_vars(Mode::Dev); + + mock_env.capture_keys(&["KEY1", "KEY2"]); + + assert_eq!(env::var("KEY1").unwrap(), "value1"); + assert_eq!(env::var("KEY2").unwrap(), "value2"); + } +} diff --git a/crates/tuono_lib/src/lib.rs b/crates/tuono_lib/src/lib.rs index 96ff6996..0f36c1e4 100644 --- a/crates/tuono_lib/src/lib.rs +++ b/crates/tuono_lib/src/lib.rs @@ -5,6 +5,7 @@ mod catch_all; mod config; +mod env; mod logger; mod manifest; mod mode; diff --git a/crates/tuono_lib/src/server.rs b/crates/tuono_lib/src/server.rs index 0bb5c24c..b28fafc0 100644 --- a/crates/tuono_lib/src/server.rs +++ b/crates/tuono_lib/src/server.rs @@ -7,6 +7,7 @@ use ssr_rs::Ssr; use tower_http::services::ServeDir; use tuono_internal::config::Config; +use crate::env::load_env_vars; use crate::{ catch_all::catch_all, logger::LoggerLayer, vite_reverse_proxy::vite_reverse_proxy, vite_websocket_proxy::vite_websocket_proxy, @@ -62,6 +63,8 @@ impl Server { let server_address = format!("{}:{}", config.server.host, config.server.port); + load_env_vars(mode); + Server { router, mode, diff --git a/crates/tuono_lib/tests/server_test.rs b/crates/tuono_lib/tests/server_test.rs index 93b8c247..ce0558f2 100644 --- a/crates/tuono_lib/tests/server_test.rs +++ b/crates/tuono_lib/tests/server_test.rs @@ -147,3 +147,25 @@ async fn it_reads_the_path_parameter() { assert!(response.status().is_success()); assert_eq!(response.text().await.unwrap(), "url_parameter"); } + +#[tokio::test] +#[serial] +async fn it_reads_an_env_var() { + 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}/env")) + .send() + .await + .expect("Failed to execute request."); + + assert!(response.status().is_success()); + assert_eq!(response.text().await.unwrap(), "foobar"); +} diff --git a/crates/tuono_lib/tests/utils/env.rs b/crates/tuono_lib/tests/utils/env.rs new file mode 100644 index 00000000..8ac74cad --- /dev/null +++ b/crates/tuono_lib/tests/utils/env.rs @@ -0,0 +1,7 @@ +use std::env; +use tuono_lib::Request; + +#[tuono_lib::api(GET)] +pub async fn test_env(_req: Request) -> String { + env::var("MY_TEST_KEY").unwrap_or("error".parse().unwrap()) +} diff --git a/crates/tuono_lib/tests/utils/mock_server.rs b/crates/tuono_lib/tests/utils/mock_server.rs index 4f490847..93d8d8e7 100644 --- a/crates/tuono_lib/tests/utils/mock_server.rs +++ b/crates/tuono_lib/tests/utils/mock_server.rs @@ -9,6 +9,7 @@ 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::env::get_tuono_internal_api as test_env; 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; @@ -74,13 +75,16 @@ impl MockTuonoServer { r#"{"client-main.tsx": { "file": "assets/index.js", "name": "index", "src": "index.tsx", "isEntry": true,"dynamicImports": [],"css": []}}"#, ); + add_file_with_content("./.env", r#"MY_TEST_KEY="foobar""#); + 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)); + .route("/dynamic/{parameter}", get(dynamic_parameter)) + .route("/env", get(test_env)); let server = Server::init(router, Mode::Prod).await; diff --git a/crates/tuono_lib/tests/utils/mod.rs b/crates/tuono_lib/tests/utils/mod.rs index ed23aaf4..9bf184ab 100644 --- a/crates/tuono_lib/tests/utils/mod.rs +++ b/crates/tuono_lib/tests/utils/mod.rs @@ -1,5 +1,6 @@ pub mod catch_all; pub mod dynamic_parameter; +pub mod env; pub mod health_check; pub mod mock_server; pub mod route; diff --git a/examples/tuono-app/.gitignore b/examples/tuono-app/.gitignore index 071acad0..d77106ff 100644 --- a/examples/tuono-app/.gitignore +++ b/examples/tuono-app/.gitignore @@ -11,3 +11,6 @@ node_modules .tuono out target + +# Ignore local env files +.env*.local diff --git a/examples/tuono-app/tsconfig.json b/examples/tuono-app/tsconfig.json index ea323b8b..aeced3d3 100644 --- a/examples/tuono-app/tsconfig.json +++ b/examples/tuono-app/tsconfig.json @@ -11,6 +11,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "allowImportingTsExtensions": true, + "types": ["tuono/build-client"], // Interop Constraints "isolatedModules": true, diff --git a/examples/tuono-tutorial/.gitignore b/examples/tuono-tutorial/.gitignore index 071acad0..d77106ff 100644 --- a/examples/tuono-tutorial/.gitignore +++ b/examples/tuono-tutorial/.gitignore @@ -11,3 +11,6 @@ node_modules .tuono out target + +# Ignore local env files +.env*.local diff --git a/examples/tuono-tutorial/src/css-modules.d.ts b/examples/tuono-tutorial/src/css-modules.d.ts deleted file mode 100644 index 8811db12..00000000 --- a/examples/tuono-tutorial/src/css-modules.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module '*.module.css' { - const classes: Record - export default classes -} diff --git a/examples/tuono-tutorial/tsconfig.json b/examples/tuono-tutorial/tsconfig.json index ea323b8b..aeced3d3 100644 --- a/examples/tuono-tutorial/tsconfig.json +++ b/examples/tuono-tutorial/tsconfig.json @@ -11,6 +11,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "allowImportingTsExtensions": true, + "types": ["tuono/build-client"], // Interop Constraints "isolatedModules": true, diff --git a/examples/with-mdx/.gitignore b/examples/with-mdx/.gitignore index 071acad0..d77106ff 100644 --- a/examples/with-mdx/.gitignore +++ b/examples/with-mdx/.gitignore @@ -11,3 +11,6 @@ node_modules .tuono out target + +# Ignore local env files +.env*.local diff --git a/examples/with-mdx/tsconfig.json b/examples/with-mdx/tsconfig.json index ea323b8b..aeced3d3 100644 --- a/examples/with-mdx/tsconfig.json +++ b/examples/with-mdx/tsconfig.json @@ -11,6 +11,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "allowImportingTsExtensions": true, + "types": ["tuono/build-client"], // Interop Constraints "isolatedModules": true, diff --git a/examples/with-tailwind/.gitignore b/examples/with-tailwind/.gitignore index 071acad0..d77106ff 100644 --- a/examples/with-tailwind/.gitignore +++ b/examples/with-tailwind/.gitignore @@ -11,3 +11,6 @@ node_modules .tuono out target + +# Ignore local env files +.env*.local diff --git a/examples/with-tailwind/tsconfig.json b/examples/with-tailwind/tsconfig.json index ea323b8b..aeced3d3 100644 --- a/examples/with-tailwind/tsconfig.json +++ b/examples/with-tailwind/tsconfig.json @@ -11,6 +11,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "allowImportingTsExtensions": true, + "types": ["tuono/build-client"], // Interop Constraints "isolatedModules": true, diff --git a/packages/tuono/package.json b/packages/tuono/package.json index 797dae84..f98a40e0 100644 --- a/packages/tuono/package.json +++ b/packages/tuono/package.json @@ -27,6 +27,9 @@ "types": "./dist/esm/build/index.d.ts", "default": "./dist/esm/build/index.js" }, + "./build-client": { + "types": "./dist/esm/build-client/index.d.ts" + }, "./config": { "types": "./dist/esm/config/index.d.ts", "default": "./dist/esm/config/index.js" diff --git a/packages/tuono/src/build-client/index.ts b/packages/tuono/src/build-client/index.ts new file mode 100644 index 00000000..5597b4fb --- /dev/null +++ b/packages/tuono/src/build-client/index.ts @@ -0,0 +1 @@ +import 'vite/client' diff --git a/packages/tuono/src/build/constants.ts b/packages/tuono/src/build/constants.ts index c298bebd..da24c614 100644 --- a/packages/tuono/src/build/constants.ts +++ b/packages/tuono/src/build/constants.ts @@ -2,3 +2,4 @@ export const DOT_TUONO_FOLDER_NAME = '.tuono' export const CONFIG_FOLDER_NAME = 'config' export const CONFIG_FILE_NAME = 'config.mjs' export const SERVER_CONFIG_NAME = 'config.json' +export const ENV_PREFIX = 'TUONO_PUBLIC_' diff --git a/packages/tuono/src/build/index.ts b/packages/tuono/src/build/index.ts index 75e5d1ac..0aacf3d3 100644 --- a/packages/tuono/src/build/index.ts +++ b/packages/tuono/src/build/index.ts @@ -10,6 +10,7 @@ import { ErrorOverlayVitePlugin } from './error-overlay' import { blockingAsync } from './utils' import { createJsonConfig, loadConfig } from './config' +import { ENV_PREFIX } from './constants' const VITE_SSR_PLUGINS: Array = [ { @@ -47,6 +48,7 @@ function createBaseViteConfigFromTuonoConfig( publicDir: '../public', cacheDir: 'cache', envDir: '../', + envPrefix: ENV_PREFIX, resolve: { alias: tuonoConfig.vite?.alias ?? {}, diff --git a/packages/tuono/vite.config.ts b/packages/tuono/vite.config.ts index 5c631f98..2a3839d4 100644 --- a/packages/tuono/vite.config.ts +++ b/packages/tuono/vite.config.ts @@ -17,6 +17,7 @@ export default mergeConfig( entry: [ './src/index.ts', './src/build/index.ts', + './src/build-client/index.ts', './src/config/index.ts', './src/ssr/index.ts', './src/hydration/index.tsx',