Skip to main content

typst_kit/
server.rs

1//! A minimal hot-reloading HTTP server.
2
3#![cfg(feature = "http-server")]
4
5use std::io::{self, Write};
6use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener};
7use std::sync::Arc;
8
9use ecow::eco_format;
10use parking_lot::{Condvar, Mutex, MutexGuard};
11use percent_encoding::percent_decode_str;
12use tiny_http::{Header, Request, Response, StatusCode};
13use typst_library::diag::{StrResult, bail};
14use typst_library::foundations::Bytes;
15
16type Router = Box<dyn Fn(&str) -> Option<HttpBody> + Send + Sync>;
17type RouterBucket = Bucket<Router>;
18
19/// Serves HTML with live reload.
20pub struct HttpServer {
21    addr: SocketAddr,
22    bucket: Arc<RouterBucket>,
23}
24
25impl HttpServer {
26    /// Create a new HTTP server that serves live HTML.
27    pub fn new(title: &str, port: Option<u16>, live: bool) -> StrResult<Self> {
28        let (addr, server) = start_server(port)?;
29
30        let placeholder = PLACEHOLDER_HTML.replace("{INPUT}", title);
31        let bucket = Arc::new(Bucket::new(html_single_fs(placeholder)));
32        let bucket2 = bucket.clone();
33
34        std::thread::spawn(move || {
35            for req in server.incoming_requests() {
36                let _ = handle(req, live, &bucket2);
37            }
38        });
39
40        Ok(Self { addr, bucket })
41    }
42
43    /// The address that we serve the HTML on.
44    pub fn addr(&self) -> SocketAddr {
45        self.addr
46    }
47
48    /// Updates the served contents to a page of HTML served on `/`, triggering
49    /// a reload in all connected browsers.
50    pub fn set_html(&self, html: String) {
51        self.bucket.put(html_single_fs(html));
52    }
53
54    /// Updates the served contents to a bundle.
55    #[cfg(feature = "bundle")]
56    pub fn set_bundle(&self, bundle: typst_bundle::Bundle, fs: typst_bundle::VirtualFs) {
57        self.set_router(move |route| {
58            let path = typst_syntax::VirtualPath::new(route).ok()?;
59            let with_index = path.join("index.html").unwrap();
60            for path in [path, with_index] {
61                let Some(data) = fs.get(&path) else { continue };
62                let body = if matches!(
63                    bundle.files.get(&path),
64                    Some(typst_bundle::BundleFile::Document(
65                        typst_bundle::BundleDocument::Html(_)
66                    ))
67                ) && let Ok(string) = data.as_str()
68                {
69                    HttpBody::Html(string.to_owned())
70                } else {
71                    HttpBody::Raw(data.clone())
72                };
73                return Some(body);
74            }
75
76            None
77        });
78    }
79
80    /// Updates the content handler, triggering a reload in all connected browsers.
81    pub fn set_router<R>(&self, router: R)
82    where
83        R: Fn(&str) -> Option<HttpBody> + Send + Sync + 'static,
84    {
85        self.bucket.put(Box::new(router));
86    }
87}
88
89/// Creates a handler that serves just one HTML page at `/`.
90fn html_single_fs(html: String) -> Router {
91    Box::new(move |route| (route == "/").then(|| HttpBody::Html(html.clone())))
92}
93
94/// Something that can be served by the [`HttpServer`].
95pub enum HttpBody {
96    /// An HTML page.
97    ///
98    /// The string must contain valid HTML. If live reload is enabled, a script
99    /// will be injected into the HTML.
100    Html(String),
101    /// A raw body that does not support live reload.
102    Raw(Bytes),
103}
104
105/// Starts a local HTTP server.
106///
107/// Uses the specified port or tries to find a free port in the range
108/// `3000..=3005`.
109fn start_server(port: Option<u16>) -> StrResult<(SocketAddr, tiny_http::Server)> {
110    const BASE_PORT: u16 = 3000;
111
112    let mut addr;
113    let mut retries = 0;
114
115    let listener = loop {
116        addr = SocketAddr::new(
117            IpAddr::V4(Ipv4Addr::LOCALHOST),
118            port.unwrap_or(BASE_PORT + retries),
119        );
120
121        match TcpListener::bind(addr) {
122            Ok(listener) => break listener,
123            Err(err) if err.kind() == io::ErrorKind::AddrInUse => {
124                if let Some(port) = port {
125                    bail!("port {port} is already in use")
126                } else if retries < 5 {
127                    // If the port is in use, try the next one.
128                    retries += 1;
129                } else {
130                    bail!("could not find free port for HTTP server");
131                }
132            }
133            Err(err) => bail!("failed to start TCP server: {err}"),
134        }
135    };
136
137    let server = tiny_http::Server::from_listener(listener, None)
138        .map_err(|err| eco_format!("failed to start HTTP server: {err}"))?;
139
140    Ok((addr, server))
141}
142
143/// Handles a request.
144fn handle(req: Request, reload: bool, bucket: &Arc<RouterBucket>) -> io::Result<()> {
145    let base = url::Url::parse("http://localhost").unwrap();
146    let Ok(url) = base.join(req.url()) else {
147        return req.respond(Response::empty(StatusCode(400)));
148    };
149
150    let path = url.path();
151    let Ok(path) = percent_decode_str(path).decode_utf8() else {
152        return req.respond(Response::empty(StatusCode(400)));
153    };
154
155    if path == "/__events" {
156        return handle_events(req, bucket.clone());
157    }
158
159    let fs = bucket.get();
160    let Some(body) = fs(path.as_ref()) else {
161        return req.respond(Response::empty(StatusCode(404)));
162    };
163
164    handle_body(req, reload, body)
165}
166
167/// Handles for the `/` route. Serves the compiled HTML.
168fn handle_body(req: Request, reload: bool, mut body: HttpBody) -> io::Result<()> {
169    let (data, mime) = match &mut body {
170        HttpBody::Html(html) => {
171            if reload {
172                inject_live_reload_script(html);
173            }
174            (html.as_bytes(), Some("text/html"))
175        }
176        HttpBody::Raw(data) => (data.as_slice(), select_mime_type(req.url(), data)),
177    };
178
179    let mut headers = Vec::new();
180    if let Some(mime) = mime {
181        headers.push(Header::from_bytes("Content-Type", mime).unwrap());
182    }
183
184    req.respond(Response::new(StatusCode(200), headers, data, Some(data.len()), None))
185}
186
187/// Handler for the `/__events` route.
188fn handle_events(req: Request, bucket: Arc<RouterBucket>) -> io::Result<()> {
189    std::thread::spawn(move || {
190        // When this returns an error, the client is disconnected and we can
191        // terminate the thread.
192        let _ = handle_events_blocking(req, &bucket);
193    });
194    Ok(())
195}
196
197/// Event stream for the `/events` route.
198fn handle_events_blocking(req: Request, bucket: &RouterBucket) -> io::Result<()> {
199    let mut writer = req.into_writer();
200    let writer: &mut dyn Write = &mut *writer;
201
202    // We need to write the header manually because `tiny-http` defaults to
203    // `Transfer-Encoding: chunked` when no `Content-Length` is provided, which
204    // Chrome & Safari dislike for `Content-Type: text/event-stream`.
205    write!(writer, "HTTP/1.1 200 OK\r\n")?;
206    write!(writer, "Content-Type: text/event-stream\r\n")?;
207    write!(writer, "Cache-Control: no-cache\r\n")?;
208    write!(writer, "\r\n")?;
209    writer.flush()?;
210
211    // If the user closes the browser tab, this loop will terminate once it
212    // tries to write to the dead socket for the first time.
213    loop {
214        bucket.wait();
215        // Trigger a server-sent event. The browser is listening to it via
216        // an `EventSource` listener` (see `inject_script`).
217        write!(writer, "event: reload\ndata:\n\n")?;
218        writer.flush()?;
219    }
220}
221
222/// Injects the live reload script into a string of HTML.
223fn inject_live_reload_script(html: &mut String) {
224    let pos = html.rfind("</body>").unwrap_or(html.len());
225    html.insert_str(pos, LIVE_RELOAD_SCRIPT);
226}
227
228/// Selects a MIME type for a request based on file extension and/or data.
229///
230/// First, tries to find a match for the file extension and if that yields
231/// nothing, inspects the data itself.
232fn select_mime_type(path: &str, buf: &[u8]) -> Option<&'static str> {
233    path.rsplit_once('.')
234        .and_then(|(_, r)| select_mime_type_by_extension(r))
235        .or_else(|| infer::get(buf).map(|ty| ty.mime_type()))
236}
237
238/// Selects a MIME type for a request based on the file extension.
239///
240/// See <https://www.iana.org/assignments/media-types/media-types.xhtml> for the
241/// full list of standardized MIME types. This function only handles a subset of
242/// this list.
243///
244/// Additionally, it handles a few very commonly used unstandardized types.
245fn select_mime_type_by_extension(ext: &str) -> Option<&'static str> {
246    Some(match ext.to_lowercase().as_str() {
247        // Web.
248        "html" | "htm" => "text/html",
249        "xhtml" => "application/xhtml+xml",
250        "css" => "text/css",
251        "js" | "mjs" => "text/javascript",
252        "wasm" => "application/wasm",
253
254        // Document.
255        "typ" => "text/vnd.typst",
256        "txt" => "text/plain",
257        "pdf" => "application/pdf",
258        "md" => "text/markdown",
259
260        // Font.
261        "ttc" | "otc" => "font/collection",
262        "ttf" => "font/ttf",
263        "otf" => "font/otf",
264        "woff" => "font/woff",
265        "woff2" => "font/woff2",
266
267        // Container.
268        "zip" => "application/zip",
269        // Not standardized by IANA.
270        "tar" => "application/x-tar",
271
272        // Data.
273        // This file extension is not specified, but commonly used.
274        "bin" => "application/octet-stream",
275        "csv" => "text/csv",
276        // RFC 7303, § 4.1 recommends `application/` over `text/`.
277        "xml" => "application/xml",
278        "json" => "application/json",
279        "yaml" | "yml" => "application/yaml",
280
281        // Image.
282        "avif" => "image/avif",
283        "bmp" => "image/bmp",
284        "gif" => "image/gif",
285        "ico" => "image/vnd.microsoft.icon",
286        "jpg" | "jpeg" => "image/jpeg",
287        "jxl" => "image/jxl",
288        "png" => "image/png",
289        "svg" => "image/svg+xml",
290        "tif" | "tiff" => "image/tiff",
291        "webp" => "image/webp",
292
293        // Audio.
294        "mp3" => "audio/mpeg",
295        "ogg" | "oga" | "spx" | "opus" => "audio/ogg",
296        // Not standardized by IANA and there are other common variations (e.g.
297        // `audio/x-wav` and `audio/vnd.wave`), but this one seems most commonly
298        // in use.
299        "wav" => "audio/wav",
300
301        // Video.
302        "mp4" => "video/mp4",
303        "mpeg" => "video/mpeg",
304        "ogv" => "video/ogg",
305        // Not standardized by IANA, but this type is practically in use.
306        "webm" => "video/webm",
307
308        _ => return None,
309    })
310}
311
312/// Holds data and notifies consumers when it's updated.
313struct Bucket<T> {
314    mutex: Mutex<T>,
315    condvar: Condvar,
316}
317
318impl<T> Bucket<T> {
319    /// Creates a new bucket with initial data.
320    fn new(init: T) -> Self {
321        Self { mutex: Mutex::new(init), condvar: Condvar::new() }
322    }
323
324    /// Retrieves the current data in the bucket.
325    fn get(&self) -> MutexGuard<'_, T> {
326        self.mutex.lock()
327    }
328
329    /// Puts new data into the bucket and notifies everyone who's currently
330    /// [waiting](Self::wait).
331    fn put(&self, data: T) {
332        *self.mutex.lock() = data;
333        self.condvar.notify_all();
334    }
335
336    /// Waits for new data in the bucket.
337    fn wait(&self) {
338        self.condvar.wait(&mut self.mutex.lock());
339    }
340}
341
342/// The initial HTML before compilation is finished.
343const PLACEHOLDER_HTML: &str = "\
344<!DOCTYPE html>
345<html>
346  <head>
347    <meta charset=\"utf-8\">
348    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
349    <title>Waiting for {INPUT}</title>
350    <style>
351      body {
352        display: flex;
353        justify-content: center;
354        align-items: center;
355        color: #565565;
356        background: #eff0f3;
357      }
358
359      body > main > div {
360        margin-block: 16px;
361        text-align: center;
362      }
363    </style>
364  </head>
365  <body>
366    <main>
367      <div>Waiting for output…</div>
368      <div><code>typst watch {INPUT}</code></div>
369    </main>
370  </body>
371</html>
372";
373
374/// Reloads the page whenever it receives a "reload" server-sent event
375/// on the `/__events` route.
376const LIVE_RELOAD_SCRIPT: &str = "\
377<script>\
378  new EventSource(\"/__events\")\
379    .addEventListener(\"reload\", () => location.reload())\
380</script>\
381";