1#[cfg(feature = "web")]
20use include_dir::{include_dir, Dir};
21use include_dir::{include_dir as include_source_dir, Dir as SourceDir, DirEntry};
22use std::{fs, path::Path};
23
24#[cfg(feature = "web")]
25static WEB_ASSETS: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/../../../apps/web/out");
26static WEB_SOURCE: SourceDir<'static> = include_source_dir!("$CARGO_MANIFEST_DIR/assets/source");
27
28#[cfg(feature = "web")]
29use axum::http::header;
30use axum::{
31 body::Body,
32 extract::Request,
33 http::StatusCode,
34 response::{IntoResponse, Response},
35};
36
37pub fn web_assets_available() -> bool {
42 #[cfg(feature = "web")]
43 {
44 WEB_ASSETS.get_file("index.html").is_some()
45 }
46 #[cfg(not(feature = "web"))]
47 {
48 false
49 }
50}
51
52pub fn web_source_available() -> bool {
54 WEB_SOURCE.get_file("package.json").is_some()
55 && WEB_SOURCE.get_file("components/aurora.css").is_some()
56 && WEB_SOURCE.get_file("app/page.tsx").is_some()
57}
58
59pub fn write_web_source_to(destination: impl AsRef<Path>, overwrite: bool) -> std::io::Result<()> {
65 write_dir(&WEB_SOURCE, destination.as_ref(), overwrite)
66}
67
68pub async fn serve_web_assets(request: Request<Body>) -> Response {
70 #[cfg(feature = "web")]
71 {
72 let path = normalize_asset_path(request.uri().path());
73
74 let candidates = asset_candidates(path);
76
77 for candidate in candidates {
78 if let Some(file) = WEB_ASSETS.get_file(candidate.as_str()) {
79 let content_type = guess_mime(candidate.as_str());
80 let cache_control = cache_control_for(candidate.as_str());
81 return (
82 StatusCode::OK,
83 [
84 (header::CONTENT_TYPE, content_type),
85 (header::CACHE_CONTROL, cache_control),
86 ],
87 file.contents().to_vec(),
88 )
89 .into_response();
90 }
91 }
92
93 if let Some(file) = WEB_ASSETS.get_file("index.html") {
95 return (
96 StatusCode::OK,
97 [
98 (header::CONTENT_TYPE, "text/html; charset=utf-8"),
99 (header::CACHE_CONTROL, "no-store"),
100 ],
101 file.contents().to_vec(),
102 )
103 .into_response();
104 }
105
106 StatusCode::NOT_FOUND.into_response()
107 }
108
109 #[cfg(not(feature = "web"))]
110 {
111 let _ = request;
112 StatusCode::NOT_FOUND.into_response()
113 }
114}
115
116fn write_dir(dir: &SourceDir<'static>, destination: &Path, overwrite: bool) -> std::io::Result<()> {
117 fs::create_dir_all(destination)?;
118 for entry in dir.entries() {
119 let path = destination.join(entry.path());
120 match entry {
121 DirEntry::Dir(child) => {
122 fs::create_dir_all(&path)?;
123 write_dir(child, destination, overwrite)?;
124 }
125 DirEntry::File(file) => {
126 if path.exists() && !overwrite {
127 return Err(std::io::Error::new(
128 std::io::ErrorKind::AlreadyExists,
129 format!("{} already exists", path.display()),
130 ));
131 }
132 if let Some(parent) = path.parent() {
133 fs::create_dir_all(parent)?;
134 }
135 fs::write(path, file.contents())?;
136 }
137 }
138 }
139 Ok(())
140}
141
142#[cfg(any(feature = "web", test))]
143fn normalize_asset_path(path: &str) -> &str {
144 path.trim_start_matches('/').trim_end_matches('/')
145}
146
147#[cfg(any(feature = "web", test))]
148fn asset_candidates(path: &str) -> Vec<String> {
149 if path.is_empty() {
150 return vec!["index.html".to_string()];
151 }
152
153 vec![
154 path.to_string(),
155 format!("{path}.html"),
156 format!("{path}/index.html"),
157 ]
158}
159
160#[cfg(any(feature = "web", test))]
161fn cache_control_for(path: &str) -> &'static str {
162 if path == "index.html" || path.ends_with(".html") {
163 "no-store"
164 } else if path.starts_with("_next/static/") {
165 "public, max-age=31536000, immutable"
166 } else {
167 "public, max-age=3600"
168 }
169}
170
171#[cfg(any(feature = "web", test))]
172fn guess_mime(path: &str) -> &'static str {
173 if path.ends_with(".html") {
174 "text/html; charset=utf-8"
175 } else if path.ends_with(".css") {
176 "text/css; charset=utf-8"
177 } else if path.ends_with(".js") || path.ends_with(".mjs") {
178 "application/javascript; charset=utf-8"
179 } else if path.ends_with(".json") {
180 "application/json"
181 } else if path.ends_with(".svg") {
182 "image/svg+xml"
183 } else if path.ends_with(".png") {
184 "image/png"
185 } else if path.ends_with(".jpg") || path.ends_with(".jpeg") {
186 "image/jpeg"
187 } else if path.ends_with(".ico") {
188 "image/x-icon"
189 } else if path.ends_with(".woff2") {
190 "font/woff2"
191 } else if path.ends_with(".woff") {
192 "font/woff"
193 } else if path.ends_with(".ttf") {
194 "font/ttf"
195 } else if path.ends_with(".txt") {
196 "text/plain; charset=utf-8"
197 } else if path.ends_with(".webmanifest") {
198 "application/manifest+json"
199 } else {
200 "application/octet-stream"
201 }
202}
203
204#[cfg(test)]
205#[path = "web_tests.rs"]
206mod tests;