Skip to main content

anycms_spa/core/
mod.rs

1pub mod path;
2
3use rust_embed::RustEmbed;
4use std::borrow::Cow;
5use thiserror::Error;
6
7#[cfg(feature = "gzip")]
8use std::collections::HashMap;
9
10#[derive(Debug, Error)]
11#[non_exhaustive]
12pub enum SpaError {
13    #[error("Resource not found: {0}")]
14    NotFound(String),
15    #[error("MIME type detection failed")]
16    MimeDetection,
17    #[error("Path error: {0}")]
18    PathError(#[from] crate::core::path::PathError),
19    #[error("Index file not found")]
20    IndexFileNotFound,
21}
22
23/// SPA 配置
24#[derive(Clone)]
25#[non_exhaustive]
26pub struct SpaConfig {
27    pub base_path: String,
28    pub index_files: Vec<String>,
29}
30
31impl Default for SpaConfig {
32    fn default() -> Self {
33        SpaConfig {
34            base_path: "/".to_string(),
35            index_files: vec!["index.html".to_string()],
36        }
37    }
38}
39
40impl SpaConfig {
41    pub fn with_base_path(mut self, base_path: &str) -> Self {
42        self.base_path = base_path.to_string();
43        self
44    }
45
46    pub fn with_index_files(mut self, files: &[&str]) -> Self {
47        self.index_files = files.iter().map(|s| s.to_string()).collect();
48        self
49    }
50
51    pub fn add_index_file(mut self, file: &str) -> Self {
52        self.index_files.push(file.to_string());
53        self
54    }
55}
56
57/// SPA 响应数据
58pub struct SpaResponse {
59    pub data: Cow<'static, [u8]>,
60    pub mime: &'static str,
61    pub etag: String,
62    pub is_html: bool,
63    #[cfg(feature = "gzip")]
64    pub gzip_data: Option<Vec<u8>>,
65}
66
67/// 判断 MIME 类型是否值得压缩
68#[cfg(feature = "gzip")]
69fn is_compressible(mime: &str) -> bool {
70    const COMPRESSIBLE: &[&str] = &[
71        "text/",
72        "application/javascript",
73        "application/json",
74        "application/xml",
75        "application/wasm",
76        "image/svg+xml",
77    ];
78    COMPRESSIBLE.iter().any(|prefix| mime.starts_with(prefix))
79}
80
81/// 格式化 ETag:取 SHA256 前 16 字节 hex 编码,加双引号
82fn format_etag(hash: &[u8; 32]) -> String {
83    let hex: String = hash[..16].iter().map(|b| format!("{:02x}", b)).collect();
84    format!("\"{}\"", hex)
85}
86
87/// 检查 Accept-Encoding 是否包含 gzip
88pub fn accepts_gzip(accept_encoding: &str) -> bool {
89    accept_encoding.contains("gzip")
90}
91
92/// 检查 If-None-Match 是否匹配当前 ETag
93pub fn etag_matches(if_none_match: &str, etag: &str) -> bool {
94    if if_none_match.trim() == "*" {
95        return true;
96    }
97    if_none_match
98        .split(',')
99        .any(|tag| tag.trim() == etag)
100}
101
102#[cfg(feature = "gzip")]
103fn gzip_compress(data: &[u8]) -> Vec<u8> {
104    use flate2::write::GzEncoder;
105    use flate2::Compression;
106    use std::io::Write;
107
108    let mut encoder = GzEncoder::new(Vec::with_capacity(data.len() / 2), Compression::fast());
109    encoder.write_all(data).expect("gzip compression failed");
110    encoder.finish().expect("gzip finalization failed")
111}
112
113/// SPA 处理器
114pub struct SpaHandler<E: RustEmbed> {
115    config: SpaConfig,
116    #[cfg(feature = "gzip")]
117    compression_cache: HashMap<String, Vec<u8>>,
118    _marker: std::marker::PhantomData<E>,
119}
120
121impl<E: RustEmbed> SpaHandler<E> {
122    pub fn new(config: SpaConfig) -> Self {
123        #[cfg(feature = "gzip")]
124        let compression_cache = {
125            let mut cache = HashMap::new();
126            for path in E::iter() {
127                let path_str = path.as_ref();
128                if let Some(file) = E::get(path_str) {
129                    let mime = mime_guess::from_path(path_str)
130                        .first_raw()
131                        .unwrap_or("");
132                    if is_compressible(mime) {
133                        let compressed = gzip_compress(&file.data);
134                        if compressed.len() < file.data.len() {
135                            cache.insert(path_str.to_string(), compressed);
136                        }
137                    }
138                }
139            }
140            cache
141        };
142
143        Self {
144            config,
145            #[cfg(feature = "gzip")]
146            compression_cache,
147            _marker: std::marker::PhantomData,
148        }
149    }
150
151    /// 获取嵌入的文件(考虑基础路径)
152    pub fn get_file(&self, request_path: &str) -> Result<SpaResponse, SpaError> {
153        let clean_path = crate::core::path::collapse_slashes(request_path);
154        let normalized_path = crate::core::path::normalize_path(&clean_path)?;
155        let resource_path = crate::core::path::relative_to_base(&normalized_path, &self.config.base_path);
156
157        if let Some(content) = E::get(&resource_path) {
158            let mime = mime_guess::from_path(&resource_path)
159                .first_raw()
160                .ok_or(SpaError::MimeDetection)?;
161            let is_html = mime.starts_with("text/html");
162            let etag = format_etag(&content.metadata.sha256_hash());
163
164            #[cfg(feature = "gzip")]
165            let gzip_data = self.compression_cache.get(&resource_path).cloned();
166
167            return Ok(SpaResponse {
168                data: content.data,
169                mime,
170                etag,
171                is_html,
172                #[cfg(feature = "gzip")]
173                gzip_data,
174            });
175        }
176
177        // SPA fallback:尝试索引文件
178        for index_file in &self.config.index_files {
179            if let Some(content) = E::get(index_file) {
180                let etag = format_etag(&content.metadata.sha256_hash());
181
182                #[cfg(feature = "gzip")]
183                let gzip_data = self.compression_cache.get(index_file).cloned();
184
185                return Ok(SpaResponse {
186                    data: content.data,
187                    mime: "text/html",
188                    etag,
189                    is_html: true,
190                    #[cfg(feature = "gzip")]
191                    gzip_data,
192                });
193            }
194        }
195
196        Err(SpaError::IndexFileNotFound)
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[cfg(feature = "gzip")]
205    #[test]
206    fn test_is_compressible() {
207        assert!(is_compressible("text/html"));
208        assert!(is_compressible("text/css"));
209        assert!(is_compressible("application/javascript"));
210        assert!(is_compressible("application/json"));
211        assert!(is_compressible("image/svg+xml"));
212        assert!(!is_compressible("image/png"));
213        assert!(!is_compressible("image/jpeg"));
214        assert!(!is_compressible("font/woff2"));
215    }
216
217    #[test]
218    fn test_format_etag() {
219        let hash = [0xab; 32];
220        let etag = format_etag(&hash);
221        assert!(etag.starts_with('"'));
222        assert!(etag.ends_with('"'));
223        assert_eq!(etag.len(), 34); // 16 hex chars + 2 quotes
224    }
225
226    #[test]
227    fn test_etag_matches() {
228        let etag = "\"abc123\"";
229        assert!(etag_matches("\"abc123\"", etag));
230        assert!(etag_matches("\"abc123\", \"def456\"", etag));
231        assert!(etag_matches("*", etag));
232        assert!(!etag_matches("\"def456\"", etag));
233    }
234
235    #[test]
236    fn test_accepts_gzip() {
237        assert!(accepts_gzip("gzip"));
238        assert!(accepts_gzip("gzip, deflate, br"));
239        assert!(accepts_gzip("deflate, gzip"));
240        assert!(!accepts_gzip("deflate, br"));
241        assert!(!accepts_gzip(""));
242    }
243}