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::{CacheHandle, CacheStore};
13use proxy::ProxyState;
14use serde::{Deserialize, Serialize};
15use std::path::PathBuf;
16use std::sync::Arc;
17use tokio::sync::mpsc;
18
19/// Controls which backend responses are eligible for caching.
20#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum CacheStrategy {
23    /// Cache every response that passes the existing path and method filters.
24    #[default]
25    All,
26    /// Disable caching entirely, including 404 cache entries.
27    None,
28    /// Cache HTML documents only.
29    OnlyHtml,
30    /// Cache everything except image responses.
31    NoImages,
32    /// Cache image responses only.
33    OnlyImages,
34    /// Cache non-HTML static/application assets.
35    OnlyAssets,
36}
37
38impl CacheStrategy {
39    /// Check whether a response with the given content type can be cached.
40    pub fn allows_content_type(&self, content_type: Option<&str>) -> bool {
41        let content_type = content_type
42            .and_then(|value| value.split(';').next())
43            .map(|value| value.trim().to_ascii_lowercase());
44
45        match self {
46            Self::All => true,
47            Self::None => false,
48            Self::OnlyHtml => content_type
49                .as_deref()
50                .is_some_and(|value| value == "text/html" || value == "application/xhtml+xml"),
51            Self::NoImages => !content_type
52                .as_deref()
53                .is_some_and(|value| value.starts_with("image/")),
54            Self::OnlyImages => content_type
55                .as_deref()
56                .is_some_and(|value| value.starts_with("image/")),
57            Self::OnlyAssets => content_type.as_deref().is_some_and(|value| {
58                value.starts_with("image/")
59                    || value.starts_with("font/")
60                    || value == "text/css"
61                    || value == "text/javascript"
62                    || value == "application/javascript"
63                    || value == "application/x-javascript"
64                    || value == "application/json"
65                    || value == "application/manifest+json"
66                    || value == "application/wasm"
67                    || value == "application/xml"
68                    || value == "text/xml"
69            }),
70        }
71    }
72}
73
74impl std::fmt::Display for CacheStrategy {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        let value = match self {
77            Self::All => "all",
78            Self::None => "none",
79            Self::OnlyHtml => "only_html",
80            Self::NoImages => "no_images",
81            Self::OnlyImages => "only_images",
82            Self::OnlyAssets => "only_assets",
83        };
84
85        f.write_str(value)
86    }
87}
88
89/// Controls how cacheable responses are stored in memory.
90#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "snake_case")]
92pub enum CompressStrategy {
93    /// Store cache entries without additional compression.
94    None,
95    /// Store cache entries with Brotli compression.
96    #[default]
97    Brotli,
98    /// Store cache entries with gzip compression.
99    Gzip,
100    /// Store cache entries with deflate compression.
101    Deflate,
102}
103
104impl std::fmt::Display for CompressStrategy {
105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106        let value = match self {
107            Self::None => "none",
108            Self::Brotli => "brotli",
109            Self::Gzip => "gzip",
110            Self::Deflate => "deflate",
111        };
112
113        f.write_str(value)
114    }
115}
116
117/// Controls where cached response bodies are stored.
118#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "snake_case")]
120pub enum CacheStorageMode {
121    /// Keep cached bodies in process memory.
122    #[default]
123    Memory,
124    /// Persist cached bodies to the filesystem and load them on cache hits.
125    Filesystem,
126}
127
128impl std::fmt::Display for CacheStorageMode {
129    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130        let value = match self {
131            Self::Memory => "memory",
132            Self::Filesystem => "filesystem",
133        };
134
135        f.write_str(value)
136    }
137}
138
139/// Controls the operating mode of the proxy.
140#[derive(Clone, Debug, Default)]
141pub enum ProxyMode {
142    /// Dynamic mode: every request is served from cache when available, or
143    /// forwarded to the upstream backend on a cache miss (default).
144    #[default]
145    Dynamic,
146    /// Pre-generate (SSG) mode: a fixed list of `paths` is fetched from the
147    /// upstream server at startup and served exclusively from the cache.
148    ///
149    /// On a cache miss:
150    /// - `fallthrough = false` (default): return 404 immediately.
151    /// - `fallthrough = true`: fall through to the upstream backend.
152    ///
153    /// Use the [`CacheHandle`] returned by [`create_proxy`] to manage snapshots
154    /// at runtime via `add_snapshot`, `refresh_snapshot`, `remove_snapshot`, and
155    /// `refresh_all_snapshots`.
156    PreGenerate {
157        /// The paths to pre-generate at startup (e.g. `"/book/1"`).
158        paths: Vec<String>,
159        /// When `true`, cache misses fall through to the upstream backend.
160        /// Defaults to `false`.
161        fallthrough: bool,
162    },
163}
164
165/// Information about an incoming request for cache key generation
166#[derive(Clone, Debug)]
167pub struct RequestInfo<'a> {
168    /// HTTP method (e.g., "GET", "POST", "PUT", "DELETE")
169    pub method: &'a str,
170    /// Request path (e.g., "/api/users")
171    pub path: &'a str,
172    /// Query string (e.g., "id=123&sort=asc")
173    pub query: &'a str,
174    /// Request headers (for custom cache key logic based on headers)
175    pub headers: &'a axum::http::HeaderMap,
176}
177
178/// Configuration for creating a proxy
179#[derive(Clone)]
180pub struct CreateProxyConfig {
181    /// The backend URL to proxy requests to
182    pub proxy_url: String,
183
184    /// Paths to include in caching (empty means include all)
185    /// Supports wildcards and method prefixes: "/api/*", "POST /api/*", "GET /*/users", etc.
186    pub include_paths: Vec<String>,
187
188    /// Paths to exclude from caching (empty means exclude none)
189    /// Supports wildcards and method prefixes: "/admin/*", "POST *", "PUT /api/*", etc.
190    /// Exclude overrides include
191    pub exclude_paths: Vec<String>,
192
193    /// Enable WebSocket and protocol upgrade support (default: true)
194    /// When enabled, requests with Connection: Upgrade headers will bypass
195    /// the cache and establish a direct bidirectional TCP tunnel
196    pub enable_websocket: bool,
197
198    /// Only allow GET requests, reject all others (default: false)
199    /// When true, only GET requests are processed; POST, PUT, DELETE, etc. return 405 Method Not Allowed
200    /// Useful for static site prerendering where mutations shouldn't be allowed
201    pub forward_get_only: bool,
202
203    /// Custom cache key generator
204    /// Takes request info and returns a cache key
205    /// Default: method + path + query string
206    pub cache_key_fn: Arc<dyn Fn(&RequestInfo) -> String + Send + Sync>,
207    /// Capacity for special 404 cache. When 0, 404 caching is disabled.
208    pub cache_404_capacity: usize,
209
210    /// When true, treat a response containing the meta tag `<meta name="phantom-404" content="true">` as a 404
211    /// This is an optional performance-affecting fallback to detect framework-generated 404 pages.
212    pub use_404_meta: bool,
213
214    /// Controls which responses should be cached after the backend responds.
215    pub cache_strategy: CacheStrategy,
216
217    /// Controls how cached bodies are stored in memory.
218    pub compress_strategy: CompressStrategy,
219
220    /// Controls where cached response bodies are stored.
221    pub cache_storage_mode: CacheStorageMode,
222
223    /// Optional override for filesystem-backed cache bodies.
224    pub cache_directory: Option<PathBuf>,
225
226    /// Controls the operating mode of the proxy (Dynamic vs PreGenerate/SSG).
227    pub proxy_mode: ProxyMode,
228}
229
230impl CreateProxyConfig {
231    /// Create a new config with default settings
232    pub fn new(proxy_url: String) -> Self {
233        Self {
234            proxy_url,
235            include_paths: vec![],
236            exclude_paths: vec![],
237            enable_websocket: true,
238            forward_get_only: false,
239            cache_key_fn: Arc::new(|req_info| {
240                if req_info.query.is_empty() {
241                    format!("{}:{}", req_info.method, req_info.path)
242                } else {
243                    format!("{}:{}?{}", req_info.method, req_info.path, req_info.query)
244                }
245            }),
246            cache_404_capacity: 100,
247            use_404_meta: false,
248            cache_strategy: CacheStrategy::All,
249            compress_strategy: CompressStrategy::Brotli,
250            cache_storage_mode: CacheStorageMode::Memory,
251            cache_directory: None,
252            proxy_mode: ProxyMode::Dynamic,
253        }
254    }
255
256    /// Set include paths
257    pub fn with_include_paths(mut self, paths: Vec<String>) -> Self {
258        self.include_paths = paths;
259        self
260    }
261
262    /// Set exclude paths
263    pub fn with_exclude_paths(mut self, paths: Vec<String>) -> Self {
264        self.exclude_paths = paths;
265        self
266    }
267
268    /// Enable or disable WebSocket and protocol upgrade support
269    pub fn with_websocket_enabled(mut self, enabled: bool) -> Self {
270        self.enable_websocket = enabled;
271        self
272    }
273
274    /// Only allow GET requests, reject all others
275    pub fn with_forward_get_only(mut self, enabled: bool) -> Self {
276        self.forward_get_only = enabled;
277        self
278    }
279
280    /// Set custom cache key function
281    pub fn with_cache_key_fn<F>(mut self, f: F) -> Self
282    where
283        F: Fn(&RequestInfo) -> String + Send + Sync + 'static,
284    {
285        self.cache_key_fn = Arc::new(f);
286        self
287    }
288
289    /// Set 404 cache capacity. When 0, 404 caching is disabled.
290    pub fn with_cache_404_capacity(mut self, capacity: usize) -> Self {
291        self.cache_404_capacity = capacity;
292        self
293    }
294
295    /// Treat pages that include the special meta tag as 404 pages
296    pub fn with_use_404_meta(mut self, enabled: bool) -> Self {
297        self.use_404_meta = enabled;
298        self
299    }
300
301    /// Set the cache strategy used to decide which response types are stored.
302    pub fn with_cache_strategy(mut self, strategy: CacheStrategy) -> Self {
303        self.cache_strategy = strategy;
304        self
305    }
306
307    /// Alias for callers that prefer a more fluent builder name.
308    pub fn caching_strategy(self, strategy: CacheStrategy) -> Self {
309        self.with_cache_strategy(strategy)
310    }
311
312    /// Set the compression strategy used for stored cache entries.
313    pub fn with_compress_strategy(mut self, strategy: CompressStrategy) -> Self {
314        self.compress_strategy = strategy;
315        self
316    }
317
318    /// Alias for callers that prefer a more fluent builder name.
319    pub fn compression_strategy(self, strategy: CompressStrategy) -> Self {
320        self.with_compress_strategy(strategy)
321    }
322
323    /// Set the backing store for cached response bodies.
324    pub fn with_cache_storage_mode(mut self, mode: CacheStorageMode) -> Self {
325        self.cache_storage_mode = mode;
326        self
327    }
328
329    /// Set the filesystem directory used for disk-backed cache bodies.
330    pub fn with_cache_directory(mut self, directory: impl Into<PathBuf>) -> Self {
331        self.cache_directory = Some(directory.into());
332        self
333    }
334
335    /// Set the proxy operating mode.
336    /// Use `ProxyMode::PreGenerate { paths, fallthrough }` to enable SSG mode.
337    pub fn with_proxy_mode(mut self, mode: ProxyMode) -> Self {
338        self.proxy_mode = mode;
339        self
340    }
341}
342
343/// The main library interface for using phantom-frame as a library
344/// Returns a proxy handler function and a cache handle
345pub fn create_proxy(config: CreateProxyConfig) -> (Router, CacheHandle) {
346    // In PreGenerate mode, create a channel for the snapshot worker
347    let (handle, snapshot_rx) = if let ProxyMode::PreGenerate { .. } = &config.proxy_mode {
348        let (tx, rx) = mpsc::channel(32);
349        (CacheHandle::new_with_snapshots(tx), Some(rx))
350    } else {
351        (CacheHandle::new(), None)
352    };
353
354    let cache = CacheStore::with_storage(
355        handle.clone(),
356        config.cache_404_capacity,
357        config.cache_storage_mode.clone(),
358        config.cache_directory.clone(),
359    );
360
361    // Spawn background task to listen for invalidation events
362    spawn_invalidation_listener(cache.clone());
363
364    // Spawn snapshot worker (warm-up + runtime snapshot management) in PreGenerate mode
365    if let (Some(rx), ProxyMode::PreGenerate { paths, .. }) =
366        (snapshot_rx, &config.proxy_mode)
367    {
368        let worker = SnapshotWorker {
369            rx,
370            cache: cache.clone(),
371            proxy_url: config.proxy_url.clone(),
372            compress_strategy: config.compress_strategy.clone(),
373            cache_key_fn: config.cache_key_fn.clone(),
374            snapshots: paths.clone(),
375        };
376        tokio::spawn(worker.run());
377    }
378
379    let proxy_state = Arc::new(ProxyState::new(cache, config));
380
381    let app = Router::new()
382        .fallback(proxy::proxy_handler)
383        .layer(Extension(proxy_state));
384
385    (app, handle)
386}
387
388/// Create a proxy handler with an existing cache handle.
389/// Useful for sharing a single handle across multiple proxy instances so that
390/// invalidation propagates to all caches simultaneously.
391///
392/// Note: snapshot operations (PreGenerate mode warm-up) are not available
393/// through this variant — use [`create_proxy`] for full PreGenerate support.
394pub fn create_proxy_with_handle(config: CreateProxyConfig, handle: CacheHandle) -> Router {
395    let cache = CacheStore::with_storage(
396        handle,
397        config.cache_404_capacity,
398        config.cache_storage_mode.clone(),
399        config.cache_directory.clone(),
400    );
401
402    // Spawn background task to listen for invalidation events
403    spawn_invalidation_listener(cache.clone());
404
405    let proxy_state = Arc::new(ProxyState::new(cache, config));
406
407    Router::new()
408        .fallback(proxy::proxy_handler)
409        .layer(Extension(proxy_state))
410}
411
412/// Spawn a background task to listen for cache invalidation events.
413fn spawn_invalidation_listener(cache: CacheStore) {
414    let mut receiver = cache.handle().subscribe();
415
416    tokio::spawn(async move {
417        loop {
418            match receiver.recv().await {
419                Ok(cache::InvalidationMessage::All) => {
420                    tracing::debug!("Cache invalidation triggered: clearing all entries");
421                    cache.clear().await;
422                }
423                Ok(cache::InvalidationMessage::Pattern(pattern)) => {
424                    tracing::debug!(
425                        "Cache invalidation triggered: clearing entries matching pattern '{}'",
426                        pattern
427                    );
428                    cache.clear_by_pattern(&pattern).await;
429                }
430                Err(e) => {
431                    tracing::error!("Invalidation channel error: {}", e);
432                    break;
433                }
434            }
435        }
436    });
437}
438
439/// Background worker that handles snapshot warm-up and runtime snapshot operations
440/// for `ProxyMode::PreGenerate`.
441struct SnapshotWorker {
442    rx: mpsc::Receiver<cache::SnapshotRequest>,
443    cache: CacheStore,
444    proxy_url: String,
445    compress_strategy: CompressStrategy,
446    cache_key_fn: Arc<dyn Fn(&RequestInfo) -> String + Send + Sync>,
447    /// Current snapshot list — grows/shrinks via add/remove operations.
448    snapshots: Vec<String>,
449}
450
451impl SnapshotWorker {
452    async fn run(mut self) {
453        // Warm-up: pre-generate all initial snapshot paths before handling requests.
454        let initial = self.snapshots.clone();
455        for path in &initial {
456            if let Err(e) = self.fetch_and_store(path).await {
457                tracing::warn!("Failed to pre-generate snapshot '{}': {}", path, e);
458            }
459        }
460
461        // Process runtime snapshot requests.
462        while let Some(req) = self.rx.recv().await {
463            match req.op {
464                cache::SnapshotOp::Add(path) => {
465                    match self.fetch_and_store(&path).await {
466                        Ok(()) => self.snapshots.push(path),
467                        Err(e) => tracing::warn!("add_snapshot '{}' failed: {}", path, e),
468                    }
469                }
470                cache::SnapshotOp::Refresh(path) => {
471                    if let Err(e) = self.fetch_and_store(&path).await {
472                        tracing::warn!("refresh_snapshot '{}' failed: {}", path, e);
473                    }
474                }
475                cache::SnapshotOp::Remove(path) => {
476                    let empty_headers = axum::http::HeaderMap::new();
477                    let req_info = RequestInfo {
478                        method: "GET",
479                        path: &path,
480                        query: "",
481                        headers: &empty_headers,
482                    };
483                    let key = (self.cache_key_fn)(&req_info);
484                    self.cache.clear_by_pattern(&key).await;
485                    self.snapshots.retain(|s| s != &path);
486                }
487                cache::SnapshotOp::RefreshAll => {
488                    let paths: Vec<String> = self.snapshots.clone();
489                    for path in &paths {
490                        if let Err(e) = self.fetch_and_store(path).await {
491                            tracing::warn!("refresh_all_snapshots '{}' failed: {}", path, e);
492                        }
493                    }
494                }
495            }
496            // Signal completion to the caller.
497            let _ = req.done.send(());
498        }
499    }
500
501    async fn fetch_and_store(&self, path: &str) -> anyhow::Result<()> {
502        proxy::fetch_and_cache_snapshot(
503            path,
504            &self.proxy_url,
505            &self.cache,
506            &self.compress_strategy,
507            &self.cache_key_fn,
508        )
509        .await
510    }
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516
517    #[test]
518    fn test_cache_strategy_content_types() {
519        assert!(CacheStrategy::All.allows_content_type(None));
520        assert!(!CacheStrategy::None.allows_content_type(Some("text/html")));
521        assert!(CacheStrategy::OnlyHtml.allows_content_type(Some("text/html; charset=utf-8")));
522        assert!(!CacheStrategy::OnlyHtml.allows_content_type(Some("image/png")));
523        assert!(CacheStrategy::NoImages.allows_content_type(Some("text/css")));
524        assert!(!CacheStrategy::NoImages.allows_content_type(Some("image/webp")));
525        assert!(CacheStrategy::OnlyImages.allows_content_type(Some("image/svg+xml")));
526        assert!(!CacheStrategy::OnlyImages.allows_content_type(Some("application/javascript")));
527        assert!(CacheStrategy::OnlyAssets.allows_content_type(Some("application/javascript")));
528        assert!(CacheStrategy::OnlyAssets.allows_content_type(Some("image/png")));
529        assert!(!CacheStrategy::OnlyAssets.allows_content_type(Some("text/html")));
530        assert!(!CacheStrategy::OnlyAssets.allows_content_type(None));
531    }
532
533    #[test]
534    fn test_compress_strategy_display() {
535        assert_eq!(CompressStrategy::default().to_string(), "brotli");
536        assert_eq!(CompressStrategy::None.to_string(), "none");
537        assert_eq!(CompressStrategy::Gzip.to_string(), "gzip");
538        assert_eq!(CompressStrategy::Deflate.to_string(), "deflate");
539    }
540
541    #[tokio::test]
542    async fn test_create_proxy() {
543        let config = CreateProxyConfig::new("http://localhost:8080".to_string());
544        assert_eq!(config.compress_strategy, CompressStrategy::Brotli);
545        let (_app, handle) = create_proxy(config);
546        handle.invalidate_all();
547        handle.invalidate("GET:/api/*");
548        // Just ensure it compiles and runs without panic
549    }
550}