on v2026.04.14.130115 help sign in
htmlang/src/serve.rs
160 lines 4.7 kB history raw
1use std::path::{Path, PathBuf};
3use tokio::io::{AsyncReadExt, AsyncWriteExt};
4use tokio::net::{TcpListener, TcpStream};
5use tokio::sync::broadcast;
7const RELOAD_SCRIPT: &str =
8 r#"<script>new EventSource("/_events").onmessage=()=>location.reload()</script>"#;
10pub async fn run(port: u16, html_path: PathBuf, reload: broadcast::Sender<()>) {
11 let listener = match TcpListener::bind(("127.0.0.1", port)).await {
12 Ok(l) => l,
13 Err(e) => {
14 eprintln!("error: failed to bind to port {}: {}", port, e);
15 return;
16 }
17 };
19 loop {
20 if let Ok((stream, _)) = listener.accept().await {
21 let path = html_path.clone();
22 let rx = reload.subscribe();
23 tokio::spawn(async move {
24 let _ = handle(stream, path, rx).await;
25 });
26 }
27 }
30async fn handle(
31 mut stream: TcpStream,
32 html_path: PathBuf,
33 reload_rx: broadcast::Receiver<()>,
34) -> std::io::Result<()> {
35 let mut buf = [0u8; 4096];
36 let n = stream.read(&mut buf).await?;
37 if n == 0 {
38 return Ok(());
39 }
41 let request = String::from_utf8_lossy(&buf[..n]);
42 let path = request
43 .lines()
44 .next()
45 .and_then(|line| line.split_whitespace().nth(1))
46 .unwrap_or("/");
48 if path == "/_events" {
49 handle_sse(stream, reload_rx).await
50 } else {
51 handle_file(stream, &html_path, path).await
52 }
55async fn handle_sse(
56 mut stream: TcpStream,
57 mut rx: broadcast::Receiver<()>,
58) -> std::io::Result<()> {
59 let headers = "HTTP/1.1 200 OK\r\n\
60 Content-Type: text/event-stream\r\n\
61 Cache-Control: no-cache\r\n\
62 Connection: keep-alive\r\n\r\n";
63 stream.write_all(headers.as_bytes()).await?;
65 loop {
66 match rx.recv().await {
67 Ok(()) => {
68 if stream.write_all(b"data: reload\n\n").await.is_err() {
69 break;
70 }
71 }
72 Err(broadcast::error::RecvError::Lagged(_)) => continue,
73 Err(broadcast::error::RecvError::Closed) => break,
74 }
75 }
77 Ok(())
80async fn handle_file(
81 mut stream: TcpStream,
82 html_path: &Path,
83 request_path: &str,
84) -> std::io::Result<()> {
85 if request_path.contains("..") {
86 stream
87 .write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n")
88 .await?;
89 return Ok(());
90 }
92 let file_path = if request_path == "/" {
93 html_path.to_path_buf()
94 } else {
95 let dir = html_path.parent().unwrap_or(Path::new("."));
96 dir.join(&request_path[1..])
97 };
99 let body = match std::fs::read(&file_path) {
100 Ok(content) => {
101 if file_path.extension().map_or(false, |e| e == "html") {
102 inject_reload_script(&content)
103 } else {
104 content
105 }
106 }
107 Err(_) => {
108 stream
109 .write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n")
110 .await?;
111 return Ok(());
112 }
113 };
115 let content_type = content_type_for(&file_path);
116 let header = format!(
117 "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nCache-Control: no-cache\r\n\r\n",
118 body.len(),
119 );
120 stream.write_all(header.as_bytes()).await?;
121 stream.write_all(&body).await?;
123 Ok(())
126fn inject_reload_script(content: &[u8]) -> Vec<u8> {
127 let html = String::from_utf8_lossy(content);
128 match html.rfind("</body>") {
129 Some(pos) => {
130 let mut result = String::with_capacity(html.len() + RELOAD_SCRIPT.len() + 1);
131 result.push_str(&html[..pos]);
132 result.push_str(RELOAD_SCRIPT);
133 result.push('\n');
134 result.push_str(&html[pos..]);
135 result.into_bytes()
136 }
137 None => {
138 let mut result = content.to_vec();
139 result.extend_from_slice(RELOAD_SCRIPT.as_bytes());
140 result
141 }
142 }
145fn content_type_for(path: &Path) -> &'static str {
146 match path.extension().and_then(|e| e.to_str()) {
147 Some("html") => "text/html; charset=utf-8",
148 Some("css") => "text/css; charset=utf-8",
149 Some("js") => "application/javascript",
150 Some("json") => "application/json",
151 Some("png") => "image/png",
152 Some("jpg" | "jpeg") => "image/jpeg",
153 Some("gif") => "image/gif",
154 Some("svg") => "image/svg+xml",
155 Some("ico") => "image/x-icon",
156 Some("woff") => "font/woff",
157 Some("woff2") => "font/woff2",
158 _ => "application/octet-stream",
159 }