Skip to main content

phantom_frame/
lib.rs

1#[cfg(all(feature = "native-tls", feature = "rustls"))]
2compile_error!("Features `native-tls` and `rustls` are mutually exclusive — enable only one.");
3
4pub mod cache;
5pub mod compression;
6pub mod config;
7pub mod control;
8pub mod path_matcher;
9pub mod proxy;
10
11use axum::{extract::Extension, Router};
12use cache::{CacheStore, RefreshTrigger};
13use proxy::ProxyState;
14use serde::{Deserialize, Serialize};
15use std::path::PathBuf;
16use std::sync::Arc;
17
18/// Controls which backend responses are eligible for caching.
19#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum CacheStrategy {
22    /// Cache every response that passes the existing path and method filters.
23    #[default]
24    All,
25    /// Disable caching entirely, including 404 cache entries.
26    None,
27    /// Cache HTML documents only.
28    OnlyHtml,
29    /// Cache everything except image responses.
30    NoImages,
31    /// Cache image responses only.
32    OnlyImages,
33    /// Cache non-HTML static/application assets.
34    OnlyAssets,
35}
36
37impl CacheStrategy {
38    /// Check whether a response with the given content type can be cached.
39    pub fn allows_content_type(&self, content_type: Option<&str>) -> bool {
40        let content_type = content_type
41            .and_then(|value| value.split(';').next())
42            .map(|value| value.trim().to_ascii_lowercase());
43
44        match self {
45            Self::All => true,
46            Self::None => false,
47            Self::OnlyHtml => content_type
48                .as_deref()
49                .is_some_and(|value| value == "text/html" || value == "application/xhtml+xml"),
50            Self::NoImages => !content_type
51                .as_deref()
52                .is_some_and(|value| value.starts_with("image/")),
53            Self::OnlyImages => content_type
54                .as_deref()
55                .is_some_and(|value| value.starts_with("image/")),
56            Self::OnlyAssets => content_type.as_deref().is_some_and(|value| {
57                value.starts_with("image/")
58                    || value.starts_with("font/")
59                    || value == "text/css"
60                    || value == "text/javascript"
61                    || value == "application/javascript"
62                    || value == "application/x-javascript"
63                    || value == "application/json"
64                    || value == "application/manifest+json"
65                    || value == "application/wasm"
66                    || value == "application/xml"
67                    || value == "text/xml"
68            }),
69        }
70    }
71}
72
73impl std::fmt::Display for CacheStrategy {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        let value = match self {
76            Self::All => "all",
77            Self::None => "none",
78            Self::OnlyHtml => "only_html",
79            Self::NoImages => "no_images",
80            Self::OnlyImages => "only_images",
81            Self::OnlyAssets => "only_assets",
82        };
83
84        f.write_str(value)
85    }
86}
87
88/// Controls how cacheable responses are stored in memory.
89#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum CompressStrategy {
92    /// Store cache entries without additional compression.
93    None,
94    /// Store cache entries with Brotli compression.
95    #[default]
96    Brotli,
97    /// Store cache entries with gzip compression.
98    Gzip,
99    /// Store cache entries with deflate compression.
100    Deflate,
101}
102
103impl std::fmt::Display for CompressStrategy {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        let value = match self {
106            Self::None => "none",
107            Self::Brotli => "brotli",
108            Self::Gzip => "gzip",
109            Self::Deflate => "deflate",
110        };
111
112        f.write_str(value)
113    }
114}
115
116/// Controls where cached response bodies are stored.
117#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(rename_all = "snake_case")]
119pub enum CacheStorageMode {
120    /// Keep cached bodies in process memory.
121    #[default]
122    Memory,
123    /// Persist cached bodies to the filesystem and load them on cache hits.
124    Filesystem,
125}
126
127impl std::fmt::Display for CacheStorageMode {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        let value = match self {
130            Self::Memory => "memory",
131            Self::Filesystem => "filesystem",
132        };
133
134        f.write_str(value)
135    }
136}
137
138/// Information about an incoming request for cache key generation
139#[derive(Clone, Debug)]
140pub struct RequestInfo<'a> {
141    /// HTTP method (e.g., "GET", "POST", "PUT", "DELETE")
142    pub method: &'a str,
143    /// Request path (e.g., "/api/users")
144    pub path: &'a str,
145    /// Query string (e.g., "id=123&sort=asc")
146    pub query: &'a str,
147    /// Request headers (for custom cache key logic based on headers)
148    pub headers: &'a axum::http::HeaderMap,
149}
150
151/// Configuration for creating a proxy
152#[derive(Clone)]
153pub struct CreateProxyConfig {
154    /// The backend URL to proxy requests to
155    pub proxy_url: String,
156
157    /// Paths to include in caching (empty means include all)
158    /// Supports wildcards and method prefixes: "/api/*", "POST /api/*", "GET /*/users", etc.
159    pub include_paths: Vec<String>,
160
161    /// Paths to exclude from caching (empty means exclude none)
162    /// Supports wildcards and method prefixes: "/admin/*", "POST *", "PUT /api/*", etc.
163    /// Exclude overrides include
164    pub exclude_paths: Vec<String>,
165
166    /// Enable WebSocket and protocol upgrade support (default: true)
167    /// When enabled, requests with Connection: Upgrade headers will bypass
168    /// the cache and establish a direct bidirectional TCP tunnel
169    pub enable_websocket: bool,
170
171    /// Only allow GET requests, reject all others (default: false)
172    /// When true, only GET requests are processed; POST, PUT, DELETE, etc. return 405 Method Not Allowed
173    /// Useful for static site prerendering where mutations shouldn't be allowed
174    pub forward_get_only: bool,
175
176    /// Custom cache key generator
177    /// Takes request info and returns a cache key
178    /// Default: method + path + query string
179    pub cache_key_fn: Arc<dyn Fn(&RequestInfo) -> String + Send + Sync>,
180    /// Capacity for special 404 cache. When 0, 404 caching is disabled.
181    pub cache_404_capacity: usize,
182
183    /// When true, treat a response containing the meta tag `<meta name="phantom-404" content="true">` as a 404
184    /// This is an optional performance-affecting fallback to detect framework-generated 404 pages.
185    pub use_404_meta: bool,
186
187    /// Controls which responses should be cached after the backend responds.
188    pub cache_strategy: CacheStrategy,
189
190    /// Controls how cached bodies are stored in memory.
191    pub compress_strategy: CompressStrategy,
192
193    /// Controls where cached response bodies are stored.
194    pub cache_storage_mode: CacheStorageMode,
195
196    /// Optional override for filesystem-backed cache bodies.
197    pub cache_directory: Option<PathBuf>,
198}
199
200impl CreateProxyConfig {
201    /// Create a new config with default settings
202    pub fn new(proxy_url: String) -> Self {
203        Self {
204            proxy_url,
205            include_paths: vec![],
206            exclude_paths: vec![],
207            enable_websocket: true,
208            forward_get_only: false,
209            cache_key_fn: Arc::new(|req_info| {
210                if req_info.query.is_empty() {
211                    format!("{}:{}", req_info.method, req_info.path)
212                } else {
213                    format!("{}:{}?{}", req_info.method, req_info.path, req_info.query)
214                }
215            }),
216            cache_404_capacity: 100,
217            use_404_meta: false,
218            cache_strategy: CacheStrategy::All,
219            compress_strategy: CompressStrategy::Brotli,
220            cache_storage_mode: CacheStorageMode::Memory,
221            cache_directory: None,
222        }
223    }
224
225    /// Set include paths
226    pub fn with_include_paths(mut self, paths: Vec<String>) -> Self {
227        self.include_paths = paths;
228        self
229    }
230
231    /// Set exclude paths
232    pub fn with_exclude_paths(mut self, paths: Vec<String>) -> Self {
233        self.exclude_paths = paths;
234        self
235    }
236
237    /// Enable or disable WebSocket and protocol upgrade support
238    pub fn with_websocket_enabled(mut self, enabled: bool) -> Self {
239        self.enable_websocket = enabled;
240        self
241    }
242
243    /// Only allow GET requests, reject all others
244    pub fn with_forward_get_only(mut self, enabled: bool) -> Self {
245        self.forward_get_only = enabled;
246        self
247    }
248
249    /// Set custom cache key function
250    pub fn with_cache_key_fn<F>(mut self, f: F) -> Self
251    where
252        F: Fn(&RequestInfo) -> String + Send + Sync + 'static,
253    {
254        self.cache_key_fn = Arc::new(f);
255        self
256    }
257
258    /// Set 404 cache capacity. When 0, 404 caching is disabled.
259    pub fn with_cache_404_capacity(mut self, capacity: usize) -> Self {
260        self.cache_404_capacity = capacity;
261        self
262    }
263
264    /// Treat pages that include the special meta tag as 404 pages
265    pub fn with_use_404_meta(mut self, enabled: bool) -> Self {
266        self.use_404_meta = enabled;
267        self
268    }
269
270    /// Set the cache strategy used to decide which response types are stored.
271    pub fn with_cache_strategy(mut self, strategy: CacheStrategy) -> Self {
272        self.cache_strategy = strategy;
273        self
274    }
275
276    /// Alias for callers that prefer a more fluent builder name.
277    pub fn caching_strategy(self, strategy: CacheStrategy) -> Self {
278        self.with_cache_strategy(strategy)
279    }
280
281    /// Set the compression strategy used for stored cache entries.
282    pub fn with_compress_strategy(mut self, strategy: CompressStrategy) -> Self {
283        self.compress_strategy = strategy;
284        self
285    }
286
287    /// Alias for callers that prefer a more fluent builder name.
288    pub fn compression_strategy(self, strategy: CompressStrategy) -> Self {
289        self.with_compress_strategy(strategy)
290    }
291
292    /// Set the backing store for cached response bodies.
293    pub fn with_cache_storage_mode(mut self, mode: CacheStorageMode) -> Self {
294        self.cache_storage_mode = mode;
295        self
296    }
297
298    /// Set the filesystem directory used for disk-backed cache bodies.
299    pub fn with_cache_directory(mut self, directory: impl Into<PathBuf>) -> Self {
300        self.cache_directory = Some(directory.into());
301        self
302    }
303}
304
305/// The main library interface for using phantom-frame as a library
306/// Returns a proxy handler function and a refresh trigger
307pub fn create_proxy(config: CreateProxyConfig) -> (Router, RefreshTrigger) {
308    let refresh_trigger = RefreshTrigger::new();
309    let cache = CacheStore::with_storage(
310        refresh_trigger.clone(),
311        config.cache_404_capacity,
312        config.cache_storage_mode.clone(),
313        config.cache_directory.clone(),
314    );
315
316    // Spawn background task to listen for refresh events
317    spawn_refresh_listener(cache.clone());
318
319    let proxy_state = Arc::new(ProxyState::new(cache, config));
320
321    let app = Router::new()
322        .fallback(proxy::proxy_handler)
323        .layer(Extension(proxy_state));
324
325    (app, refresh_trigger)
326}
327
328/// Create a proxy handler with an existing refresh trigger
329pub fn create_proxy_with_trigger(
330    config: CreateProxyConfig,
331    refresh_trigger: RefreshTrigger,
332) -> Router {
333    let cache = CacheStore::with_storage(
334        refresh_trigger,
335        config.cache_404_capacity,
336        config.cache_storage_mode.clone(),
337        config.cache_directory.clone(),
338    );
339
340    // Spawn background task to listen for refresh events
341    spawn_refresh_listener(cache.clone());
342
343    let proxy_state = Arc::new(ProxyState::new(cache, config));
344
345    Router::new()
346        .fallback(proxy::proxy_handler)
347        .layer(Extension(proxy_state))
348}
349
350/// Spawn a background task to listen for refresh events
351fn spawn_refresh_listener(cache: CacheStore) {
352    let mut receiver = cache.refresh_trigger().subscribe();
353
354    tokio::spawn(async move {
355        loop {
356            match receiver.recv().await {
357                Ok(cache::RefreshMessage::All) => {
358                    tracing::debug!("Cache refresh triggered: clearing all entries");
359                    cache.clear().await;
360                }
361                Ok(cache::RefreshMessage::Pattern(pattern)) => {
362                    tracing::debug!(
363                        "Cache refresh triggered: clearing entries matching pattern '{}'",
364                        pattern
365                    );
366                    cache.clear_by_pattern(&pattern).await;
367                }
368                Err(e) => {
369                    tracing::error!("Refresh trigger channel error: {}", e);
370                    break;
371                }
372            }
373        }
374    });
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    #[test]
382    fn test_cache_strategy_content_types() {
383        assert!(CacheStrategy::All.allows_content_type(None));
384        assert!(!CacheStrategy::None.allows_content_type(Some("text/html")));
385        assert!(CacheStrategy::OnlyHtml.allows_content_type(Some("text/html; charset=utf-8")));
386        assert!(!CacheStrategy::OnlyHtml.allows_content_type(Some("image/png")));
387        assert!(CacheStrategy::NoImages.allows_content_type(Some("text/css")));
388        assert!(!CacheStrategy::NoImages.allows_content_type(Some("image/webp")));
389        assert!(CacheStrategy::OnlyImages.allows_content_type(Some("image/svg+xml")));
390        assert!(!CacheStrategy::OnlyImages.allows_content_type(Some("application/javascript")));
391        assert!(CacheStrategy::OnlyAssets.allows_content_type(Some("application/javascript")));
392        assert!(CacheStrategy::OnlyAssets.allows_content_type(Some("image/png")));
393        assert!(!CacheStrategy::OnlyAssets.allows_content_type(Some("text/html")));
394        assert!(!CacheStrategy::OnlyAssets.allows_content_type(None));
395    }
396
397    #[test]
398    fn test_compress_strategy_display() {
399        assert_eq!(CompressStrategy::default().to_string(), "brotli");
400        assert_eq!(CompressStrategy::None.to_string(), "none");
401        assert_eq!(CompressStrategy::Gzip.to_string(), "gzip");
402        assert_eq!(CompressStrategy::Deflate.to_string(), "deflate");
403    }
404
405    #[tokio::test]
406    async fn test_create_proxy() {
407        let config = CreateProxyConfig::new("http://localhost:8080".to_string());
408        assert_eq!(config.compress_strategy, CompressStrategy::Brotli);
409        let (_app, trigger) = create_proxy(config);
410        trigger.trigger();
411        trigger.trigger_by_key_match("GET:/api/*");
412        // Just ensure it compiles and runs without panic
413    }
414}