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