1#![allow(clippy::needless_doctest_main)]
2#![doc = include_str!("../README.md")]
3use axum::{
4 http::{HeaderMap, StatusCode},
5 routing::get,
6};
7use std::future::ready;
8use tracing::{info, warn};
9
10mod asset;
11mod build;
12mod cache_control;
13mod load;
14mod manifest;
15mod options;
16mod util;
17
18use crate::util::route::hashed_route;
19pub use crate::{
20 asset::Asset,
21 build::{assets_to_code, load_directory, load_directory_with_embed, load_names_directories},
22 cache_control::CacheControl,
23 manifest::Manifest,
24};
25
26#[derive(Debug, Default)]
29pub struct MemoryServe {
30 options: options::ServeOptions,
31 assets: &'static [Asset],
32 aliases: Vec<(&'static str, &'static str)>,
33}
34
35impl MemoryServe {
36 pub fn new(assets: &'static [Asset]) -> Self {
40 Self {
41 assets,
42 ..Default::default()
43 }
44 }
45
46 pub fn index_file(mut self, index_file: Option<&'static str>) -> Self {
52 self.options.index_file = index_file;
53
54 self
55 }
56
57 pub fn index_on_subdirectories(mut self, enable: bool) -> Self {
60 self.options.index_on_subdirectories = enable;
61
62 self
63 }
64
65 pub fn fallback(mut self, fallback: Option<&'static str>) -> Self {
73 self.options.fallback = fallback;
74
75 self
76 }
77
78 pub fn fallback_status(mut self, fallback_status: StatusCode) -> Self {
81 self.options.fallback_status = fallback_status;
82
83 self
84 }
85
86 pub fn enable_gzip(mut self, enable_gzip: bool) -> Self {
90 self.options.enable_gzip = enable_gzip;
91
92 self
93 }
94
95 pub fn enable_brotli(mut self, enable_brotli: bool) -> Self {
98 self.options.enable_brotli = enable_brotli;
99
100 self
101 }
102
103 pub fn enable_clean_url(mut self, enable_clean_url: bool) -> Self {
107 self.options.enable_clean_url = enable_clean_url;
108
109 self
110 }
111
112 pub fn html_cache_control(mut self, html_cache_control: CacheControl) -> Self {
115 self.options.html_cache_control = html_cache_control;
116
117 self
118 }
119
120 pub fn cache_control(mut self, cache_control: CacheControl) -> Self {
123 self.options.cache_control = cache_control;
124
125 self
126 }
127
128 pub fn add_alias(mut self, from: &'static str, to: &'static str) -> Self {
130 self.aliases.push((from, to));
131
132 self
133 }
134
135 pub fn enable_hashed_routes(mut self, enable_hashed_routes: bool) -> Self {
142 self.options.enable_hashed_routes = enable_hashed_routes;
143
144 self
145 }
146
147 pub fn manifest(&self) -> Manifest {
153 let routes = self
154 .assets
155 .iter()
156 .map(|asset| {
157 let served = self
158 .hashed_route(asset)
159 .unwrap_or_else(|| asset.route.to_string());
160
161 (asset.route, served)
162 })
163 .collect();
164
165 Manifest::new(routes)
166 }
167
168 fn hashed_route(&self, asset: &Asset) -> Option<String> {
170 if !self.options.enable_hashed_routes || asset.is_html() {
171 return None;
172 }
173
174 let hash = asset.content_hash()?;
175
176 Some(hashed_route(asset.route, &hash))
177 }
178
179 pub fn into_router<S>(self) -> axum::Router<S>
182 where
183 S: Clone + Send + Sync + 'static,
184 {
185 let mut router = axum::Router::new();
186 let options: &'static options::ServeOptions = Box::leak(Box::new(self.options));
187
188 let hashed_options: &'static options::ServeOptions =
190 Box::leak(Box::new(options::ServeOptions {
191 cache_control: CacheControl::Long,
192 ..*options
193 }));
194
195 let route_exists = |route: &str| self.assets.iter().any(|a| a.route == route);
198
199 if let Some(index) = options.index_file
200 && !route_exists(index)
201 {
202 warn!("index_file {index} does not match any asset route, \"/\" will not be served");
203 }
204 if let Some(fallback) = options.fallback
205 && !route_exists(fallback)
206 {
207 warn!("fallback {fallback} does not match any asset route, it will be ignored");
208 }
209 for (from, to) in self.aliases.iter() {
210 if !route_exists(to) {
211 warn!("alias {from} points to {to}, which does not match any asset route");
212 }
213 }
214
215 for asset in self.assets {
216 let (uncompressed_bytes, brotli_bytes, gzip_bytes) = asset.leak_bytes(options);
217
218 if !uncompressed_bytes.is_empty() {
219 if !brotli_bytes.is_empty() {
220 info!(
221 "serving {} {} -> {} bytes (compressed)",
222 asset.route,
223 uncompressed_bytes.len(),
224 brotli_bytes.len()
225 );
226 } else {
227 info!("serving {} {} bytes", asset.route, uncompressed_bytes.len());
228 }
229 } else {
230 info!("serving {} (dynamically)", asset.route);
231 }
232
233 let handler = |headers: HeaderMap| {
234 ready(asset.handler(
235 &headers,
236 StatusCode::OK,
237 uncompressed_bytes,
238 brotli_bytes,
239 gzip_bytes,
240 options,
241 ))
242 };
243
244 if Some(asset.route) == options.fallback {
245 info!("serving {} as fallback", asset.route);
246
247 router = router.fallback(|headers: HeaderMap| {
248 ready(asset.handler(
249 &headers,
250 options.fallback_status,
251 uncompressed_bytes,
252 brotli_bytes,
253 gzip_bytes,
254 options,
255 ))
256 });
257 }
258
259 if let Some(index) = options.index_file {
260 if asset.route == index {
261 info!("serving {} as index on /", asset.route);
262
263 router = router.route("/", get(handler));
264 } else if options.index_on_subdirectories && asset.route.ends_with(index) {
265 let path = &asset.route[..asset.route.len() - index.len()];
266 info!("serving {} as index on {}", asset.route, path);
267
268 router = router.route(path, get(handler));
269 }
270 }
271
272 let path = if options.enable_clean_url && asset.route.ends_with(".html") {
273 &asset.route[..asset.route.len() - 5]
274 } else {
275 asset.route
276 };
277 router = router.route(path, get(handler));
278
279 for (from, to) in self.aliases.iter() {
281 if *to == asset.route {
282 info!("serving {} on alias {}", asset.route, from);
283
284 router = router.route(from, get(handler));
285 }
286 }
287
288 if let Some(hashed) = self.hashed_route(asset) {
289 info!("serving {} on hashed route {}", asset.route, hashed);
290
291 let hashed_options = if uncompressed_bytes.is_empty() {
293 options
294 } else {
295 hashed_options
296 };
297
298 router = router.route(
299 &hashed,
300 get(move |headers: HeaderMap| {
301 ready(asset.handler(
302 &headers,
303 StatusCode::OK,
304 uncompressed_bytes,
305 brotli_bytes,
306 gzip_bytes,
307 hashed_options,
308 ))
309 }),
310 );
311 }
312 }
313
314 router
315 }
316}
317
318#[cfg(test)]
319mod tests {
320 use axum::{
321 Router,
322 body::Body,
323 http::{
324 self, HeaderMap, HeaderName, HeaderValue, Request, StatusCode,
325 header::{self, CACHE_CONTROL, CONTENT_ENCODING, CONTENT_LENGTH},
326 },
327 };
328 use std::sync::LazyLock;
329 use tower::ServiceExt;
330
331 use crate::{self as memory_serve, Asset, CacheControl, MemoryServe};
332
333 static ASSETS: LazyLock<&'static [Asset]> =
334 LazyLock::new(|| memory_serve::build::load_test_assets("./static"));
335
336 macro_rules! test_load {
337 () => {{ MemoryServe::new(*ASSETS) }};
338 }
339
340 async fn get(
341 router: Router,
342 path: &str,
343 key: &str,
344 value: &str,
345 ) -> (StatusCode, HeaderMap<HeaderValue>) {
346 let response = router
347 .oneshot(
348 Request::builder()
349 .method(http::Method::GET)
350 .header(key, value)
351 .uri(path)
352 .body(Body::empty())
353 .unwrap(),
354 )
355 .await
356 .unwrap();
357
358 (response.status(), response.headers().to_owned())
359 }
360
361 fn get_header<'s>(headers: &'s HeaderMap, name: &HeaderName) -> &'s str {
362 headers.get(name).unwrap().to_str().unwrap()
363 }
364
365 #[tokio::test]
366 async fn test_load_assets() {
367 let routes: Vec<&str> = ASSETS.iter().map(|a| a.route).collect();
368 let content_types: Vec<&str> = ASSETS.iter().map(|a| a.content_type).collect();
369 let etags: Vec<&str> = ASSETS.iter().map(|a| a.etag).collect();
370
371 assert_eq!(
372 routes,
373 [
374 "/about.html",
375 "/assets/icon.jpg",
376 "/assets/index.css",
377 "/assets/index.js",
378 "/assets/stars.svg",
379 "/blog/index.html",
380 "/index.html"
381 ]
382 );
383 assert_eq!(
384 content_types,
385 [
386 "text/html",
387 "image/jpeg",
388 "text/css",
389 "text/javascript",
390 "image/svg+xml",
391 "text/html",
392 "text/html"
393 ]
394 );
395 if cfg!(debug_assertions) && !cfg!(feature = "force-embed") {
396 assert_eq!(etags, ["", "", "", "", "", "", ""]);
397 } else {
398 assert_eq!(
399 etags,
400 [
401 "56a0dcb83ec56b6c967966a1c06c7b1392e261069d0844aa4e910ca5c1e8cf58",
402 "e64f4683bf82d854df40b7246666f6f0816666ad8cd886a8e159535896eb03d6",
403 "ec4edeea111c854901385011f403e1259e3f1ba016dcceabb6d566316be3677b",
404 "86a7fdfd19700843e5f7344a63d27e0b729c2554c8572903ceee71f5658d2ecf",
405 "bd9dccc152de48cb7bedc35b9748ceeade492f6f904710f9c5d480bd6299cc7d",
406 "89e9873a8e49f962fe83ad2bfe6ac9b21ef7c1b4040b99c34eb783dccbadebc5",
407 "0639dc8aac157b58c74f65bbb026b2fd42bc81d9a0a64141df456fa23c214537"
408 ]
409 );
410 }
411 }
412
413 #[tokio::test]
414 async fn if_none_match_handling() {
415 let memory_router = test_load!().into_router();
416 let (code, headers) =
417 get(memory_router.clone(), "/index.html", "accept", "text/html").await;
418 let etag: &str = headers.get(header::ETAG).unwrap().to_str().unwrap();
419
420 assert_eq!(code, 200);
421 assert_eq!(
422 etag,
423 "\"0639dc8aac157b58c74f65bbb026b2fd42bc81d9a0a64141df456fa23c214537\""
424 );
425
426 let (code, headers) = get(memory_router, "/index.html", "If-None-Match", etag).await;
427 let length = get_header(&headers, &CONTENT_LENGTH);
428
429 assert_eq!(code, 304);
430 assert_eq!(length.parse::<i32>().unwrap(), 0);
431 }
432
433 #[tokio::test]
434 async fn brotli_compression() {
435 let memory_router = test_load!().enable_brotli(true).into_router();
436 let (code, headers) = get(
437 memory_router.clone(),
438 "/index.html",
439 "accept-encoding",
440 "br",
441 )
442 .await;
443 let encoding = get_header(&headers, &CONTENT_ENCODING);
444 let length = get_header(&headers, &CONTENT_LENGTH);
445
446 assert_eq!(code, 200);
447 assert_eq!(encoding, "br");
448 assert_eq!(length.parse::<i32>().unwrap(), 178);
449
450 let memory_router = test_load!().enable_brotli(false).into_router();
452 let (code, headers) = get(
453 memory_router.clone(),
454 "/index.html",
455 "accept-encoding",
456 "br",
457 )
458 .await;
459 let length: &str = get_header(&headers, &CONTENT_LENGTH);
460
461 assert_eq!(code, 200);
462 assert_eq!(length.parse::<i32>().unwrap(), 437);
463 }
464
465 #[tokio::test]
466 async fn gzip_compression() {
467 let memory_router = test_load!().enable_gzip(true).into_router();
468 let (code, headers) = get(
469 memory_router.clone(),
470 "/index.html",
471 "accept-encoding",
472 "gzip",
473 )
474 .await;
475
476 let encoding = get_header(&headers, &CONTENT_ENCODING);
477 let length = get_header(&headers, &CONTENT_LENGTH);
478
479 assert_eq!(code, 200);
480 assert_eq!(encoding, "gzip");
481 assert_eq!(length.parse::<i32>().unwrap(), 274);
482
483 let memory_router = test_load!().enable_gzip(false).into_router();
485 let (code, headers) = get(
486 memory_router.clone(),
487 "/index.html",
488 "accept-encoding",
489 "gzip",
490 )
491 .await;
492 let length: &str = get_header(&headers, &CONTENT_LENGTH);
493
494 assert_eq!(code, 200);
495 assert_eq!(length.parse::<i32>().unwrap(), 437);
496 }
497
498 #[tokio::test]
499 async fn encoding_preference() {
500 let memory_router = test_load!()
502 .enable_brotli(true)
503 .enable_gzip(true)
504 .into_router();
505 let (code, headers) = get(
506 memory_router.clone(),
507 "/index.html",
508 "accept-encoding",
509 "br;q=0.1, gzip;q=1.0",
510 )
511 .await;
512 assert_eq!(code, 200);
513 assert_eq!(get_header(&headers, &CONTENT_ENCODING), "gzip");
514
515 let (code, headers) = get(
517 memory_router.clone(),
518 "/index.html",
519 "accept-encoding",
520 "br;q=0.8, gzip;q=0.7",
521 )
522 .await;
523 assert_eq!(code, 200);
524 assert_eq!(get_header(&headers, &CONTENT_ENCODING), "br");
525 }
526
527 #[tokio::test]
528 async fn index_file() {
529 let memory_router = test_load!().index_file(None).into_router();
530
531 let (code, _) = get(memory_router.clone(), "/", "accept", "*").await;
532 assert_eq!(code, 404);
533
534 let memory_router = test_load!().index_file(Some("/index.html")).into_router();
535
536 let (code, _) = get(memory_router.clone(), "/", "accept", "*").await;
537 assert_eq!(code, 200);
538 }
539
540 #[tokio::test]
541 async fn index_file_on_subdirs() {
542 let memory_router = test_load!()
543 .index_file(Some("/index.html"))
544 .index_on_subdirectories(false)
545 .into_router();
546
547 let (code, _) = get(memory_router.clone(), "/blog", "accept", "*").await;
548 assert_eq!(code, 404);
549
550 let memory_router = test_load!()
551 .index_file(Some("/index.html"))
552 .index_on_subdirectories(true)
553 .into_router();
554
555 let (code, _) = get(memory_router.clone(), "/blog", "accept", "*").await;
556 assert_eq!(code, 200);
557 }
558
559 #[tokio::test]
560 async fn clean_url() {
561 let memory_router = test_load!().enable_clean_url(true).into_router();
562
563 let (code, _) = get(memory_router.clone(), "/about.html", "accept", "*").await;
564 assert_eq!(code, 404);
565
566 let (code, _) = get(memory_router.clone(), "/about", "accept", "*").await;
567 assert_eq!(code, 200);
568 }
569
570 #[tokio::test]
571 async fn fallback() {
572 let memory_router = test_load!().into_router();
573 let (code, _) = get(memory_router.clone(), "/foobar", "accept", "*").await;
574 assert_eq!(code, 404);
575
576 let memory_router = test_load!().fallback(Some("/index.html")).into_router();
577 let (code, headers) = get(memory_router.clone(), "/foobar", "accept", "*").await;
578 let length = get_header(&headers, &CONTENT_LENGTH);
579 assert_eq!(code, 404);
580 assert_eq!(length.parse::<i32>().unwrap(), 437);
581
582 let memory_router = test_load!()
583 .fallback(Some("/index.html"))
584 .fallback_status(StatusCode::OK)
585 .into_router();
586 let (code, headers) = get(memory_router.clone(), "/foobar", "accept", "*").await;
587 let length = get_header(&headers, &CONTENT_LENGTH);
588 assert_eq!(code, 200);
589 assert_eq!(length.parse::<i32>().unwrap(), 437);
590 }
591
592 #[tokio::test]
593 async fn cache_control() {
594 async fn check_cache_control(cache_control: CacheControl, expected: &str) {
595 let memory_router = test_load!().cache_control(cache_control).into_router();
596
597 let (code, headers) =
598 get(memory_router.clone(), "/assets/icon.jpg", "accept", "*").await;
599
600 let cache_control = get_header(&headers, &CACHE_CONTROL);
601 assert_eq!(code, 200);
602 assert_eq!(cache_control, expected);
603 }
604
605 check_cache_control(
606 CacheControl::NoCache,
607 CacheControl::NoCache.as_header().1.to_str().unwrap(),
608 )
609 .await;
610 check_cache_control(
611 CacheControl::Short,
612 CacheControl::Short.as_header().1.to_str().unwrap(),
613 )
614 .await;
615 check_cache_control(
616 CacheControl::Medium,
617 CacheControl::Medium.as_header().1.to_str().unwrap(),
618 )
619 .await;
620 check_cache_control(
621 CacheControl::Long,
622 CacheControl::Long.as_header().1.to_str().unwrap(),
623 )
624 .await;
625
626 async fn check_html_cache_control(cache_control: CacheControl, expected: &str) {
627 let memory_router = test_load!().html_cache_control(cache_control).into_router();
628
629 let (code, headers) = get(memory_router.clone(), "/index.html", "accept", "*").await;
630 let cache_control = get_header(&headers, &CACHE_CONTROL);
631 assert_eq!(code, 200);
632 assert_eq!(cache_control, expected);
633 }
634
635 check_html_cache_control(
636 CacheControl::NoCache,
637 CacheControl::NoCache.as_header().1.to_str().unwrap(),
638 )
639 .await;
640 check_html_cache_control(
641 CacheControl::Short,
642 CacheControl::Short.as_header().1.to_str().unwrap(),
643 )
644 .await;
645 check_html_cache_control(
646 CacheControl::Medium,
647 CacheControl::Medium.as_header().1.to_str().unwrap(),
648 )
649 .await;
650 check_html_cache_control(
651 CacheControl::Long,
652 CacheControl::Long.as_header().1.to_str().unwrap(),
653 )
654 .await;
655 }
656
657 #[tokio::test]
658 async fn hashed_routes() {
659 let memory_serve = test_load!().enable_hashed_routes(true);
660 let manifest = memory_serve.manifest();
661 let memory_router = memory_serve.into_router();
662
663 let hashed = manifest.get("/assets/index.css").unwrap();
666 let hash = hashed
667 .strip_prefix("/assets/index.")
668 .and_then(|rest| rest.strip_suffix(".css"))
669 .unwrap();
670 assert_eq!(hash.len(), 16);
671 assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
672
673 if !cfg!(debug_assertions) || cfg!(feature = "force-embed") {
674 assert_eq!(hashed, "/assets/index.ec4edeea111c8549.css");
675 }
676
677 assert_eq!(manifest.get("/index.html"), Some("/index.html"));
679 assert_eq!(manifest.get("/blog/index.html"), Some("/blog/index.html"));
680 assert_eq!(manifest.get("/missing.css"), None);
681 assert_eq!(manifest.len(), ASSETS.len());
682
683 let (code, headers) = get(memory_router.clone(), hashed, "accept", "*").await;
685 assert_eq!(code, 200);
686 assert_eq!(get_header(&headers, &header::CONTENT_TYPE), "text/css");
687 assert_eq!(get_header(&headers, &CONTENT_LENGTH), "1552");
688
689 let expected_cache_control = if !cfg!(debug_assertions) || cfg!(feature = "force-embed") {
692 CacheControl::Long
693 } else {
694 CacheControl::Medium
695 };
696 assert_eq!(
697 get_header(&headers, &CACHE_CONTROL),
698 expected_cache_control.as_header().1.to_str().unwrap()
699 );
700
701 let (code, headers) = get(memory_router.clone(), "/assets/index.css", "accept", "*").await;
703 assert_eq!(code, 200);
704 assert_eq!(
705 get_header(&headers, &CACHE_CONTROL),
706 CacheControl::Medium.as_header().1.to_str().unwrap()
707 );
708
709 for (_, served) in manifest.iter() {
711 let (code, _) = get(memory_router.clone(), served, "accept", "*").await;
712 assert_eq!(code, 200, "{served} should be served");
713 }
714 }
715
716 #[tokio::test]
717 async fn hashed_routes_disabled() {
718 let memory_serve = test_load!();
719 let manifest = memory_serve.manifest();
720 let memory_router = memory_serve.into_router();
721
722 assert_eq!(manifest.get("/assets/index.css"), Some("/assets/index.css"));
724 assert_eq!(manifest.get("/index.html"), Some("/index.html"));
725 assert_eq!(manifest.len(), ASSETS.len());
726
727 let (code, _) = get(
728 memory_router.clone(),
729 "/assets/index.ec4edeea111c8549.css",
730 "accept",
731 "*",
732 )
733 .await;
734 assert_eq!(code, 404);
735 }
736
737 #[tokio::test]
738 async fn aliases() {
739 let memory_router = test_load!()
740 .add_alias("/foobar", "/index.html")
741 .add_alias("/baz", "/index.html")
742 .into_router();
743 let (code, _) = get(memory_router.clone(), "/foobar", "accept", "*").await;
744 assert_eq!(code, 200);
745
746 let (code, _) = get(memory_router.clone(), "/baz", "accept", "*").await;
747 assert_eq!(code, 200);
748
749 let (code, _) = get(memory_router.clone(), "/index.html", "accept", "*").await;
750 assert_eq!(code, 200);
751
752 let (code, _) = get(memory_router.clone(), "/barfoo", "accept", "*").await;
753 assert_eq!(code, 404);
754 }
755}