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}