rahti_native/server.rs
1//! The embedded loopback server.
2//!
3//! A packaged Rahti application serves itself. The generated Axum router — the
4//! same one a deployment runs, with the same auth guard, the same CSRF layer
5//! and the same static fallback — binds a socket inside the installed process,
6//! and the WebView is pointed at it.
7//!
8//! Serving HTTP to a WebView on the same machine looks like a detour, and the
9//! alternative is worse. Rahti's RPCs are Axum requests: they carry cookies,
10//! a CSRF header, multipart bodies and streaming responses, and its sockets
11//! are real HTTP upgrades. A Tauri IPC transport would have to reimplement
12//! every one of those, and would be a different protocol wearing the same
13//! names. Keeping HTTP keeps the application identical on both sides.
14//!
15//! ## Two rules, both about the address
16//!
17//! **Loopback only.** [`EmbeddedServer::bind`] binds `127.0.0.1` and offers no
18//! way to bind anything else. A packaged application that bound the configured
19//! `0.0.0.0:3000` would be a web server on the user's network, serving their
20//! signed-in session to it — which is what a deployment wants and is a
21//! vulnerability in a program somebody installed.
22//!
23//! **A port the operating system picks.** Port `0` asks for a free one. A
24//! fixed port collides with whatever else holds it, and two copies of the
25//! application could not run at once.
26//!
27//! ## Bind, then serve, then navigate
28//!
29//! The order is the whole of the startup race, and it is enforced by the
30//! types: [`EmbeddedServer::bind`] is the only constructor, it is `async`, and
31//! it returns a value that already holds a bound listener. There is no way to
32//! obtain a URL from this module that nothing is listening on.
33//!
34//! That the socket is *bound* is the part that matters, not that
35//! [`RunningServer`] has started polling it. A bound TCP listener queues
36//! connections in the kernel from the moment it exists, so a WebView that
37//! races ahead and connects is not refused — it waits, and is answered when
38//! the accept loop reaches it. [`RunningServer::wait_until_ready`] is
39//! available for a host that would rather prove it than reason about it.
40
41use std::net::{Ipv4Addr, SocketAddr};
42use std::time::Duration;
43
44use axum::Router;
45use tokio::net::{TcpListener, TcpStream};
46use tokio::sync::oneshot;
47use tokio::task::JoinHandle;
48
49use crate::error::NativeError;
50
51/// How long [`RunningServer::wait_until_ready`] tries before giving up.
52const READY_TIMEOUT: Duration = Duration::from_secs(5);
53
54/// How long a shutdown waits for open connections before it stops waiting.
55///
56/// Bounded because a graceful shutdown waits for *every* open connection, and
57/// a WebSocket that a page left open has no reason to close on its own. Rahti's
58/// shutdown broadcast tells those to end, and this is the answer to the ones
59/// that do not.
60pub const DEFAULT_GRACE: Duration = Duration::from_secs(5);
61
62/// A bound loopback listener, not yet serving anything.
63pub struct EmbeddedServer {
64 listener: TcpListener,
65 addr: SocketAddr,
66}
67
68impl EmbeddedServer {
69 /// Bind `127.0.0.1` on a port the operating system chooses.
70 ///
71 /// There is deliberately no `bind_to(host, port)`. Every reason to want
72 /// one — a fixed port for a bookmark, a LAN address for a second device —
73 /// is a reason a packaged application should not have.
74 pub async fn bind() -> Result<Self, NativeError> {
75 let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
76 .await
77 .map_err(|e| {
78 NativeError::new(
79 "listener",
80 format!("cannot bind a loopback port for the embedded server: {e}"),
81 )
82 })?;
83
84 let addr = listener.local_addr().map_err(|e| {
85 NativeError::new("listener", format!("the listener has no address: {e}"))
86 })?;
87
88 Ok(EmbeddedServer { listener, addr })
89 }
90
91 /// The address that actually bound, port included.
92 pub fn addr(&self) -> SocketAddr {
93 self.addr
94 }
95
96 /// The port the operating system assigned.
97 pub fn port(&self) -> u16 {
98 self.addr.port()
99 }
100
101 /// Where to point the WebView.
102 ///
103 /// `127.0.0.1` rather than `localhost`: the name resolves to both stacks
104 /// and a WebView that tried `::1` first would spend a timeout on every
105 /// launch reaching a server that is not there. It is also the origin the
106 /// document will be on, and an origin that is decided by a resolver is one
107 /// that CSRF and the socket handshake's origin check cannot rely on.
108 pub fn base_url(&self) -> String {
109 format!("http://127.0.0.1:{}", self.addr.port())
110 }
111
112 /// Start serving `router`, on a task.
113 ///
114 /// Returns immediately. The listener was bound by [`bind`](Self::bind), so
115 /// the port in [`base_url`](Self::base_url) is already accepting.
116 pub fn serve(self, router: Router) -> RunningServer {
117 let addr = self.addr;
118 let (stop, stopped) = oneshot::channel::<()>();
119
120 let task = tokio::spawn(async move {
121 axum::serve(self.listener, router)
122 .with_graceful_shutdown(async {
123 // A closed sender counts as a stop: it means the
124 // `RunningServer` was dropped without a shutdown, which is
125 // the host going away.
126 let _ = stopped.await;
127 })
128 .await
129 });
130
131 RunningServer {
132 addr,
133 stop: Some(stop),
134 task: Some(task),
135 }
136 }
137}
138
139/// A server that is serving.
140pub struct RunningServer {
141 addr: SocketAddr,
142 stop: Option<oneshot::Sender<()>>,
143 task: Option<JoinHandle<std::io::Result<()>>>,
144}
145
146impl RunningServer {
147 /// The address it is serving on.
148 pub fn addr(&self) -> SocketAddr {
149 self.addr
150 }
151
152 /// Where to point the WebView.
153 pub fn base_url(&self) -> String {
154 format!("http://127.0.0.1:{}", self.addr.port())
155 }
156
157 /// Prove the port answers before anything is told to go there.
158 ///
159 /// Not strictly required — see the module note on binding before serving —
160 /// but a host that would rather check than reason gets a check, and one
161 /// that finds this failing has a real problem to report rather than a
162 /// blank window.
163 pub async fn wait_until_ready(&self) -> Result<(), NativeError> {
164 let deadline = tokio::time::Instant::now() + READY_TIMEOUT;
165
166 loop {
167 if TcpStream::connect(self.addr).await.is_ok() {
168 return Ok(());
169 }
170 if tokio::time::Instant::now() >= deadline {
171 return Err(NativeError::new(
172 "server",
173 format!(
174 "the embedded server did not answer on {} within {} seconds",
175 self.addr,
176 READY_TIMEOUT.as_secs()
177 ),
178 ));
179 }
180 tokio::time::sleep(Duration::from_millis(20)).await;
181 }
182 }
183
184 /// Stop accepting, let open work finish, and end.
185 ///
186 /// Two signals, because they stop two different things. Rahti's shutdown
187 /// broadcast tells every long-lived response — the dev event stream, every
188 /// open WebSocket — to close, and the oneshot tells Axum to stop accepting
189 /// and wait for what is left. Without the first, the second would wait for
190 /// connections that were never going to end.
191 ///
192 /// `grace` bounds that wait. Whatever is still open when it expires is
193 /// abandoned, because a window the user closed must not leave a process
194 /// behind.
195 pub async fn shutdown(mut self, grace: Duration) -> Result<(), NativeError> {
196 // Every `rahti::watch_shutdown()` holder, first.
197 rahti::begin_shutdown();
198
199 if let Some(stop) = self.stop.take() {
200 let _ = stop.send(());
201 }
202
203 let Some(task) = self.task.take() else {
204 return Ok(());
205 };
206
207 match tokio::time::timeout(grace, task).await {
208 Ok(Ok(Ok(()))) => Ok(()),
209 Ok(Ok(Err(e))) => Err(NativeError::new(
210 "server",
211 format!("the embedded server stopped with an error: {e}"),
212 )),
213 Ok(Err(e)) => Err(NativeError::new(
214 "server",
215 format!("the embedded server task did not finish: {e}"),
216 )),
217 Err(_) => Err(NativeError::new(
218 "server",
219 format!(
220 "the embedded server still had work open after {} seconds and was abandoned",
221 grace.as_secs()
222 ),
223 )),
224 }
225 }
226}
227
228impl Drop for RunningServer {
229 /// A host that drops this without calling
230 /// [`shutdown`](RunningServer::shutdown) still stops the server.
231 ///
232 /// Which is the Android case: the operating system can destroy the process
233 /// without giving anything a chance to run a shutdown, and a serve task
234 /// that outlived its handle would keep a socket open into the next launch.
235 fn drop(&mut self) {
236 if let Some(stop) = self.stop.take() {
237 let _ = stop.send(());
238 }
239 if let Some(task) = self.task.take() {
240 task.abort();
241 }
242 }
243}