Skip to main content

phantom_frame/
config.rs

1use crate::{CacheStorageMode, CacheStrategy, CompressStrategy, WebhookConfig};
2use anyhow::{bail, Result};
3use serde::{
4    de::{self, Visitor},
5    Deserialize, Serialize,
6};
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10/// Controls whether a `.env` file is loaded before environment variable resolution.
11///
12/// - Absent or `false`: do not load any `.env` file.
13/// - `true`: load `.env` from the current working directory (silently ignored if absent).
14/// - `"./path/to/.env"`: load from the given path (error if the file does not exist).
15#[derive(Debug, Clone, Default)]
16pub enum DotenvConfig {
17    /// Do not load a `.env` file.
18    #[default]
19    Disabled,
20    /// Load `.env` from the current working directory.
21    Default,
22    /// Load from the specified path.
23    Path(PathBuf),
24}
25
26impl serde::Serialize for DotenvConfig {
27    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
28        match self {
29            DotenvConfig::Disabled => serializer.serialize_bool(false),
30            DotenvConfig::Default => serializer.serialize_bool(true),
31            DotenvConfig::Path(p) => serializer.serialize_str(&p.to_string_lossy()),
32        }
33    }
34}
35
36impl<'de> Deserialize<'de> for DotenvConfig {
37    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
38        struct DotenvVisitor;
39
40        impl<'de> Visitor<'de> for DotenvVisitor {
41            type Value = DotenvConfig;
42
43            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
44                write!(f, "a boolean or a path string for the .env file")
45            }
46
47            fn visit_bool<E: de::Error>(self, v: bool) -> Result<DotenvConfig, E> {
48                if v {
49                    Ok(DotenvConfig::Default)
50                } else {
51                    Ok(DotenvConfig::Disabled)
52                }
53            }
54
55            fn visit_str<E: de::Error>(self, v: &str) -> Result<DotenvConfig, E> {
56                Ok(DotenvConfig::Path(PathBuf::from(v)))
57            }
58        }
59
60        deserializer.deserialize_any(DotenvVisitor)
61    }
62}
63
64/// TOML-friendly proxy mode selector.
65#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)]
66#[serde(rename_all = "snake_case")]
67pub enum ProxyModeConfig {
68    /// Dynamic mode: requests are proxied and cached on demand.
69    #[default]
70    Dynamic,
71    /// PreGenerate (SSG) mode: a fixed set of paths is fetched at startup and
72    /// served exclusively from the cache.
73    PreGenerate,
74}
75
76/// Top-level configuration, deserialized directly from the TOML root.
77///
78/// Named server blocks are declared as `[server.NAME]` sections.
79/// Global ports and TLS settings live at the root (no section header).
80///
81/// Example:
82/// ```toml
83/// http_port = 3000
84/// control_port = 17809
85///
86/// [server.frontend]
87/// bind_to = "*"
88/// proxy_url = "http://localhost:5173"
89///
90/// [server.api]
91/// bind_to = "/api"
92/// proxy_url = "http://localhost:8080"
93/// ```
94#[derive(Debug, Clone, Deserialize, Serialize)]
95pub struct Config {
96    /// HTTP listen port (default: 3000).
97    #[serde(default = "default_http_port")]
98    pub http_port: u16,
99
100    /// Optional HTTPS listen port.
101    /// When set, `cert_path` and `key_path` are required.
102    pub https_port: Option<u16>,
103
104    /// Path to the TLS certificate file (PEM). Required when `https_port` is set.
105    pub cert_path: Option<PathBuf>,
106
107    /// Path to the TLS private key file (PEM). Required when `https_port` is set.
108    pub key_path: Option<PathBuf>,
109
110    /// Control-plane listen port (default: 17809).
111    #[serde(default = "default_control_port")]
112    pub control_port: u16,
113
114    /// Optional bearer token required to call `/refresh-cache`.
115    pub control_auth: Option<String>,
116
117    /// Named server entries, each mapping to a `[server.NAME]` TOML block.
118    pub server: HashMap<String, ServerConfig>,
119
120    /// Controls `.env` file loading before environment variable resolution.
121    ///
122    /// - Absent or `false`: disabled.
123    /// - `true`: load `.env` from the current working directory.
124    /// - `"./path/to/.env"`: load from the specified path.
125    #[serde(default)]
126    pub dotenv: DotenvConfig,
127}
128
129/// Per-server configuration block (one `[server.NAME]` entry).
130#[derive(Debug, Clone, Deserialize, Serialize)]
131pub struct ServerConfig {
132    /// Axum router mount point.
133    ///
134    /// - `"*"` (default): catch-all fallback, bound via `Router::fallback_service`.
135    /// - Any other value (e.g. `"/api"`): specific prefix, bound via `Router::nest`.
136    ///
137    /// When multiple specific paths are registered, longer paths are nested first
138    /// so Axum can match them before shorter prefixes.
139    ///
140    /// **Note**: `Router::nest` strips the prefix before the inner proxy handler
141    /// sees the path. Set `proxy_url` accordingly if the upstream expects the
142    /// full path.
143    #[serde(default = "default_bind_to")]
144    pub bind_to: String,
145
146    /// The URL of the backend to proxy to.
147    #[serde(default = "default_proxy_url")]
148    pub proxy_url: String,
149
150    /// Paths to include in caching (empty means include all).
151    /// Supports wildcards: `["/api/*", "/*/users"]`
152    #[serde(default)]
153    pub include_paths: Vec<String>,
154
155    /// Paths to exclude from caching (empty means exclude none).
156    /// Supports wildcards: `["/admin/*", "/*/private"]`.
157    /// Exclude overrides include.
158    #[serde(default)]
159    pub exclude_paths: Vec<String>,
160
161    /// Enable WebSocket / protocol-upgrade support (default: `true`).
162    ///
163    /// When `true`, upgrade requests bypass the cache and establish a direct
164    /// bidirectional TCP tunnel to the backend — **but only when the proxy mode
165    /// supports it** (i.e. Dynamic, or PreGenerate with `pre_generate_fallthrough
166    /// = true`).  Pure SSG servers (`proxy_mode = "pre_generate"` with the
167    /// default `pre_generate_fallthrough = false`) always return 501 for upgrade
168    /// requests, regardless of this flag.
169    #[serde(default = "default_enable_websocket")]
170    pub enable_websocket: bool,
171
172    /// Only allow GET requests, reject all others (default: `false`).
173    #[serde(default = "default_forward_get_only")]
174    pub forward_get_only: bool,
175
176    /// Capacity for the 404 cache (default: 100).
177    #[serde(default = "default_cache_404_capacity")]
178    pub cache_404_capacity: usize,
179
180    /// Detect 404 pages via `<meta name="phantom-404">` in addition to HTTP status.
181    #[serde(default = "default_use_404_meta")]
182    pub use_404_meta: bool,
183
184    /// Controls which response types should be cached.
185    #[serde(default)]
186    pub cache_strategy: CacheStrategy,
187
188    /// Controls how cached responses are compressed in memory.
189    #[serde(default)]
190    pub compress_strategy: CompressStrategy,
191
192    /// Controls where cached response bodies are stored.
193    #[serde(default)]
194    pub cache_storage_mode: CacheStorageMode,
195
196    /// Optional directory override for filesystem-backed cache bodies.
197    #[serde(default)]
198    pub cache_directory: Option<PathBuf>,
199
200    /// Proxy operating mode. Set to `"pre_generate"` to enable SSG mode.
201    #[serde(default)]
202    pub proxy_mode: ProxyModeConfig,
203
204    /// Paths to pre-generate at startup when `proxy_mode = "pre_generate"`.
205    #[serde(default)]
206    pub pre_generate_paths: Vec<String>,
207
208    /// In PreGenerate mode, fall through to the upstream backend on a cache miss.
209    /// Defaults to `false` (return 404 on miss).
210    #[serde(default = "default_pre_generate_fallthrough")]
211    pub pre_generate_fallthrough: bool,
212
213    /// Optional shell command to execute before the proxy starts for this server.
214    /// phantom-frame will spawn the process and wait until `proxy_url`'s port
215    /// accepts TCP connections before serving traffic.
216    ///
217    /// Example: `"pnpm run dev"`, `"cargo run --release"`
218    #[serde(default)]
219    pub execute: Option<String>,
220
221    /// Working directory for the `execute` command.
222    /// Relative paths are resolved from the directory where phantom-frame is run.
223    ///
224    /// Example: `"./apps/client"`
225    #[serde(default)]
226    pub execute_dir: Option<String>,
227
228    /// Webhooks called for every request before cache reads.
229    /// Blocking webhooks gate access; notify webhooks are fire-and-forget.
230    #[serde(default)]
231    pub webhooks: Vec<WebhookConfig>,
232}
233
234// ── defaults ────────────────────────────────────────────────────────────────
235
236fn default_http_port() -> u16 {
237    3000
238}
239
240fn default_control_port() -> u16 {
241    17809
242}
243
244fn default_bind_to() -> String {
245    "*".to_string()
246}
247
248fn default_proxy_url() -> String {
249    "http://localhost:8080".to_string()
250}
251
252fn default_enable_websocket() -> bool {
253    true
254}
255
256fn default_forward_get_only() -> bool {
257    false
258}
259
260fn default_cache_404_capacity() -> usize {
261    100
262}
263
264fn default_use_404_meta() -> bool {
265    false
266}
267
268fn default_pre_generate_fallthrough() -> bool {
269    false
270}
271
272// ── Config impl ──────────────────────────────────────────────────────────────
273
274/// Recursively walk a `toml::Value` tree, resolving `$env:VAR` references.
275///
276/// A string value equal to `"$env:VAR_NAME"` is replaced with the value of
277/// the environment variable `VAR_NAME`.  If the variable is not set the key
278/// (or array element) is silently dropped, so `Option<T>` fields become `None`
279/// and fields with `#[serde(default)]` fall back to their defaults.
280fn resolve_env_vars(value: toml::Value) -> Option<toml::Value> {
281    match value {
282        toml::Value::String(ref s) if s.starts_with("$env:") => {
283            let var_name = &s[5..];
284            std::env::var(var_name).ok().map(toml::Value::String)
285        }
286        toml::Value::Table(table) => {
287            let resolved: toml::map::Map<String, toml::Value> = table
288                .into_iter()
289                .filter_map(|(k, v)| resolve_env_vars(v).map(|rv| (k, rv)))
290                .collect();
291            Some(toml::Value::Table(resolved))
292        }
293        toml::Value::Array(arr) => {
294            let resolved: Vec<toml::Value> = arr.into_iter().filter_map(resolve_env_vars).collect();
295            Some(toml::Value::Array(resolved))
296        }
297        other => Some(other),
298    }
299}
300
301impl Config {
302    pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
303        let content = std::fs::read_to_string(path)?;
304
305        // Parse into a raw TOML value so we can load the .env before
306        // deserializing and then resolve $env: references.
307        let mut raw: toml::Value = toml::from_str(&content)?;
308
309        // Extract the `dotenv` key from the raw table (before env resolution
310        // so the path itself is a literal value, not an env-expanded one).
311        let dotenv_cfg: DotenvConfig = raw
312            .as_table()
313            .and_then(|t| t.get("dotenv"))
314            .map(|v| v.clone().try_into::<DotenvConfig>())
315            .transpose()
316            .map_err(|e| anyhow::anyhow!("invalid `dotenv` value: {e}"))?
317            .unwrap_or_default();
318
319        match dotenv_cfg {
320            DotenvConfig::Disabled => {}
321            DotenvConfig::Default => {
322                dotenvy::dotenv().ok(); // silently ignore if .env absent
323            }
324            DotenvConfig::Path(ref p) => {
325                dotenvy::from_path(p).map_err(|e| {
326                    anyhow::anyhow!("failed to load .env from `{}`: {e}", p.display())
327                })?;
328            }
329        }
330
331        // Walk the full TOML tree and resolve all $env: references.
332        raw = resolve_env_vars(raw).unwrap_or_else(|| toml::Value::Table(toml::map::Map::new()));
333
334        let config: Config = raw.try_into()?;
335        config.validate()?;
336        Ok(config)
337    }
338
339    fn validate(&self) -> Result<()> {
340        if self.https_port.is_some() {
341            if self.cert_path.is_none() {
342                bail!("`cert_path` is required when `https_port` is set");
343            }
344            if self.key_path.is_none() {
345                bail!("`key_path` is required when `https_port` is set");
346            }
347        }
348        if self.server.is_empty() {
349            bail!("at least one `[server.NAME]` block is required");
350        }
351        Ok(())
352    }
353}
354
355impl Default for ServerConfig {
356    fn default() -> Self {
357        Self {
358            bind_to: default_bind_to(),
359            proxy_url: default_proxy_url(),
360            include_paths: vec![],
361            exclude_paths: vec![],
362            enable_websocket: default_enable_websocket(),
363            forward_get_only: default_forward_get_only(),
364            cache_404_capacity: default_cache_404_capacity(),
365            use_404_meta: default_use_404_meta(),
366            cache_strategy: CacheStrategy::default(),
367            compress_strategy: CompressStrategy::default(),
368            cache_storage_mode: CacheStorageMode::default(),
369            cache_directory: None,
370            proxy_mode: ProxyModeConfig::default(),
371            pre_generate_paths: vec![],
372            pre_generate_fallthrough: false,
373            execute: None,
374            execute_dir: None,
375            webhooks: vec![],
376        }
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    fn single_server_toml(extra: &str) -> String {
385        format!(
386            "[server.default]\nproxy_url = \"http://localhost:8080\"\n{}",
387            extra
388        )
389    }
390
391    #[test]
392    fn test_config_defaults_cache_strategy_to_all() {
393        let config: Config = toml::from_str(&single_server_toml("")).unwrap();
394        let s = config.server.get("default").unwrap();
395        assert_eq!(s.cache_strategy, CacheStrategy::All);
396        assert_eq!(s.compress_strategy, CompressStrategy::Brotli);
397        assert_eq!(s.cache_storage_mode, CacheStorageMode::Memory);
398        assert_eq!(s.cache_directory, None);
399    }
400
401    #[test]
402    fn test_config_parses_cache_strategy() {
403        let config: Config =
404            toml::from_str(&single_server_toml("cache_strategy = \"none\"\n")).unwrap();
405        let s = config.server.get("default").unwrap();
406        assert_eq!(s.cache_strategy, CacheStrategy::None);
407    }
408
409    #[test]
410    fn test_config_parses_compress_strategy() {
411        let config: Config =
412            toml::from_str(&single_server_toml("compress_strategy = \"gzip\"\n")).unwrap();
413        let s = config.server.get("default").unwrap();
414        assert_eq!(s.compress_strategy, CompressStrategy::Gzip);
415    }
416
417    #[test]
418    fn test_config_parses_cache_storage_mode() {
419        let config: Config = toml::from_str(&single_server_toml(
420            "cache_storage_mode = \"filesystem\"\ncache_directory = \"cache-bodies\"\n",
421        ))
422        .unwrap();
423        let s = config.server.get("default").unwrap();
424        assert_eq!(s.cache_storage_mode, CacheStorageMode::Filesystem);
425        assert_eq!(s.cache_directory, Some(PathBuf::from("cache-bodies")));
426    }
427
428    #[test]
429    fn test_config_top_level_ports() {
430        let toml = "http_port = 8080\ncontrol_port = 9000\n".to_string() + &single_server_toml("");
431        let config: Config = toml::from_str(&toml).unwrap();
432        assert_eq!(config.http_port, 8080);
433        assert_eq!(config.control_port, 9000);
434        assert_eq!(config.https_port, None);
435    }
436
437    #[test]
438    fn test_https_validation_requires_cert_and_key() {
439        let toml = "https_port = 443\n".to_string() + &single_server_toml("");
440        let config: Config = toml::from_str(&toml).unwrap();
441        assert!(config.validate().is_err());
442    }
443
444    #[test]
445    fn test_multiple_servers() {
446        let toml = "[server.frontend]\nbind_to = \"*\"\nproxy_url = \"http://localhost:5173\"\n\
447                    [server.api]\nbind_to = \"/api\"\nproxy_url = \"http://localhost:8080\"\n";
448        let config: Config = toml::from_str(toml).unwrap();
449        assert_eq!(config.server.len(), 2);
450        assert_eq!(config.server.get("api").unwrap().bind_to, "/api");
451        assert_eq!(config.server.get("frontend").unwrap().bind_to, "*");
452    }
453
454    // ── env-var resolution tests ─────────────────────────────────────────────
455
456    #[test]
457    fn test_env_var_string_field_resolves_when_set() {
458        std::env::set_var("_PF_TEST_CONTROL_AUTH", "secret-token");
459        let toml = format!(
460            "control_auth = \"$env:_PF_TEST_CONTROL_AUTH\"\n{}",
461            single_server_toml("")
462        );
463        let raw: toml::Value = toml::from_str(&toml).unwrap();
464        let resolved = resolve_env_vars(raw).unwrap();
465        let config: Config = resolved.try_into().unwrap();
466        std::env::remove_var("_PF_TEST_CONTROL_AUTH");
467        assert_eq!(config.control_auth, Some("secret-token".to_string()));
468    }
469
470    #[test]
471    fn test_env_var_option_field_becomes_none_when_unset() {
472        std::env::remove_var("_PF_TEST_HTTPS_PORT_MISSING");
473        let toml = format!(
474            "https_port = \"$env:_PF_TEST_HTTPS_PORT_MISSING\"\n{}",
475            single_server_toml("")
476        );
477        let raw: toml::Value = toml::from_str(&toml).unwrap();
478        let resolved = resolve_env_vars(raw).unwrap();
479        let config: Config = resolved.try_into().unwrap();
480        assert_eq!(config.https_port, None);
481    }
482
483    #[test]
484    fn test_env_var_port_field_resolves_as_integer_string() {
485        std::env::set_var("_PF_TEST_HTTP_PORT", "9999");
486        let toml = format!(
487            "http_port = \"$env:_PF_TEST_HTTP_PORT\"\n{}",
488            single_server_toml("")
489        );
490        let raw: toml::Value = toml::from_str(&toml).unwrap();
491        let resolved = resolve_env_vars(raw).unwrap();
492        // http_port is u16; env vars resolve to String, so toml deserialization
493        // will error — this test verifies the resolved string value is present.
494        // To use $env: for numeric fields the env value must be quoted in the
495        // config; TOML parses it as a string so serde coercion kicks in.
496        // We just check the resolved tree has the string "9999".
497        if let Some(toml::Value::Table(t)) = Some(resolved) {
498            assert_eq!(
499                t.get("http_port"),
500                Some(&toml::Value::String("9999".to_string()))
501            );
502        }
503        std::env::remove_var("_PF_TEST_HTTP_PORT");
504    }
505
506    // ── dotenv config deserialization tests ──────────────────────────────────
507
508    #[test]
509    fn test_dotenv_false_is_disabled() {
510        let toml = format!("dotenv = false\n{}", single_server_toml(""));
511        let config: Config = toml::from_str(&toml).unwrap();
512        assert!(matches!(config.dotenv, DotenvConfig::Disabled));
513    }
514
515    #[test]
516    fn test_dotenv_true_is_default() {
517        let toml = format!("dotenv = true\n{}", single_server_toml(""));
518        let config: Config = toml::from_str(&toml).unwrap();
519        assert!(matches!(config.dotenv, DotenvConfig::Default));
520    }
521
522    #[test]
523    fn test_dotenv_string_path_is_path() {
524        let toml = format!("dotenv = \"./.env.local\"\n{}", single_server_toml(""));
525        let config: Config = toml::from_str(&toml).unwrap();
526        assert!(
527            matches!(config.dotenv, DotenvConfig::Path(ref p) if p == &PathBuf::from("./.env.local"))
528        );
529    }
530
531    #[test]
532    fn test_dotenv_absent_is_disabled() {
533        let config: Config = toml::from_str(&single_server_toml("")).unwrap();
534        assert!(matches!(config.dotenv, DotenvConfig::Disabled));
535    }
536
537    #[test]
538    fn test_dotenv_loads_env_file() {
539        let dir = std::env::temp_dir();
540        let env_path = dir.join("_pf_test_dotenv.env");
541        std::fs::write(&env_path, "_PF_DOTENV_VAR=hello_from_dotenv\n").unwrap();
542
543        // Use from_file via a temp config that references the dotenv file and
544        // the env var.
545        let cfg_path = dir.join("_pf_test_dotenv.toml");
546        let cfg_content = format!(
547            "dotenv = \"{}\"\ncontrol_auth = \"$env:_PF_DOTENV_VAR\"\n[server.default]\nproxy_url = \"http://localhost:8080\"\n",
548            env_path.to_string_lossy().replace('\\', "/")
549        );
550        std::fs::write(&cfg_path, &cfg_content).unwrap();
551
552        std::env::remove_var("_PF_DOTENV_VAR");
553        let config = Config::from_file(&cfg_path).unwrap();
554
555        std::fs::remove_file(&env_path).ok();
556        std::fs::remove_file(&cfg_path).ok();
557
558        assert_eq!(config.control_auth, Some("hello_from_dotenv".to_string()));
559    }
560}