//! Serve files from `/state/www` and expose a control endpoint at `/self/control`. //! Start this process detached or as a service so it outlives its caller. //! Clients read `/proc//control`. A fresh core call cannot inspect this process's counters. use sausage_plugin::{Ctx, Result, background, listen, pid, rpc, schema, state_dir}; use std::{cell::Cell, rc::Rc}; use wstd::{ io::{AsyncRead, AsyncWrite}, iter::AsyncIterator, net::TcpListener, }; /// The HTTP plugin starts a server and returns its process ID. struct Www; /// The server configuration. #[schema] struct StartParams { /// The HTTP port on all network interfaces. port: u16, } #[rpc(endpoints(control=Control),grace_seconds=5)] impl Www { /// Start the HTTP server and return its process ID. async fn start(&self, p: StartParams) -> Result { let tcp = TcpListener::bind(&format!("0.0.0.0:{}", p.port)).await?; let control = Rc::new(Control { hits: Cell::new(0) }); listen("/self/control", control.clone()).await?; background(serve(tcp, control)); Ok(pid()) } } /// Shared process-local state for the HTTP task and its endpoint. struct Control { /// The number of answered requests. hits: Cell, } #[rpc] impl Control { /// Count the HTTP requests this server answered. fn stats(&self) -> Result { Ok(self.hits.get()) } /// End this process after the response reaches the client. fn stop(&self, ctx: &Ctx) -> Result<()> { ctx.exit_after_response(); Ok(()) } } /// Answer HTTP requests with files and count all responses, including missing files. async fn serve(tcp: TcpListener, control: Rc) { let mut incoming = tcp.incoming(); while let Some(Ok(mut stream)) = incoming.next().await { let mut request = [0; 8192]; let Ok(size) = stream.read(&mut request).await else { continue; }; let line = String::from_utf8_lossy(&request[..size]); let path = line .split_whitespace() .nth(1) .unwrap_or("/") .trim_start_matches('/'); let (status, body) = match std::fs::read(state_dir().join(if path.is_empty() { "index.html" } else { path })) { Ok(body) => ("200 OK", body), Err(_) => ("404 Not Found", b"Not found".to_vec()), }; control.hits.set(control.hits.get() + 1); let header = format!( "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len() ); let _ = stream.write_all(header.as_bytes()).await; let _ = stream.write_all(&body).await; let _ = stream.flush().await; } } /// Start the SDK transport for the HTTP plugin. fn main() { sausage_plugin::run(Www) } /// Tests check local counters and the service entry point. #[cfg(test)] mod tests { use super::*; /// Control reports its shared counter without a core round trip. #[test] fn stats_reads_counter() { assert_eq!(Control { hits: Cell::new(3) }.stats().unwrap(), 3); } /// The listener binds an ephemeral port and publishes its control endpoint. #[sausage_plugin::test] async fn starts_on_available_port() { assert_eq!(Www.start(StartParams { port: 0 }).await.unwrap(), pid()); } }