Skip to main content

phantom_frame/
lib.rs

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