Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(env): add environment variables with hot reload for rust code #583

Open
wants to merge 22 commits into
base: feat/env
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions crates/tuono/src/app.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::mode::Mode;
use crate::route::Route;
use glob::glob;
use glob::GlobError;
use http::Method;
Expand All @@ -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"];

Expand Down
5 changes: 3 additions & 2 deletions crates/tuono/src/source_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,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);
Expand Down Expand Up @@ -213,10 +213,11 @@ mod tests {
#[test]
fn should_set_the_correct_mode() {
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 source_builder = App::new();
let prod_bundle = generate_axum_source(&source_builder, Mode::Prod);

assert!(prod_bundle.contains("const MODE: Mode = Mode::Prod;"));
Expand Down
33 changes: 31 additions & 2 deletions crates/tuono/src/watch.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::fs;
use std::path::Path;
use std::sync::Arc;
use watchexec_supervisor::command::{Command, Program};
Expand Down Expand Up @@ -86,6 +87,13 @@ pub async fn watch() -> Result<()> {

let build_ssr_bundle = build_react_ssr_src();

let env_files = fs::read_dir("./")
.unwrap()
.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;

Expand All @@ -102,6 +110,7 @@ pub async fn watch() -> Result<()> {
let wx = Watchexec::new(move |mut action| {
let mut should_reload_ssr_bundle = false;
let mut should_reload_rust_server = false;
let mut should_reload_env_file = false;

for event in action.events.iter() {
for path in event.paths() {
Expand All @@ -114,6 +123,16 @@ pub async fn watch() -> Result<()> {
if file_path.ends_with("sx") || file_path.ends_with("mdx") {
should_reload_ssr_bundle = true
}

if path
.0
.file_name()
.map(|f| f.to_string_lossy().starts_with(".env"))
.unwrap_or(false)
{
should_reload_env_file = true;
should_reload_ssr_bundle = true
}
}
}

Expand All @@ -129,6 +148,13 @@ pub async fn watch() -> Result<()> {
build_ssr_bundle.start();
}

if should_reload_env_file {
println!(" Reloading environment variables, and restarting rust server...");
rust_server.stop();
bundle_axum_source(Mode::Dev).expect("Failed to bundle rust source");
rust_server.start();
}

// if Ctrl-C is received, quit
if action.signals().any(|sig| sig == Signal::Interrupt) {
action.quit();
Expand All @@ -137,8 +163,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(())
Expand Down
227 changes: 227 additions & 0 deletions crates/tuono_lib/src/env.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
use crate::mode::Mode;
use std::collections::{HashMap, HashSet};
use std::env;
use std::fs;

#[derive(Clone, Debug)]
pub struct EnvVarManager {
env_files: Vec<String>,
system_env_names: HashSet<String>,
env_vars: HashMap<String, String>,
}

impl EnvVarManager {
pub fn new(mode: Mode) -> Self {
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();
let env_vars: HashMap<String, String> = env::vars().collect();

let mut manager = Self {
env_files,
system_env_names,
env_vars,
};

manager.reload_variables(); // Load only missing env variables
manager
}

pub fn reload_variables(&mut self) {
for env_file in &self.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 self.system_env_names.contains(&key) {
continue; // Skip if key exists in system env
}

self.env_vars.insert(key, value);
}
}
}
}
}

pub fn load_into_env(&self) {
for (key, value) in &self.env_vars {
env::set_var(key, value);
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::mode::Mode;
use serial_test::serial;

fn setup_env_file(file_name: &str, contents: &str) {
fs::write(file_name, contents).expect("Failed to write test .env file");
}

fn cleanup_env_files() {
let _ = fs::remove_file(".env");
let _ = fs::remove_file(".env.local");
let _ = fs::remove_file(".env.development");
let _ = fs::remove_file(".env.development.local");
let _ = fs::remove_file(".env.production");
let _ = fs::remove_file(".env.production.local");
}

fn cleanup_env_vars() {
env::remove_var("TEST_KEY");
env::remove_var("KEY1");
env::remove_var("KEY2");
env::remove_var("NON_EXISTENT_KEY");
env::remove_var("INVALID_LINE");
env::remove_var("MISSING_EQUALS_SIGN");
}

#[test]
#[serial]
fn test_system_env_var_precedence() {
env::set_var("TEST_KEY", "system_value");

setup_env_file(".env", "TEST_KEY=file_value");
let manager = EnvVarManager::new(Mode::Dev);
manager.load_into_env();

assert_eq!(env::var("TEST_KEY").unwrap(), "system_value");

cleanup_env_vars();
cleanup_env_files();
}

#[test]
#[serial]
fn test_mode_specific_env_var_precedence() {
setup_env_file(".env", "TEST_KEY=base_value");
setup_env_file(".env.development", "TEST_KEY=development_value");

let manager = EnvVarManager::new(Mode::Dev);
manager.load_into_env();

assert_eq!(env::var("TEST_KEY").unwrap(), "development_value");

cleanup_env_vars();
cleanup_env_files();
}

#[test]
#[serial]
fn test_local_env_var_precedence() {
setup_env_file(".env", "TEST_KEY=base_value");
setup_env_file(".env.local", "TEST_KEY=local_value");

let manager = EnvVarManager::new(Mode::Dev);
manager.load_into_env();

assert_eq!(env::var("TEST_KEY").unwrap(), "local_value");

cleanup_env_vars();
cleanup_env_files();
}

#[test]
#[serial]
fn test_mode_local_env_var_precedence() {
setup_env_file(".env", "TEST_KEY=base_value");
setup_env_file(".env.development", "TEST_KEY=development_value");
setup_env_file(".env.development.local", "TEST_KEY=local_dev_value");

let manager = EnvVarManager::new(Mode::Dev);
manager.load_into_env();

assert_eq!(env::var("TEST_KEY").unwrap(), "local_dev_value");

cleanup_env_vars();
cleanup_env_files();
}

#[test]
#[serial]
fn test_empty_env_file() {
setup_env_file(".env", "");

let manager = EnvVarManager::new(Mode::Dev);
manager.load_into_env();

assert!(env::var("NON_EXISTENT_KEY").is_err());

cleanup_env_vars();
cleanup_env_files();
}

#[test]
#[serial]
fn test_malformed_env_entries() {
setup_env_file(".env", "INVALID_LINE\nMISSING_EQUALS_SIGN");

let manager = EnvVarManager::new(Mode::Dev);
manager.load_into_env();

assert!(env::var("INVALID_LINE").is_err());
assert!(env::var("MISSING_EQUALS_SIGN").is_err());

cleanup_env_vars();
cleanup_env_files();
}

#[test]
#[serial]
fn test_quoted_values_parsing() {
setup_env_file(".env", r#"TEST_KEY="quoted_value""#);

let manager = EnvVarManager::new(Mode::Dev);
manager.load_into_env();

assert_eq!(env::var("TEST_KEY").unwrap(), "quoted_value");

cleanup_env_vars();
cleanup_env_files();
}

#[test]
#[serial]
fn test_non_existent_env_file() {
let manager = EnvVarManager::new(Mode::Dev);
manager.load_into_env();

assert!(env::var("NON_EXISTENT_KEY").is_err());

cleanup_env_vars();
cleanup_env_files();
}

#[test]
#[serial]
fn test_multiple_env_vars() {
setup_env_file(".env", "KEY1=value1\nKEY2=value2");

let manager = EnvVarManager::new(Mode::Dev);
manager.load_into_env();

assert_eq!(env::var("KEY1").unwrap(), "value1");
assert_eq!(env::var("KEY2").unwrap(), "value2");

cleanup_env_vars();
cleanup_env_files();
}
}
1 change: 1 addition & 0 deletions crates/tuono_lib/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

mod catch_all;
mod config;
mod env;
mod logger;
mod manifest;
mod mode;
Expand Down
7 changes: 7 additions & 0 deletions crates/tuono_lib/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use ssr_rs::Ssr;
use tower_http::services::ServeDir;
use tuono_internal::config::Config;

use crate::env::EnvVarManager;
use crate::{
catch_all::catch_all, logger::LoggerLayer, vite_reverse_proxy::vite_reverse_proxy,
vite_websocket_proxy::vite_websocket_proxy,
Expand All @@ -26,6 +27,7 @@ pub struct Server {
pub listener: tokio::net::TcpListener,
pub address: String,
pub origin: Option<String>,
env_var_manager: EnvVarManager,
}

impl Server {
Expand Down Expand Up @@ -62,6 +64,8 @@ impl Server {

let server_address = format!("{}:{}", config.server.host, config.server.port);

let env_var_manager = EnvVarManager::new(mode);

Server {
router,
mode,
Expand All @@ -70,12 +74,15 @@ impl Server {
listener: tokio::net::TcpListener::bind(&server_address)
.await
.expect("[SERVER] Failed to bind to address"),
env_var_manager,
}
}

pub async fn start(self) {
self.display_start_message();

self.env_var_manager.load_into_env();

if self.mode == Mode::Dev {
let router = self
.router
Expand Down
Loading