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#[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
57pub 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#[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
81fn format_etag(hash: &[u8; 32]) -> String {
83 let hex: String = hash[..16].iter().map(|b| format!("{:02x}", b)).collect();
84 format!("\"{}\"", hex)
85}
86
87pub fn accepts_gzip(accept_encoding: &str) -> bool {
89 accept_encoding.contains("gzip")
90}
91
92pub 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
113pub 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 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 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); }
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}