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#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum CacheStrategy {
22 #[default]
24 All,
25 None,
27 OnlyHtml,
29 NoImages,
31 OnlyImages,
33 OnlyAssets,
35}
36
37impl CacheStrategy {
38 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#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum CompressStrategy {
92 None,
94 #[default]
96 Brotli,
97 Gzip,
99 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#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(rename_all = "snake_case")]
119pub enum CacheStorageMode {
120 #[default]
122 Memory,
123 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#[derive(Clone, Debug)]
140pub struct RequestInfo<'a> {
141 pub method: &'a str,
143 pub path: &'a str,
145 pub query: &'a str,
147 pub headers: &'a axum::http::HeaderMap,
149}
150
151#[derive(Clone)]
153pub struct CreateProxyConfig {
154 pub proxy_url: String,
156
157 pub include_paths: Vec<String>,
160
161 pub exclude_paths: Vec<String>,
165
166 pub enable_websocket: bool,
170
171 pub forward_get_only: bool,
175
176 pub cache_key_fn: Arc<dyn Fn(&RequestInfo) -> String + Send + Sync>,
180 pub cache_404_capacity: usize,
182
183 pub use_404_meta: bool,
186
187 pub cache_strategy: CacheStrategy,
189
190 pub compress_strategy: CompressStrategy,
192
193 pub cache_storage_mode: CacheStorageMode,
195
196 pub cache_directory: Option<PathBuf>,
198}
199
200impl CreateProxyConfig {
201 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 pub fn with_include_paths(mut self, paths: Vec<String>) -> Self {
227 self.include_paths = paths;
228 self
229 }
230
231 pub fn with_exclude_paths(mut self, paths: Vec<String>) -> Self {
233 self.exclude_paths = paths;
234 self
235 }
236
237 pub fn with_websocket_enabled(mut self, enabled: bool) -> Self {
239 self.enable_websocket = enabled;
240 self
241 }
242
243 pub fn with_forward_get_only(mut self, enabled: bool) -> Self {
245 self.forward_get_only = enabled;
246 self
247 }
248
249 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 pub fn with_cache_404_capacity(mut self, capacity: usize) -> Self {
260 self.cache_404_capacity = capacity;
261 self
262 }
263
264 pub fn with_use_404_meta(mut self, enabled: bool) -> Self {
266 self.use_404_meta = enabled;
267 self
268 }
269
270 pub fn with_cache_strategy(mut self, strategy: CacheStrategy) -> Self {
272 self.cache_strategy = strategy;
273 self
274 }
275
276 pub fn caching_strategy(self, strategy: CacheStrategy) -> Self {
278 self.with_cache_strategy(strategy)
279 }
280
281 pub fn with_compress_strategy(mut self, strategy: CompressStrategy) -> Self {
283 self.compress_strategy = strategy;
284 self
285 }
286
287 pub fn compression_strategy(self, strategy: CompressStrategy) -> Self {
289 self.with_compress_strategy(strategy)
290 }
291
292 pub fn with_cache_storage_mode(mut self, mode: CacheStorageMode) -> Self {
294 self.cache_storage_mode = mode;
295 self
296 }
297
298 pub fn with_cache_directory(mut self, directory: impl Into<PathBuf>) -> Self {
300 self.cache_directory = Some(directory.into());
301 self
302 }
303}
304
305pub 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_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
328pub 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_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
350fn 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 }
414}