1use std::path::{Path, PathBuf};
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`.
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(
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<_>, _>>()?;
29 return Err(format!("no certificates found in {}", cert_path.display()).into());
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)?;
44 acceptor: TlsAcceptor::from(Arc::new(config)),
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 {
56 eprintln!("error: failed to bind to port {}: {}", port, e);
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;
72/// Same as [`run`], but wraps each accepted connection in TLS.
73pub async fn run_https(
76 reload: broadcast::Sender<()>,
79 let listener = match TcpListener::bind(("127.0.0.1", port)).await {
82 eprintln!("error: failed to bind to port {}: {}", port, e);
87 let acceptor = tls.acceptor;
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 {
96 let _ = handle(tls_stream, path, None, rx).await;
98 Err(e) => eprintln!("tls handshake failed: {}", e),
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 {
110 eprintln!("error: failed to bind to port {}: {}", port, e);
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;
126/// Same as [`run_dir`], but wraps each accepted connection in TLS.
127pub async fn run_dir_https(
130 reload: broadcast::Sender<()>,
133 let listener = match TcpListener::bind(("127.0.0.1", port)).await {
136 eprintln!("error: failed to bind to port {}: {}", port, e);
141 let acceptor = tls.acceptor;
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 {
150 let _ = handle_dir_request(tls_stream, dir, rx).await;
152 Err(e) => eprintln!("tls handshake failed: {}", e),
159async fn handle_dir_request<S>(
162 reload_rx: broadcast::Receiver<()>,
163) -> std::io::Result<()>
165 S: AsyncRead + AsyncWrite + Unpin + Send,
167 let mut buf = [0u8; 4096];
168 let n = stream.read(&mut buf).await?;
173 let request = String::from_utf8_lossy(&buf[..n]);
177 .and_then(|line| line.split_whitespace().nth(1))
180 if path == "/_events" {
181 return handle_sse(stream, reload_rx).await;
184 if path.contains("..") {
186 .write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n")
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");
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",
206 stream.write_all(header.as_bytes()).await?;
207 stream.write_all(listing.as_bytes()).await?;
211 let requested = root_dir.join(clean_path);
212 if requested.is_file() {
215 // Try .html extension
216 let with_html = requested.with_extension("html");
217 if with_html.is_file() {
220 // Try as directory with index.html
221 let dir_index = requested.join("index.html");
222 if dir_index.is_file() {
233 let body = match std::fs::read(&fp) {
235 if fp.extension().is_some_and(|e| e == "html") {
236 inject_reload_script(&content)
243 .write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n")
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",
254 stream.write_all(header.as_bytes()).await?;
255 stream.write_all(&body).await?;
258 send_404(&mut stream, clean_path).await?;
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);
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>",
280 if pages.is_empty() {
281 html.push_str("<p>No .html pages found. Compile some .hl files first.</p>");
284 let display = page.trim_start_matches('/');
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>",
296 html.push_str(RELOAD_SCRIPT);
297 html.push_str("</body></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();
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)
310 pages.push(format!("/{}", rel.display()));
319 root_dir: Option<&Path>,
320 reload_rx: broadcast::Receiver<()>,
321) -> std::io::Result<()>
323 S: AsyncRead + AsyncWrite + Unpin + Send,
325 let mut buf = [0u8; 4096];
326 let n = stream.read(&mut buf).await?;
331 let request = String::from_utf8_lossy(&buf[..n]);
335 .and_then(|line| line.split_whitespace().nth(1))
338 if path == "/_events" {
339 handle_sse(stream, reload_rx).await
341 handle_file(stream, &html_path, root_dir, path).await
345async fn handle_sse<S>(mut stream: S, mut rx: broadcast::Receiver<()>) -> std::io::Result<()>
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.
358 .write_all(b"retry: 1000\n: connected\n\n")
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
372 msg = rx.recv() => match msg {
374 if stream.write_all(b"data: reload\n\n").await.is_err() {
378 Err(broadcast::error::RecvError::Lagged(_)) => continue,
379 Err(broadcast::error::RecvError::Closed) => break,
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() {
394async fn handle_file<S>(
397 root_dir: Option<&Path>,
399) -> std::io::Result<()>
401 S: AsyncRead + AsyncWrite + Unpin + Send,
403 if request_path.contains("..") {
405 .write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n")
410 let file_path = if request_path == "/" {
411 html_path.to_path_buf()
413 let dir = root_dir.unwrap_or_else(|| html_path.parent().unwrap_or(Path::new(".")));
414 dir.join(&request_path[1..])
417 let body = match std::fs::read(&file_path) {
419 if file_path.extension().is_some_and(|e| e == "html") {
420 inject_reload_script(&content)
426 send_404(&mut stream, request_path).await?;
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",
436 stream.write_all(header.as_bytes()).await?;
437 stream.write_all(&body).await?;
442async fn send_404<S>(stream: &mut S, path: &str) -> std::io::Result<()>
444 S: AsyncRead + AsyncWrite + Unpin + Send,
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('<', "<").replace('>', ">")
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",
463 stream.write_all(header.as_bytes()).await?;
464 stream.write_all(body.as_bytes()).await?;
468fn inject_reload_script(content: &[u8]) -> Vec<u8> {
469 let html = String::from_utf8_lossy(content);
470 match html.rfind("</body>") {
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);
476 result.push_str(&html[pos..]);
480 let mut result = content.to_vec();
481 result.extend_from_slice(RELOAD_SCRIPT.as_bytes());
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",
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list