phantom_frame/
lib.rs

1pub mod cache;
2pub mod config;
3pub mod control;
4pub mod path_matcher;
5pub mod proxy;
6
7use axum::{extract::Extension, Router};
8use cache::{CacheStore, RefreshTrigger};
9use proxy::ProxyState;
10use std::sync::Arc;
11
12/// Information about an incoming request for cache key generation
13#[derive(Clone, Debug)]
14pub struct RequestInfo<'a> {
15    /// HTTP method (e.g., "GET", "POST", "PUT", "DELETE")
16    pub method: &'a str,
17    /// Request path (e.g., "/api/users")
18    pub path: &'a str,
19    /// Query string (e.g., "id=123&sort=asc")
20    pub query: &'a str,
21    /// Request headers (for custom cache key logic based on headers)
22    pub headers: &'a axum::http::HeaderMap,
23}
24
25/// Configuration for creating a proxy
26#[derive(Clone)]
27pub struct CreateProxyConfig {
28    /// The backend URL to proxy requests to
29    pub proxy_url: String,
30    
31    /// Paths to include in caching (empty means include all)
32    /// Supports wildcards and method prefixes: "/api/*", "POST /api/*", "GET /*/users", etc.
33    pub include_paths: Vec<String>,
34    
35    /// Paths to exclude from caching (empty means exclude none)
36    /// Supports wildcards and method prefixes: "/admin/*", "POST *", "PUT /api/*", etc.
37    /// Exclude overrides include
38    pub exclude_paths: Vec<String>,
39    
40    /// Enable WebSocket and protocol upgrade support (default: true)
41    /// When enabled, requests with Connection: Upgrade headers will bypass
42    /// the cache and establish a direct bidirectional TCP tunnel
43    pub enable_websocket: bool,
44    
45    /// Only allow GET requests, reject all others (default: false)
46    /// When true, only GET requests are processed; POST, PUT, DELETE, etc. return 405 Method Not Allowed
47    /// Useful for static site prerendering where mutations shouldn't be allowed
48    pub forward_get_only: bool,
49    
50    /// Custom cache key generator
51    /// Takes request info and returns a cache key
52    /// Default: method + path + query string
53    pub cache_key_fn: Arc<dyn Fn(&RequestInfo) -> String + Send + Sync>,
54    /// Capacity for special 404 cache. When 0, 404 caching is disabled.
55    pub cache_404_capacity: usize,
56
57    /// When true, treat a response containing the meta tag `<meta name="phantom-404" content="true">` as a 404
58    /// This is an optional performance-affecting fallback to detect framework-generated 404 pages.
59    pub use_404_meta: bool,
60}
61
62impl CreateProxyConfig {
63    /// Create a new config with default settings
64    pub fn new(proxy_url: String) -> Self {
65        Self {
66            proxy_url,
67            include_paths: vec![],
68            exclude_paths: vec![],
69            enable_websocket: true,
70            forward_get_only: false,
71            cache_key_fn: Arc::new(|req_info| {
72                if req_info.query.is_empty() {
73                    format!("{}:{}", req_info.method, req_info.path)
74                } else {
75                    format!("{}:{}?{}", req_info.method, req_info.path, req_info.query)
76                }
77            }),
78            cache_404_capacity: 100,
79            use_404_meta: false,
80        }
81    }
82    
83    /// Set include paths
84    pub fn with_include_paths(mut self, paths: Vec<String>) -> Self {
85        self.include_paths = paths;
86        self
87    }
88    
89    /// Set exclude paths
90    pub fn with_exclude_paths(mut self, paths: Vec<String>) -> Self {
91        self.exclude_paths = paths;
92        self
93    }
94    
95    /// Enable or disable WebSocket and protocol upgrade support
96    pub fn with_websocket_enabled(mut self, enabled: bool) -> Self {
97        self.enable_websocket = enabled;
98        self
99    }
100    
101    /// Only allow GET requests, reject all others
102    pub fn with_forward_get_only(mut self, enabled: bool) -> Self {
103        self.forward_get_only = enabled;
104        self
105    }
106    
107    /// Set custom cache key function
108    pub fn with_cache_key_fn<F>(mut self, f: F) -> Self
109    where
110        F: Fn(&RequestInfo) -> String + Send + Sync + 'static,
111    {
112        self.cache_key_fn = Arc::new(f);
113        self
114    }
115
116    /// Set 404 cache capacity. When 0, 404 caching is disabled.
117    pub fn with_cache_404_capacity(mut self, capacity: usize) -> Self {
118        self.cache_404_capacity = capacity;
119        self
120    }
121
122    /// Treat pages that include the special meta tag as 404 pages
123    pub fn with_use_404_meta(mut self, enabled: bool) -> Self {
124        self.use_404_meta = enabled;
125        self
126    }
127}
128
129/// The main library interface for using phantom-frame as a library
130/// Returns a proxy handler function and a refresh trigger
131pub fn create_proxy(config: CreateProxyConfig) -> (Router, RefreshTrigger) {
132    let refresh_trigger = RefreshTrigger::new();
133    let cache = CacheStore::new(refresh_trigger.clone(), config.cache_404_capacity);
134
135    // Spawn background task to listen for refresh events
136    spawn_refresh_listener(cache.clone());
137
138    let proxy_state = Arc::new(ProxyState::new(cache, config));
139
140    let app = Router::new()
141        .fallback(proxy::proxy_handler)
142        .layer(Extension(proxy_state));
143
144    (app, refresh_trigger)
145}
146
147/// Create a proxy handler with an existing refresh trigger
148pub fn create_proxy_with_trigger(config: CreateProxyConfig, refresh_trigger: RefreshTrigger) -> Router {
149    let cache = CacheStore::new(refresh_trigger, 100);
150    
151    // Spawn background task to listen for refresh events
152    spawn_refresh_listener(cache.clone());
153
154    let proxy_state = Arc::new(ProxyState::new(cache, config));
155
156    Router::new()
157        .fallback(proxy::proxy_handler)
158        .layer(Extension(proxy_state))
159}
160
161/// Spawn a background task to listen for refresh events
162fn spawn_refresh_listener(cache: CacheStore) {
163    let mut receiver = cache.refresh_trigger().subscribe();
164    
165    tokio::spawn(async move {
166        loop {
167            match receiver.recv().await {
168                Ok(cache::RefreshMessage::All) => {
169                    tracing::debug!("Cache refresh triggered: clearing all entries");
170                    cache.clear().await;
171                }
172                Ok(cache::RefreshMessage::Pattern(pattern)) => {
173                    tracing::debug!("Cache refresh triggered: clearing entries matching pattern '{}'", pattern);
174                    cache.clear_by_pattern(&pattern).await;
175                }
176                Err(e) => {
177                    tracing::error!("Refresh trigger channel error: {}", e);
178                    break;
179                }
180            }
181        }
182    });
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[tokio::test]
190    async fn test_create_proxy() {
191        let config = CreateProxyConfig::new("http://localhost:8080".to_string());
192        let (_app, trigger) = create_proxy(config);
193        trigger.trigger();
194        trigger.trigger_by_key_match("GET:/api/*");
195        // Just ensure it compiles and runs without panic
196    }
197}