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> =
295                arr.into_iter().filter_map(resolve_env_vars).collect();
296            Some(toml::Value::Array(resolved))
297        }
298        other => Some(other),
299    }
300}
301
302impl Config {
303    pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
304        let content = std::fs::read_to_string(path)?;
305
306        // Parse into a raw TOML value so we can load the .env before
307        // deserializing and then resolve $env: references.
308        let mut raw: toml::Value = toml::from_str(&content)?;
309
310        // Extract the `dotenv` key from the raw table (before env resolution
311        // so the path itself is a literal value, not an env-expanded one).
312        let dotenv_cfg: DotenvConfig = raw
313            .as_table()
314            .and_then(|t| t.get("dotenv"))
315            .map(|v| v.clone().try_into::<DotenvConfig>())
316            .transpose()
317            .map_err(|e| anyhow::anyhow!("invalid `dotenv` value: {e}"))?
318            .unwrap_or_default();
319
320        match dotenv_cfg {
321            DotenvConfig::Disabled => {}
322            DotenvConfig::Default => {
323                dotenvy::dotenv().ok(); // silently ignore if .env absent
324            }
325            DotenvConfig::Path(ref p) => {
326                dotenvy::from_path(p)
327                    .map_err(|e| anyhow::anyhow!("failed to load .env from `{}`: {e}", p.display()))?;
328            }
329        }
330
331        // Walk the full TOML tree and resolve all $env: references.
332        raw = resolve_env_vars(raw)
333            .unwrap_or_else(|| toml::Value::Table(toml::map::Map::new()));
334
335        let config: Config = raw.try_into()?;
336        config.validate()?;
337        Ok(config)
338    }
339
340    fn validate(&self) -> Result<()> {
341        if self.https_port.is_some() {
342            if self.cert_path.is_none() {
343                bail!("`cert_path` is required when `https_port` is set");
344            }
345            if self.key_path.is_none() {
346                bail!("`key_path` is required when `https_port` is set");
347            }
348        }
349        if self.server.is_empty() {
350            bail!("at least one `[server.NAME]` block is required");
351        }
352        Ok(())
353    }
354}
355
356impl Default for ServerConfig {
357    fn default() -> Self {
358        Self {
359            bind_to: default_bind_to(),
360            proxy_url: default_proxy_url(),
361            include_paths: vec![],
362            exclude_paths: vec![],
363            enable_websocket: default_enable_websocket(),
364            forward_get_only: default_forward_get_only(),
365            cache_404_capacity: default_cache_404_capacity(),
366            use_404_meta: default_use_404_meta(),
367            cache_strategy: CacheStrategy::default(),
368            compress_strategy: CompressStrategy::default(),
369            cache_storage_mode: CacheStorageMode::default(),
370            cache_directory: None,
371            proxy_mode: ProxyModeConfig::default(),
372            pre_generate_paths: vec![],
373            pre_generate_fallthrough: false,
374            execute: None,
375            execute_dir: None,
376            webhooks: vec![],
377        }
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    fn single_server_toml(extra: &str) -> String {
386        format!(
387            "[server.default]\nproxy_url = \"http://localhost:8080\"\n{}",
388            extra
389        )
390    }
391
392    #[test]
393    fn test_config_defaults_cache_strategy_to_all() {
394        let config: Config = toml::from_str(&single_server_toml("")).unwrap();
395        let s = config.server.get("default").unwrap();
396        assert_eq!(s.cache_strategy, CacheStrategy::All);
397        assert_eq!(s.compress_strategy, CompressStrategy::Brotli);
398        assert_eq!(s.cache_storage_mode, CacheStorageMode::Memory);
399        assert_eq!(s.cache_directory, None);
400    }
401
402    #[test]
403    fn test_config_parses_cache_strategy() {
404        let config: Config =
405            toml::from_str(&single_server_toml("cache_strategy = \"none\"\n")).unwrap();
406        let s = config.server.get("default").unwrap();
407        assert_eq!(s.cache_strategy, CacheStrategy::None);
408    }
409
410    #[test]
411    fn test_config_parses_compress_strategy() {
412        let config: Config =
413            toml::from_str(&single_server_toml("compress_strategy = \"gzip\"\n")).unwrap();
414        let s = config.server.get("default").unwrap();
415        assert_eq!(s.compress_strategy, CompressStrategy::Gzip);
416    }
417
418    #[test]
419    fn test_config_parses_cache_storage_mode() {
420        let config: Config = toml::from_str(&single_server_toml(
421            "cache_storage_mode = \"filesystem\"\ncache_directory = \"cache-bodies\"\n",
422        ))
423        .unwrap();
424        let s = config.server.get("default").unwrap();
425        assert_eq!(s.cache_storage_mode, CacheStorageMode::Filesystem);
426        assert_eq!(s.cache_directory, Some(PathBuf::from("cache-bodies")));
427    }
428
429    #[test]
430    fn test_config_top_level_ports() {
431        let toml = "http_port = 8080\ncontrol_port = 9000\n".to_string()
432            + &single_server_toml("");
433        let config: Config = toml::from_str(&toml).unwrap();
434        assert_eq!(config.http_port, 8080);
435        assert_eq!(config.control_port, 9000);
436        assert_eq!(config.https_port, None);
437    }
438
439    #[test]
440    fn test_https_validation_requires_cert_and_key() {
441        let toml = "https_port = 443\n".to_string() + &single_server_toml("");
442        let config: Config = toml::from_str(&toml).unwrap();
443        assert!(config.validate().is_err());
444    }
445
446    #[test]
447    fn test_multiple_servers() {
448        let toml = "[server.frontend]\nbind_to = \"*\"\nproxy_url = \"http://localhost:5173\"\n\
449                    [server.api]\nbind_to = \"/api\"\nproxy_url = \"http://localhost:8080\"\n";
450        let config: Config = toml::from_str(toml).unwrap();
451        assert_eq!(config.server.len(), 2);
452        assert_eq!(
453            config.server.get("api").unwrap().bind_to,
454            "/api"
455        );
456        assert_eq!(
457            config.server.get("frontend").unwrap().bind_to,
458            "*"
459        );
460    }
461
462    // ── env-var resolution tests ─────────────────────────────────────────────
463
464    #[test]
465    fn test_env_var_string_field_resolves_when_set() {
466        std::env::set_var("_PF_TEST_CONTROL_AUTH", "secret-token");
467        let toml = format!(
468            "control_auth = \"$env:_PF_TEST_CONTROL_AUTH\"\n{}",
469            single_server_toml("")
470        );
471        let raw: toml::Value = toml::from_str(&toml).unwrap();
472        let resolved = resolve_env_vars(raw).unwrap();
473        let config: Config = resolved.try_into().unwrap();
474        std::env::remove_var("_PF_TEST_CONTROL_AUTH");
475        assert_eq!(config.control_auth, Some("secret-token".to_string()));
476    }
477
478    #[test]
479    fn test_env_var_option_field_becomes_none_when_unset() {
480        std::env::remove_var("_PF_TEST_HTTPS_PORT_MISSING");
481        let toml = format!(
482            "https_port = \"$env:_PF_TEST_HTTPS_PORT_MISSING\"\n{}",
483            single_server_toml("")
484        );
485        let raw: toml::Value = toml::from_str(&toml).unwrap();
486        let resolved = resolve_env_vars(raw).unwrap();
487        let config: Config = resolved.try_into().unwrap();
488        assert_eq!(config.https_port, None);
489    }
490
491    #[test]
492    fn test_env_var_port_field_resolves_as_integer_string() {
493        std::env::set_var("_PF_TEST_HTTP_PORT", "9999");
494        let toml = format!(
495            "http_port = \"$env:_PF_TEST_HTTP_PORT\"\n{}",
496            single_server_toml("")
497        );
498        let raw: toml::Value = toml::from_str(&toml).unwrap();
499        let resolved = resolve_env_vars(raw).unwrap();
500        // http_port is u16; env vars resolve to String, so toml deserialization
501        // will error — this test verifies the resolved string value is present.
502        // To use $env: for numeric fields the env value must be quoted in the
503        // config; TOML parses it as a string so serde coercion kicks in.
504        // We just check the resolved tree has the string "9999".
505        if let Some(toml::Value::Table(t)) = Some(resolved) {
506            assert_eq!(t.get("http_port"), Some(&toml::Value::String("9999".to_string())));
507        }
508        std::env::remove_var("_PF_TEST_HTTP_PORT");
509    }
510
511    // ── dotenv config deserialization tests ──────────────────────────────────
512
513    #[test]
514    fn test_dotenv_false_is_disabled() {
515        let toml = format!("dotenv = false\n{}", single_server_toml(""));
516        let config: Config = toml::from_str(&toml).unwrap();
517        assert!(matches!(config.dotenv, DotenvConfig::Disabled));
518    }
519
520    #[test]
521    fn test_dotenv_true_is_default() {
522        let toml = format!("dotenv = true\n{}", single_server_toml(""));
523        let config: Config = toml::from_str(&toml).unwrap();
524        assert!(matches!(config.dotenv, DotenvConfig::Default));
525    }
526
527    #[test]
528    fn test_dotenv_string_path_is_path() {
529        let toml = format!("dotenv = \"./.env.local\"\n{}", single_server_toml(""));
530        let config: Config = toml::from_str(&toml).unwrap();
531        assert!(matches!(config.dotenv, DotenvConfig::Path(ref p) if p == &PathBuf::from("./.env.local")));
532    }
533
534    #[test]
535    fn test_dotenv_absent_is_disabled() {
536        let config: Config = toml::from_str(&single_server_toml("")).unwrap();
537        assert!(matches!(config.dotenv, DotenvConfig::Disabled));
538    }
539
540    #[test]
541    fn test_dotenv_loads_env_file() {
542        let dir = std::env::temp_dir();
543        let env_path = dir.join("_pf_test_dotenv.env");
544        std::fs::write(&env_path, "_PF_DOTENV_VAR=hello_from_dotenv\n").unwrap();
545
546        // Use from_file via a temp config that references the dotenv file and
547        // the env var.
548        let cfg_path = dir.join("_pf_test_dotenv.toml");
549        let cfg_content = format!(
550            "dotenv = \"{}\"\ncontrol_auth = \"$env:_PF_DOTENV_VAR\"\n[server.default]\nproxy_url = \"http://localhost:8080\"\n",
551            env_path.to_string_lossy().replace('\\', "/")
552        );
553        std::fs::write(&cfg_path, &cfg_content).unwrap();
554
555        std::env::remove_var("_PF_DOTENV_VAR");
556        let config = Config::from_file(&cfg_path).unwrap();
557
558        std::fs::remove_file(&env_path).ok();
559        std::fs::remove_file(&cfg_path).ok();
560
561        assert_eq!(config.control_auth, Some("hello_from_dotenv".to_string()));
562    }
563}