diff --git a/.idea/workspace.xml b/.idea/workspace.xml index d8c14ad..f374377 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -11,12 +11,11 @@ - - - - - + + + + @@ -109,6 +116,7 @@ - \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index e1e46de..d83e368 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,7 +26,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871" dependencies = [ "axum-core", + "axum-macros", "bytes", + "form_urlencoded", "futures-util", "http", "http-body", @@ -38,6 +40,8 @@ dependencies = [ "percent-encoding", "pin-project-lite", "serde_core", + "serde_path_to_error", + "serde_urlencoded", "sync_wrapper", "tower", "tower-layer", @@ -62,6 +66,17 @@ dependencies = [ "tower-service", ] +[[package]] +name = "axum-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "bumpalo" version = "3.19.0" @@ -353,6 +368,28 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "maud" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8156733e27020ea5c684db5beac5d1d611e1272ab17901a49466294b84fc217e" +dependencies = [ + "itoa", + "maud_macros", +] + +[[package]] +name = "maud_macros" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7261b00f3952f617899bc012e3dbd56e4f0110a038175929fa5d18e5a19913ca" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn", +] + [[package]] name = "memchr" version = "2.7.6" @@ -370,7 +407,12 @@ name = "mis-interpreter" version = "0.1.0" dependencies = [ "axum", + "futures-util", + "maud", + "serde", + "serde_json", "tower-service", + "urlencoding", "worker", "worker-macros", ] @@ -446,6 +488,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "version_check", +] + [[package]] name = "quote" version = "1.0.41" @@ -521,6 +575,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -642,12 +707,24 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasm-bindgen" version = "0.2.105" diff --git a/Cargo.toml b/Cargo.toml index 10530af..6c5161c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,13 +7,25 @@ authors = ["Rivulet "] [lib] crate-type = ["cdylib"] +# (Wrangler will ignore these; fine to keep or remove) [package.metadata.wasm-pack] target = "web" out-dir = "build" out-name = "index" [dependencies] -worker = { version = "0.6", features = ['http', 'axum'] } -worker-macros = { version = "0.6", features = ['http'] } -axum = { version = "0.8", default-features = false } +# Cloudflare Workers (HTTP + Axum adapter) +worker = { version = "0.6", features = ["http", "axum"] } +worker-macros = { version = "0.6", features = ["http"] } + +# Axum *without* tokio/hyper; we don't use its WS, so don't enable it. +axum = { version = "0.8", default-features = false, features = ["form", "macros"] } + tower-service = "0.3.3" + +maud = { version = "0.27.0", default-features = false } + +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" +urlencoding = "2.1.3" +futures-util = "0.3.31" diff --git a/src/lib.rs b/src/lib.rs index f80ee2b..46aadef 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,20 +1,339 @@ -use axum::{routing::get, Router}; +use axum::{ + body::{to_bytes, Body}, + http::{Response as AxumResponse, StatusCode}, + response::Html, + routing::{get, post}, + Form, Router, +}; +use futures_util::StreamExt; +use maud::{html, Markup}; +use serde::Deserialize; use tower_service::Service; use worker::*; +/* ===================== Router ===================== */ + fn router() -> Router { - Router::new().route("/", get(root)) + Router::new() + .route("/", get(root)) + .route("/create", post(create_game)) + // /ws is handled in fetch() using CF WebSocketPair. } +/* ===================== Cloudflare entry ===================== */ + #[event(fetch)] -async fn fetch( - req: HttpRequest, - _env: Env, - _ctx: Context, -) -> Result> { - Ok(router().call(req).await?) +async fn fetch(req: HttpRequest, _env: Env, _ctx: Context) -> Result { + // Intercept WebSocket before handing off to Axum + let is_ws = req + .headers() + .get("Upgrade") + .and_then(|v| v.to_str().ok()) + .map(|v| v.eq_ignore_ascii_case("websocket")) + .unwrap_or(false); + + if req.uri().path() == "/ws" && is_ws { + return handle_ws_upgrade(req).await; + } + + // Otherwise hand off to Axum and adapt Axum -> Worker Response + let axum_resp: AxumResponse = router().call(req).await?; + axum_to_worker(axum_resp).await } -pub async fn root() -> &'static str { - "Hello Axum!" +/* Convert an Axum response into a worker::Response */ +async fn axum_to_worker(ax: AxumResponse) -> Result { + let (parts, body) = ax.into_parts(); + let bytes = to_bytes(body, usize::MAX).await.unwrap_or_default(); + + let mut resp = Response::from_bytes(bytes.to_vec())?; + resp = resp.with_status(parts.status.as_u16()); + + for (name, value) in parts.headers.iter() { + if let Ok(val) = value.to_str() { + resp.headers_mut().set(name.as_str(), val)?; // <- note: no ? after headers_mut() + } + } + Ok(resp) } + +/* ===================== Pages ===================== */ + +/// GET / +pub async fn root() -> Html { + // If you want to read cookies here, switch to a custom Response and pass headers in via extractors. + let page = render_page(None); + Html::from(page) +} + +/// POST /create (classic PRG with cookie flash) +#[derive(Deserialize)] +struct CreateForm { + game_name: Option, + password: Option, + player_name: Option, +} + +pub async fn create_game(Form(form): Form) -> AxumResponse { + let msg = flash_msg(&form); + AxumResponse::builder() + .status(StatusCode::SEE_OTHER) + .header("Location", "/") + .header( + "Set-Cookie", + format!( + "flash={}; Path=/; Max-Age=30; HttpOnly; SameSite=Lax", + urlencoding::encode(&msg) + ), + ) + .body(Body::empty()) + .unwrap() +} + +/* ===================== WebSocket (HTMX ws-send) ===================== */ + +async fn handle_ws_upgrade(_req: HttpRequest) -> Result { + // Create the CF WebSocketPair at the worker layer + let pair = WebSocketPair::new()?; + let server = pair.server; + let client = pair.client; + + server.accept()?; + + // Spawn a local task to read messages and push OOB HTML back. + wasm_bindgen_futures::spawn_local(async move { + // Create the borrow-bound event stream *inside* the task and keep the clone alive. + let server_clone = server.clone(); + let mut events = match server_clone.events() { + Ok(e) => e, + Err(_) => return, + }; + + while let Some(Ok(evt)) = events.next().await { + if let WebsocketEvent::Message(m) = evt { + if let Some(txt) = m.text() { + if let Ok(parsed) = serde_json::from_str::(&*txt) { + let msg = flash_msg(&parsed); + let oob = render_flash_oob(&msg); + let _ = server_clone.send_with_str(oob); + } else { + let _ = server_clone.send_with_str(render_log_oob("Unrecognized message")); + } + } + } + } + }); + + // Return the upgrade response (must be worker::Response) + Response::from_websocket(client) +} + +/* ===================== View / HTML ===================== */ + +fn render_page(flash: Option<&str>) -> String { + (html! { + (maud::DOCTYPE) + html lang="en" { + head { + meta charset="utf-8"; + meta name="viewport" content="width=device-width, initial-scale=1"; + title { "Mis-Interpreter" } + + // HTMX + WS Extension + script src="https://unpkg.com/htmx.org@2.0.4" {} + script src="https://unpkg.com/htmx-ext-ws@2.0.3/ws.js" {} + + style { (PASTEL_CSS) } + } + body { + main class="shell" { + section class="card cartoon" { + h1 class="title" { "Mis-Interpreter" } + p class="subtitle" { "Create a game?" } + + // Flash target + div id="flash" { + @if let Some(msg) = flash { + (render_flash_inline(msg)) + } + } + + // Live form using htmx WebSocket + div hx-ext="ws" ws-connect="/ws" { + form id="create-form" class="form" ws-send { + label class="sr" for="game_name" { "Game Name" } + input id="game_name" type="text" name="game_name" + placeholder="Game Name (Optional)" class="input"; + + label class="sr" for="password" { "Password" } + input id="password" type="password" name="password" + placeholder="Password (Optional)" class="input"; + + label class="sr" for="player_name" { "Player Name" } + input id="player_name" type="text" name="player_name" + value="John Wick" class="input"; + + button type="submit" class="btn" { "Create (Live)" } + } + div id="ws-log" class="wslog" {} + } + } + } + } + } + }).into_string() +} + +fn render_flash_inline(msg: &str) -> Markup { + html! { div class="flash cartoon" { (escape(msg)) } } +} + +fn render_flash_oob(msg: &str) -> String { + format!( + r#"
{}
"#, + escape(msg) + ) +} + +fn render_log_oob(text: &str) -> String { + format!(r#"
{}
"#, escape(text)) +} + +/* ===================== Utilities ===================== */ + +fn flash_msg(form: &CreateForm) -> String { + let game = form.game_name.as_deref().unwrap_or("Untitled"); + let player = form.player_name.as_deref().unwrap_or("Anonymous"); + let priv_tag = if form.password.as_deref().unwrap_or("").is_empty() { "" } else { " (private)" }; + format!("Created game '{}' for '{}'{priv_tag}", escape(game), escape(player)) +} + +// minimal safe escaping for text nodes +fn escape(s: &str) -> String { + s.replace('&', "&").replace('<', "<").replace('>', ">") +} + +/* ===================== Theme ===================== */ + +const PASTEL_CSS: &str = r#" +/* ===== Pastel Blues, Cartoon Borders ===== */ +:root{ + /* palette */ + --sky-50:#eff6ff; --sky-100:#dbeafe; --sky-200:#bfdbfe; --sky-300:#93c5fd; + --sky-400:#60a5fa; --sky-500:#3b82f6; --indigo-600:#4f46e5; --teal-300:#99f6e4; + + --bg-top: color-mix(in oklab, var(--sky-100) 70%, white); + --bg-bot: color-mix(in oklab, var(--teal-300) 35%, white); + + --card: #f2f7ff; + --ink: #0b1220; + --muted:#44506a; + + /* cartoon outlines */ + --outline: #133a84; /* deep blue stroke */ + --shadow: #0e2250; /* offset shadow for sticker look */ + + --ring: var(--sky-400); + --field-a: #e9f2ff; + --field-b: #e6fffb; + + --btn-a: #a5b4ff; /* indigo pastel */ + --btn-b: #7dd3fc; /* sky pastel */ + + --flash-bg:#c4f1f9; /* cyan-200 */ + --flash-border:#0891b2; +} + +*{box-sizing:border-box} +html,body{height:100%} +body{ + margin:0; + font:17px/1.55 ui-sans-serif, system-ui, -apple-system, Segoe UI, Inter, Roboto, Arial; + color:var(--ink); + background: + radial-gradient(1000px 700px at 15% -10%, var(--sky-200) 0%, transparent 60%), + radial-gradient(1100px 800px at 110% 0%, var(--teal-300) 0%, transparent 60%), + linear-gradient(180deg, var(--bg-top), var(--bg-bot)); +} + +/* ——— cartoon helpers ——— */ +.cartoon{ + border:4px solid var(--outline); + box-shadow: 8px 8px 0 var(--shadow); + border-radius: 26px; +} + +/* layout */ +.shell{min-height:100%; display:grid; place-items:center; padding:3rem 1rem} +.card{ + width:min(740px, 94vw); + background: var(--card); + padding:3rem 2.5rem; +} +.card.cartoon{} /* (keep class for specificity) */ + +.title{ + text-align:center; margin:0 0 .5rem; font-weight:900; letter-spacing:.3px; + font-size: clamp(2.1rem, 3.4vw + 1rem, 3.4rem); + /* shiny blue gradient text */ + background: linear-gradient(90deg, var(--sky-500), var(--indigo-600)); + -webkit-background-clip: text; background-clip: text; color: transparent; + text-shadow: 0 2px 0 #ffffff66; +} +.subtitle{ text-align:center; color:var(--muted); margin:0 0 2rem; font-weight:700 } + +.flash{ + background: var(--flash-bg); + border:4px solid var(--flash-border); + color:#0c4a6e; + padding:.85rem 1rem; border-radius:16px; margin:0 0 1.25rem; + box-shadow: 6px 6px 0 var(--shadow); +} + +/* form */ +.form{ display:grid; grid-template-columns:1fr auto; gap:16px 16px; align-items:center } +.input{ + width:100%; + padding:1rem 1.1rem; + border:4px solid var(--outline); + border-radius:16px; + background: + linear-gradient(0deg, #ffffff, #ffffff) padding-box, + radial-gradient(120% 120% at 0% 0%, var(--field-a) 0%, var(--field-b) 100%) border-box; + color:var(--ink); + box-shadow: 4px 4px 0 var(--shadow); + transition: transform .06s ease, box-shadow .15s ease, border-color .15s ease; +} +.input::placeholder{color:#6b7280} +.input:focus{ + outline:none; transform: translateY(-1px); + border-color: var(--ring); + box-shadow: 6px 6px 0 var(--shadow), 0 0 0 6px color-mix(in oklab, var(--ring) 40%, white); +} + +/* button: chunky pill with dual-tone blue */ +.btn{ + padding:1rem 1.35rem; + font-weight:900; + border:4px solid var(--outline); + border-radius:999px; + color:#06223a; + background: linear-gradient(180deg, + color-mix(in oklab, var(--btn-b) 75%, white), + color-mix(in oklab, var(--btn-a) 85%, white)); + cursor:pointer; + box-shadow: 6px 6px 0 var(--shadow); + transition: transform .07s ease, filter .15s ease, box-shadow .15s ease; +} +.btn:hover{ filter:brightness(1.06) saturate(1.05) } +.btn:active{ transform: translateY(2px); box-shadow: 4px 4px 0 var(--shadow) } + +/* accessibility */ +.sr{ position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0 } + +/* responsive */ +@media (max-width: 560px){ + .form{ grid-template-columns: 1fr } + .btn{ width:100% } +} +"#; diff --git a/wrangler.toml b/wrangler.toml index afee802..24c3db1 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,12 +1,7 @@ name = "mis-interpreter" workers_dev = true -compatibility_date = "2025-10-28" +compatibility_date = "2025-10-27" main = "build/worker/shim.mjs" - -[[rules]] -globs = [ "**/*.wasm" ] -type = "CompiledWasm" - [build] -command = "cargo install -q worker-build --version ^0.0.8 && worker-build --release" # required \ No newline at end of file +command = "cargo install -q worker-build && worker-build --release" # required \ No newline at end of file