mirror of
https://github.com/tuono-labs/tuono
synced 2026-07-25 12:52:47 -07:00
feat: Implement .env file support (#618)
Co-authored-by: Jacob Marshall <me@jhqcat.com>
This commit is contained in:
committed by
GitHub
parent
8fcebae4a9
commit
67de777fd1
@@ -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"];
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ fn create_modules_declaration(routes: &HashMap<String, Route>) -> String {
|
||||
}
|
||||
|
||||
pub fn bundle_axum_source(mode: Mode) -> io::Result<App> {
|
||||
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;"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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::<Vec<String>>();
|
||||
|
||||
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(())
|
||||
|
||||
@@ -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<String> = 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<String>,
|
||||
vars: HashMap<String, String>,
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
mod catch_all;
|
||||
mod config;
|
||||
mod env;
|
||||
mod logger;
|
||||
mod manifest;
|
||||
mod mode;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user