From ef47209c796a9a68b149ef1230840bfc5eb3ca8e Mon Sep 17 00:00:00 2001 From: Steve Biedermann Date: Wed, 7 Oct 2026 20:00:39 +0200 Subject: [PATCH] refactor: stream simulation state over WebSockets --- Cargo.lock | 455 ++++++++++++++++++++++++++++++++++++++++++++++---- Cargo.toml | 4 +- src/main.rs | 301 ++++++++++++++++++--------------- ui/Cargo.toml | 3 +- ui/build.sh | 0 ui/src/api.rs | 61 ++++++- ui/src/lib.rs | 27 +-- 7 files changed, 676 insertions(+), 175 deletions(-) mode change 100644 => 100755 ui/build.sh diff --git a/Cargo.lock b/Cargo.lock index 4790a1c..d41b4f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -40,12 +40,6 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" -[[package]] -name = "ascii" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" - [[package]] name = "async-lock" version = "3.4.2" @@ -74,6 +68,12 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "attribute-derive" version = "0.11.0" @@ -109,6 +109,61 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base16" version = "0.2.1" @@ -121,6 +176,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + [[package]] name = "block-buffer" version = "0.10.4" @@ -164,12 +225,6 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" -[[package]] -name = "chunked_transfer" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" - [[package]] name = "codee" version = "0.3.5" @@ -305,6 +360,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "derive-where" version = "1.7.0" @@ -519,6 +580,18 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -528,7 +601,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 6.0.0", "wasm-bindgen", ] @@ -606,6 +679,41 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "httpdate" version = "1.0.3" @@ -625,6 +733,40 @@ dependencies = [ "throw_error", ] +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "icu_collections" version = "2.3.0" @@ -802,7 +944,7 @@ dependencies = [ "cfg-if", "either_of", "futures", - "getrandom", + "getrandom 0.4.3", "hydration_context", "leptos_config", "leptos_dom", @@ -984,12 +1126,45 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + [[package]] name = "musli" version = "0.0.124" @@ -1200,6 +1375,15 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.3.0" @@ -1273,12 +1457,47 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "reactive_graph" version = "0.2.15" @@ -1568,6 +1787,17 @@ dependencies = [ "zmij", ] +[[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_qs" version = "0.15.0" @@ -1588,6 +1818,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "server_fn" version = "0.8.13" @@ -1646,6 +1888,17 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1676,8 +1929,10 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" name = "sim" version = "0.1.0" dependencies = [ + "axum", "rune", - "tiny_http", + "tokio", + "tower-http", ] [[package]] @@ -1707,6 +1962,16 @@ version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1735,6 +2000,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "synstructure" version = "0.14.0" @@ -1851,18 +2122,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "tiny_http" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" -dependencies = [ - "ascii", - "chunked_transfer", - "httpdate", - "log", -] - [[package]] name = "tinystr" version = "0.8.4" @@ -1879,7 +2138,49 @@ version = "1.53.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" dependencies = [ + "bytes", + "libc", + "mio", "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", ] [[package]] @@ -1921,12 +2222,66 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-core", ] @@ -1970,6 +2325,22 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror 2.0.21", +] + [[package]] name = "twox-hash" version = "2.1.5" @@ -2012,11 +2383,18 @@ dependencies = [ "js-sys", "leptos", "serde", + "serde_json", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", ] +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + [[package]] name = "unicode-ident" version = "1.0.26" @@ -2065,7 +2443,7 @@ version = "1.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" dependencies = [ - "getrandom", + "getrandom 0.4.3", "js-sys", "wasm-bindgen", ] @@ -2092,6 +2470,21 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.129" @@ -2235,6 +2628,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "writeable" version = "0.6.4" diff --git a/Cargo.toml b/Cargo.toml index f20b045..bfd62ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,4 +8,6 @@ members = ["ui"] [dependencies] rune = "0.14.2" -tiny_http = "0.12" +axum = { version = "0.8", features = ["ws"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "time"] } +tower-http = { version = "0.6", features = ["fs"] } diff --git a/src/main.rs b/src/main.rs index e401137..437db66 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,16 @@ mod world; -use std::io::Read; -use std::path::Path; -use std::sync::mpsc::{self, Sender}; +use axum::Router; +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::extract::{DefaultBodyLimit, Request, State}; +use axum::http::{StatusCode, header}; +use axum::middleware::{self, Next}; +use axum::response::{Html, IntoResponse, Response}; +use axum::routing::{get, post}; use std::sync::{Arc, Mutex}; -use std::thread; use std::time::Duration; -use tiny_http::{Header, Method, Request, Response, Server}; +use tokio::sync::{mpsc, oneshot, watch}; +use tower_http::services::ServeDir; use world::{DEFAULT_SCRIPT, World}; // Minimal shell; the whole UI is the Leptos app in ui/ compiled to wasm. @@ -17,144 +21,181 @@ try { const m = await import('/pkg/ui.js'); await m.default(); } catch (e) { document.body.textContent = 'Failed to load the UI (did you run ui/build.sh?): ' + e; } "#; -fn serve_static(req: Request, dir: &Path, rel: &str) { - let rel = rel.split('?').next().unwrap_or(""); - let safe = !rel.is_empty() - && rel - .split('/') - .all(|c| !c.is_empty() && c != ".." && c != "." && !c.contains('\\')); - let ctype = match rel.rsplit('.').next() { - Some("js") => "application/javascript", - Some("wasm") => "application/wasm", - Some("css") => "text/css", - _ => "application/octet-stream", - }; - match safe - .then(|| std::fs::read(dir.join(rel))) - .and_then(Result::ok) - { - Some(bytes) => { - let h = Header::from_bytes("Content-Type", ctype).unwrap(); - let _ = req.respond(Response::from_data(bytes).with_header(h)); +enum Msg { + Edit(String), + Script(String, oneshot::Sender>), +} + +#[derive(Clone)] +struct App { + terrain: Arc>, + script: Arc>, + state: watch::Receiver>, + tx: mpsc::UnboundedSender, +} + +/// The Rune VM is not Send, so the world lives on this thread and publishes JSON snapshots. +fn run_sim( + seed: u64, + mut rx: mpsc::UnboundedReceiver, + terrain: Arc>, + script: Arc>, + state_tx: watch::Sender>, +) { + let mut world = World::new(seed); + let mut rev = u32::MAX; + loop { + while let Ok(msg) = rx.try_recv() { + match msg { + Msg::Edit(cmd) => world.apply(&cmd), + Msg::Script(src, reply) => { + let r = world.set_script(&src); + if r.is_ok() { + *script.lock().unwrap() = src; + } + let _ = reply.send(r); + } + } } - None => { - let _ = req.respond( - Response::from_string("not found (build the UI: see ui/build.sh)") - .with_status_code(404), - ); + if !world.paused { + for _ in 0..world.speed { + world.step(); + } + } + if world.terrain_rev != rev { + rev = world.terrain_rev; + *terrain.lock().unwrap() = world.terrain_json(); + } + state_tx.send_replace(Arc::from(world.state_json())); + std::thread::sleep(Duration::from_millis(100)); + } +} + +/// Rejects cross-site requests (including WebSocket upgrades), which could otherwise edit the world. +async fn same_origin(req: Request, next: Next) -> Response { + let h = req.headers(); + if let Some(origin) = h.get(header::ORIGIN).and_then(|v| v.to_str().ok()) { + let host = h + .get(header::HOST) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let authority = origin.split_once("://").map_or(origin, |(_, rest)| rest); + if authority != host { + return StatusCode::FORBIDDEN.into_response(); + } + } + next.run(req).await +} + +fn json(body: String) -> impl IntoResponse { + ([(header::CONTENT_TYPE, "application/json")], body) +} + +async fn index() -> Html<&'static str> { + Html(INDEX) +} + +async fn terrain(State(app): State) -> impl IntoResponse { + json(app.terrain.lock().unwrap().clone()) +} + +async fn state(State(app): State) -> impl IntoResponse { + json(app.state.borrow().to_string()) +} + +async fn get_script(State(app): State) -> String { + app.script.lock().unwrap().clone() +} + +async fn post_script(State(app): State, body: String) -> Response { + let (rtx, rrx) = oneshot::channel(); + let _ = app.tx.send(Msg::Script(body, rtx)); + match tokio::time::timeout(Duration::from_secs(5), rrx).await { + Ok(Ok(Ok(()))) => "ok".into_response(), + Ok(Ok(Err(e))) => (StatusCode::BAD_REQUEST, e).into_response(), + _ => (StatusCode::SERVICE_UNAVAILABLE, "timed out").into_response(), + } +} + +async fn post_edit(State(app): State, body: String) -> StatusCode { + if body.len() > 256 { + return StatusCode::PAYLOAD_TOO_LARGE; + } + let _ = app.tx.send(Msg::Edit(body)); + StatusCode::OK +} + +async fn ws(upgrade: WebSocketUpgrade, State(app): State) -> Response { + upgrade.on_upgrade(move |socket| client(socket, app)) +} + +/// Pushes every new snapshot to the client; text frames from the client are edit commands. +async fn client(mut socket: WebSocket, app: App) { + let mut rx = app.state.clone(); + loop { + let snapshot = rx.borrow_and_update().to_string(); + if socket.send(Message::Text(snapshot.into())).await.is_err() { + return; + } + loop { + tokio::select! { + changed = rx.changed() => { + if changed.is_err() { + return; + } + break; + } + msg = socket.recv() => match msg { + Some(Ok(Message::Text(t))) if t.len() <= 256 => { + let _ = app.tx.send(Msg::Edit(t.as_str().to_owned())); + } + Some(Ok(Message::Close(_))) | Some(Err(_)) | None => return, + _ => {} + }, + } } } } -enum Msg { - Edit(String), - Script(String, Sender>), -} - -fn main() { +#[tokio::main] +async fn main() { let seed = std::env::args() .nth(1) .and_then(|s| s.parse().ok()) .unwrap_or(42u64); let addr = std::env::var("SIM_ADDR").unwrap_or_else(|_| "127.0.0.1:7878".into()); - // The Rune VM is not Send, so the world lives on the sim thread and publishes JSON snapshots. - let terrain = Arc::new(Mutex::new(String::new())); - let state = Arc::new(Mutex::new(String::new())); + let ui_dir = std::env::var("SIM_UI_DIR").unwrap_or_else(|_| "ui/pkg".into()); + + let terrain_buf = Arc::new(Mutex::new(String::new())); let script = Arc::new(Mutex::new(DEFAULT_SCRIPT.to_string())); - let (tx, rx) = mpsc::channel::(); + let (tx, rx) = mpsc::unbounded_channel(); + let (state_tx, state_rx) = watch::channel(Arc::::from("{}")); { - let (terrain, state, script) = ( - Arc::clone(&terrain), - Arc::clone(&state), - Arc::clone(&script), - ); - thread::spawn(move || { - let mut world = World::new(seed); - let mut rev = u32::MAX; - loop { - while let Ok(msg) = rx.try_recv() { - match msg { - Msg::Edit(cmd) => world.apply(&cmd), - Msg::Script(src, reply) => { - let r = world.set_script(&src); - if r.is_ok() { - *script.lock().unwrap() = src; - } - let _ = reply.send(r); - } - } - } - if !world.paused { - for _ in 0..world.speed { - world.step(); - } - } - if world.terrain_rev != rev { - rev = world.terrain_rev; - *terrain.lock().unwrap() = world.terrain_json(); - } - *state.lock().unwrap() = world.state_json(); - thread::sleep(Duration::from_millis(100)); - } - }); + let (terrain_buf, script) = (Arc::clone(&terrain_buf), Arc::clone(&script)); + std::thread::spawn(move || run_sim(seed, rx, terrain_buf, script, state_tx)); } - let server = Server::http(&addr).expect("failed to bind"); - let ui_dir = Arc::new(std::path::PathBuf::from( - std::env::var("SIM_UI_DIR").unwrap_or_else(|_| "ui/pkg".into()), - )); + let app = App { + terrain: terrain_buf, + script, + state: state_rx, + tx, + }; + let router = Router::new() + .route("/", get(index)) + .route("/api/terrain", get(terrain)) + .route("/api/state", get(state)) + .route("/api/script", get(get_script).post(post_script)) + .route("/api/edit", post(post_edit)) + .route("/ws", get(ws)) + .nest_service("/pkg", ServeDir::new(ui_dir)) + .layer(DefaultBodyLimit::max(65536)) + .layer(middleware::from_fn(same_origin)) + .with_state(app); + + let listener = tokio::net::TcpListener::bind(&addr) + .await + .expect("failed to bind"); println!("Simulation running at http://{addr}"); - for mut req in server.incoming_requests() { - let (terrain, state, script) = ( - Arc::clone(&terrain), - Arc::clone(&state), - Arc::clone(&script), - ); - let tx = tx.clone(); - let ui_dir = Arc::clone(&ui_dir); - thread::spawn(move || { - let post = *req.method() == Method::Post; - if !post { - if let Some(rel) = req.url().strip_prefix("/pkg/").map(str::to_owned) { - return serve_static(req, &ui_dir, &rel); - } - } - let mut input = String::new(); - if post { - let _ = req.as_reader().take(65536).read_to_string(&mut input); - } - let (status, body, ctype) = match (post, req.url()) { - (false, "/") => (200, INDEX.to_string(), "text/html; charset=utf-8"), - (false, "/api/terrain") => { - (200, terrain.lock().unwrap().clone(), "application/json") - } - (false, "/api/state") => (200, state.lock().unwrap().clone(), "application/json"), - (false, "/api/script") => ( - 200, - script.lock().unwrap().clone(), - "text/plain; charset=utf-8", - ), - (true, "/api/edit") if input.len() <= 256 => { - let _ = tx.send(Msg::Edit(input)); - (200, "ok".to_string(), "text/plain") - } - (true, "/api/script") => { - let (rtx, rrx) = mpsc::channel(); - let _ = tx.send(Msg::Script(input, rtx)); - match rrx.recv_timeout(Duration::from_secs(5)) { - Ok(Ok(())) => (200, "ok".to_string(), "text/plain"), - Ok(Err(e)) => (400, e, "text/plain; charset=utf-8"), - Err(_) => (503, "timed out".to_string(), "text/plain"), - } - } - _ => (404, "not found".to_string(), "text/plain"), - }; - let header = Header::from_bytes("Content-Type", ctype).unwrap(); - let _ = req.respond( - Response::from_string(body) - .with_status_code(status) - .with_header(header), - ); - }); - } + axum::serve(listener, router).await.unwrap(); } diff --git a/ui/Cargo.toml b/ui/Cargo.toml index de4e157..4d8dba1 100644 --- a/ui/Cargo.toml +++ b/ui/Cargo.toml @@ -11,8 +11,9 @@ leptos = { version = "0.8", features = ["csr"] } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["CanvasRenderingContext2d", "HtmlCanvasElement", "HtmlElement", "MouseEvent", "Element", "DomRect", "HtmlDivElement", "Window", "Storage"] } +web-sys = { version = "0.3", features = ["CanvasRenderingContext2d", "HtmlCanvasElement", "HtmlElement", "MouseEvent", "WebSocket", "MessageEvent", "Location", "Element", "DomRect", "HtmlDivElement", "Window", "Storage"] } gloo-net = { version = "0.6", default-features = false, features = ["http", "json"] } gloo-timers = { version = "0.3", features = ["futures"] } serde = { version = "1", features = ["derive"] } console_error_panic_hook = "0.1" +serde_json = "1" diff --git a/ui/build.sh b/ui/build.sh old mode 100644 new mode 100755 diff --git a/ui/src/api.rs b/ui/src/api.rs index 717346e..077b982 100644 --- a/ui/src/api.rs +++ b/ui/src/api.rs @@ -1,6 +1,55 @@ use gloo_net::http::Request; +use gloo_timers::future::TimeoutFuture; use leptos::task::spawn_local; use serde::de::DeserializeOwned; +use std::cell::RefCell; +use std::rc::Rc; +use wasm_bindgen::JsCast; +use wasm_bindgen::prelude::Closure; +use web_sys::{MessageEvent, WebSocket}; + +thread_local! { + static SOCKET: RefCell> = const { RefCell::new(None) }; +} + +/// Opens the live socket and calls `on_state` with each pushed snapshot; reconnects after a drop. +pub fn connect(on_state: impl Fn(String) + 'static) { + let Some(loc) = web_sys::window().map(|w| w.location()) else { + return; + }; + let proto = if loc.protocol().is_ok_and(|p| p == "https:") { + "wss" + } else { + "ws" + }; + let Ok(host) = loc.host() else { return }; + open(format!("{proto}://{host}/ws"), Rc::new(on_state)); +} + +fn open(url: String, on_state: Rc) { + let Ok(ws) = WebSocket::new(&url) else { return }; + let cb = on_state.clone(); + let onmessage = Closure::::new(move |e: MessageEvent| { + if let Some(text) = e.data().as_string() { + cb(text); + } + }); + ws.set_onmessage(Some(onmessage.as_ref().unchecked_ref())); + onmessage.forget(); + + let url2 = url.clone(); + let onclose = Closure::::new(move || { + SOCKET.with(|s| *s.borrow_mut() = None); + let (url, on_state) = (url2.clone(), on_state.clone()); + spawn_local(async move { + TimeoutFuture::new(1000).await; + open(url, on_state); + }); + }); + ws.set_onclose(Some(onclose.as_ref().unchecked_ref())); + onclose.forget(); + SOCKET.with(|s| *s.borrow_mut() = Some(ws)); +} pub async fn get_json(url: &str) -> Option { Request::get(url).send().await.ok()?.json().await.ok() @@ -17,9 +66,17 @@ pub async fn post(url: &str, body: String) -> Option<(bool, String)> { } pub fn edit(cmd: String) { - spawn_local(async move { - let _ = post("/api/edit", cmd).await; + let sent = SOCKET.with(|s| { + s.borrow() + .as_ref() + .filter(|w| w.ready_state() == WebSocket::OPEN) + .is_some_and(|w| w.send_with_str(&cmd).is_ok()) }); + if !sent { + spawn_local(async move { + let _ = post("/api/edit", cmd).await; + }); + } } fn storage() -> Option { diff --git a/ui/src/lib.rs b/ui/src/lib.rs index 1acced1..5f8e863 100644 --- a/ui/src/lib.rs +++ b/ui/src/lib.rs @@ -3,7 +3,6 @@ mod canvas; mod editor; mod model; -use gloo_timers::future::TimeoutFuture; use leptos::mount::mount_to_body; use leptos::prelude::*; use leptos::task::spawn_local; @@ -28,20 +27,22 @@ fn App() -> impl IntoView { // (selection counter, people at the time of selection); the counter forces editor rows to rebuild. let editing = RwSignal::new((0u64, Vec::::new())); - spawn_local(async move { - let mut rev = None; - loop { - if let Some(s) = api::get_json::("/api/state").await { - if rev != Some(s.rev) || terrain.with_untracked(|t| t.is_none()) { - if let Some(t) = api::get_json::("/api/terrain").await { - terrain.set(Some(t)); - rev = Some(s.rev); - } + // The server pushes a snapshot per tick; terrain is refetched only when its revision changes. + let last_rev = StoredValue::new(None::); + api::connect(move |text| { + let Ok(s) = serde_json::from_str::(&text) else { + return; + }; + if last_rev.get_value() != Some(s.rev) { + last_rev.set_value(Some(s.rev)); + spawn_local(async move { + match api::get_json::("/api/terrain").await { + Some(t) => terrain.set(Some(t)), + None => last_rev.set_value(None), } - state.set(Some(s)); - } - TimeoutFuture::new(200).await; + }); } + state.set(Some(s)); }); let canvas = NodeRef::::new();