on v2026.4.26151538 help sign in
htmlang/src/serve.rs
518 lines 18 kB history raw
1use std::path::{Path, PathBuf};
2use std::sync::Arc;
4use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
5use tokio::net::TcpListener;
6use tokio::sync::broadcast;
7use tokio_rustls::TlsAcceptor;
8use tokio_rustls::rustls::ServerConfig;
9use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer};
11/// TLS configuration for the dev server. Load with `load_tls_config` and pass
12/// to `run_https` / `run_dir_https`.
13pub struct TlsConfig {
14 pub acceptor: TlsAcceptor,
17/// Load a PEM-encoded certificate chain and private key into a `TlsConfig`.
18/// Supports PKCS#8, RSA, and SEC1-encoded private keys.
19pub fn load_tls_config(
20 cert_path: &Path,
21 key_path: &Path,
22) -> Result<TlsConfig, Box<dyn std::error::Error + Send + Sync>> {
23 let cert_bytes = std::fs::read(cert_path)?;
24 let key_bytes = std::fs::read(key_path)?;
26 let certs: Vec<CertificateDer<'static>> =
27 rustls_pemfile::certs(&mut cert_bytes.as_slice()).collect::<Result<Vec<_>, _>>()?;
28 if certs.is_empty() {
29 return Err(format!("no certificates found in {}", cert_path.display()).into());
30 }
32 let key: PrivateKeyDer<'static> = rustls_pemfile::private_key(&mut key_bytes.as_slice())?
33 .ok_or_else(|| format!("no private key found in {}", key_path.display()))?;
35 // rustls requires a crypto provider to be installed globally; install the
36 // default ring-based provider once. Subsequent calls are no-ops.
37 let _ = tokio_rustls::rustls::crypto::ring::default_provider().install_default();
39 let config = ServerConfig::builder()
40 .with_no_client_auth()
41 .with_single_cert(certs, key)?;
43 Ok(TlsConfig {
44 acceptor: TlsAcceptor::from(Arc::new(config)),
45 })
48const RELOAD_SCRIPT: &str =
49 r#"<script>new EventSource("/_events").onmessage=()=>location.reload()</script>"#;
51/// Serve a single HTML file (legacy single-file mode).
52pub async fn run(port: u16, html_path: PathBuf, reload: broadcast::Sender<()>) {
53 let listener = match TcpListener::bind(("127.0.0.1", port)).await {
54 Ok(l) => l,
55 Err(e) => {
56 eprintln!("error: failed to bind to port {}: {}", port, e);
57 return;
58 }
59 };
61 loop {
62 if let Ok((stream, _)) = listener.accept().await {
63 let path = html_path.clone();
64 let rx = reload.subscribe();
65 tokio::spawn(async move {
66 let _ = handle(stream, path, None, rx).await;
67 });
68 }
69 }
72/// Same as [`run`], but wraps each accepted connection in TLS.
73pub async fn run_https(
74 port: u16,
75 html_path: PathBuf,
76 reload: broadcast::Sender<()>,
77 tls: TlsConfig,
78) {
79 let listener = match TcpListener::bind(("127.0.0.1", port)).await {
80 Ok(l) => l,
81 Err(e) => {
82 eprintln!("error: failed to bind to port {}: {}", port, e);
83 return;
84 }
85 };
87 let acceptor = tls.acceptor;
88 loop {
89 if let Ok((stream, _)) = listener.accept().await {
90 let path = html_path.clone();
91 let rx = reload.subscribe();
92 let acceptor = acceptor.clone();
93 tokio::spawn(async move {
94 match acceptor.accept(stream).await {
95 Ok(tls_stream) => {
96 let _ = handle(tls_stream, path, None, rx).await;
97 }
98 Err(e) => eprintln!("tls handshake failed: {}", e),
99 }
100 });
101 }
102 }
105/// Serve a directory of HTML files with auto-compilation, directory index, and route mapping.
106pub async fn run_dir(port: u16, root_dir: PathBuf, reload: broadcast::Sender<()>) {
107 let listener = match TcpListener::bind(("127.0.0.1", port)).await {
108 Ok(l) => l,
109 Err(e) => {
110 eprintln!("error: failed to bind to port {}: {}", port, e);
111 return;
112 }
113 };
115 loop {
116 if let Ok((stream, _)) = listener.accept().await {
117 let dir = root_dir.clone();
118 let rx = reload.subscribe();
119 tokio::spawn(async move {
120 let _ = handle_dir_request(stream, dir, rx).await;
121 });
122 }
123 }
126/// Same as [`run_dir`], but wraps each accepted connection in TLS.
127pub async fn run_dir_https(
128 port: u16,
129 root_dir: PathBuf,
130 reload: broadcast::Sender<()>,
131 tls: TlsConfig,
132) {
133 let listener = match TcpListener::bind(("127.0.0.1", port)).await {
134 Ok(l) => l,
135 Err(e) => {
136 eprintln!("error: failed to bind to port {}: {}", port, e);
137 return;
138 }
139 };
141 let acceptor = tls.acceptor;
142 loop {
143 if let Ok((stream, _)) = listener.accept().await {
144 let dir = root_dir.clone();
145 let rx = reload.subscribe();
146 let acceptor = acceptor.clone();
147 tokio::spawn(async move {
148 match acceptor.accept(stream).await {
149 Ok(tls_stream) => {
150 let _ = handle_dir_request(tls_stream, dir, rx).await;
151 }
152 Err(e) => eprintln!("tls handshake failed: {}", e),
153 }
154 });
155 }
156 }
159async fn handle_dir_request<S>(
160 mut stream: S,
161 root_dir: PathBuf,
162 reload_rx: broadcast::Receiver<()>,
163) -> std::io::Result<()>
164where
165 S: AsyncRead + AsyncWrite + Unpin + Send,
167 let mut buf = [0u8; 4096];
168 let n = stream.read(&mut buf).await?;
169 if n == 0 {
170 return Ok(());
171 }
173 let request = String::from_utf8_lossy(&buf[..n]);
174 let path = request
175 .lines()
176 .next()
177 .and_then(|line| line.split_whitespace().nth(1))
178 .unwrap_or("/");
180 if path == "/_events" {
181 return handle_sse(stream, reload_rx).await;
182 }
184 if path.contains("..") {
185 stream
186 .write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n")
187 .await?;
188 return Ok(());
189 }
191 let clean_path = path.trim_start_matches('/');
193 // Try to resolve the request to a file
194 let file_path = if clean_path.is_empty() {
195 // Root: try index.html
196 let index = root_dir.join("index.html");
197 if index.exists() {
198 Some(index)
199 } else {
200 // Generate directory listing
201 let listing = generate_directory_listing(&root_dir);
202 let header = format!(
203 "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nCache-Control: no-cache\r\n\r\n",
204 listing.len(),
205 );
206 stream.write_all(header.as_bytes()).await?;
207 stream.write_all(listing.as_bytes()).await?;
208 return Ok(());
209 }
210 } else {
211 let requested = root_dir.join(clean_path);
212 if requested.is_file() {
213 Some(requested)
214 } else {
215 // Try .html extension
216 let with_html = requested.with_extension("html");
217 if with_html.is_file() {
218 Some(with_html)
219 } else {
220 // Try as directory with index.html
221 let dir_index = requested.join("index.html");
222 if dir_index.is_file() {
223 Some(dir_index)
224 } else {
225 None
226 }
227 }
228 }
229 };
231 match file_path {
232 Some(fp) => {
233 let body = match std::fs::read(&fp) {
234 Ok(content) => {
235 if fp.extension().is_some_and(|e| e == "html") {
236 inject_reload_script(&content)
237 } else {
238 content
239 }
240 }
241 Err(_) => {
242 stream
243 .write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n")
244 .await?;
245 return Ok(());
246 }
247 };
248 let content_type = content_type_for(&fp);
249 let header = format!(
250 "HTTP/1.1 200 OK\r\nContent-Type: {}\r\nContent-Length: {}\r\nCache-Control: no-cache\r\nAccess-Control-Allow-Origin: *\r\n\r\n",
251 content_type,
252 body.len(),
253 );
254 stream.write_all(header.as_bytes()).await?;
255 stream.write_all(&body).await?;
256 }
257 None => {
258 send_404(&mut stream, clean_path).await?;
259 }
260 }
262 Ok(())
265/// Generate a directory listing HTML page showing available .html pages.
266fn generate_directory_listing(dir: &Path) -> String {
267 let mut pages = Vec::new();
268 collect_html_pages(dir, dir, &mut pages);
269 pages.sort();
271 let mut html = String::from(
272 "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>htmlang dev server</title>\
273 <style>*{margin:0;box-sizing:border-box}body{font-family:system-ui,-apple-system,sans-serif;padding:2rem;max-width:600px;margin:0 auto}\
274 h1{margin-bottom:1.5rem;font-size:1.5rem;color:#333}a{display:block;padding:0.6rem 1rem;margin:0.25rem 0;color:#2563eb;\
275 text-decoration:none;border-radius:6px;transition:background 0.15s}a:hover{background:#f0f4ff}\
276 .path{color:#888;font-size:0.85rem}</style></head><body>\
277 <h1>htmlang dev server</h1>",
278 );
280 if pages.is_empty() {
281 html.push_str("<p>No .html pages found. Compile some .hl files first.</p>");
282 } else {
283 for page in &pages {
284 let display = page.trim_start_matches('/');
285 let clean = display
286 .strip_suffix("/index.html")
287 .unwrap_or(display.strip_suffix(".html").unwrap_or(display));
288 let label = if clean.is_empty() { "index" } else { clean };
289 html.push_str(&format!(
290 "<a href=\"{}\">{} <span class=\"path\">{}</span></a>",
291 page, label, display
292 ));
293 }
294 }
296 html.push_str(RELOAD_SCRIPT);
297 html.push_str("</body></html>");
298 html
301fn collect_html_pages(base: &Path, dir: &Path, pages: &mut Vec<String>) {
302 if let Ok(entries) = std::fs::read_dir(dir) {
303 for entry in entries.flatten() {
304 let path = entry.path();
305 if path.is_dir() {
306 collect_html_pages(base, &path, pages);
307 } else if path.extension().is_some_and(|e| e == "html")
308 && let Ok(rel) = path.strip_prefix(base)
309 {
310 pages.push(format!("/{}", rel.display()));
311 }
312 }
313 }
316async fn handle<S>(
317 mut stream: S,
318 html_path: PathBuf,
319 root_dir: Option<&Path>,
320 reload_rx: broadcast::Receiver<()>,
321) -> std::io::Result<()>
322where
323 S: AsyncRead + AsyncWrite + Unpin + Send,
325 let mut buf = [0u8; 4096];
326 let n = stream.read(&mut buf).await?;
327 if n == 0 {
328 return Ok(());
329 }
331 let request = String::from_utf8_lossy(&buf[..n]);
332 let path = request
333 .lines()
334 .next()
335 .and_then(|line| line.split_whitespace().nth(1))
336 .unwrap_or("/");
338 if path == "/_events" {
339 handle_sse(stream, reload_rx).await
340 } else {
341 handle_file(stream, &html_path, root_dir, path).await
342 }
345async fn handle_sse<S>(mut stream: S, mut rx: broadcast::Receiver<()>) -> std::io::Result<()>
346where
347 S: AsyncRead + AsyncWrite + Unpin + Send,
349 let headers = "HTTP/1.1 200 OK\r\n\
350 Content-Type: text/event-stream\r\n\
351 Cache-Control: no-cache\r\n\
352 Connection: keep-alive\r\n\
353 X-Accel-Buffering: no\r\n\r\n";
354 stream.write_all(headers.as_bytes()).await?;
355 // Send retry hint (ms) so browsers reconnect quickly after a drop, plus an
356 // initial comment line that flushes headers through proxies.
357 if stream
358 .write_all(b"retry: 1000\n: connected\n\n")
359 .await
360 .is_err()
361 {
362 return Ok(());
363 }
365 // Periodic keepalive comment lines are sent every 15s; intervening channel
366 // events cause an immediate reload dispatch. `select!` multiplexes the two.
367 let mut heartbeat = tokio::time::interval(std::time::Duration::from_secs(15));
368 heartbeat.tick().await; // consume immediate first tick
370 loop {
371 tokio::select! {
372 msg = rx.recv() => match msg {
373 Ok(()) => {
374 if stream.write_all(b"data: reload\n\n").await.is_err() {
375 break;
376 }
377 }
378 Err(broadcast::error::RecvError::Lagged(_)) => continue,
379 Err(broadcast::error::RecvError::Closed) => break,
380 },
381 _ = heartbeat.tick() => {
382 // Comment line (starts with `:`) — keeps the connection alive
383 // through proxies and surfaces dead peers as a write error.
384 if stream.write_all(b": keepalive\n\n").await.is_err() {
385 break;
386 }
387 }
388 }
389 }
391 Ok(())
394async fn handle_file<S>(
395 mut stream: S,
396 html_path: &Path,
397 root_dir: Option<&Path>,
398 request_path: &str,
399) -> std::io::Result<()>
400where
401 S: AsyncRead + AsyncWrite + Unpin + Send,
403 if request_path.contains("..") {
404 stream
405 .write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n")
406 .await?;
407 return Ok(());
408 }
410 let file_path = if request_path == "/" {
411 html_path.to_path_buf()
412 } else {
413 let dir = root_dir.unwrap_or_else(|| html_path.parent().unwrap_or(Path::new(".")));
414 dir.join(&request_path[1..])
415 };
417 let body = match std::fs::read(&file_path) {
418 Ok(content) => {
419 if file_path.extension().is_some_and(|e| e == "html") {
420 inject_reload_script(&content)
421 } else {
422 content
423 }
424 }
425 Err(_) => {
426 send_404(&mut stream, request_path).await?;
427 return Ok(());
428 }
429 };
431 let content_type = content_type_for(&file_path);
432 let header = format!(
433 "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nCache-Control: no-cache\r\nAccess-Control-Allow-Origin: *\r\n\r\n",
434 body.len(),
435 );
436 stream.write_all(header.as_bytes()).await?;
437 stream.write_all(&body).await?;
439 Ok(())
442async fn send_404<S>(stream: &mut S, path: &str) -> std::io::Result<()>
443where
444 S: AsyncRead + AsyncWrite + Unpin + Send,
446 let body = format!(
447 "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>404</title>\
448 <style>*{{margin:0;box-sizing:border-box}}body{{font-family:system-ui,-apple-system,sans-serif;\
449 display:flex;align-items:center;justify-content:center;min-height:100vh;background:#fafafa;color:#333}}\
450 .box{{text-align:center;padding:3rem}}\
451 h1{{font-size:4rem;font-weight:200;color:#999;margin-bottom:0.5rem}}\
452 p{{color:#666;margin-bottom:1.5rem}}\
453 code{{background:#eee;padding:0.2em 0.5em;border-radius:4px;font-size:0.9rem}}\
454 a{{color:#2563eb;text-decoration:none}}a:hover{{text-decoration:underline}}</style></head>\
455 <body><div class=\"box\"><h1>404</h1><p>Not found: <code>{}</code></p>\
456 <a href=\"/\">Back to index</a></div></body></html>",
457 path.replace('<', "&lt;").replace('>', "&gt;")
458 );
459 let header = format!(
460 "HTTP/1.1 404 Not Found\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nCache-Control: no-cache\r\n\r\n",
461 body.len()
462 );
463 stream.write_all(header.as_bytes()).await?;
464 stream.write_all(body.as_bytes()).await?;
465 Ok(())
468fn inject_reload_script(content: &[u8]) -> Vec<u8> {
469 let html = String::from_utf8_lossy(content);
470 match html.rfind("</body>") {
471 Some(pos) => {
472 let mut result = String::with_capacity(html.len() + RELOAD_SCRIPT.len() + 1);
473 result.push_str(&html[..pos]);
474 result.push_str(RELOAD_SCRIPT);
475 result.push('\n');
476 result.push_str(&html[pos..]);
477 result.into_bytes()
478 }
479 None => {
480 let mut result = content.to_vec();
481 result.extend_from_slice(RELOAD_SCRIPT.as_bytes());
482 result
483 }
484 }
487fn content_type_for(path: &Path) -> &'static str {
488 match path.extension().and_then(|e| e.to_str()) {
489 Some("html") => "text/html; charset=utf-8",
490 Some("css") => "text/css; charset=utf-8",
491 Some("js" | "mjs") => "application/javascript; charset=utf-8",
492 Some("json") => "application/json; charset=utf-8",
493 Some("png") => "image/png",
494 Some("jpg" | "jpeg") => "image/jpeg",
495 Some("gif") => "image/gif",
496 Some("svg") => "image/svg+xml",
497 Some("ico") => "image/x-icon",
498 Some("woff") => "font/woff",
499 Some("woff2") => "font/woff2",
500 Some("ttf") => "font/ttf",
501 Some("otf") => "font/otf",
502 Some("eot") => "application/vnd.ms-fontobject",
503 Some("webp") => "image/webp",
504 Some("avif") => "image/avif",
505 Some("mp4") => "video/mp4",
506 Some("webm") => "video/webm",
507 Some("ogg") => "audio/ogg",
508 Some("mp3") => "audio/mpeg",
509 Some("wav") => "audio/wav",
510 Some("xml") => "application/xml",
511 Some("txt" | "md") => "text/plain; charset=utf-8",
512 Some("pdf") => "application/pdf",
513 Some("zip") => "application/zip",
514 Some("wasm") => "application/wasm",
515 Some("map") => "application/json",
516 _ => "application/octet-stream",
517 }