1#![doc = include_str!("../README.md")]
2
3use std::{convert::Infallible, future};
4
5use axum::{
6 Router,
7 extract::FromRequestParts,
8 http::{
9 StatusCode,
10 header::{
11 ACCEPT_ENCODING, ACCEPT_RANGES, CACHE_CONTROL, CONTENT_ENCODING, CONTENT_TYPE, ETAG,
12 HeaderValue, IF_NONE_MATCH, VARY,
13 },
14 request::Parts,
15 },
16 response::IntoResponse,
17 routing::{MethodRouter, get},
18};
19use bytes::Bytes;
20use range_requests::{
21 headers::{if_range::IfRange, range::HttpRange},
22 serve_file_with_http_range,
23};
24
25pub use static_serve_macro::{embed_asset, embed_assets};
26
27#[derive(Debug, Copy, Clone)]
29struct AcceptEncoding {
30 pub gzip: bool,
32 pub zstd: bool,
34}
35
36impl<S> FromRequestParts<S> for AcceptEncoding
37where
38 S: Send + Sync,
39{
40 type Rejection = Infallible;
41
42 fn from_request_parts(
43 parts: &mut Parts,
44 _state: &S,
45 ) -> impl Future<Output = Result<Self, Self::Rejection>> {
46 let accept_encoding = parts.headers.get(ACCEPT_ENCODING);
47 let accept_encoding = accept_encoding
48 .and_then(|accept_encoding| accept_encoding.to_str().ok())
49 .unwrap_or_default();
50
51 future::ready(Ok(Self {
52 gzip: accept_encoding.contains("gzip"),
53 zstd: accept_encoding.contains("zstd"),
54 }))
55 }
56}
57
58#[derive(Debug)]
60struct IfNoneMatch(Option<HeaderValue>);
61
62impl IfNoneMatch {
63 fn matches(&self, etag: &str) -> bool {
65 self.0
66 .as_ref()
67 .is_some_and(|if_none_match| if_none_match.as_bytes() == etag.as_bytes())
68 }
69}
70
71impl<S> FromRequestParts<S> for IfNoneMatch
72where
73 S: Send + Sync,
74{
75 type Rejection = Infallible;
76
77 fn from_request_parts(
78 parts: &mut Parts,
79 _state: &S,
80 ) -> impl Future<Output = Result<Self, Self::Rejection>> {
81 let if_none_match = parts.headers.get(IF_NONE_MATCH).cloned();
82 future::ready(Ok(Self(if_none_match)))
83 }
84}
85
86#[doc(hidden)]
87#[expect(clippy::too_many_arguments)]
88pub fn static_route<S>(
90 router: Router<S>,
91 web_path: &'static str,
92 content_type: &'static str,
93 etag: &'static str,
94 body: &'static [u8],
95 body_gz: Option<&'static [u8]>,
96 body_zst: Option<&'static [u8]>,
97 cache_busted: bool,
98) -> Router<S>
99where
100 S: Clone + Send + Sync + 'static,
101{
102 router.route(
103 web_path,
104 get(
105 move |accept_encoding: AcceptEncoding,
106 if_none_match: IfNoneMatch,
107 http_range: Option<HttpRange>,
108 if_range: Option<IfRange>| async move {
109 static_inner(StaticInnerData {
110 content_type,
111 etag,
112 body,
113 body_gz,
114 body_zst,
115 cache_busted,
116 accept_encoding,
117 if_none_match,
118 http_range,
119 if_range,
120 })
121 },
122 ),
123 )
124}
125
126#[doc(hidden)]
127pub fn static_method_router<S>(
131 content_type: &'static str,
132 etag: &'static str,
133 body: &'static [u8],
134 body_gz: Option<&'static [u8]>,
135 body_zst: Option<&'static [u8]>,
136 cache_busted: bool,
137) -> MethodRouter<S>
138where
139 S: Clone + Send + Sync + 'static,
140{
141 MethodRouter::get(
142 MethodRouter::new(),
143 move |accept_encoding: AcceptEncoding,
144 if_none_match: IfNoneMatch,
145 http_range: Option<HttpRange>,
146 if_range: Option<IfRange>| async move {
147 static_inner(StaticInnerData {
148 content_type,
149 etag,
150 body,
151 body_gz,
152 body_zst,
153 cache_busted,
154 accept_encoding,
155 if_none_match,
156 http_range,
157 if_range,
158 })
159 },
160 )
161}
162
163struct StaticInnerData {
169 content_type: &'static str,
170 etag: &'static str,
171 body: &'static [u8],
172 body_gz: Option<&'static [u8]>,
173 body_zst: Option<&'static [u8]>,
174 cache_busted: bool,
175 accept_encoding: AcceptEncoding,
176 if_none_match: IfNoneMatch,
177 http_range: Option<HttpRange>,
178 if_range: Option<IfRange>,
179}
180
181fn static_inner(static_inner_data: StaticInnerData) -> impl IntoResponse {
182 let StaticInnerData {
183 content_type,
184 etag,
185 body,
186 body_gz,
187 body_zst,
188 cache_busted,
189 accept_encoding,
190 if_none_match,
191 http_range,
192 if_range,
193 } = static_inner_data;
194
195 let optional_cache_control = if cache_busted {
196 Some([(
197 CACHE_CONTROL,
198 HeaderValue::from_static("public, max-age=31536000, immutable"),
199 )])
200 } else {
201 None
202 };
203
204 let resp_base = (
205 [
206 (CONTENT_TYPE, HeaderValue::from_static(content_type)),
207 (ETAG, HeaderValue::from_static(etag)),
208 (VARY, HeaderValue::from_static("Accept-Encoding")),
209 ],
210 optional_cache_control,
211 );
212
213 if if_none_match.matches(etag) {
214 return (resp_base, StatusCode::NOT_MODIFIED).into_response();
215 }
216
217 let resp_base = (
218 [(ACCEPT_RANGES, HeaderValue::from_static("bytes"))],
219 resp_base,
220 );
221
222 let http_range = match (http_range, if_range) {
223 (Some(range), Some(if_range)) => {
224 let etag_value = HeaderValue::from_static(etag);
225 if_range.evaluate(range, None, Some(&etag_value))
226 }
227 (range, _) => range,
228 };
229
230 let (selected_body, optional_content_encoding) = match (
231 (accept_encoding.gzip, body_gz),
232 (accept_encoding.zstd, body_zst),
233 &http_range,
234 ) {
235 (_, (true, Some(body_zst)), None) => (
236 Bytes::from_static(body_zst),
237 Some([(CONTENT_ENCODING, HeaderValue::from_static("zstd"))]),
238 ),
239 ((true, Some(body_gz)), _, None) => (
240 Bytes::from_static(body_gz),
241 Some([(CONTENT_ENCODING, HeaderValue::from_static("gzip"))]),
242 ),
243 _ => (Bytes::from_static(body), None),
244 };
245
246 match serve_file_with_http_range(selected_body, http_range) {
247 Ok(body_range) => (resp_base, optional_content_encoding, body_range).into_response(),
248 Err(unsatisfiable) => (resp_base, unsatisfiable).into_response(),
249 }
250}