use std::{collections::HashMap, net::SocketAddr, sync::Arc, time::Duration}; use anyhow::{Context, Result}; use axum::{ body::Body, extract::{ ws::{Message, WebSocket}, Path, State, WebSocketUpgrade, }, http::StatusCode, response::{IntoResponse, Response}, routing::{get, post}, Form, Router, }; use maud::{html, Markup, PreEscaped, DOCTYPE}; use rand::random; use serde::Deserialize; use tokio::sync::{ broadcast::{self, Receiver, Sender}, Mutex, }; use tower_http::{ catch_panic::CatchPanicLayer, services::ServeDir, trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer}, }; use tracing::{error, info, trace, warn, Level}; use tracing_subscriber::FmtSubscriber; /// If a handler panics we run this function and return the [Response]. fn handle_panic(err: Box) -> Response { let details = if let Some(s) = err.downcast_ref::() { s.clone() } else if let Some(s) = err.downcast_ref::<&str>() { s.to_string() } else { "Unknown panic message".to_string() }; error!(details = details, "Handler paniced"); (StatusCode::INTERNAL_SERVER_ERROR).into_response() } #[derive(Deserialize)] struct NoteDatum { // Epoch time in ms time: u64, // Fraction up/down y: f64, } #[derive(Deserialize)] struct Notes { notes: Vec, } #[derive(Deserialize)] struct NotesForm { notes: String, } struct Game { id: u64, is_started: bool, submitted: Option, /// Sender to broadcast things broadcast_tx: Sender, /// Broadcast receiver to send to WebSocket broadcast_rx: Receiver, } impl Game { /// Return the appropriate HTML for the game state. fn get_html(&self) -> Markup { let gid = self.id; if self.submitted.is_some() { return html! { h1 { "Submitted!" } }; } if self.is_started { return html! { script { ({PreEscaped("document.notes = []; ")})} h1 { "Playing Game" } p { "Were playing here" } canvas #canvas height=(256) width=(256) style="position:relative; background: cyan;" {}; script { (PreEscaped(r#"document.getElementById("canvas").onclick = (e) => { const notes = JSON.parse(e.target.dataset.notes ?? "[]"); notes.push({ time: Date.now(), y: e.layerY / e.target.height}); e.target.dataset.notes = JSON.stringify(notes); }"#))} button hx-post=(format!("/game/{gid}/submit")) hx-vals=r#"js:{ "notes": document.getElementById("canvas").dataset.notes }"# { "Submit" } }; } html! { h1 { "Game" } p { "Current game id is " (gid)} div #messages {}; button hx-post=(format!("/game/{gid}/start")) { "Start" } } } } async fn submit_game_answer(lock: Arc>, notes: Notes) { lock.lock().await.submitted = Some(notes); tokio::spawn(async move { let _ = tokio::time::sleep(Duration::from_secs(2)).await; let mut g = lock.lock().await; g.is_started = false; g.submitted = None; g.broadcast_tx.send( html! { section #content { (g.get_html()) } } .into_string(), ) }); } struct ServerState { games: HashMap>>, } impl ServerState { fn new_game(&mut self, gid: u64) { let (tx, rx) = broadcast::channel(16); let g = Game { id: gid, is_started: false, submitted: None, broadcast_rx: rx, broadcast_tx: tx, }; self.games.insert(gid, Arc::new(Mutex::new(g))); } } type Server = Arc>; async fn route_index(State(st): State) -> Response { let game_ids = st.lock().await.games.keys().cloned().collect::>(); let game_list = if game_ids.is_empty() { html! { "No current games"} } else { html! { h3 { "Games" } ul { @for id in &game_ids { li { a href=(format!("/game/{id}")) { (id) } } } } } }; let html = html! { h1 { "Good game "} (game_list) button hx-post="/game" { "Create game" } }; html_response(html! { (DOCTYPE) head { meta charset="utf-8"; title { "Good Game" }; meta name="viewport" content="width=device-width, initial-scale=1" {}; script src="https://unpkg.com/htmx.org@2.0.4" {}; link rel="stylesheet" type="text/css" href="static/style.css"; } body { (html) } }) } fn html_response(html: PreEscaped) -> Response { Response::builder() .status(StatusCode::OK) .body(Body::from(html.into_string())) .expect("Failed to set body") } async fn create_game(State(st): State) -> Response { let gid = random::(); let _ = { let mut s = st.lock().await; s.new_game(gid); }; Response::builder() .header("hx-redirect", format!("/game/{gid}")) .body(Body::empty()) .expect("failed to build response") } async fn start_game(Path(gid): Path, State(st): State) -> Response { let lock = { if let Some(g) = st.lock().await.games.get(&gid) { g.clone() } else { return StatusCode::NOT_FOUND.into_response(); } }; let mut g = lock.lock().await; g.is_started = true; let html = g.get_html(); g.broadcast_tx .send(html! { section #content { (html) } }.into_string()) .expect("failed to send"); StatusCode::OK.into_response() } async fn get_game(Path(gid): Path, State(st): State) -> Response { let game = { if let Some(g) = st.lock().await.games.get(&gid) { g.clone() } else { return (StatusCode::NOT_FOUND).into_response(); } }; let html = game.lock().await.get_html(); html_response(html! { (DOCTYPE) head { meta charset="utf-8"; title { "Good Game" }; meta name="viewport" content="width=device-width, initial-scale=1" {}; script src="https://unpkg.com/htmx.org@2.0.4" {}; script src="https://unpkg.com/htmx-ext-ws@2.0.1/ws.js" {}; link rel="stylesheet" type="text/css" href="/static/style.css"; } body hx-ext="ws" ws-connect=(format!("/game/{gid}/ws")) { footer { } section #content { (html) } footer { span { "Good Game" } } } }) } async fn submit_game( Path(gid): Path, State(st): State, Form(form): Form, ) -> Response { let glock = { if let Some(g) = st.lock().await.games.get(&gid) { g.clone() } else { return (StatusCode::NOT_FOUND).into_response(); } }; let Ok(notes) = serde_json::from_str(&form.notes) else { warn!(notes = form.notes); return (StatusCode::UNPROCESSABLE_ENTITY).into_response(); }; submit_game_answer(glock.clone(), Notes { notes }).await; let g = glock.lock().await; let html = g.get_html(); g.broadcast_tx .send(html! { section #content { (html) } }.into_string()) .expect("failed to send"); StatusCode::OK.into_response() } async fn game_ws(ws: WebSocketUpgrade, Path(gid): Path, State(st): State) -> Response { let game = { let s = st.lock().await; if let Some(g) = s.games.get(&gid) { g.clone() } else { return StatusCode::NOT_FOUND.into_response(); } }; ws.on_upgrade(move |socket| handle_socket(socket, game)) .into_response() } async fn handle_socket(mut socket: WebSocket, game: Arc>) { if socket .send(Message::Ping(bytes::Bytes::from("wat"))) .await .is_ok() { trace!("Pinged ws"); } else { println!("Could not send ping!"); return; } { game.lock() .await .broadcast_tx .send( html! { div #messages hx-swap-oob="beforeend" { p { "Someone joined" } } } .into_string(), ) .unwrap(); } let mut rx = { game.lock().await.broadcast_rx.resubscribe() }; while let Ok(a) = rx.recv().await { trace!(a, "send "); let res = socket.send(a.into()).await; trace!(?res); } } #[tokio::main] async fn main() -> Result<()> { let subscriber = FmtSubscriber::builder() .with_max_level(Level::TRACE) .finish(); tracing::subscriber::set_global_default(subscriber) .expect("set global default subscriber failed"); let mut server_state = ServerState { games: HashMap::new(), }; server_state.new_game(123); let app = Router::new() .route("/", get(route_index)) .route("/game", post(create_game)) .route("/game/{gid}", get(get_game)) .route("/game/{gid}/start", post(start_game)) .route("/game/{gid}/submit", post(submit_game)) .route("/game/{gid}/ws", get(game_ws)) .nest_service("/static", ServeDir::new("static")) .layer(CatchPanicLayer::custom(handle_panic)) .layer( TraceLayer::new_for_http() .make_span_with(DefaultMakeSpan::new().level(Level::INFO)) .on_response(DefaultOnResponse::new().level(Level::INFO)), ) .with_state(Arc::new(Mutex::new(server_state))); let addr = "192.168.0.106:4800"; info!("Listening on {addr}"); let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); axum::serve( listener, app.into_make_service_with_connect_info::(), ) .await .context("serve") }