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/// The type of a webhook — controls whether the webhook gates access or just receives a notification.
140#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(rename_all = "snake_case")]
142pub enum WebhookType {
143    /// The webhook call must complete with a 2xx response before the request is forwarded.
144    /// A non-2xx response (or a timeout / network error) causes the request to be denied.
145    /// A 3xx response is forwarded to the client as a redirect with the `Location` header intact.
146    Blocking,
147    /// The webhook call is dispatched in the background without blocking the request.
148    #[default]
149    Notify,
150    /// The webhook response body (plain text) is used as the cache key for this request.
151    /// On failure, a non-2xx response, or an empty body, the default cache key is used instead.
152    /// Works in both Dynamic and PreGenerate modes.
153    CacheKey,
154}
155
156/// Configuration for a single webhook attached to a server.
157#[derive(Clone, Debug, Serialize, Deserialize)]
158pub struct WebhookConfig {
159    /// The URL to POST the request metadata to.
160    pub url: String,
161
162    /// Whether this webhook is blocking (gates access) or notify (fire-and-forget).
163    #[serde(rename = "type", default)]
164    pub webhook_type: WebhookType,
165
166    /// Timeout in milliseconds for the webhook call (default: 5000 ms).
167    /// On timeout, a blocking webhook denies the request with 503.
168    #[serde(default)]
169    pub timeout_ms: Option<u64>,
170}
171
172/// Controls the operating mode of the proxy.
173#[derive(Clone, Debug, Default)]
174pub enum ProxyMode {
175    /// Dynamic mode: every request is served from cache when available, or
176    /// forwarded to the upstream backend on a cache miss (default).
177    #[default]
178    Dynamic,
179    /// Pre-generate (SSG) mode: a fixed list of `paths` is fetched from the
180    /// upstream server at startup and served exclusively from the cache.
181    ///
182    /// On a cache miss:
183    /// - `fallthrough = false` (default): return 404 immediately.
184    /// - `fallthrough = true`: fall through to the upstream backend.
185    ///
186    /// Use the [`CacheHandle`] returned by [`create_proxy`] to manage snapshots
187    /// at runtime via `add_snapshot`, `refresh_snapshot`, `remove_snapshot`, and
188    /// `refresh_all_snapshots`.
189    PreGenerate {
190        /// The paths to pre-generate at startup (e.g. `"/book/1"`).
191        paths: Vec<String>,
192        /// When `true`, cache misses fall through to the upstream backend.
193        /// Defaults to `false`.
194        fallthrough: bool,
195    },
196}
197
198/// Information about an incoming request for cache key generation
199#[derive(Clone, Debug)]
200pub struct RequestInfo<'a> {
201    /// HTTP method (e.g., "GET", "POST", "PUT", "DELETE")
202    pub method: &'a str,
203    /// Request path (e.g., "/api/users")
204    pub path: &'a str,
205    /// Query string (e.g., "id=123&sort=asc")
206    pub query: &'a str,
207    /// Request headers (for custom cache key logic based on headers)
208    pub headers: &'a axum::http::HeaderMap,
209}
210
211/// Configuration for creating a proxy
212#[derive(Clone)]
213pub struct CreateProxyConfig {
214    /// The backend URL to proxy requests to
215    pub proxy_url: String,
216
217    /// Paths to include in caching (empty means include all)
218    /// Supports wildcards and method prefixes: "/api/*", "POST /api/*", "GET /*/users", etc.
219    pub include_paths: Vec<String>,
220
221    /// Paths to exclude from caching (empty means exclude none)
222    /// Supports wildcards and method prefixes: "/admin/*", "POST *", "PUT /api/*", etc.
223    /// Exclude overrides include
224    pub exclude_paths: Vec<String>,
225
226    /// Enable WebSocket and protocol upgrade support (default: true)
227    /// When enabled, requests with Connection: Upgrade headers will bypass
228    /// the cache and establish a direct bidirectional TCP tunnel
229    pub enable_websocket: bool,
230
231    /// Only allow GET requests, reject all others (default: false)
232    /// When true, only GET requests are processed; POST, PUT, DELETE, etc. return 405 Method Not Allowed
233    /// Useful for static site prerendering where mutations shouldn't be allowed
234    pub forward_get_only: bool,
235
236    /// Custom cache key generator
237    /// Takes request info and returns a cache key
238    /// Default: method + path + query string
239    pub cache_key_fn: Arc<dyn Fn(&RequestInfo) -> String + Send + Sync>,
240    /// Capacity for special 404 cache. When 0, 404 caching is disabled.
241    pub cache_404_capacity: usize,
242
243    /// When true, treat a response containing the meta tag `<meta name="phantom-404" content="true">` as a 404
244    /// This is an optional performance-affecting fallback to detect framework-generated 404 pages.
245    pub use_404_meta: bool,
246
247    /// Controls which responses should be cached after the backend responds.
248    pub cache_strategy: CacheStrategy,
249
250    /// Controls how cached bodies are stored in memory.
251    pub compress_strategy: CompressStrategy,
252
253    /// Controls where cached response bodies are stored.
254    pub cache_storage_mode: CacheStorageMode,
255
256    /// Optional override for filesystem-backed cache bodies.
257    pub cache_directory: Option<PathBuf>,
258
259    /// Controls the operating mode of the proxy (Dynamic vs PreGenerate/SSG).
260    pub proxy_mode: ProxyMode,
261
262    /// Webhooks called for every request before cache reads.
263    /// Blocking webhooks gate access; notify webhooks are fire-and-forget.
264    pub webhooks: Vec<WebhookConfig>,
265}
266
267impl CreateProxyConfig {
268    /// Create a new config with default settings
269    pub fn new(proxy_url: String) -> Self {
270        Self {
271            proxy_url,
272            include_paths: vec![],
273            exclude_paths: vec![],
274            enable_websocket: true,
275            forward_get_only: false,
276            cache_key_fn: Arc::new(|req_info| {
277                if req_info.query.is_empty() {
278                    format!("{}:{}", req_info.method, req_info.path)
279                } else {
280                    format!("{}:{}?{}", req_info.method, req_info.path, req_info.query)
281                }
282            }),
283            cache_404_capacity: 100,
284            use_404_meta: false,
285            cache_strategy: CacheStrategy::All,
286            compress_strategy: CompressStrategy::Brotli,
287            cache_storage_mode: CacheStorageMode::Memory,
288            cache_directory: None,
289            proxy_mode: ProxyMode::Dynamic,
290            webhooks: vec![],
291        }
292    }
293
294    /// Set include paths
295    pub fn with_include_paths(mut self, paths: Vec<String>) -> Self {
296        self.include_paths = paths;
297        self
298    }
299
300    /// Set exclude paths
301    pub fn with_exclude_paths(mut self, paths: Vec<String>) -> Self {
302        self.exclude_paths = paths;
303        self
304    }
305
306    /// Enable or disable WebSocket and protocol upgrade support
307    pub fn with_websocket_enabled(mut self, enabled: bool) -> Self {
308        self.enable_websocket = enabled;
309        self
310    }
311
312    /// Only allow GET requests, reject all others
313    pub fn with_forward_get_only(mut self, enabled: bool) -> Self {
314        self.forward_get_only = enabled;
315        self
316    }
317
318    /// Set custom cache key function
319    pub fn with_cache_key_fn<F>(mut self, f: F) -> Self
320    where
321        F: Fn(&RequestInfo) -> String + Send + Sync + 'static,
322    {
323        self.cache_key_fn = Arc::new(f);
324        self
325    }
326
327    /// Set 404 cache capacity. When 0, 404 caching is disabled.
328    pub fn with_cache_404_capacity(mut self, capacity: usize) -> Self {
329        self.cache_404_capacity = capacity;
330        self
331    }
332
333    /// Treat pages that include the special meta tag as 404 pages
334    pub fn with_use_404_meta(mut self, enabled: bool) -> Self {
335        self.use_404_meta = enabled;
336        self
337    }
338
339    /// Set the cache strategy used to decide which response types are stored.
340    pub fn with_cache_strategy(mut self, strategy: CacheStrategy) -> Self {
341        self.cache_strategy = strategy;
342        self
343    }
344
345    /// Alias for callers that prefer a more fluent builder name.
346    pub fn caching_strategy(self, strategy: CacheStrategy) -> Self {
347        self.with_cache_strategy(strategy)
348    }
349
350    /// Set the compression strategy used for stored cache entries.
351    pub fn with_compress_strategy(mut self, strategy: CompressStrategy) -> Self {
352        self.compress_strategy = strategy;
353        self
354    }
355
356    /// Alias for callers that prefer a more fluent builder name.
357    pub fn compression_strategy(self, strategy: CompressStrategy) -> Self {
358        self.with_compress_strategy(strategy)
359    }
360
361    /// Set the backing store for cached response bodies.
362    pub fn with_cache_storage_mode(mut self, mode: CacheStorageMode) -> Self {
363        self.cache_storage_mode = mode;
364        self
365    }
366
367    /// Set the filesystem directory used for disk-backed cache bodies.
368    pub fn with_cache_directory(mut self, directory: impl Into<PathBuf>) -> Self {
369        self.cache_directory = Some(directory.into());
370        self
371    }
372
373    /// Set the proxy operating mode.
374    /// Use `ProxyMode::PreGenerate { paths, fallthrough }` to enable SSG mode.
375    pub fn with_proxy_mode(mut self, mode: ProxyMode) -> Self {
376        self.proxy_mode = mode;
377        self
378    }
379
380    /// Set the webhooks for this server.
381    /// Blocking webhooks gate access; notify webhooks are fire-and-forget.
382    pub fn with_webhooks(mut self, webhooks: Vec<WebhookConfig>) -> Self {
383        self.webhooks = webhooks;
384        self
385    }
386}
387
388/// The main library interface for using phantom-frame as a library
389/// Returns a proxy handler function and a cache handle
390pub fn create_proxy(config: CreateProxyConfig) -> (Router, CacheHandle) {
391    // In PreGenerate mode, create a channel for the snapshot worker
392    let (handle, snapshot_rx) = if let ProxyMode::PreGenerate { .. } = &config.proxy_mode {
393        let (tx, rx) = mpsc::channel(32);
394        (CacheHandle::new_with_snapshots(tx), Some(rx))
395    } else {
396        (CacheHandle::new(), None)
397    };
398
399    let cache = CacheStore::with_storage(
400        handle.clone(),
401        config.cache_404_capacity,
402        config.cache_storage_mode.clone(),
403        config.cache_directory.clone(),
404    );
405
406    // Spawn background task to listen for invalidation events
407    spawn_invalidation_listener(cache.clone());
408
409    // Spawn snapshot worker (warm-up + runtime snapshot management) in PreGenerate mode
410    if let (Some(rx), ProxyMode::PreGenerate { paths, .. }) =
411        (snapshot_rx, &config.proxy_mode)
412    {
413        let worker = SnapshotWorker {
414            rx,
415            cache: cache.clone(),
416            proxy_url: config.proxy_url.clone(),
417            compress_strategy: config.compress_strategy.clone(),
418            cache_key_fn: config.cache_key_fn.clone(),
419            snapshots: paths.clone(),
420        };
421        tokio::spawn(worker.run());
422    }
423
424    let proxy_state = Arc::new(ProxyState::new(cache, config));
425
426    let app = Router::new()
427        .fallback(proxy::proxy_handler)
428        .layer(Extension(proxy_state));
429
430    (app, handle)
431}
432
433/// Create a proxy handler with an existing cache handle.
434/// Useful for sharing a single handle across multiple proxy instances so that
435/// invalidation propagates to all caches simultaneously.
436///
437/// Note: snapshot operations (PreGenerate mode warm-up) are not available
438/// through this variant — use [`create_proxy`] for full PreGenerate support.
439pub fn create_proxy_with_handle(config: CreateProxyConfig, handle: CacheHandle) -> Router {
440    let cache = CacheStore::with_storage(
441        handle,
442        config.cache_404_capacity,
443        config.cache_storage_mode.clone(),
444        config.cache_directory.clone(),
445    );
446
447    // Spawn background task to listen for invalidation events
448    spawn_invalidation_listener(cache.clone());
449
450    let proxy_state = Arc::new(ProxyState::new(cache, config));
451
452    Router::new()
453        .fallback(proxy::proxy_handler)
454        .layer(Extension(proxy_state))
455}
456
457/// Spawn a background task to listen for cache invalidation events.
458fn spawn_invalidation_listener(cache: CacheStore) {
459    let mut receiver = cache.handle().subscribe();
460
461    tokio::spawn(async move {
462        loop {
463            match receiver.recv().await {
464                Ok(cache::InvalidationMessage::All) => {
465                    tracing::debug!("Cache invalidation triggered: clearing all entries");
466                    cache.clear().await;
467                }
468                Ok(cache::InvalidationMessage::Pattern(pattern)) => {
469                    tracing::debug!(
470                        "Cache invalidation triggered: clearing entries matching pattern '{}'",
471                        pattern
472                    );
473                    cache.clear_by_pattern(&pattern).await;
474                }
475                Err(e) => {
476                    tracing::error!("Invalidation channel error: {}", e);
477                    break;
478                }
479            }
480        }
481    });
482}
483
484/// Background worker that handles snapshot warm-up and runtime snapshot operations
485/// for `ProxyMode::PreGenerate`.
486struct SnapshotWorker {
487    rx: mpsc::Receiver<cache::SnapshotRequest>,
488    cache: CacheStore,
489    proxy_url: String,
490    compress_strategy: CompressStrategy,
491    cache_key_fn: Arc<dyn Fn(&RequestInfo) -> String + Send + Sync>,
492    /// Current snapshot list — grows/shrinks via add/remove operations.
493    snapshots: Vec<String>,
494}
495
496impl SnapshotWorker {
497    async fn run(mut self) {
498        // Warm-up: pre-generate all initial snapshot paths before handling requests.
499        let initial = self.snapshots.clone();
500        for path in &initial {
501            if let Err(e) = self.fetch_and_store(path).await {
502                tracing::warn!("Failed to pre-generate snapshot '{}': {}", path, e);
503            }
504        }
505
506        // Process runtime snapshot requests.
507        while let Some(req) = self.rx.recv().await {
508            match req.op {
509                cache::SnapshotOp::Add(path) => {
510                    match self.fetch_and_store(&path).await {
511                        Ok(()) => self.snapshots.push(path),
512                        Err(e) => tracing::warn!("add_snapshot '{}' failed: {}", path, e),
513                    }
514                }
515                cache::SnapshotOp::Refresh(path) => {
516                    if let Err(e) = self.fetch_and_store(&path).await {
517                        tracing::warn!("refresh_snapshot '{}' failed: {}", path, e);
518                    }
519                }
520                cache::SnapshotOp::Remove(path) => {
521                    let empty_headers = axum::http::HeaderMap::new();
522                    let req_info = RequestInfo {
523                        method: "GET",
524                        path: &path,
525                        query: "",
526                        headers: &empty_headers,
527                    };
528                    let key = (self.cache_key_fn)(&req_info);
529                    self.cache.clear_by_pattern(&key).await;
530                    self.snapshots.retain(|s| s != &path);
531                }
532                cache::SnapshotOp::RefreshAll => {
533                    let paths: Vec<String> = self.snapshots.clone();
534                    for path in &paths {
535                        if let Err(e) = self.fetch_and_store(path).await {
536                            tracing::warn!("refresh_all_snapshots '{}' failed: {}", path, e);
537                        }
538                    }
539                }
540            }
541            // Signal completion to the caller.
542            let _ = req.done.send(());
543        }
544    }
545
546    async fn fetch_and_store(&self, path: &str) -> anyhow::Result<()> {
547        proxy::fetch_and_cache_snapshot(
548            path,
549            &self.proxy_url,
550            &self.cache,
551            &self.compress_strategy,
552            &self.cache_key_fn,
553        )
554        .await
555    }
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561
562    #[test]
563    fn test_cache_strategy_content_types() {
564        assert!(CacheStrategy::All.allows_content_type(None));
565        assert!(!CacheStrategy::None.allows_content_type(Some("text/html")));
566        assert!(CacheStrategy::OnlyHtml.allows_content_type(Some("text/html; charset=utf-8")));
567        assert!(!CacheStrategy::OnlyHtml.allows_content_type(Some("image/png")));
568        assert!(CacheStrategy::NoImages.allows_content_type(Some("text/css")));
569        assert!(!CacheStrategy::NoImages.allows_content_type(Some("image/webp")));
570        assert!(CacheStrategy::OnlyImages.allows_content_type(Some("image/svg+xml")));
571        assert!(!CacheStrategy::OnlyImages.allows_content_type(Some("application/javascript")));
572        assert!(CacheStrategy::OnlyAssets.allows_content_type(Some("application/javascript")));
573        assert!(CacheStrategy::OnlyAssets.allows_content_type(Some("image/png")));
574        assert!(!CacheStrategy::OnlyAssets.allows_content_type(Some("text/html")));
575        assert!(!CacheStrategy::OnlyAssets.allows_content_type(None));
576    }
577
578    #[test]
579    fn test_compress_strategy_display() {
580        assert_eq!(CompressStrategy::default().to_string(), "brotli");
581        assert_eq!(CompressStrategy::None.to_string(), "none");
582        assert_eq!(CompressStrategy::Gzip.to_string(), "gzip");
583        assert_eq!(CompressStrategy::Deflate.to_string(), "deflate");
584    }
585
586    #[tokio::test]
587    async fn test_create_proxy() {
588        let config = CreateProxyConfig::new("http://localhost:8080".to_string());
589        assert_eq!(config.compress_strategy, CompressStrategy::Brotli);
590        let (_app, handle) = create_proxy(config);
591        handle.invalidate_all();
592        handle.invalidate("GET:/api/*");
593        // Just ensure it compiles and runs without panic
594    }
595}