1use async_compression::tokio::bufread::{BrotliEncoder, DeflateEncoder, GzipEncoder};
11use async_compression::Level;
12use bytes::Bytes;
13use http::header::{CACHE_CONTROL, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, ETAG, VARY};
14use http::Method;
15use sova_core::extend::{named, with_leaked};
16use sova_core::{App, Next, Plugin, Request, Response};
17use std::io::Cursor;
18use std::sync::Arc;
19use tokio::io::{AsyncReadExt, BufReader};
20
21pub type Filter = Arc<dyn Fn(&Request, &Response) -> bool + Send + Sync>;
23
24pub struct Compress {
26 threshold: usize,
27 level: i32,
29 brotli_quality: i32,
31 filter: Filter,
32}
33
34impl Compress {
35 pub fn new() -> Self {
36 Self {
37 threshold: 1024,
38 level: 6,
39 brotli_quality: 4,
40 filter: Arc::new(Self::default_filter),
41 }
42 }
43
44 pub fn threshold(mut self, bytes: usize) -> Self {
46 self.threshold = bytes;
47 self
48 }
49
50 pub fn level(mut self, level: i32) -> Self {
52 self.level = level.clamp(0, 9);
53 self
54 }
55
56 pub fn brotli_quality(mut self, q: i32) -> Self {
58 self.brotli_quality = q.clamp(0, 11);
59 self
60 }
61
62 pub fn filter<F>(mut self, f: F) -> Self
64 where
65 F: Fn(&Request, &Response) -> bool + Send + Sync + 'static,
66 {
67 self.filter = Arc::new(f);
68 self
69 }
70
71 pub fn default_filter(req: &Request, res: &Response) -> bool {
74 if req.header("x-no-compression").is_some() {
75 return false;
76 }
77 let Some(ct) = res
78 .headers()
79 .get(CONTENT_TYPE)
80 .and_then(|v| v.to_str().ok())
81 else {
82 return false;
83 };
84 is_compressible(ct)
85 }
86}
87
88impl Default for Compress {
89 fn default() -> Self {
90 Self::new()
91 }
92}
93
94impl Plugin for Compress {
95 fn id(&self) -> &'static str {
96 "compress"
97 }
98
99 fn meta(&self) -> sova_core::PluginMeta {
100 sova_core::PluginMeta::new("Compress")
101 .description("gzip / deflate / brotli response compression")
102 .version(env!("CARGO_PKG_VERSION"))
103 }
104
105 fn install(self, app: &mut App) {
106 app.use_middleware(named(
107 "compress",
108 with_leaked(self, |cfg, req, next| async move { run(cfg, req, next).await }),
109 ));
110 }
111}
112
113async fn run(cfg: &'static Compress, req: Request, next: Next) -> Response {
114 let accept = req
115 .header("accept-encoding")
116 .unwrap_or("")
117 .to_ascii_lowercase();
118 let is_head = req.method == Method::HEAD;
119 let filter_req = snapshot_request(&req);
120 let mut res = next(req).await;
121
122 if !accept.is_empty() {
124 append_vary(&mut res, "Accept-Encoding");
125 }
126
127 if is_head || res.status_code().as_u16() == 206 {
128 return res;
129 }
130 if !should_transform(&res) {
131 return res;
132 }
133 if res.headers().get(CONTENT_ENCODING).is_some() {
134 return res;
135 }
136 if !(cfg.filter)(&filter_req, &res) {
137 return res;
138 }
139
140 let Some(encoding) = negotiate(&accept) else {
141 return res;
142 };
143
144 let body = res.take_body();
146 let data = match body.collect().await {
147 Ok(b) => b,
148 Err(_) => return res,
149 };
150
151 if data.len() < cfg.threshold {
152 res.set_body(data);
153 return res;
154 }
155
156 match encode(encoding, &data, cfg.level, cfg.brotli_quality).await {
157 Ok(out) => {
158 res.headers_mut().remove(CONTENT_LENGTH);
159 if let Ok(v) = encoding.parse() {
160 res.headers_mut().insert(CONTENT_ENCODING, v);
161 }
162 weaken_etag(&mut res);
163 res.set_body(out);
164 res
165 }
166 Err(_) => {
167 res.set_body(data);
168 res
169 }
170 }
171}
172
173fn snapshot_request(req: &Request) -> Request {
174 let mut b = Request::builder()
175 .method(req.method.clone())
176 .path(req.path.clone());
177 for (name, value) in req.headers.iter() {
178 if let Ok(v) = value.to_str() {
179 b = b.header(name.as_str(), v);
180 }
181 }
182 b.build()
183}
184
185fn should_transform(res: &Response) -> bool {
186 let Some(cc) = res
187 .headers()
188 .get(CACHE_CONTROL)
189 .and_then(|v| v.to_str().ok())
190 else {
191 return true;
192 };
193 !cc.to_ascii_lowercase()
194 .split(',')
195 .any(|d| d.trim() == "no-transform")
196}
197
198fn append_vary(res: &mut Response, token: &str) {
199 let existing = res
200 .headers()
201 .get(VARY)
202 .and_then(|v| v.to_str().ok())
203 .unwrap_or("")
204 .to_string();
205 if existing
206 .split(',')
207 .any(|t| t.trim().eq_ignore_ascii_case(token))
208 {
209 return;
210 }
211 let value = if existing.is_empty() {
212 token.to_string()
213 } else {
214 format!("{existing}, {token}")
215 };
216 if let Ok(v) = value.parse() {
217 res.headers_mut().insert(VARY, v);
218 }
219}
220
221fn is_compressible(content_type: &str) -> bool {
223 let ct = content_type
224 .split(';')
225 .next()
226 .unwrap_or(content_type)
227 .trim()
228 .to_ascii_lowercase();
229 if ct.is_empty() {
230 return false;
231 }
232 if ct.starts_with("text/") || ct == "image/svg+xml" {
233 return true;
234 }
235 if ct.starts_with("image/")
236 || ct.starts_with("audio/")
237 || ct.starts_with("video/")
238 || ct.contains("zip")
239 || ct.contains("gzip")
240 || ct.contains("octet-stream")
241 || ct.contains("wasm")
242 {
243 return false;
244 }
245 matches!(
246 ct.as_str(),
247 "application/json"
248 | "application/javascript"
249 | "application/ecmascript"
250 | "application/xml"
251 | "application/x-www-form-urlencoded"
252 | "application/graphql"
253 | "application/ld+json"
254 | "application/manifest+json"
255 | "application/vnd.api+json"
256 | "application/xhtml+xml"
257 | "application/rss+xml"
258 | "application/atom+xml"
259 | "font/ttf"
260 | "font/otf"
261 ) || ct.ends_with("+json")
262 || ct.ends_with("+xml")
263 || ct.ends_with("+text")
264}
265
266fn weaken_etag(res: &mut Response) {
267 let Some(tag) = res.headers().get(ETAG).and_then(|v| v.to_str().ok()) else {
268 return;
269 };
270 if tag.starts_with("W/") {
271 return;
272 }
273 let weak = format!("W/{tag}");
274 if let Ok(v) = weak.parse() {
275 res.headers_mut().insert(ETAG, v);
276 }
277}
278
279fn negotiate(accept: &str) -> Option<&'static str> {
280 const CANDIDATES: &[&str] = &["br", "gzip", "deflate"];
282 let mut best: Option<(&'static str, f32, usize)> = None;
283 for part in accept.split(',') {
284 let mut it = part.trim().split(';');
285 let coding = it.next().unwrap_or("").trim();
286 let idx = match coding {
287 "br" => 0,
288 "gzip" => 1,
289 "deflate" => 2,
290 _ => continue,
291 };
292 let mut q = 1.0_f32;
293 for param in it {
294 let p = param.trim();
295 if let Some(v) = p.strip_prefix("q=") {
296 q = v.parse().unwrap_or(0.0);
297 }
298 }
299 if q <= 0.0 {
300 continue;
301 }
302 let coding = CANDIDATES[idx];
303 match best {
304 None => best = Some((coding, q, idx)),
305 Some((_, prev_q, prev_idx)) => {
306 if q > prev_q || (q == prev_q && idx < prev_idx) {
307 best = Some((coding, q, idx));
308 }
309 }
310 }
311 }
312 best.map(|(c, _, _)| c)
313}
314
315async fn encode(encoding: &str, data: &Bytes, level: i32, brotli_q: i32) -> std::io::Result<Bytes> {
316 let reader = BufReader::new(Cursor::new(data.clone()));
317 let mut out = Vec::new();
318 match encoding {
319 "br" => {
320 let mut enc = BrotliEncoder::with_quality(reader, Level::Precise(brotli_q));
321 enc.read_to_end(&mut out).await?;
322 }
323 "deflate" => {
324 let mut enc = DeflateEncoder::with_quality(reader, Level::Precise(level));
325 enc.read_to_end(&mut out).await?;
326 }
327 _ => {
328 let mut enc = GzipEncoder::with_quality(reader, Level::Precise(level));
329 enc.read_to_end(&mut out).await?;
330 }
331 }
332 Ok(Bytes::from(out))
333}