Skip to main content

xtask/
http.rs

1//! A minimal static file server, so serving the example needs nothing
2//! installed.
3//!
4//! Built on hyper's HTTP/1 server, with `async-io` as the reactor and
5//! `async-executor` as the runtime: hyper parses and encodes the protocol,
6//! the server only maps a request to a file. WebGPU requires a secure
7//! context, which `http://localhost` is, so the listener binds to loopback
8//! only.
9
10use std::path::{Path, PathBuf};
11
12use async_executor::Executor;
13use async_io::Async;
14use futures_lite::future;
15use http_body_util::Full;
16use hyper::body::{Bytes, Incoming};
17use hyper::server::conn::http1;
18use hyper::service::service_fn;
19use hyper::{Request, Response, StatusCode};
20use smol_hyper::rt::{FuturesIo, SmolTimer};
21
22/// Serve `dir` until stopped.
23///
24/// Binds to loopback, trying the preferred port first and its successors
25/// after, and prints the URL to open once listening.
26pub fn serve(dir: &Path, preferred_port: u16) -> Result<(), String> {
27    serve_listener(bind(preferred_port)?, dir)
28}
29
30/// Serve `dir` on an already-bound `listener` until stopped.
31///
32/// Split from [`serve`] so a caller that has to know the URL — a test whose
33/// client connects to it — can bind first and read the address the listener
34/// actually got, rather than guess which port [`bind`] settled on.
35fn serve_listener(listener: Async<std::net::TcpListener>, dir: &Path) -> Result<(), String> {
36    let url = listener
37        .get_ref()
38        .local_addr()
39        .map_err(|error| format!("reading the listener's address: {error}"))?;
40    println!(
41        "serving {} on http://{url}/index.html — Ctrl-C stops the server",
42        dir.display()
43    );
44
45    let executor = Executor::new();
46    future::block_on(executor.run(async {
47        loop {
48            let (stream, peer) = match listener.accept().await {
49                Ok(accepted) => accepted,
50                Err(error) => {
51                    eprintln!("accepting a connection: {error}");
52                    continue;
53                }
54            };
55            let dir = dir.to_path_buf();
56            executor
57                .spawn(async move {
58                    if let Err(error) = serve_connection(stream, dir).await {
59                        eprintln!("{peer}: {error}");
60                    }
61                })
62                .detach();
63        }
64    }))
65}
66
67/// Bind to loopback at `preferred_port` or, if taken, the ports after it.
68fn bind(preferred_port: u16) -> Result<Async<std::net::TcpListener>, String> {
69    let loopback = std::net::Ipv4Addr::LOCALHOST;
70    let last = preferred_port.saturating_add(10);
71    (preferred_port..=last)
72        .find_map(|port| Async::<std::net::TcpListener>::bind((loopback, port)).ok())
73        .ok_or_else(|| format!("no free port in {preferred_port}..={last} on loopback"))
74}
75
76/// Answer every request on one connection.
77///
78/// hyper keeps the connection alive between requests by default, which is
79/// what a browser expects; a connection error ends it.
80async fn serve_connection(stream: Async<std::net::TcpStream>, dir: PathBuf) -> Result<(), String> {
81    // `FuturesIo` bridges `async-io`'s futures-io traits to hyper's own; the
82    // timer lets hyper enforce its header-read timeout.
83    let service = service_fn(move |request: Request<Incoming>| {
84        let dir = dir.clone();
85        async move { Ok::<_, std::convert::Infallible>(answer(request, &dir)) }
86    });
87
88    http1::Builder::new()
89        .timer(SmolTimer::new())
90        .serve_connection(FuturesIo::new(stream), service)
91        .await
92        .map_err(|error| format!("serving the connection: {error}"))
93}
94
95/// The response for one request.
96fn answer(request: Request<Incoming>, dir: &Path) -> Response<Full<Bytes>> {
97    if request.method() != hyper::Method::GET && request.method() != hyper::Method::HEAD {
98        return plain(StatusCode::METHOD_NOT_ALLOWED, "method not allowed");
99    }
100
101    let Some(path) = resolve(request.uri().path(), dir) else {
102        return plain(StatusCode::NOT_FOUND, "not found");
103    };
104
105    match std::fs::read(&path) {
106        Ok(bytes) => Response::builder()
107            .status(StatusCode::OK)
108            .header(hyper::header::CONTENT_TYPE, mime(&path))
109            // So a rebuilt wasm is picked up on refresh rather than cached.
110            .header(hyper::header::CACHE_CONTROL, "no-store")
111            .body(Full::new(Bytes::from(bytes)))
112            .expect("the response is well formed"),
113        Err(error) => {
114            eprintln!("reading {}: {error}", path.display());
115            plain(StatusCode::NOT_FOUND, "not found")
116        }
117    }
118}
119
120/// A `text/plain` response, for the statuses that carry no file.
121fn plain(status: StatusCode, message: &'static str) -> Response<Full<Bytes>> {
122    Response::builder()
123        .status(status)
124        .header(hyper::header::CONTENT_TYPE, "text/plain; charset=utf-8")
125        .body(Full::new(Bytes::from_static(message.as_bytes())))
126        .expect("the response is well formed")
127}
128
129/// The file `target` asks for, or `None` for a request to refuse.
130///
131/// The root serves the page; anything escaping `dir` (`..`) is refused rather
132/// than resolved.
133fn resolve(target: &str, dir: &Path) -> Option<PathBuf> {
134    if target.contains("..") {
135        return None;
136    }
137    let path = target.trim_start_matches('/');
138    let path = if path.is_empty() { "index.html" } else { path };
139    let path = dir.join(path);
140    // A directory request resolves to its index, the usual static-server
141    // behavior; a missing file stays a 404.
142    let path = if path.is_dir() {
143        path.join("index.html")
144    } else {
145        path
146    };
147    path.is_file().then_some(path)
148}
149
150/// The response `Content-Type` for `path`, by extension.
151fn mime(path: &Path) -> &'static str {
152    match path.extension().and_then(|ext| ext.to_str()) {
153        Some("html") => "text/html; charset=utf-8",
154        // `text/javascript` is the current RFC; browsers accept the older
155        // `application/javascript` too.
156        Some("js") | Some("mjs") => "text/javascript; charset=utf-8",
157        // Required for `WebAssembly.instantiateStreaming` to work.
158        Some("wasm") => "application/wasm",
159        Some("css") => "text/css; charset=utf-8",
160        Some("json") => "application/json",
161        Some("png") => "image/png",
162        Some("svg") => "image/svg+xml",
163        Some("ico") => "image/x-icon",
164        Some("txt") => "text/plain; charset=utf-8",
165        _ => "application/octet-stream",
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use std::io::{Read, Write};
172    use std::net::TcpStream;
173
174    use super::*;
175
176    /// The directory the test server serves, with one file per case that
177    /// matters: the page, a script, and a wasm binary.
178    fn fixture() -> PathBuf {
179        let dir = std::env::temp_dir().join(format!("unlit3d-http-{}", std::process::id()));
180        std::fs::create_dir_all(&dir).expect("the fixture directory is writable");
181        std::fs::write(dir.join("index.html"), "<html></html>").expect("index is writable");
182        std::fs::write(dir.join("app.js"), "console.log(1)").expect("script is writable");
183        std::fs::write(dir.join("mod.wasm"), b"\0asm").expect("wasm is writable");
184        dir
185    }
186
187    /// Start the server on a free port, returning the base URL.
188    ///
189    /// The listener is bound here, on the test's own thread, and the URL is
190    /// read back from it: the port is then whatever the OS gave, and the client
191    /// cannot end up talking to a different test's server because a fixed port
192    /// was already taken.
193    fn serve_fixture(dir: PathBuf) -> String {
194        // Port 0 asks the OS for a free port, which is the only way to be sure
195        // the client and the server agree on it without racing another test.
196        let listener = bind(0).expect("a free port is available on loopback");
197        let addr = listener
198            .get_ref()
199            .local_addr()
200            .expect("the listener has an address");
201        std::thread::spawn(move || {
202            let _ = serve_listener(listener, &dir);
203        });
204        // The listener is already bound, so the first connection either
205        // succeeds or the server has not reached `accept` yet.
206        for _ in 0..100 {
207            if TcpStream::connect(addr).is_ok() {
208                break;
209            }
210            std::thread::sleep(std::time::Duration::from_millis(20));
211        }
212        format!("{addr}")
213    }
214
215    /// Send one request and return the whole raw response.
216    fn request(addr: &str, method: &str, target: &str) -> String {
217        let mut stream = TcpStream::connect(addr).expect("the test server is listening");
218        write!(
219            stream,
220            "{method} {target} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n"
221        )
222        .expect("the request is written");
223        let mut response = Vec::new();
224        stream
225            .read_to_end(&mut response)
226            .expect("the response is read");
227        String::from_utf8_lossy(&response).into_owned()
228    }
229
230    /// The status line of a response, e.g. `HTTP/1.1 200 OK`.
231    fn status(response: &str) -> &str {
232        response
233            .lines()
234            .next()
235            .expect("a response has a status line")
236    }
237
238    /// The value of a header, matched case-insensitively.
239    fn header<'a>(response: &'a str, name: &str) -> Option<&'a str> {
240        response
241            .lines()
242            .take_while(|line| !line.is_empty())
243            .find_map(|line| {
244                let (key, value) = line.split_once(':')?;
245                key.eq_ignore_ascii_case(name).then(|| value.trim())
246            })
247    }
248
249    #[test]
250    fn serves_files_with_the_mime_a_browser_needs() {
251        let addr = serve_fixture(fixture());
252
253        let index = request(&addr, "GET", "/");
254        assert_eq!(status(&index), "HTTP/1.1 200 OK");
255        assert_eq!(
256            header(&index, "content-type"),
257            Some("text/html; charset=utf-8")
258        );
259        assert!(index.ends_with("<html></html>"));
260
261        let script = request(&addr, "GET", "/app.js");
262        assert_eq!(
263            header(&script, "content-type"),
264            Some("text/javascript; charset=utf-8")
265        );
266
267        // The one that matters: without it `instantiateStreaming` refuses the
268        // module, and the example fails to start.
269        let wasm = request(&addr, "GET", "/mod.wasm");
270        assert_eq!(header(&wasm, "content-type"), Some("application/wasm"));
271    }
272
273    #[test]
274    fn refuses_what_is_not_there_and_what_escapes_the_directory() {
275        let addr = serve_fixture(fixture());
276
277        assert_eq!(
278            status(&request(&addr, "GET", "/missing.html")),
279            "HTTP/1.1 404 Not Found"
280        );
281        // A traversal that escapes the served directory is not resolved.
282        assert_eq!(
283            status(&request(&addr, "GET", "/../Cargo.toml")),
284            "HTTP/1.1 404 Not Found"
285        );
286        assert_eq!(
287            status(&request(&addr, "POST", "/")),
288            "HTTP/1.1 405 Method Not Allowed"
289        );
290    }
291
292    #[test]
293    fn head_answers_with_headers_only() {
294        let addr = serve_fixture(fixture());
295        let response = request(&addr, "HEAD", "/index.html");
296
297        assert_eq!(status(&response), "HTTP/1.1 200 OK");
298        assert_eq!(header(&response, "content-length"), Some("13"));
299        // The body is suppressed, so nothing follows the blank line.
300        let body = response
301            .split_once("\r\n\r\n")
302            .expect("a response has a body separator")
303            .1;
304        assert!(body.is_empty(), "HEAD returned a body: {body:?}");
305    }
306}