Skip to main content

nyx_agent_ui/
lib.rs

1//! Embedded single-page UI for the nyx-agent daemon.
2//!
3//! This crate is published so the `nyx-agent` binary can be installed
4//! from crates.io with versioned internal dependencies. It is an
5//! implementation detail of Nyx Agent, not a stable public API.
6//!
7//! `build.rs` prepares the asset directory under `OUT_DIR`:
8//! * release builds from a repository checkout run the Vite pipeline;
9//! * release builds from crates.io use the packaged prebuilt `dist/`;
10//! * debug builds use a tiny stub page.
11//!
12//! Consumers wire [`spa_handler`] as the Axum fallback so any path
13//! outside `/api/v1/...` resolves to either the matching asset or the
14//! SPA's `index.html` (for client-side routing).
15
16use 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/// Bootstrap context passed into the SPA at request time. Currently
29/// carries the bearer token the API middleware expects on every
30/// non-`/setup` request. The fallback handler rewrites `index.html` to
31/// embed it before sending the response; the SPA reads it from
32/// `window.__NYX_AGENT_BOOTSTRAP__`.
33#[derive(Clone, Default)]
34pub struct UiBootstrap {
35    pub auth_token: Option<String>,
36}
37
38/// Resolve the given URI path against the embedded asset tree.
39/// Returns `None` if no asset (including `index.html`) is available
40/// for that path.
41pub fn resolve(path: &str) -> Option<UiResponse> {
42    resolve_with(path, &UiBootstrap::default())
43}
44
45/// `resolve` with bootstrap context. When the resolved asset is
46/// `index.html` and `bootstrap.auth_token` is set, the response body
47/// has a `<script>` tag injected at the start of `<head>` so the SPA
48/// has the token before any API call fires.
49pub 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    // SPA fallback: unknown path with no extension → return index.html
56    // so client-side routing can render the requested view.
57    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    // Inject right after the opening <head>. Falls back to prepending
89    // if no <head> tag is present.
90    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    // Tiny hand-rolled JSON to avoid adding a serde dependency to this
105    // crate just for one field. The token is hex, so escaping is moot.
106    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
122/// Axum-compatible fallback handler. Pass to `Router::fallback`.
123///
124/// Paths under `/api/` are never served from the embedded SPA: if a
125/// matching `/api/v1/...` route exists the router resolves it before
126/// the fallback fires, and a miss (typo, wrong version prefix) should
127/// surface as a real 404 instead of being swallowed by the SPA
128/// index.html.
129pub 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
144/// Stateful Axum handler bound to a clonable [`UiBootstrap`]. Used as
145/// the daemon's fallback so every served `index.html` carries the live
146/// auth token.
147pub 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}