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}