Skip to main content

soma_web/
web.rs

1//! Static web asset serving — embeds apps/web/out/ into the binary at compile time.
2//!
3//! When the `web` feature is enabled (default), `include_dir!` bakes every file from
4//! `apps/web/out/` into the binary at compile time.  At runtime, `serve_web_assets`
5//! responds to any HTTP request that wasn't matched by a more specific route (MCP, REST,
6//! health, OAuth).
7//!
8//! The SPA fallback strategy:
9//!   1. Serve the exact path if it exists          (e.g. `/favicon.ico`)
10//!   2. Try path + `.html`                          (e.g. `/tools` → `tools.html`)
11//!   3. Try path + `/index.html`                   (e.g. `/tools/` → `tools/index.html`)
12//!   4. Fall back to `index.html` for client-side routing
13//!
14//! Cache-control:
15//!   - HTML shells          → `no-store`  (routes must never be stale)
16//!   - `_next/static/*`     → `public, max-age=31536000, immutable` (content-hashed)
17//!   - Other assets         → `public, max-age=3600` (bounded cache)
18
19#[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
37/// Returns `true` when the embedded `apps/web/out/index.html` is present.
38///
39/// Used by `AppState::web_assets_enabled()` and the router to decide whether to wire
40/// up the SPA fallback handler.
41pub 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
52/// Returns `true` when the bundled editable Aurora frontend scaffold is present.
53pub 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
59/// Copy the bundled editable Aurora frontend source into `destination`.
60///
61/// The bundle is intended for scaffolding a derived app's `apps/web` directory.
62/// It contains source/config files only: no `node_modules`, `.next`, `out`, or
63/// TypeScript build cache artifacts.
64pub 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
68/// Axum fallback handler — serves embedded static assets with SPA fallback.
69pub 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        // Ordered candidate list — first match wins.
75        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        // SPA fallback — let client-side router handle the path
94        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;