Skip to main content

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    /// The host must first signal its application's framework shutdown
187    /// broadcast, which closes long-lived responses such as the dev event
188    /// stream and WebSockets. This method then tells Axum to stop accepting and
189    /// waits for what is left. Keeping the framework signal in the host makes
190    /// it use the application's Rahti version rather than a second copy linked
191    /// by this platform-neutral crate.
192    ///
193    /// `grace` bounds that wait. Whatever is still open when it expires is
194    /// abandoned, because a window the user closed must not leave a process
195    /// behind.
196    pub async fn shutdown(mut self, grace: Duration) -> Result<(), NativeError> {
197        if let Some(stop) = self.stop.take() {
198            let _ = stop.send(());
199        }
200
201        let Some(task) = self.task.take() else {
202            return Ok(());
203        };
204
205        match tokio::time::timeout(grace, task).await {
206            Ok(Ok(Ok(()))) => Ok(()),
207            Ok(Ok(Err(e))) => Err(NativeError::new(
208                "server",
209                format!("the embedded server stopped with an error: {e}"),
210            )),
211            Ok(Err(e)) => Err(NativeError::new(
212                "server",
213                format!("the embedded server task did not finish: {e}"),
214            )),
215            Err(_) => Err(NativeError::new(
216                "server",
217                format!(
218                    "the embedded server still had work open after {} seconds and was abandoned",
219                    grace.as_secs()
220                ),
221            )),
222        }
223    }
224}
225
226impl Drop for RunningServer {
227    /// A host that drops this without calling
228    /// [`shutdown`](RunningServer::shutdown) still stops the server.
229    ///
230    /// Which is the Android case: the operating system can destroy the process
231    /// without giving anything a chance to run a shutdown, and a serve task
232    /// that outlived its handle would keep a socket open into the next launch.
233    fn drop(&mut self) {
234        if let Some(stop) = self.stop.take() {
235            let _ = stop.send(());
236        }
237        if let Some(task) = self.task.take() {
238            task.abort();
239        }
240    }
241}