Skip to main content

phantom_frame/
config.rs

1use crate::{CacheStorageMode, CacheStrategy, CompressStrategy};
2use anyhow::{bail, Result};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6
7/// TOML-friendly proxy mode selector.
8#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)]
9#[serde(rename_all = "snake_case")]
10pub enum ProxyModeConfig {
11    /// Dynamic mode: requests are proxied and cached on demand.
12    #[default]
13    Dynamic,
14    /// PreGenerate (SSG) mode: a fixed set of paths is fetched at startup and
15    /// served exclusively from the cache.
16    PreGenerate,
17}
18
19/// Top-level configuration, deserialized directly from the TOML root.
20///
21/// Named server blocks are declared as `[server.NAME]` sections.
22/// Global ports and TLS settings live at the root (no section header).
23///
24/// Example:
25/// ```toml
26/// http_port = 3000
27/// control_port = 17809
28///
29/// [server.frontend]
30/// bind_to = "*"
31/// proxy_url = "http://localhost:5173"
32///
33/// [server.api]
34/// bind_to = "/api"
35/// proxy_url = "http://localhost:8080"
36/// ```
37#[derive(Debug, Clone, Deserialize, Serialize)]
38pub struct Config {
39    /// HTTP listen port (default: 3000).
40    #[serde(default = "default_http_port")]
41    pub http_port: u16,
42
43    /// Optional HTTPS listen port.
44    /// When set, `cert_path` and `key_path` are required.
45    pub https_port: Option<u16>,
46
47    /// Path to the TLS certificate file (PEM). Required when `https_port` is set.
48    pub cert_path: Option<PathBuf>,
49
50    /// Path to the TLS private key file (PEM). Required when `https_port` is set.
51    pub key_path: Option<PathBuf>,
52
53    /// Control-plane listen port (default: 17809).
54    #[serde(default = "default_control_port")]
55    pub control_port: u16,
56
57    /// Optional bearer token required to call `/refresh-cache`.
58    pub control_auth: Option<String>,
59
60    /// Named server entries, each mapping to a `[server.NAME]` TOML block.
61    pub server: HashMap<String, ServerConfig>,
62}
63
64/// Per-server configuration block (one `[server.NAME]` entry).
65#[derive(Debug, Clone, Deserialize, Serialize)]
66pub struct ServerConfig {
67    /// Axum router mount point.
68    ///
69    /// - `"*"` (default): catch-all fallback, bound via `Router::fallback_service`.
70    /// - Any other value (e.g. `"/api"`): specific prefix, bound via `Router::nest`.
71    ///
72    /// When multiple specific paths are registered, longer paths are nested first
73    /// so Axum can match them before shorter prefixes.
74    ///
75    /// **Note**: `Router::nest` strips the prefix before the inner proxy handler
76    /// sees the path. Set `proxy_url` accordingly if the upstream expects the
77    /// full path.
78    #[serde(default = "default_bind_to")]
79    pub bind_to: String,
80
81    /// The URL of the backend to proxy to.
82    #[serde(default = "default_proxy_url")]
83    pub proxy_url: String,
84
85    /// Paths to include in caching (empty means include all).
86    /// Supports wildcards: `["/api/*", "/*/users"]`
87    #[serde(default)]
88    pub include_paths: Vec<String>,
89
90    /// Paths to exclude from caching (empty means exclude none).
91    /// Supports wildcards: `["/admin/*", "/*/private"]`.
92    /// Exclude overrides include.
93    #[serde(default)]
94    pub exclude_paths: Vec<String>,
95
96    /// Enable WebSocket / protocol-upgrade support (default: `true`).
97    ///
98    /// When `true`, upgrade requests bypass the cache and establish a direct
99    /// bidirectional TCP tunnel to the backend — **but only when the proxy mode
100    /// supports it** (i.e. Dynamic, or PreGenerate with `pre_generate_fallthrough
101    /// = true`).  Pure SSG servers (`proxy_mode = "pre_generate"` with the
102    /// default `pre_generate_fallthrough = false`) always return 501 for upgrade
103    /// requests, regardless of this flag.
104    #[serde(default = "default_enable_websocket")]
105    pub enable_websocket: bool,
106
107    /// Only allow GET requests, reject all others (default: `false`).
108    #[serde(default = "default_forward_get_only")]
109    pub forward_get_only: bool,
110
111    /// Capacity for the 404 cache (default: 100).
112    #[serde(default = "default_cache_404_capacity")]
113    pub cache_404_capacity: usize,
114
115    /// Detect 404 pages via `<meta name="phantom-404">` in addition to HTTP status.
116    #[serde(default = "default_use_404_meta")]
117    pub use_404_meta: bool,
118
119    /// Controls which response types should be cached.
120    #[serde(default)]
121    pub cache_strategy: CacheStrategy,
122
123    /// Controls how cached responses are compressed in memory.
124    #[serde(default)]
125    pub compress_strategy: CompressStrategy,
126
127    /// Controls where cached response bodies are stored.
128    #[serde(default)]
129    pub cache_storage_mode: CacheStorageMode,
130
131    /// Optional directory override for filesystem-backed cache bodies.
132    #[serde(default)]
133    pub cache_directory: Option<PathBuf>,
134
135    /// Proxy operating mode. Set to `"pre_generate"` to enable SSG mode.
136    #[serde(default)]
137    pub proxy_mode: ProxyModeConfig,
138
139    /// Paths to pre-generate at startup when `proxy_mode = "pre_generate"`.
140    #[serde(default)]
141    pub pre_generate_paths: Vec<String>,
142
143    /// In PreGenerate mode, fall through to the upstream backend on a cache miss.
144    /// Defaults to `false` (return 404 on miss).
145    #[serde(default = "default_pre_generate_fallthrough")]
146    pub pre_generate_fallthrough: bool,
147}
148
149// ── defaults ────────────────────────────────────────────────────────────────
150
151fn default_http_port() -> u16 {
152    3000
153}
154
155fn default_control_port() -> u16 {
156    17809
157}
158
159fn default_bind_to() -> String {
160    "*".to_string()
161}
162
163fn default_proxy_url() -> String {
164    "http://localhost:8080".to_string()
165}
166
167fn default_enable_websocket() -> bool {
168    true
169}
170
171fn default_forward_get_only() -> bool {
172    false
173}
174
175fn default_cache_404_capacity() -> usize {
176    100
177}
178
179fn default_use_404_meta() -> bool {
180    false
181}
182
183fn default_pre_generate_fallthrough() -> bool {
184    false
185}
186
187// ── Config impl ──────────────────────────────────────────────────────────────
188
189impl Config {
190    pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
191        let content = std::fs::read_to_string(path)?;
192        let config: Config = toml::from_str(&content)?;
193        config.validate()?;
194        Ok(config)
195    }
196
197    fn validate(&self) -> Result<()> {
198        if self.https_port.is_some() {
199            if self.cert_path.is_none() {
200                bail!("`cert_path` is required when `https_port` is set");
201            }
202            if self.key_path.is_none() {
203                bail!("`key_path` is required when `https_port` is set");
204            }
205        }
206        if self.server.is_empty() {
207            bail!("at least one `[server.NAME]` block is required");
208        }
209        Ok(())
210    }
211}
212
213impl Default for ServerConfig {
214    fn default() -> Self {
215        Self {
216            bind_to: default_bind_to(),
217            proxy_url: default_proxy_url(),
218            include_paths: vec![],
219            exclude_paths: vec![],
220            enable_websocket: default_enable_websocket(),
221            forward_get_only: default_forward_get_only(),
222            cache_404_capacity: default_cache_404_capacity(),
223            use_404_meta: default_use_404_meta(),
224            cache_strategy: CacheStrategy::default(),
225            compress_strategy: CompressStrategy::default(),
226            cache_storage_mode: CacheStorageMode::default(),
227            cache_directory: None,
228            proxy_mode: ProxyModeConfig::default(),
229            pre_generate_paths: vec![],
230            pre_generate_fallthrough: false,
231        }
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    fn single_server_toml(extra: &str) -> String {
240        format!(
241            "[server.default]\nproxy_url = \"http://localhost:8080\"\n{}",
242            extra
243        )
244    }
245
246    #[test]
247    fn test_config_defaults_cache_strategy_to_all() {
248        let config: Config = toml::from_str(&single_server_toml("")).unwrap();
249        let s = config.server.get("default").unwrap();
250        assert_eq!(s.cache_strategy, CacheStrategy::All);
251        assert_eq!(s.compress_strategy, CompressStrategy::Brotli);
252        assert_eq!(s.cache_storage_mode, CacheStorageMode::Memory);
253        assert_eq!(s.cache_directory, None);
254    }
255
256    #[test]
257    fn test_config_parses_cache_strategy() {
258        let config: Config =
259            toml::from_str(&single_server_toml("cache_strategy = \"none\"\n")).unwrap();
260        let s = config.server.get("default").unwrap();
261        assert_eq!(s.cache_strategy, CacheStrategy::None);
262    }
263
264    #[test]
265    fn test_config_parses_compress_strategy() {
266        let config: Config =
267            toml::from_str(&single_server_toml("compress_strategy = \"gzip\"\n")).unwrap();
268        let s = config.server.get("default").unwrap();
269        assert_eq!(s.compress_strategy, CompressStrategy::Gzip);
270    }
271
272    #[test]
273    fn test_config_parses_cache_storage_mode() {
274        let config: Config = toml::from_str(&single_server_toml(
275            "cache_storage_mode = \"filesystem\"\ncache_directory = \"cache-bodies\"\n",
276        ))
277        .unwrap();
278        let s = config.server.get("default").unwrap();
279        assert_eq!(s.cache_storage_mode, CacheStorageMode::Filesystem);
280        assert_eq!(s.cache_directory, Some(PathBuf::from("cache-bodies")));
281    }
282
283    #[test]
284    fn test_config_top_level_ports() {
285        let toml = "http_port = 8080\ncontrol_port = 9000\n".to_string()
286            + &single_server_toml("");
287        let config: Config = toml::from_str(&toml).unwrap();
288        assert_eq!(config.http_port, 8080);
289        assert_eq!(config.control_port, 9000);
290        assert_eq!(config.https_port, None);
291    }
292
293    #[test]
294    fn test_https_validation_requires_cert_and_key() {
295        let toml = "https_port = 443\n".to_string() + &single_server_toml("");
296        let config: Config = toml::from_str(&toml).unwrap();
297        assert!(config.validate().is_err());
298    }
299
300    #[test]
301    fn test_multiple_servers() {
302        let toml = "[server.frontend]\nbind_to = \"*\"\nproxy_url = \"http://localhost:5173\"\n\
303                    [server.api]\nbind_to = \"/api\"\nproxy_url = \"http://localhost:8080\"\n";
304        let config: Config = toml::from_str(toml).unwrap();
305        assert_eq!(config.server.len(), 2);
306        assert_eq!(
307            config.server.get("api").unwrap().bind_to,
308            "/api"
309        );
310        assert_eq!(
311            config.server.get("frontend").unwrap().bind_to,
312            "*"
313        );
314    }
315}