use cow_utils::CowUtils;
use http::HeaderValue;
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::OnceLock;
pub static GLOBAL_CONFIG: OnceLock<Config> = OnceLock::new();
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum Mode {
#[default]
Development,
Production,
}
impl std::fmt::Display for Mode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Mode::Development => write!(f, "development"),
Mode::Production => write!(f, "production"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
pub host: String,
pub port: u16,
pub origin: Option<String>,
pub enable_logging: bool,
pub timeout_seconds: u64,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
host: "127.0.0.1".to_string(),
port: 3000,
origin: None,
enable_logging: true,
timeout_seconds: 30,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CorsConfig {
pub allowed_origins: Vec<String>,
pub allow_credentials: bool,
pub max_age: u32,
}
impl Default for CorsConfig {
fn default() -> Self {
Self { allowed_origins: vec![], allow_credentials: true, max_age: 86400 }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RedirectConfig {
pub allowed_hosts: Vec<String>,
pub allow_relative: bool,
pub allow_subdomains: bool,
}
impl Default for RedirectConfig {
fn default() -> Self {
Self { allowed_hosts: vec![], allow_relative: true, allow_subdomains: false }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ActionConfig {
pub allowed_origins: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CspConfig {
pub script_src: Vec<String>,
pub style_src: Vec<String>,
pub img_src: Vec<String>,
pub font_src: Vec<String>,
pub connect_src: Vec<String>,
pub default_src: Vec<String>,
pub worker_src: Vec<String>,
}
impl Default for CspConfig {
fn default() -> Self {
Self {
default_src: vec!["'self'".to_string()],
script_src: vec!["'self'".to_string()],
style_src: vec!["'self'".to_string()],
img_src: vec!["'self'".to_string(), "data:".to_string(), "https:".to_string()],
font_src: vec!["'self'".to_string(), "data:".to_string()],
connect_src: vec!["'self'".to_string(), "ws:".to_string(), "wss:".to_string()],
worker_src: vec!["'self'".to_string()],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RateLimitConfig {
pub enabled: bool,
pub requests_per_second: u32,
pub burst_size: u32,
#[serde(default = "default_revalidate_rpm")]
pub revalidate_requests_per_minute: u32,
}
fn default_revalidate_rpm() -> u32 {
10
}
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
enabled: false,
requests_per_second: 100,
burst_size: 200,
revalidate_requests_per_minute: 10,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpamBlockerConfig {
pub enabled: bool,
}
impl Default for SpamBlockerConfig {
fn default() -> Self {
Self { enabled: true }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ViteConfig {
pub host: String,
pub port: u16,
pub enable_hmr_proxy: bool,
pub ws_protocol: String,
}
impl Default for ViteConfig {
fn default() -> Self {
Self {
host: "localhost".to_string(),
port: 5173,
enable_hmr_proxy: true,
ws_protocol: "vite-hmr".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StaticConfig {
pub dev_public_dir: PathBuf,
pub prod_public_dir: PathBuf,
pub enable_directory_listing: bool,
pub cache_control: String,
}
impl Default for StaticConfig {
fn default() -> Self {
Self {
dev_public_dir: PathBuf::from("public"),
prod_public_dir: PathBuf::from("dist"),
enable_directory_listing: false,
cache_control: "public, max-age=31536000".to_string(),
}
}
}
#[derive(Debug, Clone)]
enum RoutePattern {
Exact(String),
Prefix(String),
Regex(regex::Regex),
}
impl RoutePattern {
fn from_pattern(pattern: &str) -> Self {
if let Some(prefix) = pattern.strip_suffix("/*") {
RoutePattern::Prefix(prefix.to_string())
} else if pattern.contains('*') {
let escaped = regex::escape(pattern);
let regex_pattern = escaped.cow_replace(r"\*", ".*");
match regex::Regex::new(&format!("^{}$", regex_pattern)) {
Ok(regex) => RoutePattern::Regex(regex),
Err(_) => RoutePattern::Exact(pattern.to_string()),
}
} else {
RoutePattern::Exact(pattern.to_string())
}
}
fn matches(&self, path: &str) -> bool {
match self {
RoutePattern::Exact(pattern) => pattern == path,
RoutePattern::Prefix(prefix) => {
path == prefix || path.starts_with(&format!("{}/", prefix))
}
RoutePattern::Regex(regex) => regex.is_match(path),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheControlConfig {
pub routes: FxHashMap<String, String>,
pub static_files: String,
pub server_components: String,
}
#[derive(Debug, Clone)]
struct CompiledCacheControlConfig {
routes: Vec<(RoutePattern, String)>,
}
impl From<&CacheControlConfig> for CompiledCacheControlConfig {
fn from(config: &CacheControlConfig) -> Self {
let mut routes: Vec<(RoutePattern, String)> = config
.routes
.iter()
.map(|(pattern, cache_control)| {
(RoutePattern::from_pattern(pattern), cache_control.clone())
})
.collect();
routes.sort_by(|(a, _), (b, _)| match (a, b) {
(RoutePattern::Exact(_), RoutePattern::Exact(_)) => std::cmp::Ordering::Equal,
(RoutePattern::Exact(_), _) => std::cmp::Ordering::Less,
(_, RoutePattern::Exact(_)) => std::cmp::Ordering::Greater,
(RoutePattern::Prefix(a_prefix), RoutePattern::Prefix(b_prefix)) => {
b_prefix.len().cmp(&a_prefix.len())
}
(RoutePattern::Prefix(_), RoutePattern::Regex(_)) => std::cmp::Ordering::Less,
(RoutePattern::Regex(_), RoutePattern::Prefix(_)) => std::cmp::Ordering::Greater,
(RoutePattern::Regex(_), RoutePattern::Regex(_)) => std::cmp::Ordering::Equal,
});
CompiledCacheControlConfig { routes }
}
}
impl Default for CacheControlConfig {
fn default() -> Self {
Self {
routes: FxHashMap::default(),
static_files: "public, max-age=31536000, immutable".to_string(),
server_components: "public, max-age=31536000, stale-while-revalidate=86400".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RscHtmlConfig {
pub enabled: bool,
pub timeout_ms: u64,
pub cache_template: bool,
pub pretty_print: bool,
}
impl Default for RscHtmlConfig {
fn default() -> Self {
Self { enabled: true, timeout_ms: 5000, cache_template: true, pretty_print: false }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoadingConfig {
pub enabled: bool,
pub min_display_time_ms: u64,
pub cache_loading_components: bool,
}
impl Default for LoadingConfig {
fn default() -> Self {
Self { enabled: true, min_display_time_ms: 200, cache_loading_components: true }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamingConfig {
pub enabled: bool,
pub buffer_size: usize,
pub resolution_timeout_ms: u64,
}
impl Default for StreamingConfig {
fn default() -> Self {
Self { enabled: true, buffer_size: 64, resolution_timeout_ms: 5000 }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RscConfig {
pub enable_ssr: bool,
pub enable_streaming: bool,
pub component_cache_size: usize,
pub render_timeout_ms: u64,
pub script_execution_timeout_ms: u64,
pub enable_hot_reload: bool,
pub hmr_reload_enabled: bool,
pub hmr_max_retry_attempts: usize,
pub hmr_reload_timeout_ms: u64,
pub hmr_parallel_reloads: bool,
pub hmr_debounce_delay_ms: u64,
}
impl Default for RscConfig {
fn default() -> Self {
Self {
enable_ssr: true,
enable_streaming: true,
component_cache_size: 2000,
render_timeout_ms: 8000,
script_execution_timeout_ms: 3000,
enable_hot_reload: true,
hmr_reload_enabled: true,
hmr_max_retry_attempts: 3,
hmr_reload_timeout_ms: 5000,
hmr_parallel_reloads: true,
hmr_debounce_delay_ms: 150,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
pub mode: Mode,
pub server: ServerConfig,
pub vite: ViteConfig,
pub static_files: StaticConfig,
pub rsc: RscConfig,
pub rsc_html: RscHtmlConfig,
pub caching: CacheControlConfig,
pub loading: LoadingConfig,
#[serde(default)]
pub streaming: StreamingConfig,
#[serde(default)]
pub cors: CorsConfig,
#[serde(default)]
pub redirect: RedirectConfig,
#[serde(default)]
pub action: ActionConfig,
#[serde(default)]
pub csp: CspConfig,
#[serde(default)]
pub rate_limit: RateLimitConfig,
#[serde(default)]
pub spam_blocker: SpamBlockerConfig,
#[serde(default)]
pub images: crate::server::image::ImageConfig,
}
impl Config {
pub fn new(mode: Mode) -> Self {
let default_config = Self::default();
Self {
mode,
vite: ViteConfig {
port: default_config.vite.port,
host: default_config.server.host.clone(),
..default_config.vite
},
rsc: RscConfig {
enable_hot_reload: mode != Mode::Production,
hmr_reload_enabled: mode != Mode::Production,
..default_config.rsc
},
rsc_html: RscHtmlConfig {
pretty_print: mode == Mode::Development,
..default_config.rsc_html
},
caching: CacheControlConfig {
server_components: if mode == Mode::Production {
"public, max-age=31536000, stale-while-revalidate=86400".to_string()
} else {
"no-cache, no-store, must-revalidate".to_string()
},
..default_config.caching
},
..default_config
}
}
pub fn from_env() -> Result<Self, ConfigError> {
Self::from_env_with_base(None)
}
pub fn from_env_with_base(base: Option<&std::path::Path>) -> Result<Self, ConfigError> {
let mut config = Self::default();
if let Ok(mode_str) = std::env::var("RARI_MODE") {
config.mode = match mode_str.cow_to_lowercase().as_ref() {
"development" | "dev" => Mode::Development,
"production" | "prod" => Mode::Production,
_ => return Err(ConfigError::Mode(mode_str)),
};
}
if let Ok(host) = std::env::var("RARI_HOST") {
config.server.host = host;
}
if let Ok(port_str) = std::env::var("RARI_PORT") {
config.server.port = port_str.parse().map_err(|_| ConfigError::Port(port_str))?;
}
if let Ok(origin) = std::env::var("RARI_ORIGIN") {
config.server.origin = Some(origin);
}
if let Ok(vite_host) = std::env::var("RARI_VITE_HOST") {
config.vite.host = vite_host;
}
if let Ok(vite_port_str) = std::env::var("RARI_VITE_PORT") {
config.vite.port =
vite_port_str.parse().map_err(|_| ConfigError::VitePort(vite_port_str))?;
}
if let Ok(public_dir) = std::env::var("RARI_PUBLIC_DIR") {
config.static_files.dev_public_dir = PathBuf::from(public_dir);
}
if let Ok(dist_dir) = std::env::var("RARI_DIST_DIR") {
config.static_files.prod_public_dir = PathBuf::from(dist_dir);
}
if let Ok(timeout_str) = std::env::var("RARI_SCRIPT_EXECUTION_TIMEOUT_MS") {
config.rsc.script_execution_timeout_ms =
timeout_str.parse().map_err(|_| ConfigError::Timeout(timeout_str.clone()))?;
}
if let Ok(disable_hmr) = std::env::var("DISABLE_HMR_RELOAD") {
config.rsc.hmr_reload_enabled = disable_hmr.cow_to_lowercase() != "true"
&& disable_hmr != "1"
&& disable_hmr.cow_to_lowercase() != "yes";
}
if let Ok(max_retry_str) = std::env::var("RARI_HMR_MAX_RETRY_ATTEMPTS") {
config.rsc.hmr_max_retry_attempts = max_retry_str
.parse()
.map_err(|_| ConfigError::Config("RARI_HMR_MAX_RETRY_ATTEMPTS".to_string()))?;
}
if let Ok(timeout_str) = std::env::var("RARI_HMR_RELOAD_TIMEOUT_MS") {
config.rsc.hmr_reload_timeout_ms = timeout_str
.parse()
.map_err(|_| ConfigError::Config("RARI_HMR_RELOAD_TIMEOUT_MS".to_string()))?;
}
if let Ok(parallel_str) = std::env::var("RARI_HMR_PARALLEL_RELOADS") {
config.rsc.hmr_parallel_reloads = parallel_str.cow_to_lowercase() == "true"
|| parallel_str == "1"
|| parallel_str.cow_to_lowercase() == "yes";
}
if let Ok(debounce_str) = std::env::var("RARI_HMR_DEBOUNCE_DELAY_MS") {
config.rsc.hmr_debounce_delay_ms = debounce_str
.parse()
.map_err(|_| ConfigError::Config("RARI_HMR_DEBOUNCE_DELAY_MS".to_string()))?;
}
if let Ok(rsc_html_enabled_str) = std::env::var("RARI_RSC_HTML_ENABLED") {
config.rsc_html.enabled = rsc_html_enabled_str.cow_to_lowercase() == "true"
|| rsc_html_enabled_str == "1"
|| rsc_html_enabled_str.cow_to_lowercase() == "yes";
}
if let Ok(rsc_html_timeout_str) = std::env::var("RARI_RSC_HTML_TIMEOUT_MS") {
config.rsc_html.timeout_ms = rsc_html_timeout_str
.parse()
.map_err(|_| ConfigError::Config("RARI_RSC_HTML_TIMEOUT_MS".to_string()))?;
}
if let Ok(rsc_html_cache_template_str) = std::env::var("RARI_RSC_HTML_CACHE_TEMPLATE") {
config.rsc_html.cache_template = rsc_html_cache_template_str.cow_to_lowercase()
== "true"
|| rsc_html_cache_template_str == "1"
|| rsc_html_cache_template_str.cow_to_lowercase() == "yes";
}
if let Ok(rsc_html_pretty_print_str) = std::env::var("RARI_RSC_HTML_PRETTY_PRINT") {
config.rsc_html.pretty_print = rsc_html_pretty_print_str.cow_to_lowercase() == "true"
|| rsc_html_pretty_print_str == "1"
|| rsc_html_pretty_print_str.cow_to_lowercase() == "yes";
}
if let Ok(loading_enabled_str) = std::env::var("RARI_LOADING_ENABLED") {
config.loading.enabled = loading_enabled_str.cow_to_lowercase() == "true"
|| loading_enabled_str == "1"
|| loading_enabled_str.cow_to_lowercase() == "yes";
}
if let Ok(min_display_time_str) = std::env::var("RARI_LOADING_MIN_DISPLAY_TIME_MS") {
config.loading.min_display_time_ms = min_display_time_str
.parse()
.map_err(|_| ConfigError::Config("RARI_LOADING_MIN_DISPLAY_TIME_MS".to_string()))?;
}
if let Ok(cache_loading_str) = std::env::var("RARI_LOADING_CACHE_COMPONENTS") {
config.loading.cache_loading_components = cache_loading_str.cow_to_lowercase()
== "true"
|| cache_loading_str == "1"
|| cache_loading_str.cow_to_lowercase() == "yes";
}
if let Ok(streaming_enabled_str) = std::env::var("RARI_STREAMING_ENABLED") {
config.streaming.enabled = streaming_enabled_str.cow_to_lowercase() == "true"
|| streaming_enabled_str == "1"
|| streaming_enabled_str.cow_to_lowercase() == "yes";
}
if let Ok(buffer_size_str) = std::env::var("RARI_STREAMING_BUFFER_SIZE") {
config.streaming.buffer_size = buffer_size_str
.parse()
.map_err(|_| ConfigError::Config("RARI_STREAMING_BUFFER_SIZE".to_string()))?;
}
if let Ok(resolution_timeout_str) = std::env::var("RARI_STREAMING_RESOLUTION_TIMEOUT_MS") {
config.streaming.resolution_timeout_ms =
resolution_timeout_str.parse().map_err(|_| {
ConfigError::Config("RARI_STREAMING_RESOLUTION_TIMEOUT_MS".to_string())
})?;
}
let config_path = match base {
Some(b) => b.join("dist/server/config.json"),
None => PathBuf::from("dist/server/config.json"),
};
if let Ok(server_config_json) = std::fs::read_to_string(&config_path) {
let config_data = match serde_json::from_str::<serde_json::Value>(&server_config_json) {
Ok(data) => data,
Err(e) => {
tracing::warn!(
"Failed to parse dist/server/config.json: {}. Using defaults.",
e
);
return Ok(config);
}
};
if let Some(csp_data) = config_data.get("csp") {
if let Some(script_src) = csp_data.get("scriptSrc").and_then(|v| v.as_array()) {
config.csp.script_src = script_src
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
}
if let Some(style_src) = csp_data.get("styleSrc").and_then(|v| v.as_array()) {
config.csp.style_src = style_src
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
}
if let Some(img_src) = csp_data.get("imgSrc").and_then(|v| v.as_array()) {
config.csp.img_src =
img_src.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect();
}
if let Some(font_src) = csp_data.get("fontSrc").and_then(|v| v.as_array()) {
config.csp.font_src =
font_src.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect();
}
if let Some(connect_src) = csp_data.get("connectSrc").and_then(|v| v.as_array()) {
config.csp.connect_src = connect_src
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
}
if let Some(default_src) = csp_data.get("defaultSrc").and_then(|v| v.as_array()) {
config.csp.default_src = default_src
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
}
if let Some(worker_src) = csp_data.get("workerSrc").and_then(|v| v.as_array()) {
config.csp.worker_src = worker_src
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
}
}
if let Some(rate_limit_data) = config_data.get("rateLimit") {
if let Some(enabled) = rate_limit_data.get("enabled").and_then(|v| v.as_bool()) {
config.rate_limit.enabled = enabled;
}
if let Some(rps) = rate_limit_data.get("requestsPerSecond").and_then(|v| v.as_u64())
{
config.rate_limit.requests_per_second = rps.try_into().unwrap_or_else(|_| {
tracing::warn!(
"requestsPerSecond value {} exceeds u32::MAX, using default",
rps
);
100
});
}
if let Some(burst) = rate_limit_data.get("burstSize").and_then(|v| v.as_u64()) {
config.rate_limit.burst_size = burst.try_into().unwrap_or_else(|_| {
tracing::warn!("burstSize value {} exceeds u32::MAX, using default", burst);
200
});
}
if let Some(revalidate_rpm) =
rate_limit_data.get("revalidateRequestsPerMinute").and_then(|v| v.as_u64())
{
config.rate_limit.revalidate_requests_per_minute = revalidate_rpm.try_into().unwrap_or_else(|_| {
let default_value = default_revalidate_rpm();
tracing::warn!("revalidateRequestsPerMinute value {} exceeds u32::MAX, using default {}", revalidate_rpm, default_value);
default_value
});
}
}
if let Some(spam_blocker_data) = config_data.get("spamBlocker")
&& let Some(enabled) = spam_blocker_data.get("enabled").and_then(|v| v.as_bool())
{
config.spam_blocker.enabled = enabled;
}
if let Some(action_data) = config_data.get("action")
&& let Some(allowed_origins) =
action_data.get("allowedOrigins").and_then(|v| v.as_array())
{
config.action.allowed_origins = allowed_origins
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
}
if let Some(cache_control_data) = config_data.get("cacheControl")
&& let Some(routes) = cache_control_data.get("routes").and_then(|v| v.as_object())
{
for (route, cache_value) in routes {
if let Some(cache_str) = cache_value.as_str() {
if HeaderValue::from_str(cache_str).is_ok() {
config.caching.routes.insert(route.clone(), cache_str.to_string());
} else {
tracing::warn!(
"Invalid cache-control header value for route '{}': '{}' (contains invalid characters)",
route,
cache_str
);
}
} else {
tracing::warn!(
"Invalid cache-control value type for route '{}': expected string, got {:?}",
route,
cache_value
);
}
}
}
} else {
tracing::debug!("No dist/server/config.json found, using defaults");
}
if config.mode == Mode::Development {
config.caching.server_components = "no-cache, no-store, must-revalidate".to_string();
}
Ok(config)
}
pub fn get() -> Option<&'static Config> {
GLOBAL_CONFIG.get()
}
pub fn set_global(config: Config) -> Result<(), Box<Config>> {
GLOBAL_CONFIG.set(config).map_err(Box::new)
}
pub fn server_address(&self) -> String {
format!("{}:{}", self.server.host, self.server.port)
}
pub fn vite_address(&self) -> String {
format!("{}:{}", self.vite.host, self.vite.port)
}
pub fn public_dir(&self) -> &PathBuf {
match self.mode {
Mode::Development => &self.static_files.dev_public_dir,
Mode::Production => &self.static_files.prod_public_dir,
}
}
pub fn is_development(&self) -> bool {
self.mode == Mode::Development
}
pub fn is_production(&self) -> bool {
self.mode == Mode::Production
}
pub fn hmr_reload_enabled(&self) -> bool {
self.is_development() && self.rsc.hmr_reload_enabled
}
pub fn cors_config(&self) -> CorsConfig {
if !self.cors.allowed_origins.is_empty() {
return self.cors.clone();
}
if self.is_development() {
CorsConfig {
allowed_origins: vec![
format!("http://{}:{}", self.server.host, self.server.port),
format!("http://localhost:{}", self.server.port),
format!("http://127.0.0.1:{}", self.server.port),
format!("http://{}:{}", self.vite.host, self.vite.port),
format!("http://localhost:{}", self.vite.port),
],
allow_credentials: true,
max_age: 86400,
}
} else {
let allowed_origins = if let Some(origin) = &self.server.origin {
vec![origin.clone()]
} else {
vec![format!("http://{}:{}", self.server.host, self.server.port)]
};
CorsConfig { allowed_origins, allow_credentials: true, max_age: 86400 }
}
}
pub fn action_origins(&self) -> Vec<String> {
if !self.action.allowed_origins.is_empty() {
return self.action.allowed_origins.clone();
}
if self.is_development() {
vec![
format!("http://{}:{}", self.server.host, self.server.port),
format!("http://localhost:{}", self.server.port),
format!("http://127.0.0.1:{}", self.server.port),
]
} else if let Some(origin) = &self.server.origin {
vec![origin.clone()]
} else {
tracing::warn!(
"No origin configured for server actions in production. \
Using same-origin validation (comparing origin/referer with host header). \
Set RARI_ORIGIN environment variable for explicit origin validation."
);
vec![]
}
}
pub fn redirect_config(&self) -> RedirectConfig {
if !self.redirect.allowed_hosts.is_empty() {
return self.redirect.clone();
}
if self.is_development() {
let mut allowed_hosts =
vec!["localhost".to_string(), "127.0.0.1".to_string(), self.server.host.clone()];
if let Some(origin) = &self.server.origin
&& let Ok(url) = url::Url::parse(origin)
&& let Some(host) = url.host_str()
{
allowed_hosts.push(host.to_string());
}
RedirectConfig { allowed_hosts, allow_relative: true, allow_subdomains: false }
} else {
let allowed_hosts = if let Some(origin) = &self.server.origin {
if let Ok(url) = url::Url::parse(origin) {
if let Some(host) = url.host_str() { vec![host.to_string()] } else { vec![] }
} else {
vec![]
}
} else {
vec![self.server.host.clone()]
};
RedirectConfig { allowed_hosts, allow_relative: true, allow_subdomains: false }
}
}
pub fn get_cache_control_for_route(&self, path: &str) -> &str {
if let Some(cache_control) = self.caching.routes.get(path) {
return cache_control;
}
let compiled = CompiledCacheControlConfig::from(&self.caching);
for (pattern, cache_control) in &compiled.routes {
if pattern.matches(path) {
for orig_cache_control in self.caching.routes.values() {
if orig_cache_control == cache_control {
return orig_cache_control;
}
}
}
}
&self.caching.server_components
}
pub fn csp_config(&self) -> CspConfig {
let mut config = self.csp.clone();
if !config.script_src.contains(&"'unsafe-inline'".to_string()) {
config.script_src.push("'unsafe-inline'".to_string());
}
if !config.style_src.contains(&"'unsafe-inline'".to_string()) {
config.style_src.push("'unsafe-inline'".to_string());
}
if self.is_development() && !config.script_src.contains(&"'unsafe-eval'".to_string()) {
config.script_src.push("'unsafe-eval'".to_string());
}
config
}
pub fn build_csp_policy(&self) -> String {
let config = self.csp_config();
let mut directives = Vec::new();
if !config.default_src.is_empty() {
directives.push(format!("default-src {}", config.default_src.join(" ")));
}
if !config.script_src.is_empty() {
directives.push(format!("script-src {}", config.script_src.join(" ")));
}
if !config.style_src.is_empty() {
directives.push(format!("style-src {}", config.style_src.join(" ")));
}
if !config.img_src.is_empty() {
directives.push(format!("img-src {}", config.img_src.join(" ")));
}
if !config.font_src.is_empty() {
directives.push(format!("font-src {}", config.font_src.join(" ")));
}
if !config.connect_src.is_empty() {
directives.push(format!("connect-src {}", config.connect_src.join(" ")));
}
if !config.worker_src.is_empty() {
directives.push(format!("worker-src {}", config.worker_src.join(" ")));
}
directives.join("; ")
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("Invalid mode: {0}")]
Mode(String),
#[error("Invalid port: {0}")]
Port(String),
#[error("Invalid Vite port: {0}")]
VitePort(String),
#[error("Invalid timeout: {0}")]
Timeout(String),
#[error("Invalid config value for {0}")]
Config(String),
}
#[cfg(test)]
#[allow(clippy::disallowed_methods)]
mod tests {
use super::*;
#[test]
fn test_config_default() {
let config = Config::default();
assert_eq!(config.mode, Mode::Development);
assert_eq!(config.server.host, "127.0.0.1");
assert_eq!(config.server.port, 3000);
assert_eq!(config.vite.port, 5173);
}
#[test]
fn test_config_new_with_mode() {
let config = Config::new(Mode::Production);
assert_eq!(config.mode, Mode::Production);
assert!(!config.rsc.enable_hot_reload);
}
#[test]
fn test_server_address() {
let config = Config::default();
assert_eq!(config.server_address(), "127.0.0.1:3000");
}
#[test]
fn test_vite_address() {
let config = Config::default();
assert_eq!(config.vite_address(), "localhost:5173");
}
#[test]
fn test_public_dir() {
let dev_config = Config::new(Mode::Development);
assert_eq!(dev_config.public_dir(), &PathBuf::from("public"));
let prod_config = Config::new(Mode::Production);
assert_eq!(prod_config.public_dir(), &PathBuf::from("dist"));
}
#[test]
fn test_mode_display() {
assert_eq!(Mode::Development.to_string(), "development");
assert_eq!(Mode::Production.to_string(), "production");
}
#[test]
fn test_cache_control_exact_match() {
let mut config = Config::default();
config.caching.routes.insert("/api/users".to_string(), "no-cache".to_string());
let cache_control = config.get_cache_control_for_route("/api/users");
assert_eq!(cache_control, "no-cache");
}
#[test]
fn test_cache_control_glob_pattern() {
let mut config = Config::default();
config.caching.routes.insert("/api/*".to_string(), "no-cache".to_string());
let cache_control = config.get_cache_control_for_route("/api/users");
assert_eq!(cache_control, "no-cache");
let cache_control = config.get_cache_control_for_route("/api/products/123");
assert_eq!(cache_control, "no-cache");
}
#[test]
fn test_cache_control_default_fallback() {
let config = Config::default();
let cache_control = config.get_cache_control_for_route("/some/random/path");
assert_eq!(cache_control, "public, max-age=31536000, stale-while-revalidate=86400");
}
#[test]
fn test_cache_control_pattern_priority() {
let mut config = Config::default();
config.caching.routes.insert("/api/*".to_string(), "no-cache".to_string());
config.caching.routes.insert("/api/public".to_string(), "public, max-age=3600".to_string());
let cache_control = config.get_cache_control_for_route("/api/public");
assert_eq!(cache_control, "public, max-age=3600");
let cache_control = config.get_cache_control_for_route("/api/private");
assert_eq!(cache_control, "no-cache");
}
#[test]
fn test_pattern_matching() {
let pattern = RoutePattern::from_pattern("/api/*");
assert!(pattern.matches("/api/users"));
assert!(pattern.matches("/api/products/123"));
assert!(!pattern.matches("/blog/posts"));
assert!(!pattern.matches("/apiv2/users"));
assert!(pattern.matches("/api"));
let pattern = RoutePattern::from_pattern("/blog/*/comments");
assert!(pattern.matches("/blog/post-1/comments"));
assert!(!pattern.matches("/blog/post-1"));
let pattern = RoutePattern::from_pattern("*");
assert!(pattern.matches("/any/path"));
let pattern = RoutePattern::from_pattern("*.js");
assert!(pattern.matches("/static/app.js"));
assert!(!pattern.matches("/static/app.css"));
let pattern = RoutePattern::from_pattern("/v1.0/*");
assert!(pattern.matches("/v1.0/users"));
assert!(!pattern.matches("/v1X0/users"));
}
#[test]
fn test_cache_config_serialization() {
let mut routes = FxHashMap::default();
routes.insert("/api/*".to_string(), "no-cache".to_string());
routes.insert("/blog/*".to_string(), "public, max-age=3600".to_string());
let cache_config = CacheControlConfig {
routes,
static_files: "public, max-age=31536000, immutable".to_string(),
server_components: "no-cache".to_string(),
};
let serialized = serde_json::to_string(&cache_config).unwrap();
let deserialized: CacheControlConfig = serde_json::from_str(&serialized).unwrap();
assert_eq!(cache_config.static_files, deserialized.static_files);
assert_eq!(cache_config.server_components, deserialized.server_components);
assert_eq!(cache_config.routes.len(), deserialized.routes.len());
}
#[test]
fn test_streaming_config_default() {
let streaming_config = StreamingConfig::default();
assert!(streaming_config.enabled);
assert_eq!(streaming_config.buffer_size, 64);
assert_eq!(streaming_config.resolution_timeout_ms, 5000);
}
#[test]
fn test_config_includes_streaming() {
let config = Config::default();
assert!(config.streaming.enabled);
assert_eq!(config.streaming.buffer_size, 64);
assert_eq!(config.streaming.resolution_timeout_ms, 5000);
}
#[test]
fn test_streaming_config_serialization() {
let streaming_config =
StreamingConfig { enabled: false, buffer_size: 128, resolution_timeout_ms: 10000 };
let serialized = serde_json::to_string(&streaming_config).unwrap();
let deserialized: StreamingConfig = serde_json::from_str(&serialized).unwrap();
assert_eq!(streaming_config.enabled, deserialized.enabled);
assert_eq!(streaming_config.buffer_size, deserialized.buffer_size);
assert_eq!(streaming_config.resolution_timeout_ms, deserialized.resolution_timeout_ms);
}
#[test]
fn test_config_from_env_cache_control_validation() {
use std::io::Write;
let temp_dir = std::env::temp_dir().join(format!("rari_test_{}", std::process::id()));
let dist_server_dir = temp_dir.join("dist").join("server");
std::fs::create_dir_all(&dist_server_dir).unwrap();
let config_json = serde_json::json!({
"cacheControl": {
"routes": {
"/valid": "public, max-age=3600",
"/invalid-newline": "public\nmax-age=3600",
"/invalid-type": 12345
}
}
});
let config_path = dist_server_dir.join("config.json");
let mut file = std::fs::File::create(&config_path).unwrap();
file.write_all(config_json.to_string().as_bytes()).unwrap();
let result = Config::from_env_with_base(Some(&temp_dir));
let _ = std::fs::remove_dir_all(&temp_dir);
let config = result.unwrap();
assert!(
config.caching.routes.contains_key("/valid"),
"Valid cache-control route should be accepted"
);
assert_eq!(config.caching.routes.get("/valid").unwrap(), "public, max-age=3600");
assert!(
!config.caching.routes.contains_key("/invalid-newline"),
"Cache-control with newline should be rejected by HeaderValue::from_str"
);
assert!(
!config.caching.routes.contains_key("/invalid-type"),
"Non-string cache-control value should be rejected"
);
}
#[test]
fn test_dev_mode_cache_control() {
let dev_config = Config::new(Mode::Development);
assert_eq!(
dev_config.caching.server_components, "no-cache, no-store, must-revalidate",
"Development mode should use no-cache for server components"
);
let prod_config = Config::new(Mode::Production);
assert_eq!(
prod_config.caching.server_components,
"public, max-age=31536000, stale-while-revalidate=86400",
"Production mode should use long cache for server components"
);
}
}