Skip to main content

memory_serve/
asset.rs

1use axum::{
2    http::{
3        HeaderMap, HeaderName, HeaderValue, StatusCode,
4        header::{CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, ETAG, IF_NONE_MATCH, VARY},
5    },
6    response::{IntoResponse, Response},
7};
8use fs_err as fs;
9use tracing::debug;
10
11use crate::{
12    options::ServeOptions,
13    util::{
14        compression::{compress_brotli, compress_gzip, decompress_brotli},
15        headers::{encoding_qvalue, if_none_match_matches},
16    },
17};
18
19const BROTLI_ENCODING: &str = "br";
20
21const BROTLI_HEADER: (HeaderName, HeaderValue) =
22    (CONTENT_ENCODING, HeaderValue::from_static(BROTLI_ENCODING));
23
24const GZIP_ENCODING: &str = "gzip";
25
26const GZIP_HEADER: (HeaderName, HeaderValue) =
27    (CONTENT_ENCODING, HeaderValue::from_static(GZIP_ENCODING));
28
29const VARY_HEADER: (HeaderName, HeaderValue) = (VARY, HeaderValue::from_static("accept-encoding"));
30
31/// Preferred compression for a dynamically served asset.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33enum OnDemandEncoding {
34    /// Send the original bytes without further compression.
35    Identity,
36    /// Compress the response using brotli.
37    Brotli,
38    /// Compress the response using gzip.
39    Gzip,
40}
41
42/// Represents a static asset that can be served
43#[derive(Debug)]
44pub struct Asset {
45    /// The HTTP route used to serve the asset, e.g. `/index.html`.
46    pub route: &'static str,
47    /// Absolute filesystem path pointing to the source asset on disk.
48    pub path: &'static str,
49    /// Strong validator (SHA-256) used for HTTP caching semantics.
50    pub etag: &'static str,
51    /// MIME type advertised for the asset.
52    pub content_type: &'static str,
53    /// Optional embedded bytes for the asset; `None` when dynamic loading is used.
54    pub bytes: Option<&'static [u8]>,
55    /// Indicates if the embedded bytes are already brotli compressed.
56    pub is_compressed: bool,
57    /// Whether the asset should be compressed before sending to clients.
58    pub should_compress: bool,
59}
60
61/// Aggregates response metadata and payloads for an asset request.
62struct AssetResponse<'t, B> {
63    options: &'t ServeOptions,
64    headers: &'t HeaderMap,
65    status: StatusCode,
66    asset: &'t Asset,
67    etag: &'t str,
68    bytes: B,
69    bytes_len: usize,
70    brotli_bytes: B,
71    brotli_bytes_len: usize,
72    gzip_bytes: B,
73    gzip_bytes_len: usize,
74}
75
76impl<B: IntoResponse> AssetResponse<'_, B> {
77    /// Construct an Axum `Response` from the gathered asset data.
78    fn into_response(self) -> Response {
79        // Strong validator per RFC 7232: wrap the SHA-256 hex digest in quotes.
80        let quoted_etag = format!("\"{}\"", self.etag);
81        let varies_by_encoding =
82            self.asset.should_compress && (self.options.enable_brotli || self.options.enable_gzip);
83
84        let mut headers = HeaderMap::new();
85        headers.extend([
86            self.asset.content_type(),
87            self.asset.cache_control(self.options),
88            (ETAG, HeaderValue::from_str(&quoted_etag).unwrap()),
89        ]);
90        if varies_by_encoding {
91            headers.extend([VARY_HEADER]);
92        }
93
94        if let Some(if_none_match) = self.headers.get(IF_NONE_MATCH)
95            && let Ok(if_none_match) = if_none_match.to_str()
96            && if_none_match_matches(if_none_match, &quoted_etag)
97        {
98            return (StatusCode::NOT_MODIFIED, headers).into_response();
99        }
100
101        // Offer brotli and/or gzip only when enabled and the compressed payload
102        // is actually available, then honor the client's preference (q-value).
103        let brotli_q = (self.options.enable_brotli && self.brotli_bytes_len > 0)
104            .then(|| encoding_qvalue(self.headers, BROTLI_ENCODING))
105            .flatten();
106        let gzip_q = (self.options.enable_gzip && self.gzip_bytes_len > 0)
107            .then(|| encoding_qvalue(self.headers, GZIP_ENCODING))
108            .flatten();
109
110        // Ties favour brotli, which generally compresses better.
111        let prefer_brotli = match (brotli_q, gzip_q) {
112            (Some(br), Some(gz)) => br >= gz,
113            (Some(_), None) => true,
114            _ => false,
115        };
116
117        if prefer_brotli {
118            headers.extend([
119                (CONTENT_LENGTH, HeaderValue::from(self.brotli_bytes_len)),
120                BROTLI_HEADER,
121            ]);
122            return (self.status, headers, self.brotli_bytes).into_response();
123        }
124
125        if gzip_q.is_some() {
126            headers.extend([
127                (CONTENT_LENGTH, HeaderValue::from(self.gzip_bytes_len)),
128                GZIP_HEADER,
129            ]);
130            return (self.status, headers, self.gzip_bytes).into_response();
131        }
132
133        headers.extend([(CONTENT_LENGTH, HeaderValue::from(self.bytes_len))]);
134        (self.status, headers, self.bytes).into_response()
135    }
136}
137
138impl Asset {
139    /// Pick the cache policy for the asset based on its MIME type.
140    fn cache_control(&self, options: &ServeOptions) -> (HeaderName, HeaderValue) {
141        match self.content_type {
142            "text/html" => options.html_cache_control.as_header(),
143            _ => options.cache_control.as_header(),
144        }
145    }
146
147    /// Produce the Content-Type header tuple for the asset.
148    fn content_type(&self) -> (HeaderName, HeaderValue) {
149        (CONTENT_TYPE, HeaderValue::from_static(self.content_type))
150    }
151
152    /// Get the bytes for the asset, which is possibly compressed in the binary
153    pub(crate) fn leak_bytes(
154        &self,
155        options: &'static ServeOptions,
156    ) -> (&'static [u8], &'static [u8], &'static [u8]) {
157        let mut uncompressed = self.bytes.unwrap_or_default();
158
159        if self.is_compressed {
160            uncompressed = Box::new(decompress_brotli(uncompressed).unwrap_or_default()).leak()
161        }
162
163        let gzip_bytes = if self.should_compress && options.enable_gzip {
164            Box::new(compress_gzip(uncompressed).unwrap_or_default()).leak()
165        } else {
166            Default::default()
167        };
168
169        let brotli_bytes = if self.should_compress && options.enable_brotli {
170            if self.is_compressed {
171                // The embedded bytes are already brotli compressed.
172                self.bytes.unwrap_or_default()
173            } else {
174                // The embedded bytes are stored uncompressed (e.g. `force-embed`
175                // in debug builds), so compress them on the fly.
176                Box::new(compress_brotli(uncompressed).unwrap_or_default()).leak()
177            }
178        } else {
179            Default::default()
180        };
181
182        (uncompressed, brotli_bytes, gzip_bytes)
183    }
184
185    /// Load the asset bytes from disk, returning a `404` if the file is missing.
186    fn read_source_bytes(&self) -> Result<Vec<u8>, StatusCode> {
187        fs::read(self.path).map_err(|_| StatusCode::NOT_FOUND)
188    }
189
190    /// Decide which compression algorithm (if any) to use for a dynamic request.
191    fn negotiate_dynamic_encoding(
192        &self,
193        headers: &HeaderMap,
194        options: &ServeOptions,
195    ) -> OnDemandEncoding {
196        if !self.should_compress {
197            return OnDemandEncoding::Identity;
198        }
199
200        let brotli_q = options
201            .enable_brotli
202            .then(|| encoding_qvalue(headers, BROTLI_ENCODING))
203            .flatten();
204        let gzip_q = options
205            .enable_gzip
206            .then(|| encoding_qvalue(headers, GZIP_ENCODING))
207            .flatten();
208
209        // Ties favour brotli, which generally compresses better.
210        match (brotli_q, gzip_q) {
211            (Some(br), Some(gz)) if br >= gz => OnDemandEncoding::Brotli,
212            (Some(_), None) => OnDemandEncoding::Brotli,
213            (_, Some(_)) => OnDemandEncoding::Gzip,
214            _ => OnDemandEncoding::Identity,
215        }
216    }
217
218    /// Compress the provided bytes according to the negotiated encoding.
219    fn encode_dynamic_bytes(&self, bytes: &[u8], encoding: OnDemandEncoding) -> (Vec<u8>, Vec<u8>) {
220        match encoding {
221            OnDemandEncoding::Brotli => (compress_brotli(bytes).unwrap_or_default(), Vec::new()),
222            OnDemandEncoding::Gzip => (Vec::new(), compress_gzip(bytes).unwrap_or_default()),
223            OnDemandEncoding::Identity => (Vec::new(), Vec::new()),
224        }
225    }
226
227    /// Load an asset from disk and emit a response tailored to client encodings.
228    fn dynamic_handler(
229        &self,
230        headers: &HeaderMap,
231        status: StatusCode,
232        options: &ServeOptions,
233    ) -> Response {
234        let bytes = match self.read_source_bytes() {
235            Ok(bytes) => bytes,
236            Err(status) => return status.into_response(),
237        };
238
239        let encoding = self.negotiate_dynamic_encoding(headers, options);
240        let (brotli_bytes, gzip_bytes) = self.encode_dynamic_bytes(&bytes, encoding);
241
242        let etag = sha256::digest(&bytes);
243
244        AssetResponse {
245            options,
246            headers,
247            status,
248            asset: self,
249            etag: &etag,
250            bytes_len: bytes.len(),
251            bytes,
252            brotli_bytes_len: brotli_bytes.len(),
253            brotli_bytes,
254            gzip_bytes_len: gzip_bytes.len(),
255            gzip_bytes,
256        }
257        .into_response()
258    }
259
260    /// Serve an asset using either embedded bytes or on-demand loading.
261    pub(super) fn handler(
262        &self,
263        headers: &HeaderMap,
264        status: StatusCode,
265        bytes: &'static [u8],
266        brotli_bytes: &'static [u8],
267        gzip_bytes: &'static [u8],
268        options: &ServeOptions,
269    ) -> Response {
270        if bytes.is_empty() {
271            debug!("using dynamic handler for {}", self.path);
272
273            return self.dynamic_handler(headers, status, options);
274        }
275
276        AssetResponse {
277            options,
278            headers,
279            status,
280            asset: self,
281            etag: self.etag,
282            bytes_len: bytes.len(),
283            bytes,
284            brotli_bytes_len: brotli_bytes.len(),
285            brotli_bytes,
286            gzip_bytes_len: gzip_bytes.len(),
287            gzip_bytes,
288        }
289        .into_response()
290    }
291}