#![doc = include_str!("../README.md")]
use std::{
collections::HashMap,
future::Future,
hint,
io::{Read, Write, BufWriter},
net::{TcpListener, TcpStream, Shutdown},
pin::{Pin, pin},
sync::Arc,
task::{Context, Poll, Waker},
time::{Instant, SystemTime, UNIX_EPOCH},
thread
};
use std::path::{Path, PathBuf};
pub trait BodyStream: Send {
fn next_chunk(&mut self) -> Option<Vec<u8>>;
}
impl BodyStream for Vec<u8> {
fn next_chunk(&mut self) -> Option<Vec<u8>> {
if self.is_empty() { None } else { Some(std::mem::take(self)) }
}
}
pub trait IntoBytes { fn into_bytes(self) -> Vec<u8>; }
impl IntoBytes for String { fn into_bytes(self) -> Vec<u8> { self.into_bytes() } }
impl IntoBytes for &str { fn into_bytes(self) -> Vec<u8> { self.as_bytes().to_vec() } }
impl IntoBytes for Vec<u8> { fn into_bytes(self) -> Vec<u8> { self } }
pub struct Req {
pub method: String,
pub path: String,
pub body: String,
pub headers: HashMap<String, String>
}
pub struct Params(pub HashMap<String, String>);
#[derive(Copy, Clone)]
#[repr(u16)]
pub enum StatusCode {
Ok = 200,
Unauthorized = 401,
Forbidden = 403,
NotFound = 404
}
pub struct Reply {
pub status: u16,
pub headers: HashMap<String, String>,
pub body: Box<dyn BodyStream>,
}
impl Reply {
pub fn new(status: StatusCode) -> Self {
Self { status: status as u16, headers: HashMap::new(), body: Box::new(Vec::new()) }
}
pub fn header(mut self, key: &str, value: &str) -> Self {
self.headers.insert(key.to_string(), value.to_string());
self
}
pub fn body<T: IntoBytes>(mut self, data: T) -> Self {
self.body = Box::new(data.into_bytes());
self
}
}
pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
pub type Handler = Box<dyn Fn(Req, Params) -> BoxFuture<'static, Reply> + Send + Sync>;
pub type Middleware = Box<dyn Fn(&str) -> Option<Reply> + Send + Sync>;
pub enum Method { GET, POST }
pub use Method::*;
pub struct WebIo {
routes: Vec<(String, String, Handler)>,
mw: Option<Middleware>,
handlers_404: HashMap<String, Handler>,
static_dir: String,
pub log_reply_enabled: bool,
}
impl WebIo {
pub fn new() -> Self {
Self {
routes: Vec::new(),
mw: None,
handlers_404: HashMap::new() ,
static_dir: "assets".to_string(), log_reply_enabled: false, }
}
fn log_reply(&self, method: &str, path: &str, status: u16, start: Instant, should_log: bool) {
if !should_log {
return;
}
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
println!(
"[{:02}:{:02}:{:02}] {} {} -> {} ({:?})",
(now/3600)%24, (now/60)%60, now%60,
method, path, status, start.elapsed() );
}
pub fn use_static(&mut self, path: &str) {
self.static_dir = path.to_string();
}
async fn serve_static(&self, path: &str) -> Option<Reply> {
let relative_path = path.trim_start_matches('/');
let base_path = PathBuf::from(&self.static_dir);
let target_path = base_path.join(relative_path);
if target_path.exists() && target_path.is_file() {
return self.create_file_reply(&target_path);
}
if relative_path == "favicon.ico" {
if let Some(found_path) = find_file_recursive(&base_path, "favicon.ico") {
return self.create_file_reply(&found_path);
}
}
None
}
fn create_file_reply(&self, path: &Path) -> Option<Reply> {
use std::fs;
if let Ok(content) = fs::read(path) {
let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("");
let ct = match ext {
"ico" => "image/x-icon",
"css" => "text/css",
"js" => "application/javascript",
"svg" => "image/svg+xml",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"mp4" => "video/mp4",
_ => "application/octet-stream",
};
return Some(Reply::new(StatusCode::Ok).header("Content-Type", ct).body(content));
}
None
}
pub fn use_mw<F>(&mut self, f: F) where F: Fn(&str) -> Option<Reply> + Send + Sync + 'static {
self.mw = Some(Box::new(f));
}
pub fn on_404<F, Fut>(&mut self, handler: F)
where F: Fn(Req, Params) -> Fut + Send + Sync + 'static, Fut: Future<Output = Reply> + Send + 'static,
{
let h: Handler = Box::new(move |r, p| Box::pin(handler(r, p)));
let dummy_req = Req { method: "".into(), path: "".into(), body: "".into(), headers: HashMap::new() };
let sniff = launch(h(dummy_req, Params(HashMap::new())));
let ct = sniff.headers.get("Content-Type").cloned().unwrap_or_default();
if ct.contains("json") {
self.handlers_404.insert("json".to_string(), h);
} else {
self.handlers_404.insert("html".to_string(), h);
}
}
pub fn route<F, Fut>(&mut self, method: Method, path: &str, handler: F)
where F: Fn(Req, Params) -> Fut + Send + Sync + 'static, Fut: Future<Output = Reply> + Send + 'static,
{
let m = match method { GET => "GET", POST => "POST" }.to_string();
self.routes.push((m, path.to_string(), Box::new(move |r, p| Box::pin(handler(r, p)))));
}
pub async fn run(self, host: &str, port: &str) {
let listener = TcpListener::bind(format!("{}:{}", host, port)).expect("Bind failed");
println!("🦅 WebIO Live: http://{}:{}", host, port);
let app = Arc::new(self);
for stream in listener.incoming() {
if let Ok(s) = stream {
let a = Arc::clone(&app);
std::thread::spawn(move || launch(a.handle_connection(s)));
}
}
}
async fn handle_connection(&self, mut stream: TcpStream) {
let start_time = Instant::now();
let _ = stream.set_nodelay(true); let _ = stream.set_read_timeout(Some(std::time::Duration::from_millis(150)));
let mut buffer = [0; 4096];
let n = match stream.read(&mut buffer) { Ok(n) if n > 0 => n, _ => return };
let header_str = String::from_utf8_lossy(&buffer[..n]);
let body = if let Some(pos) = header_str.find("\r\n\r\n") {
header_str[pos + 4..].to_string()
} else {
String::new()
};
let mut lines = header_str.lines();
let parts: Vec<&str> = lines.next().unwrap_or("").split_whitespace().collect();
if parts.len() < 2 { return; }
let (method, full_path) = (parts[0], parts[1]);
let mut headers = HashMap::new();
for line in lines {
if line.is_empty() { break; } if let Some((k, v)) = line.split_once(": ") {
headers.insert(k.to_lowercase(), v.to_string());
}
}
if let Some(ref mw_func) = self.mw {
if let Some(early_reply) = mw_func(full_path) {
self.finalize(stream, early_reply, method, full_path, start_time).await;
return;
}
}
let path_only = full_path.split('?').next().unwrap_or("/");
let mut final_params = HashMap::new();
let mut active_handler: Option<&Handler> = None;
let path_segments: Vec<&str> = path_only.split('/').filter(|s| !s.is_empty()).collect();
for (r_method, r_path, handler) in &self.routes {
if r_method != method { continue; }
let route_segments: Vec<&str> = r_path.split('/').filter(|s| !s.is_empty()).collect();
if route_segments.len() == path_segments.len() {
let mut matches = true;
let mut temp_params = HashMap::new();
for (r_seg, p_seg) in route_segments.iter().zip(path_segments.iter()) {
if r_seg.starts_with('<') && r_seg.ends_with('>') {
temp_params.insert(r_seg[1..r_seg.len()-1].to_string(), p_seg.to_string());
} else if r_seg != p_seg { matches = false; break; }
}
if matches { final_params = temp_params; active_handler = Some(handler); break; }
}
}
let req = Req {
method: method.to_string(),
path: full_path.to_string(),
body,
headers
};
let reply = if let Some(handler) = active_handler {
handler(req, Params(final_params)).await
} else if let Some(static_reply) = self.serve_static(path_only).await {
static_reply
} else {
let accept = req.headers.get("accept").cloned().unwrap_or_default();
let h_404 = if accept.contains("text/html") {
self.handlers_404.get("html")
} else {
self.handlers_404.get("json")
};
if let Some(h) = h_404 {
h(req, Params(HashMap::new())).await
} else {
Reply::new(StatusCode::NotFound).body("404 Not Found")
}
};
self.finalize(stream, reply, method, full_path, start_time).await;
}
async fn finalize(&self, stream: TcpStream, reply: Reply, method: &str, path: &str, start: Instant) {
{
let mut writer = BufWriter::with_capacity(65536, &stream);
let mut head = format!(
"HTTP/1.1 {} OK\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n", reply.status
);
for (k, v) in &reply.headers {
head.push_str(&format!("{}: {}\r\n", k, v));
}
head.push_str("\r\n");
let _ = writer.write_all(head.as_bytes());
let mut b = reply.body;
while let Some(data) = b.next_chunk() {
let _ = writer.write_all(format!("{:X}\r\n", data.len()).as_bytes());
let _ = writer.write_all(&data);
let _ = writer.write_all(b"\r\n");
}
let _ = writer.write_all(b"0\r\n\r\n");
let _ = writer.flush();
}
self.log_reply(method, path, reply.status, start, self.log_reply_enabled);
let _ = stream.shutdown(Shutdown::Both);
}
}
pub fn launch<F: Future>(future: F) -> F::Output {
let mut future = pin!(future);
let waker = Waker::noop(); let mut cx = Context::from_waker(waker);
let mut spins = 0u64;
loop {
match future.as_mut().poll(&mut cx) {
Poll::Ready(v) => return v,
Poll::Pending => {
if spins < 150_000 { hint::spin_loop(); spins += 1;
} else {
thread::yield_now(); spins = 0;
}
}
}
}
}
fn find_file_recursive(dir: &std::path::Path, filename: &str) -> Option<std::path::PathBuf> {
if !dir.is_dir() { return None; }
let current_check = dir.join(filename);
if current_check.exists() { return Some(current_check); }
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if let Some(found) = find_file_recursive(&path, filename) {
return Some(found);
}
}
}
}
None
}