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    /// Whether the asset is an HTML document.
153    pub(crate) fn is_html(&self) -> bool {
154        self.content_type == "text/html"
155    }
156
157    /// SHA-256 hex digest of the uncompressed contents; dynamically served
158    /// assets are read from disk. `None` if the file can not be read.
159    pub(crate) fn content_hash(&self) -> Option<String> {
160        if !self.etag.is_empty() {
161            return Some(self.etag.to_string());
162        }
163
164        self.read_source_bytes()
165            .ok()
166            .map(|bytes| sha256::digest(&bytes))
167    }
168
169    /// Get the bytes for the asset, which is possibly compressed in the binary
170    pub(crate) fn leak_bytes(
171        &self,
172        options: &'static ServeOptions,
173    ) -> (&'static [u8], &'static [u8], &'static [u8]) {
174        let mut uncompressed = self.bytes.unwrap_or_default();
175
176        if self.is_compressed {
177            uncompressed = Box::new(decompress_brotli(uncompressed).unwrap_or_default()).leak()
178        }
179
180        let gzip_bytes = if self.should_compress && options.enable_gzip && !uncompressed.is_empty()
181        {
182            Box::new(compress_gzip(uncompressed).unwrap_or_default()).leak()
183        } else {
184            Default::default()
185        };
186
187        let brotli_bytes = if self.should_compress && options.enable_brotli {
188            if self.is_compressed {
189                // The embedded bytes are already brotli compressed.
190                self.bytes.unwrap_or_default()
191            } else if uncompressed.is_empty() {
192                Default::default()
193            } else {
194                // The embedded bytes are stored uncompressed (e.g. `force-embed`
195                // in debug builds), so compress them on the fly.
196                Box::new(compress_brotli(uncompressed).unwrap_or_default()).leak()
197            }
198        } else {
199            Default::default()
200        };
201
202        (uncompressed, brotli_bytes, gzip_bytes)
203    }
204
205    /// Load the asset bytes from disk, returning a `404` if the file is missing.
206    fn read_source_bytes(&self) -> Result<Vec<u8>, StatusCode> {
207        fs::read(self.path).map_err(|_| StatusCode::NOT_FOUND)
208    }
209
210    /// Decide which compression algorithm (if any) to use for a dynamic request.
211    fn negotiate_dynamic_encoding(
212        &self,
213        headers: &HeaderMap,
214        options: &ServeOptions,
215    ) -> OnDemandEncoding {
216        if !self.should_compress {
217            return OnDemandEncoding::Identity;
218        }
219
220        let brotli_q = options
221            .enable_brotli
222            .then(|| encoding_qvalue(headers, BROTLI_ENCODING))
223            .flatten();
224        let gzip_q = options
225            .enable_gzip
226            .then(|| encoding_qvalue(headers, GZIP_ENCODING))
227            .flatten();
228
229        // Ties favour brotli, which generally compresses better.
230        match (brotli_q, gzip_q) {
231            (Some(br), Some(gz)) if br >= gz => OnDemandEncoding::Brotli,
232            (Some(_), None) => OnDemandEncoding::Brotli,
233            (_, Some(_)) => OnDemandEncoding::Gzip,
234            _ => OnDemandEncoding::Identity,
235        }
236    }
237
238    /// Compress the provided bytes according to the negotiated encoding.
239    fn encode_dynamic_bytes(&self, bytes: &[u8], encoding: OnDemandEncoding) -> (Vec<u8>, Vec<u8>) {
240        match encoding {
241            OnDemandEncoding::Brotli => (compress_brotli(bytes).unwrap_or_default(), Vec::new()),
242            OnDemandEncoding::Gzip => (Vec::new(), compress_gzip(bytes).unwrap_or_default()),
243            OnDemandEncoding::Identity => (Vec::new(), Vec::new()),
244        }
245    }
246
247    /// Load an asset from disk and emit a response tailored to client encodings.
248    fn dynamic_handler(
249        &self,
250        headers: &HeaderMap,
251        status: StatusCode,
252        options: &ServeOptions,
253    ) -> Response {
254        let bytes = match self.read_source_bytes() {
255            Ok(bytes) => bytes,
256            Err(status) => return status.into_response(),
257        };
258
259        let encoding = self.negotiate_dynamic_encoding(headers, options);
260        let (brotli_bytes, gzip_bytes) = self.encode_dynamic_bytes(&bytes, encoding);
261
262        let etag = sha256::digest(&bytes);
263
264        AssetResponse {
265            options,
266            headers,
267            status,
268            asset: self,
269            etag: &etag,
270            bytes_len: bytes.len(),
271            bytes,
272            brotli_bytes_len: brotli_bytes.len(),
273            brotli_bytes,
274            gzip_bytes_len: gzip_bytes.len(),
275            gzip_bytes,
276        }
277        .into_response()
278    }
279
280    /// Serve an asset using either embedded bytes or on-demand loading.
281    pub(super) fn handler(
282        &self,
283        headers: &HeaderMap,
284        status: StatusCode,
285        bytes: &'static [u8],
286        brotli_bytes: &'static [u8],
287        gzip_bytes: &'static [u8],
288        options: &ServeOptions,
289    ) -> Response {
290        if bytes.is_empty() {
291            debug!("using dynamic handler for {}", self.path);
292
293            return self.dynamic_handler(headers, status, options);
294        }
295
296        AssetResponse {
297            options,
298            headers,
299            status,
300            asset: self,
301            etag: self.etag,
302            bytes_len: bytes.len(),
303            bytes,
304            brotli_bytes_len: brotli_bytes.len(),
305            brotli_bytes,
306            gzip_bytes_len: gzip_bytes.len(),
307            gzip_bytes,
308        }
309        .into_response()
310    }
311}