1use axum::{
17 body::Body,
18 extract::State,
19 http::{header, StatusCode, Uri},
20 response::{IntoResponse, Response},
21};
22use rust_embed::RustEmbed;
23
24#[derive(RustEmbed)]
25#[folder = "$OUT_DIR/nyx-agent-ui-dist/"]
26pub struct UiAssets;
27
28#[derive(Clone, Default)]
34pub struct UiBootstrap {
35 pub auth_token: Option<String>,
36}
37
38pub fn resolve(path: &str) -> Option<UiResponse> {
42 resolve_with(path, &UiBootstrap::default())
43}
44
45pub fn resolve_with(path: &str, bootstrap: &UiBootstrap) -> Option<UiResponse> {
50 let clean = path.trim_start_matches('/');
51 let candidate = if clean.is_empty() { "index.html" } else { clean };
52 if let Some(file) = UiAssets::get(candidate) {
53 return Some(UiResponse::from_embedded(candidate, file, bootstrap));
54 }
55 if !candidate.contains('.') {
58 if let Some(file) = UiAssets::get("index.html") {
59 return Some(UiResponse::from_embedded("index.html", file, bootstrap));
60 }
61 }
62 None
63}
64
65pub struct UiResponse {
66 body: Vec<u8>,
67 content_type: String,
68}
69
70impl UiResponse {
71 fn from_embedded(path: &str, file: rust_embed::EmbeddedFile, bootstrap: &UiBootstrap) -> Self {
72 let mime = mime_guess::from_path(path).first_or_octet_stream();
73 let body = if path == "index.html" {
74 inject_bootstrap(&file.data, bootstrap)
75 } else {
76 file.data.into_owned()
77 };
78 Self { body, content_type: mime.essence_str().to_string() }
79 }
80}
81
82fn inject_bootstrap(html: &[u8], bootstrap: &UiBootstrap) -> Vec<u8> {
83 let Ok(text) = std::str::from_utf8(html) else {
84 return html.to_vec();
85 };
86 let payload =
87 format!("<script>window.__NYX_AGENT_BOOTSTRAP__={};</script>", serde_payload(bootstrap));
88 if let Some(pos) = text.find("<head>") {
91 let mut out = String::with_capacity(text.len() + payload.len());
92 out.push_str(&text[..pos + "<head>".len()]);
93 out.push_str(&payload);
94 out.push_str(&text[pos + "<head>".len()..]);
95 out.into_bytes()
96 } else {
97 let mut out = payload.into_bytes();
98 out.extend_from_slice(html);
99 out
100 }
101}
102
103fn serde_payload(bootstrap: &UiBootstrap) -> String {
104 match &bootstrap.auth_token {
107 Some(token) => format!("{{\"authToken\":\"{}\"}}", token.replace('"', "\\\"")),
108 None => "{}".to_string(),
109 }
110}
111
112impl IntoResponse for UiResponse {
113 fn into_response(self) -> Response {
114 Response::builder()
115 .status(StatusCode::OK)
116 .header(header::CONTENT_TYPE, self.content_type)
117 .body(Body::from(self.body))
118 .expect("build static response")
119 }
120}
121
122pub async fn spa_handler(uri: Uri) -> Response {
130 spa_handler_with(uri, &UiBootstrap::default()).await
131}
132
133pub async fn spa_handler_with(uri: Uri, bootstrap: &UiBootstrap) -> Response {
134 let path = uri.path();
135 if path.starts_with("/api/") || path == "/api" {
136 return (StatusCode::NOT_FOUND, "not found").into_response();
137 }
138 match resolve_with(path, bootstrap) {
139 Some(resp) => resp.into_response(),
140 None => (StatusCode::NOT_FOUND, "not found").into_response(),
141 }
142}
143
144pub async fn spa_handler_stateful(
148 State(bootstrap): State<std::sync::Arc<UiBootstrap>>,
149 uri: Uri,
150) -> Response {
151 spa_handler_with(uri, &bootstrap).await
152}