1#![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
19pub struct HttpServer {
21 addr: SocketAddr,
22 bucket: Arc<RouterBucket>,
23}
24
25impl HttpServer {
26 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 pub fn addr(&self) -> SocketAddr {
45 self.addr
46 }
47
48 pub fn set_html(&self, html: String) {
51 self.bucket.put(html_single_fs(html));
52 }
53
54 #[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 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
89fn html_single_fs(html: String) -> Router {
91 Box::new(move |route| (route == "/").then(|| HttpBody::Html(html.clone())))
92}
93
94pub enum HttpBody {
96 Html(String),
101 Raw(Bytes),
103}
104
105fn 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 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
143fn 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
167fn 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
187fn handle_events(req: Request, bucket: Arc<RouterBucket>) -> io::Result<()> {
189 std::thread::spawn(move || {
190 let _ = handle_events_blocking(req, &bucket);
193 });
194 Ok(())
195}
196
197fn 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 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 loop {
214 bucket.wait();
215 write!(writer, "event: reload\ndata:\n\n")?;
218 writer.flush()?;
219 }
220}
221
222fn 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
228fn 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
238fn select_mime_type_by_extension(ext: &str) -> Option<&'static str> {
246 Some(match ext.to_lowercase().as_str() {
247 "html" | "htm" => "text/html",
249 "xhtml" => "application/xhtml+xml",
250 "css" => "text/css",
251 "js" | "mjs" => "text/javascript",
252 "wasm" => "application/wasm",
253
254 "typ" => "text/vnd.typst",
256 "txt" => "text/plain",
257 "pdf" => "application/pdf",
258 "md" => "text/markdown",
259
260 "ttc" | "otc" => "font/collection",
262 "ttf" => "font/ttf",
263 "otf" => "font/otf",
264 "woff" => "font/woff",
265 "woff2" => "font/woff2",
266
267 "zip" => "application/zip",
269 "tar" => "application/x-tar",
271
272 "bin" => "application/octet-stream",
275 "csv" => "text/csv",
276 "xml" => "application/xml",
278 "json" => "application/json",
279 "yaml" | "yml" => "application/yaml",
280
281 "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 "mp3" => "audio/mpeg",
295 "ogg" | "oga" | "spx" | "opus" => "audio/ogg",
296 "wav" => "audio/wav",
300
301 "mp4" => "video/mp4",
303 "mpeg" => "video/mpeg",
304 "ogv" => "video/ogg",
305 "webm" => "video/webm",
307
308 _ => return None,
309 })
310}
311
312struct Bucket<T> {
314 mutex: Mutex<T>,
315 condvar: Condvar,
316}
317
318impl<T> Bucket<T> {
319 fn new(init: T) -> Self {
321 Self { mutex: Mutex::new(init), condvar: Condvar::new() }
322 }
323
324 fn get(&self) -> MutexGuard<'_, T> {
326 self.mutex.lock()
327 }
328
329 fn put(&self, data: T) {
332 *self.mutex.lock() = data;
333 self.condvar.notify_all();
334 }
335
336 fn wait(&self) {
338 self.condvar.wait(&mut self.mutex.lock());
339 }
340}
341
342const 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
374const LIVE_RELOAD_SCRIPT: &str = "\
377<script>\
378 new EventSource(\"/__events\")\
379 .addEventListener(\"reload\", () => location.reload())\
380</script>\
381";