diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/hot_reload.js | 81 | ||||
| -rw-r--r-- | src/hot_reload.rs | 109 | ||||
| -rw-r--r-- | src/main.rs | 76 |
3 files changed, 251 insertions, 15 deletions
diff --git a/src/hot_reload.js b/src/hot_reload.js new file mode 100644 index 0000000..0777380 --- /dev/null +++ b/src/hot_reload.js @@ -0,0 +1,81 @@ +const WS_RETRY_MS = 1_000; + +function refetchCSS(file) { + const node = Array.from(document.head.childNodes).find( + (n) => n.href && n.href.includes(file), + ); + if (!node) { + console.warn("Could not find node", file); + console.log(Array.from(document.head.childNodes)); + return; + } + const url = new URL(node.href); + url.searchParams.set("v", parseInt(url.searchParams.get("v") || "0") + 1); + + const n2 = node.cloneNode(); + n2.href = url.toString(); + document.head.appendChild(n2); + setTimeout(() => { + document.head.removeChild(node); + }, 100); +} + +let __file_to_module = {}; +function refetchJS(file) { + if (file in __file_to_module) { + const o = __file_to_module[file]; + if ("__cleanup" in o) { + o.__cleanup(); + } else { + console.warn("no cleanup"); + } + } + import(`/static/${file}?v=${Date.now()}`) + .then((m) => { + __file_to_module[file] = m; + }) + .catch((e) => { + console.error(e); + }); + return; +} + +function connect() { + console.log("try to connect through websocket"); + const ws = new WebSocket(`ws://${window.location.host}/dev-hr/ws`); + + const file2timeout = new Map(); + ws.addEventListener("message", (msg) => { + const file = msg.data; // the path to a changed file relative to `static/`. + if (file2timeout.has(file)) clearTimeout(file2timeout.get(file)); + file2timeout.set( + file, + setTimeout(() => { + if (file.slice(-4) === ".css") refetchCSS(file); + else if (file.slice(-3) === ".js") refetchJS(file); + else console.warn("unknown file extension", file); + }, 100), + ); + }); + + ws.addEventListener("open", () => { + console.log("websocket connected"); + }); + + ws.addEventListener("close", () => { + console.log("websocket disconnected"); + setTimeout(() => { + try { + connect(); + } catch (e) { + console.error(e); + } + }, WS_RETRY_MS); + }); + + ws.addEventListener("error", (e) => { + console.error(e); + ws.close(); + }); +} +setTimeout(() => connect(), WS_RETRY_MS); diff --git a/src/hot_reload.rs b/src/hot_reload.rs new file mode 100644 index 0000000..88cfc21 --- /dev/null +++ b/src/hot_reload.rs @@ -0,0 +1,109 @@ +use std::{ + env, + net::SocketAddr, + path::{Path, PathBuf}, + sync::Arc, +}; + +use anyhow::Context; +use axum::{ + extract::{ + ws::{Message, WebSocket}, + ConnectInfo, State, WebSocketUpgrade, + }, + response::IntoResponse, + routing, Router, +}; +use bytes::Bytes; +use notify::{Event, FsEventWatcher, Watcher}; +use tracing::trace; + +pub struct HotReload { + watcher: FsEventWatcher, + + tx: tokio::sync::broadcast::Sender<String>, + rx: tokio::sync::broadcast::Receiver<String>, +} + +impl HotReload { + pub fn new(base_path: impl AsRef<Path>) -> Arc<Self> { + Arc::new_cyclic(|hr| { + let weak = hr.clone(); + let static_dir = PathBuf::from(base_path.as_ref()) + .canonicalize() + .context("canonicalize static_dir") + .unwrap(); + + let (_tx, rx) = tokio::sync::broadcast::channel(16); + + let tx = _tx.clone(); + + let mut watcher = notify::recommended_watcher( + move |res: std::result::Result<Event, notify::Error>| { + let Some(hot) = weak.upgrade() else { + return; + }; + match res { + Ok(event) => { + for path in &event.paths { + let Ok(p) = path.strip_prefix(&static_dir) else { + continue; + }; + if p.extension().is_some_and(|o| o == "css" || o == "js") { + let s = p.file_name().unwrap().to_string_lossy().to_string(); + tx.send(s).expect("Failed to send to channel"); + } + } + } + Err(e) => println!("watch error: {:?}", e), + } + }, + ) + .context("create watcher") + .unwrap(); + + watcher + .watch(std::path::Path::new("."), notify::RecursiveMode::Recursive) + .context("watcher.watch") + .unwrap(); + + HotReload { + watcher, + tx: _tx, + rx, + } + }) + } +} + +pub fn router(watch_dir: impl AsRef<Path>) -> Router<()> { + let hrl = HotReload::new(watch_dir); + Router::new() + .route("/ws", routing::get(handler_ws)) + .with_state(hrl) +} + +pub async fn handler_ws( + ws: WebSocketUpgrade, + State(st): State<Arc<HotReload>>, + ConnectInfo(addr): ConnectInfo<SocketAddr>, +) -> impl IntoResponse { + trace!("Connected to ws"); + ws.on_upgrade(move |socket| handle_socket(socket, addr, st)) +} + +async fn handle_socket(mut socket: WebSocket, _: SocketAddr, st: Arc<HotReload>) { + if socket.send(Message::Ping(Bytes::new())).await.is_ok() { + trace!("Pinged ws"); + } else { + println!("Could not send ping!"); + return; + } + + let mut rx = st.rx.resubscribe(); + while let Ok(a) = rx.recv().await { + trace!(a, "send "); + let res = socket.send(a.into()).await; + trace!(?res); + } +} diff --git a/src/main.rs b/src/main.rs index a6963c4..773c0e3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ use std::{ collections::{HashMap, HashSet}, + env, net::SocketAddr, sync::Arc, }; @@ -50,6 +51,23 @@ fn handle_panic(err: Box<dyn std::any::Any + Send + 'static>) -> Response { #[derive(Hash, Eq, PartialEq, Copy, Clone, Debug, PartialOrd, Ord, Deserialize)] struct UserId(u64); +impl UserId { + fn funny_name(&self) -> String { + #[rustfmt::skip] + const ADJECTIVES: [&'static str; 32] = [ "Beautiful", "Brave", "Bright", "Clever", "Calm", "Dull", "Eager", "Fancy", "Gentle", "Harsh", "Humble", "Kind", "Lazy", "Loud", "Narrow", "Quiet", "Quick", "Rare", "Rough", "Shiny", "Smart", "Smooth", "Strong", "Sweet", "Tall", "Tense", "Ugly", "Vast", "Weak", "Wise", "Young", "Zealous", ]; + #[rustfmt::skip] + const VERBS: [&'static str; 32]= [ "Running", "Jumping", "Walking", "Talking", "Eating", "Drinking", "Laughing", "Crying", "Singing", "Dancing", "Writing", "Reading", "Sleeping", "Driving", "Cooking", "Building", "Breaking", "Thinking", "Speaking", "Listening", "Watching", "Throwing", "Catching", "Swimming", "Playing", "Fighting", "Working", "Learning", "Teaching", "Growing", "Loving", "Sharing", ]; + #[rustfmt::skip] + const NOUNS: [&'static str; 32]= [ "Clock", "Cloud", "Car", "Tree", "Book", "Candle", "Mirror", "Robot", "Star", "Moon", "Sun", "River", "Rock", "Mountain", "Flower", "Train", "Shoe", "House", "Boat", "Bridge", "Key", "Pencil", "Cup", "Chair", "Phone", "Lamp", "Blanket", "Door", "Toy", "Kite", "Basket", "Balloon", ]; + + let u = self.0 as usize; + let noun = NOUNS[(u >> 0) & 31]; + let verb = VERBS[(u >> 5) & 31]; + let ajd = ADJECTIVES[(u >> 10) & 31]; + format!("{} {} {}", ajd, verb, noun) + } +} + #[derive(Serialize, Deserialize, Debug, Clone)] struct NoteDatum { // Epoch time in ms @@ -248,18 +266,21 @@ impl Game { html! { script { ({PreEscaped("document.notes = []; ")})} h1 { "Playing Game" } - p { "Were playing here" } + p { "Play a tune" } canvas #canvas - height=(256) width=(256) - style="position:relative; background: cyan;" + height=(512) width=(256) + style="position:relative;" {}; - script { (PreEscaped(r#"document.getElementById("canvas").onclick = (e) => { + script { (PreEscaped(r#"cv = document.getElementById("canvas"); + cv.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); - }"#))} + } + drawNoteLines(cv); + "#))} button hx-post=(format!("/game/{gid}/submit")) hx-vals=r#"js:{ "notes": document.getElementById("canvas").dataset.notes }"# @@ -301,7 +322,7 @@ impl Game { html! { h1 { (format!("Round {i}/{n}"))} - p { "Author was " (r.author.0)} + p { "Author was " (r.author.funny_name())} form #form-guess { input placeholder="Your guess..." name="guess" {}; input type="submit" hx-post=(format!("/game/{gid}/guess")) hx-target="#form-guess" {}; @@ -349,7 +370,7 @@ loadSample(soundUrl).then((sample) => { data-accept=(accept) hx-post=(post) hx-swap="outerHTML" - { (g) " (click to toggle)"} + { (g) } } } @@ -360,6 +381,7 @@ loadSample(soundUrl).then((sample) => { html! { h1 { "Mark correct answers" } + span { "Click an answer to toggle correctness"} ul #guesslist { @for &u in v { (self.guess_li(u)) @@ -394,7 +416,7 @@ loadSample(soundUrl).then((sample) => { h1 { "Leaderboard" } ol { @for (uid, score) in v { - li { span { (uid.0) ": " (score) " points" } } + li { span { (uid.funny_name()) ": " (score) " points" } } } } @@ -465,8 +487,8 @@ async fn route_index(State(st): State<Server>) -> Response { html! { "No current games"} } else { html! { - h3 { "Games" } - ul { + h3 { "Current games" } + ul .games-list { @for id in &game_ids { li { a href=(format!("/game/{id}")) { (id) } } } @@ -475,10 +497,12 @@ async fn route_index(State(st): State<Server>) -> Response { }; let html = html! { - h1 { "Good game "} - (game_list) + section .main-page { + h1 { "Good game "} + (game_list) - button hx-post="/game" { "Create game" } + button hx-post="/game" { "Create game" } + } }; html_response(html! { @@ -489,6 +513,7 @@ async fn route_index(State(st): State<Server>) -> Response { 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"; + @if let Some(n) = hot_reload_script() { (n) } } body { (html) @@ -597,13 +622,14 @@ async fn get_game( link rel="stylesheet" type="text/css" href="/static/style.css"; script src="/static/main.js" {}; + @if let Some(n) = hot_reload_script() { (n) } } body hx-ext="ws" ws-connect=(format!("/game/{gid}/ws")) { - footer { } + header { } section #content { (html) } - footer { span { "You are " (uid.0) } } + footer { span { "You are " (uid.funny_name()) } } } }) } @@ -804,6 +830,25 @@ async fn identity_middleware(jar: CookieJar, mut req: Request, next: Next) -> Re res } +#[cfg(debug_assertions)] +mod hot_reload; +#[cfg(not(debug_assertions))] +mod hot_reload { + use axum::Router; + pub fn router(_: &str) -> Router<()> { + Router::new() + } +} + +pub fn hot_reload_script() -> Option<Markup> { + let is_prod = env::var("STAGE").is_ok_and(|stage| stage == "prod"); + if is_prod { + None + } else { + Some(html! { script { (PreEscaped(include_str!("./hot_reload.js"))) } }) + } +} + #[tokio::main] async fn main() -> Result<()> { let subscriber = FmtSubscriber::builder() @@ -831,6 +876,7 @@ async fn main() -> Result<()> { .route("/game/{gid}/judge", post(submit_judge)) .route("/game/{gid}/ws", get(game_ws)) .nest_service("/static", ServeDir::new("static")) + .nest_service("/dev-hr", hot_reload::router("static")) .layer(CatchPanicLayer::custom(handle_panic)) .layer( TraceLayer::new_for_http() |
