Skip to main content

moire_web/proxy/
mod.rs

1use std::path::PathBuf;
2use std::process::Stdio;
3use std::str::FromStr;
4use std::time::Duration;
5
6use axum::extract::{Request, State};
7use axum::http::StatusCode;
8use axum::response::IntoResponse;
9use tokio::process::Child;
10use tokio::time::sleep;
11
12use crate::app::AppState;
13
14pub mod vite;
15
16const PROXY_BODY_LIMIT_BYTES: usize = 8 * 1024 * 1024;
17pub const DEFAULT_VITE_ADDR: &str = "[::]:9131";
18const REAPER_PIPE_FD_ENV: &str = "MOIRE_REAPER_PIPE_FD";
19const REAPER_PGID_ENV: &str = "MOIRE_REAPER_PGID";
20
21pub async fn proxy_vite(
22    State(state): State<AppState>,
23    request: Request,
24) -> axum::response::Response {
25    let Some(proxy) = state.dev_proxy.clone() else {
26        return (StatusCode::NOT_FOUND, "not found").into_response();
27    };
28    vite::proxy_vite_request(proxy.base_url.as_str(), request, PROXY_BODY_LIMIT_BYTES).await
29}
30
31pub async fn start_vite_dev_server(vite_addr: &str) -> Result<Child, String> {
32    let socket_addr = std::net::SocketAddr::from_str(vite_addr)
33        .map_err(|e| format!("invalid MOIRE_VITE_ADDR '{vite_addr}': {e}"))?;
34    let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
35    let frontend_dir = workspace_root.join("frontend");
36    if !frontend_dir.is_dir() {
37        return Err(format!(
38            "frontend directory not found at {}",
39            frontend_dir.display()
40        ));
41    }
42
43    ensure_frontend_deps(&workspace_root).await?;
44
45    let mut command = tokio::process::Command::new("pnpm");
46    command
47        .arg("--filter")
48        .arg("moire-frontend")
49        .arg("dev")
50        .arg("--")
51        .arg("--host")
52        .arg(socket_addr.ip().to_string())
53        .arg("--port")
54        .arg(socket_addr.port().to_string())
55        .arg("--strictPort")
56        .current_dir(&workspace_root)
57        .stdout(Stdio::inherit())
58        .stderr(Stdio::inherit())
59        .kill_on_drop(true);
60
61    #[cfg(unix)]
62    command.process_group(0);
63
64    let child = command.spawn().map_err(|e| {
65        format!(
66            "failed to launch Vite via pnpm in {}: {e}",
67            workspace_root.display()
68        )
69    })?;
70
71    #[cfg(unix)]
72    {
73        let vite_pgid = child.id().ok_or("Vite child has no PID")? as libc::pid_t;
74        spawn_vite_reaper(vite_pgid)?;
75    }
76
77    wait_for_tcp_ready(vite_addr, Duration::from_secs(20)).await?;
78    Ok(child)
79}
80
81#[cfg(unix)]
82fn spawn_vite_reaper(vite_pgid: libc::pid_t) -> Result<(), String> {
83    use std::os::fd::FromRawFd;
84
85    let mut fds = [0 as libc::c_int; 2];
86    let ret = unsafe { libc::pipe(fds.as_mut_ptr()) };
87    if ret != 0 {
88        return Err(format!(
89            "failed to create reaper pipe: {}",
90            std::io::Error::last_os_error()
91        ));
92    }
93    let read_fd = fds[0];
94    let write_fd = fds[1];
95
96    unsafe {
97        let flags = libc::fcntl(read_fd, libc::F_GETFD);
98        libc::fcntl(read_fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC);
99    }
100    unsafe {
101        let flags = libc::fcntl(write_fd, libc::F_GETFD);
102        libc::fcntl(write_fd, libc::F_SETFD, flags | libc::FD_CLOEXEC);
103    }
104
105    let exe = std::env::current_exe().map_err(|e| format!("failed to get current exe: {e}"))?;
106    std::process::Command::new(exe)
107        .env(REAPER_PIPE_FD_ENV, read_fd.to_string())
108        .env(REAPER_PGID_ENV, vite_pgid.to_string())
109        .stdin(Stdio::null())
110        .stdout(Stdio::null())
111        .stderr(Stdio::null())
112        .spawn()
113        .map_err(|e| format!("failed to spawn vite reaper: {e}"))?;
114
115    unsafe { libc::close(read_fd) };
116    std::mem::forget(unsafe { std::fs::File::from_raw_fd(write_fd) });
117
118    Ok(())
119}
120
121async fn ensure_frontend_deps(workspace_root: &PathBuf) -> Result<(), String> {
122    let vite_ready = tokio::process::Command::new("pnpm")
123        .arg("--filter")
124        .arg("moire-frontend")
125        .arg("exec")
126        .arg("vite")
127        .arg("--version")
128        .current_dir(workspace_root)
129        .stdout(Stdio::null())
130        .stderr(Stdio::null())
131        .status()
132        .await
133        .map(|status| status.success())
134        .unwrap_or(false);
135    if vite_ready {
136        return Ok(());
137    }
138
139    tracing::info!(
140        workspace = %workspace_root.display(),
141        "frontend dependencies missing, running pnpm install"
142    );
143
144    let status = tokio::process::Command::new("pnpm")
145        .arg("install")
146        .current_dir(workspace_root)
147        .env("CI", "true")
148        .stdout(Stdio::inherit())
149        .stderr(Stdio::inherit())
150        .status()
151        .await
152        .map_err(|e| {
153            format!(
154                "failed to run pnpm install in {}: {e}",
155                workspace_root.display()
156            )
157        })?;
158
159    if !status.success() {
160        return Err(format!(
161            "pnpm install failed in {} (status: {status})",
162            workspace_root.display()
163        ));
164    }
165
166    let vite_ready = tokio::process::Command::new("pnpm")
167        .arg("--filter")
168        .arg("moire-frontend")
169        .arg("exec")
170        .arg("vite")
171        .arg("--version")
172        .current_dir(workspace_root)
173        .stdout(Stdio::null())
174        .stderr(Stdio::null())
175        .status()
176        .await
177        .map(|status| status.success())
178        .unwrap_or(false);
179    if !vite_ready {
180        return Err(
181            "pnpm install succeeded but vite is still unavailable for moire-frontend".to_string(),
182        );
183    }
184
185    Ok(())
186}
187
188async fn wait_for_tcp_ready(addr: &str, timeout: Duration) -> Result<(), String> {
189    let deadline = tokio::time::Instant::now() + timeout;
190    loop {
191        match tokio::net::TcpStream::connect(addr).await {
192            Ok(stream) => {
193                drop(stream);
194                return Ok(());
195            }
196            Err(err) => {
197                if tokio::time::Instant::now() >= deadline {
198                    return Err(format!("timed out waiting for Vite at {addr}: {err}"));
199                }
200            }
201        }
202        sleep(Duration::from_millis(150)).await;
203    }
204}