From 30b29abacbfc5ba1d3e67258ce7f926a86649e0b Mon Sep 17 00:00:00 2001 From: David DiMaria Date: Wed, 29 Nov 2023 16:06:43 -0700 Subject: [PATCH 01/32] Simple websocket server --- .gitignore | 2 + quadratic-multiplayer/Cargo.lock | 1408 +++++++++++++++++++++++++++++ quadratic-multiplayer/Cargo.toml | 21 + quadratic-multiplayer/src/main.rs | 143 +++ 4 files changed, 1574 insertions(+) create mode 100644 quadratic-multiplayer/Cargo.lock create mode 100644 quadratic-multiplayer/Cargo.toml create mode 100644 quadratic-multiplayer/src/main.rs diff --git a/.gitignore b/.gitignore index bbb225bb2c..55b39173a2 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ venv/* # Generated Rust files quadratic-core/target/ quadratic-core/tmp.txt +quadratic-multiplayer/target/ # Generated JS files quadratic-api/node_modules/ @@ -45,3 +46,4 @@ quadratic-api/dist # Code Coverage quadratic-core/coverage +quadratic-multiplayer/coverage diff --git a/quadratic-multiplayer/Cargo.lock b/quadratic-multiplayer/Cargo.lock new file mode 100644 index 0000000000..36681b5dca --- /dev/null +++ b/quadratic-multiplayer/Cargo.lock @@ -0,0 +1,1408 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "aho-corasick" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0" +dependencies = [ + "memchr", +] + +[[package]] +name = "async-trait" +version = "0.1.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a66537f1bb974b254c98ed142ff995236e81b9d0fe4db0575f46612cb15eb0f9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "axum" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "810a80b128d70e6ed2bdf3fe8ed72c0ae56f5f5948d01c2753282dd92a84fce8" +dependencies = [ + "async-trait", + "axum-core", + "base64", + "bytes", + "futures-util", + "http 1.0.0", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de0ddc355eab88f4955090a823715df47acf0b7660aab7a69ad5ce6301ee3b73" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 1.0.0", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-extra" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "523ae92256049a3b02d3bb4df80152386cd97ddba0c8c5077619bdc8c4b1859b" +dependencies = [ + "axum", + "axum-core", + "bytes", + "futures-util", + "headers", + "http 1.0.0", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "serde", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "backtrace" +version = "0.3.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + +[[package]] +name = "base64" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327762f6e5a765692301e5bb513e0d9fef63be86bbc14528052b1cd3e6f03e07" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" + +[[package]] +name = "cc" +version = "1.0.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" +dependencies = [ + "libc", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "cpufeatures" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce420fe07aecd3e67c5f910618fe65e94158f6dcc0adf44e00d69ce2bdfe0fd0" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0290714b38af9b4a7b094b8a37086d1b4e61f2df9122c3cad2577669145335" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff4dd66668b557604244583e3e1e1eada8c5c2e96a6d0d6653ede395b78bbacb" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb1d22c66e66d9d72e1758f0bd7d4fd0bee04cad842ee34587d68c07e45d088c" + +[[package]] +name = "futures-executor" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f4fb8693db0cf099eadcca0efe2a5a22e4550f98ed16aba6c48700da29597bc" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf34a163b5c4c52d0478a4d757da8fb65cabef42ba90515efee0f6f9fa45aaa" + +[[package]] +name = "futures-macro" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b153fd91e4b0147f4aced87be237c98248656bb01050b96bf3ee89220a8ddb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e36d3378ee38c2a36ad710c5d30c2911d752cb941c00c72dbabfb786a7970817" + +[[package]] +name = "futures-task" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efd193069b0ddadc69c46389b740bbccdd97203899b48d09c5f7969591d6bae2" + +[[package]] +name = "futures-util" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a19526d624e703a3179b3d322efec918b6246ea0fa51d41124525f00f1cc8104" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe9006bed769170c11f845cf00c7c1e9092aeb3f268e007c3e760ac68008070f" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "gimli" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" + +[[package]] +name = "h2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d308f63daf4181410c242d34c11f928dcb3aa105852019e043c9d1f4e4368a" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 1.0.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" + +[[package]] +name = "headers" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "322106e6bd0cba2d5ead589ddb8150a13d7c4217cf80d7c4f682ca994ccc6aa9" +dependencies = [ + "base64", + "bytes", + "headers-core", + "http 1.0.0", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http 1.0.0", +] + +[[package]] +name = "hermit-abi" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" + +[[package]] +name = "http" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8947b1a6fad4393052c7ba1f4cd97bed3e953a95c79c92ad9b051a04611d9fbb" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b32afd38673a8016f7c9ae69e5af41a58f81b1d31689040f2f1959594ce194ea" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643" +dependencies = [ + "bytes", + "http 1.0.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41cb79eb393015dadd30fc252023adb0b2400a0caee0fa2a077e6e21a551e840" +dependencies = [ + "bytes", + "futures-util", + "http 1.0.0", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ce4ef31cda248bbdb6e6820603b82dfcd9e833db65a43e997a0ccec777d11fe" + +[[package]] +name = "httparse" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "403f9214f3e703236b221f1a9cd88ec8b4adfa5296de01ab96216361f4692f56" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "h2", + "http 1.0.0", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ca339002caeb0d159cc6e023dff48e199f081e42fa039895c7c6f38b37f2e9d" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.0.0", + "http-body", + "hyper", + "pin-project-lite", + "socket2", + "tokio", + "tower", + "tower-service", + "tracing", +] + +[[package]] +name = "idna" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "indexmap" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89d92a4743f9a61002fae18374ed11e7973f530cb3a3255fb354818118b2203c" + +[[package]] +name = "lock_api" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" + +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167" + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" +dependencies = [ + "adler", +] + +[[package]] +name = "mio" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dce281c5e46beae905d4de1870d8b1509a9142b62eedf18b443b011ca8343d0" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "object" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pin-project" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda4ed1c6c173e3fc7a83629421152e01d7b1f9b7f65fb301e490e8cfc656422" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4359fd9c9171ec6e8c62926d6faaf553a8dc3f64e1507e76da7911b4f6a04405" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + +[[package]] +name = "proc-macro2" +version = "1.0.70" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39278fbbf5fb4f646ce651690877f89d1c5811a3d4acb27700c1cb3cdb78fd3b" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quadratic-multiplayer" +version = "0.1.0" +dependencies = [ + "axum", + "axum-extra", + "futures", + "futures-util", + "headers", + "serde", + "serde_json", + "tokio", + "tokio-tungstenite", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "quote" +version = "1.0.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "regex" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata 0.4.3", + "regex-syntax 0.8.2", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", +] + +[[package]] +name = "regex-automata" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.8.2", +] + +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + +[[package]] +name = "regex-syntax" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" + +[[package]] +name = "rustc-demangle" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" + +[[package]] +name = "rustversion" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4" + +[[package]] +name = "ryu" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.193" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25dd9975e68d0cb5aa1120c288333fc98731bd1dd12f561e468ea4728c042b89" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.193" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43576ca501357b9b071ac53cdc7da8ef0cbd9493d8df094cd821777ea6e894d3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d1c7e3eac408d115102c4c24ad393e0821bb3a5df4d506a80f85f7a742a526b" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4beec8bce849d58d06238cb50db2e1c417cfeafa4c63f692b15c82b7c80f8335" +dependencies = [ + "itoa", + "serde", +] + +[[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 = "sha1" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f04293dc80c3993519f2d7f6f511707ee7094fe0c6d3406feb330cdb3540eba3" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +dependencies = [ + "libc", +] + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dccd0940a2dcdf68d092b8cbab7dc0ad8fa938bf95787e1b916b0e3d0e8e970" + +[[package]] +name = "socket2" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23e78b90f2fcf45d3e842032ce32e3f2d1545ba6636271dcbf24fa306d87be7a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "thiserror" +version = "1.0.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9a7210f5c9a7156bb50aa36aed4c95afb51df0df00713949448cf9e97d382d2" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "266b2e40bc00e5a6c09c3584011e08b06f123c00362c92b975ba9843aaaa14b8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152" +dependencies = [ + "cfg-if", + "once_cell", +] + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0c014766411e834f7af5b8f4cf46257aab4036ca95e9d2c144a10f59ad6f5b9" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "num_cpus", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "212d5dcb2a1ce06d81107c3d0ffa3121fe974b73f068c8282cb1c32328113b6c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", + "tracing", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e12e6351354851911bdf8c2b8f2ab15050c567d70a8b9a37ae7b8301a4080d" +dependencies = [ + "bitflags 2.4.1", + "bytes", + "futures-util", + "http 1.0.0", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" + +[[package]] +name = "tower-service" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" + +[[package]] +name = "tracing" +version = "0.1.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tungstenite" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e3dac10fd62eaf6617d3a904ae222845979aec67c615d1c842b4002c7666fb9" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 0.2.11", + "httparse", + "log", + "rand", + "sha1", + "thiserror", + "url", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "unicase" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460" + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "unicode-normalization" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "url" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "uuid" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e395fcf16a7a3d8127ec99782007af141946b4795001f876d54fb0d55978560" + +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" diff --git a/quadratic-multiplayer/Cargo.toml b/quadratic-multiplayer/Cargo.toml new file mode 100644 index 0000000000..7df2c78dd6 --- /dev/null +++ b/quadratic-multiplayer/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "quadratic-multiplayer" +version = "0.1.0" +edition = "2021" +authors = ["David DiMaria "] + +[dependencies] +axum = { version = "0.7.1", features = ["ws"] } +axum-extra = { version = "0.9.0", features = ["typed-header"] } +futures = "0.3.29" +futures-util = { version = "0.3.29", default-features = false, features = ["sink", "std"] } +headers = "0.4.0" +serde = { version = "1.0.193", features = ["derive"] } +serde_json = "1.0.108" +tokio = { version = "1.34.0", features = ["full"] } +tokio-tungstenite = "0.20.1" +tower = { version = "0.4.13", features = ["util"] } +tower-http = { version = "0.5.0", features = ["fs", "trace"] } +tracing = "0.1.40" +tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } +uuid = "1.6.1" diff --git a/quadratic-multiplayer/src/main.rs b/quadratic-multiplayer/src/main.rs new file mode 100644 index 0000000000..e1cbffdd76 --- /dev/null +++ b/quadratic-multiplayer/src/main.rs @@ -0,0 +1,143 @@ +//! Quadratic Multiplayer +//! + +use axum::{ + extract::{ + connect_info::ConnectInfo, + ws::{Message, WebSocket, WebSocketUpgrade}, + }, + response::IntoResponse, + routing::get, + Router, +}; +use axum_extra::TypedHeader; +use futures_util::stream::SplitSink; +use serde::{Deserialize, Serialize}; +use tokio::sync::Mutex; + +use std::ops::ControlFlow; +use std::{net::SocketAddr, sync::Arc}; +use tower_http::trace::{DefaultMakeSpan, TraceLayer}; + +use futures::stream::StreamExt; +use futures_util::SinkExt; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +#[derive(Deserialize)] +#[serde(tag = "type")] +pub enum MessageRequest { + NewRoom { name: String }, +} + +#[derive(Serialize)] +#[serde(tag = "type")] +pub enum MessageResponse { + Room { name: String }, +} + +#[tokio::main] +async fn main() { + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "quadratic_multiplayer=debug,tower_http=debug".into()), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let app = Router::new() + // handle websockets + .route("/ws", get(ws_handler)) + // logger + .layer( + TraceLayer::new_for_http() + .make_span_with(DefaultMakeSpan::default().include_headers(true)), + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") + .await + .unwrap(); + + tracing::debug!("listening on {}", listener.local_addr().unwrap()); + + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await + .unwrap(); +} + +// handle the websocket upgrade from http +async fn ws_handler( + ws: WebSocketUpgrade, + user_agent: Option>, + ConnectInfo(addr): ConnectInfo, +) -> impl IntoResponse { + let user_agent = user_agent.map_or("Unknown user agent".into(), |user_agent| { + user_agent.to_string() + }); + + tracing::info!("`{user_agent}` at {addr} connected."); + + // upgrade the connection + ws.on_upgrade(move |socket| handle_socket(socket, addr)) +} + +// after websocket is established, handle incoming messages +async fn handle_socket(socket: WebSocket, addr: SocketAddr) { + let (sender, mut receiver) = socket.split(); + let sender = Arc::new(Mutex::new(sender)); + + while let Some(Ok(msg)) = receiver.next().await { + let response = process_message(msg, Arc::clone(&sender)).await; + + if response.is_break() { + break; + } + } + + // returning from the handler closes the websocket connection + tracing::info!("Websocket context {addr} destroyed"); +} + +async fn process_message( + msg: Message, + sender: Arc>>, +) -> ControlFlow, ()> { + match msg { + Message::Text(text) => { + let messsage_request = serde_json::from_str::(&text).unwrap(); + let message_response = handle_message(messsage_request); + let response = Message::Text(serde_json::to_string(&message_response).unwrap()); + + (*sender.lock().await).send(response).await.unwrap(); + } + Message::Binary(d) => { + tracing::info!(">>> {} bytes: {:?}", d.len(), d); + } + Message::Close(c) => { + if let Some(cf) = c { + tracing::info!(">>> close with code {} and reason `{}`", cf.code, cf.reason); + } else { + tracing::info!(">>> omehow sent close message without CloseFrame"); + } + return ControlFlow::Break(None); + } + _ => { + tracing::info!("Unhandled message type"); + } + } + ControlFlow::Continue(()) +} + +fn handle_message(request: MessageRequest) -> MessageResponse { + match request { + MessageRequest::NewRoom { name } => { + tracing::info!("Handling message {name}"); + MessageResponse::Room { + name: "test".into(), + } + } + } +} From 07a6739ff6385369a1377d84cc5ab7a9d600f744 Mon Sep 17 00:00:00 2001 From: David DiMaria Date: Thu, 30 Nov 2023 08:25:47 -0700 Subject: [PATCH 02/32] Reorganize and remove unwraps --- quadratic-multiplayer/Cargo.lock | 7 ++ quadratic-multiplayer/Cargo.toml | 1 + quadratic-multiplayer/src/main.rs | 141 +-------------------------- quadratic-multiplayer/src/message.rs | 24 +++++ quadratic-multiplayer/src/server.rs | 121 +++++++++++++++++++++++ 5 files changed, 158 insertions(+), 136 deletions(-) create mode 100644 quadratic-multiplayer/src/message.rs create mode 100644 quadratic-multiplayer/src/server.rs diff --git a/quadratic-multiplayer/Cargo.lock b/quadratic-multiplayer/Cargo.lock index 36681b5dca..3d68a3d5de 100644 --- a/quadratic-multiplayer/Cargo.lock +++ b/quadratic-multiplayer/Cargo.lock @@ -26,6 +26,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "anyhow" +version = "1.0.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6" + [[package]] name = "async-trait" version = "0.1.74" @@ -751,6 +757,7 @@ dependencies = [ name = "quadratic-multiplayer" version = "0.1.0" dependencies = [ + "anyhow", "axum", "axum-extra", "futures", diff --git a/quadratic-multiplayer/Cargo.toml b/quadratic-multiplayer/Cargo.toml index 7df2c78dd6..bebaf92926 100644 --- a/quadratic-multiplayer/Cargo.toml +++ b/quadratic-multiplayer/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" authors = ["David DiMaria "] [dependencies] +anyhow = "1.0.75" axum = { version = "0.7.1", features = ["ws"] } axum-extra = { version = "0.9.0", features = ["typed-header"] } futures = "0.3.29" diff --git a/quadratic-multiplayer/src/main.rs b/quadratic-multiplayer/src/main.rs index e1cbffdd76..e3226539c4 100644 --- a/quadratic-multiplayer/src/main.rs +++ b/quadratic-multiplayer/src/main.rs @@ -1,143 +1,12 @@ //! Quadratic Multiplayer //! -use axum::{ - extract::{ - connect_info::ConnectInfo, - ws::{Message, WebSocket, WebSocketUpgrade}, - }, - response::IntoResponse, - routing::get, - Router, -}; -use axum_extra::TypedHeader; -use futures_util::stream::SplitSink; -use serde::{Deserialize, Serialize}; -use tokio::sync::Mutex; +mod message; +mod server; -use std::ops::ControlFlow; -use std::{net::SocketAddr, sync::Arc}; -use tower_http::trace::{DefaultMakeSpan, TraceLayer}; - -use futures::stream::StreamExt; -use futures_util::SinkExt; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; - -#[derive(Deserialize)] -#[serde(tag = "type")] -pub enum MessageRequest { - NewRoom { name: String }, -} - -#[derive(Serialize)] -#[serde(tag = "type")] -pub enum MessageResponse { - Room { name: String }, -} +use anyhow::Result; #[tokio::main] -async fn main() { - tracing_subscriber::registry() - .with( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "quadratic_multiplayer=debug,tower_http=debug".into()), - ) - .with(tracing_subscriber::fmt::layer()) - .init(); - - let app = Router::new() - // handle websockets - .route("/ws", get(ws_handler)) - // logger - .layer( - TraceLayer::new_for_http() - .make_span_with(DefaultMakeSpan::default().include_headers(true)), - ); - - let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") - .await - .unwrap(); - - tracing::debug!("listening on {}", listener.local_addr().unwrap()); - - axum::serve( - listener, - app.into_make_service_with_connect_info::(), - ) - .await - .unwrap(); -} - -// handle the websocket upgrade from http -async fn ws_handler( - ws: WebSocketUpgrade, - user_agent: Option>, - ConnectInfo(addr): ConnectInfo, -) -> impl IntoResponse { - let user_agent = user_agent.map_or("Unknown user agent".into(), |user_agent| { - user_agent.to_string() - }); - - tracing::info!("`{user_agent}` at {addr} connected."); - - // upgrade the connection - ws.on_upgrade(move |socket| handle_socket(socket, addr)) -} - -// after websocket is established, handle incoming messages -async fn handle_socket(socket: WebSocket, addr: SocketAddr) { - let (sender, mut receiver) = socket.split(); - let sender = Arc::new(Mutex::new(sender)); - - while let Some(Ok(msg)) = receiver.next().await { - let response = process_message(msg, Arc::clone(&sender)).await; - - if response.is_break() { - break; - } - } - - // returning from the handler closes the websocket connection - tracing::info!("Websocket context {addr} destroyed"); -} - -async fn process_message( - msg: Message, - sender: Arc>>, -) -> ControlFlow, ()> { - match msg { - Message::Text(text) => { - let messsage_request = serde_json::from_str::(&text).unwrap(); - let message_response = handle_message(messsage_request); - let response = Message::Text(serde_json::to_string(&message_response).unwrap()); - - (*sender.lock().await).send(response).await.unwrap(); - } - Message::Binary(d) => { - tracing::info!(">>> {} bytes: {:?}", d.len(), d); - } - Message::Close(c) => { - if let Some(cf) = c { - tracing::info!(">>> close with code {} and reason `{}`", cf.code, cf.reason); - } else { - tracing::info!(">>> omehow sent close message without CloseFrame"); - } - return ControlFlow::Break(None); - } - _ => { - tracing::info!("Unhandled message type"); - } - } - ControlFlow::Continue(()) -} - -fn handle_message(request: MessageRequest) -> MessageResponse { - match request { - MessageRequest::NewRoom { name } => { - tracing::info!("Handling message {name}"); - MessageResponse::Room { - name: "test".into(), - } - } - } +async fn main() -> Result<()> { + server::serve().await } diff --git a/quadratic-multiplayer/src/message.rs b/quadratic-multiplayer/src/message.rs new file mode 100644 index 0000000000..53b6d71b21 --- /dev/null +++ b/quadratic-multiplayer/src/message.rs @@ -0,0 +1,24 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize)] +#[serde(tag = "type")] +pub enum MessageRequest { + NewRoom { name: String }, +} + +#[derive(Serialize)] +#[serde(tag = "type")] +pub enum MessageResponse { + Room { name: String }, +} + +pub fn handle_message(request: MessageRequest) -> MessageResponse { + match request { + MessageRequest::NewRoom { name } => { + tracing::info!("Handling message {name}"); + MessageResponse::Room { + name: "test".into(), + } + } + } +} diff --git a/quadratic-multiplayer/src/server.rs b/quadratic-multiplayer/src/server.rs new file mode 100644 index 0000000000..80185f6ae9 --- /dev/null +++ b/quadratic-multiplayer/src/server.rs @@ -0,0 +1,121 @@ +//! Websocket Server +//! + +use anyhow::Result; +use axum::{ + extract::{ + connect_info::ConnectInfo, + ws::{Message, WebSocket, WebSocketUpgrade}, + }, + response::IntoResponse, + routing::get, + Router, +}; +use axum_extra::TypedHeader; +use futures_util::stream::SplitSink; +use tokio::sync::Mutex; + +use std::ops::ControlFlow; +use std::{net::SocketAddr, sync::Arc}; +use tower_http::trace::{DefaultMakeSpan, TraceLayer}; + +use futures::stream::StreamExt; +use futures_util::SinkExt; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +use crate::message::{handle_message, MessageRequest, MessageResponse}; + +pub async fn serve() -> Result<()> { + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "quadratic_multiplayer=debug,tower_http=debug".into()), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let app = Router::new() + // handle websockets + .route("/ws", get(ws_handler)) + // logger + .layer( + TraceLayer::new_for_http() + .make_span_with(DefaultMakeSpan::default().include_headers(true)), + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?; + + tracing::info!("listening on {}", listener.local_addr()?); + + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await?; + + Ok(()) +} + +// handle the websocket upgrade from http +async fn ws_handler( + ws: WebSocketUpgrade, + user_agent: Option>, + ConnectInfo(addr): ConnectInfo, +) -> impl IntoResponse { + let user_agent = user_agent.map_or("Unknown user agent".into(), |user_agent| { + user_agent.to_string() + }); + + tracing::info!("`{user_agent}` at {addr} connected."); + + // upgrade the connection + ws.on_upgrade(move |socket| handle_socket(socket, addr)) +} + +// after websocket is established, handle incoming messages +async fn handle_socket(socket: WebSocket, addr: SocketAddr) { + let (sender, mut receiver) = socket.split(); + let sender = Arc::new(Mutex::new(sender)); + + while let Some(Ok(msg)) = receiver.next().await { + let response = process_message(msg, Arc::clone(&sender)).await; + + if response.map_or(false, |response| response.is_break()) { + break; + } + } + + // returning from the handler closes the websocket connection + tracing::info!("Websocket context {addr} destroyed"); +} + +async fn process_message( + msg: Message, + sender: Arc>>, +) -> Result, ()>> { + match msg { + Message::Text(text) => { + let messsage_request = serde_json::from_str::(&text)?; + let message_response = handle_message(messsage_request); + let response = Message::Text(serde_json::to_string(&message_response)?); + + (*sender.lock().await).send(response).await?; + } + Message::Binary(d) => { + tracing::info!(">>> {} bytes: {:?}", d.len(), d); + } + Message::Close(c) => { + if let Some(cf) = c { + tracing::info!(">>> close with code {} and reason `{}`", cf.code, cf.reason); + } else { + tracing::info!(">>> omehow sent close message without CloseFrame"); + } + return Ok(ControlFlow::Break(None)); + } + _ => { + tracing::info!("Unhandled message type"); + } + } + + Ok(ControlFlow::Continue(())) +} From 1e939acdd3c73bb1515b8c62c33172e4b0057f90 Mon Sep 17 00:00:00 2001 From: David DiMaria Date: Thu, 30 Nov 2023 08:36:16 -0700 Subject: [PATCH 03/32] Improve NewRoom message, add MouseMove message --- quadratic-multiplayer/Cargo.lock | 4 ++++ quadratic-multiplayer/Cargo.toml | 2 +- quadratic-multiplayer/src/message.rs | 19 +++++++++++-------- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/quadratic-multiplayer/Cargo.lock b/quadratic-multiplayer/Cargo.lock index 3d68a3d5de..6d2f62090e 100644 --- a/quadratic-multiplayer/Cargo.lock +++ b/quadratic-multiplayer/Cargo.lock @@ -1307,6 +1307,10 @@ name = "uuid" version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e395fcf16a7a3d8127ec99782007af141946b4795001f876d54fb0d55978560" +dependencies = [ + "getrandom", + "serde", +] [[package]] name = "valuable" diff --git a/quadratic-multiplayer/Cargo.toml b/quadratic-multiplayer/Cargo.toml index bebaf92926..c8ae06ca4c 100644 --- a/quadratic-multiplayer/Cargo.toml +++ b/quadratic-multiplayer/Cargo.toml @@ -19,4 +19,4 @@ tower = { version = "0.4.13", features = ["util"] } tower-http = { version = "0.5.0", features = ["fs", "trace"] } tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } -uuid = "1.6.1" +uuid = { version = "1.6.1", features = ["serde", "v4"] } diff --git a/quadratic-multiplayer/src/message.rs b/quadratic-multiplayer/src/message.rs index 53b6d71b21..d6b9c0afbe 100644 --- a/quadratic-multiplayer/src/message.rs +++ b/quadratic-multiplayer/src/message.rs @@ -1,24 +1,27 @@ use serde::{Deserialize, Serialize}; +use uuid::Uuid; -#[derive(Deserialize)] +#[derive(Deserialize, Debug)] #[serde(tag = "type")] pub enum MessageRequest { - NewRoom { name: String }, + NewRoom { user_id: Uuid, file_id: Uuid }, + MouseMove { user_id: Uuid, x: f64, y: f64 }, } #[derive(Serialize)] #[serde(tag = "type")] pub enum MessageResponse { Room { name: String }, + MouseMove { user_id: Uuid, x: f64, y: f64 }, } pub fn handle_message(request: MessageRequest) -> MessageResponse { + tracing::trace!("Handling message {:?}", request); + match request { - MessageRequest::NewRoom { name } => { - tracing::info!("Handling message {name}"); - MessageResponse::Room { - name: "test".into(), - } - } + MessageRequest::NewRoom { user_id, file_id } => MessageResponse::Room { + name: format!("room-{}", user_id), + }, + MessageRequest::MouseMove { user_id, x, y } => MessageResponse::MouseMove { user_id, x, y }, } } From 38148828576cc6a8b45180b813d06c512b6b4eaa Mon Sep 17 00:00:00 2001 From: David DiMaria Date: Thu, 30 Nov 2023 08:48:44 -0700 Subject: [PATCH 04/32] Add app-level state to the server --- quadratic-multiplayer/src/main.rs | 1 + quadratic-multiplayer/src/message.rs | 6 +++++- quadratic-multiplayer/src/server.rs | 19 +++++++++++++------ quadratic-multiplayer/src/state.rs | 21 +++++++++++++++++++++ 4 files changed, 40 insertions(+), 7 deletions(-) create mode 100644 quadratic-multiplayer/src/state.rs diff --git a/quadratic-multiplayer/src/main.rs b/quadratic-multiplayer/src/main.rs index e3226539c4..b7bdc80a40 100644 --- a/quadratic-multiplayer/src/main.rs +++ b/quadratic-multiplayer/src/main.rs @@ -3,6 +3,7 @@ mod message; mod server; +mod state; use anyhow::Result; diff --git a/quadratic-multiplayer/src/message.rs b/quadratic-multiplayer/src/message.rs index d6b9c0afbe..103c685fb1 100644 --- a/quadratic-multiplayer/src/message.rs +++ b/quadratic-multiplayer/src/message.rs @@ -1,6 +1,10 @@ +use std::sync::Arc; + use serde::{Deserialize, Serialize}; use uuid::Uuid; +use crate::state::State; + #[derive(Deserialize, Debug)] #[serde(tag = "type")] pub enum MessageRequest { @@ -15,7 +19,7 @@ pub enum MessageResponse { MouseMove { user_id: Uuid, x: f64, y: f64 }, } -pub fn handle_message(request: MessageRequest) -> MessageResponse { +pub fn handle_message(request: MessageRequest, state: Arc) -> MessageResponse { tracing::trace!("Handling message {:?}", request); match request { diff --git a/quadratic-multiplayer/src/server.rs b/quadratic-multiplayer/src/server.rs index 80185f6ae9..6af86a1490 100644 --- a/quadratic-multiplayer/src/server.rs +++ b/quadratic-multiplayer/src/server.rs @@ -9,7 +9,7 @@ use axum::{ }, response::IntoResponse, routing::get, - Router, + Extension, Router, }; use axum_extra::TypedHeader; use futures_util::stream::SplitSink; @@ -23,7 +23,10 @@ use futures::stream::StreamExt; use futures_util::SinkExt; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; -use crate::message::{handle_message, MessageRequest, MessageResponse}; +use crate::{ + message::{handle_message, MessageRequest, MessageResponse}, + state::{self, State}, +}; pub async fn serve() -> Result<()> { tracing_subscriber::registry() @@ -37,6 +40,8 @@ pub async fn serve() -> Result<()> { let app = Router::new() // handle websockets .route("/ws", get(ws_handler)) + // state + .layer(Extension(Arc::new(State::new()))) // logger .layer( TraceLayer::new_for_http() @@ -61,6 +66,7 @@ async fn ws_handler( ws: WebSocketUpgrade, user_agent: Option>, ConnectInfo(addr): ConnectInfo, + Extension(state): Extension>, ) -> impl IntoResponse { let user_agent = user_agent.map_or("Unknown user agent".into(), |user_agent| { user_agent.to_string() @@ -69,16 +75,16 @@ async fn ws_handler( tracing::info!("`{user_agent}` at {addr} connected."); // upgrade the connection - ws.on_upgrade(move |socket| handle_socket(socket, addr)) + ws.on_upgrade(move |socket| handle_socket(socket, addr, state)) } // after websocket is established, handle incoming messages -async fn handle_socket(socket: WebSocket, addr: SocketAddr) { +async fn handle_socket(socket: WebSocket, addr: SocketAddr, state: Arc) { let (sender, mut receiver) = socket.split(); let sender = Arc::new(Mutex::new(sender)); while let Some(Ok(msg)) = receiver.next().await { - let response = process_message(msg, Arc::clone(&sender)).await; + let response = process_message(msg, Arc::clone(&sender), Arc::clone(&state)).await; if response.map_or(false, |response| response.is_break()) { break; @@ -92,11 +98,12 @@ async fn handle_socket(socket: WebSocket, addr: SocketAddr) { async fn process_message( msg: Message, sender: Arc>>, + state: Arc, ) -> Result, ()>> { match msg { Message::Text(text) => { let messsage_request = serde_json::from_str::(&text)?; - let message_response = handle_message(messsage_request); + let message_response = handle_message(messsage_request, state); let response = Message::Text(serde_json::to_string(&message_response)?); (*sender.lock().await).send(response).await?; diff --git a/quadratic-multiplayer/src/state.rs b/quadratic-multiplayer/src/state.rs new file mode 100644 index 0000000000..fef047fac7 --- /dev/null +++ b/quadratic-multiplayer/src/state.rs @@ -0,0 +1,21 @@ +use tokio::sync::Mutex; +use uuid::Uuid; + +struct Room { + file_id: Uuid, + users: Vec, +} + +struct Rooms(Vec); + +pub struct State { + rooms: Mutex, +} + +impl State { + pub fn new() -> Self { + State { + rooms: Mutex::new(Rooms(vec![])), + } + } +} From 5d95595a0c7f31c76e0fe7af7e0c7fcf90a71e7f Mon Sep 17 00:00:00 2001 From: David DiMaria Date: Thu, 30 Nov 2023 12:08:33 -0700 Subject: [PATCH 05/32] Add dotenv and envy for env var support --- quadratic-multiplayer/.env.example | 0 quadratic-multiplayer/Cargo.lock | 17 +++++++++++++++++ quadratic-multiplayer/Cargo.toml | 2 ++ quadratic-multiplayer/src/config.rs | 14 ++++++++++++++ quadratic-multiplayer/src/main.rs | 1 + quadratic-multiplayer/src/message.rs | 2 +- quadratic-multiplayer/src/server.rs | 21 ++++++++++++++------- 7 files changed, 49 insertions(+), 8 deletions(-) create mode 100644 quadratic-multiplayer/.env.example create mode 100644 quadratic-multiplayer/src/config.rs diff --git a/quadratic-multiplayer/.env.example b/quadratic-multiplayer/.env.example new file mode 100644 index 0000000000..e69de29bb2 diff --git a/quadratic-multiplayer/Cargo.lock b/quadratic-multiplayer/Cargo.lock index 6d2f62090e..dde0e876e9 100644 --- a/quadratic-multiplayer/Cargo.lock +++ b/quadratic-multiplayer/Cargo.lock @@ -231,6 +231,21 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dotenv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" + +[[package]] +name = "envy" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f47e0157f2cb54f5ae1bd371b30a2ae4311e1c028f575cd4e81de7353215965" +dependencies = [ + "serde", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -760,6 +775,8 @@ dependencies = [ "anyhow", "axum", "axum-extra", + "dotenv", + "envy", "futures", "futures-util", "headers", diff --git a/quadratic-multiplayer/Cargo.toml b/quadratic-multiplayer/Cargo.toml index c8ae06ca4c..e2c5278275 100644 --- a/quadratic-multiplayer/Cargo.toml +++ b/quadratic-multiplayer/Cargo.toml @@ -8,6 +8,8 @@ authors = ["David DiMaria "] anyhow = "1.0.75" axum = { version = "0.7.1", features = ["ws"] } axum-extra = { version = "0.9.0", features = ["typed-header"] } +dotenv = "0.15.0" +envy = "0.4.2" futures = "0.3.29" futures-util = { version = "0.3.29", default-features = false, features = ["sink", "std"] } headers = "0.4.0" diff --git a/quadratic-multiplayer/src/config.rs b/quadratic-multiplayer/src/config.rs new file mode 100644 index 0000000000..7a921fb5e1 --- /dev/null +++ b/quadratic-multiplayer/src/config.rs @@ -0,0 +1,14 @@ +use anyhow::Result; +use dotenv::dotenv; +use serde::Deserialize; + +#[derive(Deserialize, Debug)] +pub(crate) struct Config { + pub(crate) host: String, + pub(crate) port: String, +} + +pub(crate) fn config() -> Result { + dotenv().ok(); + Ok(envy::from_env::()?) +} diff --git a/quadratic-multiplayer/src/main.rs b/quadratic-multiplayer/src/main.rs index b7bdc80a40..ebd277807d 100644 --- a/quadratic-multiplayer/src/main.rs +++ b/quadratic-multiplayer/src/main.rs @@ -1,6 +1,7 @@ //! Quadratic Multiplayer //! +mod config; mod message; mod server; mod state; diff --git a/quadratic-multiplayer/src/message.rs b/quadratic-multiplayer/src/message.rs index 103c685fb1..baadba82ce 100644 --- a/quadratic-multiplayer/src/message.rs +++ b/quadratic-multiplayer/src/message.rs @@ -12,7 +12,7 @@ pub enum MessageRequest { MouseMove { user_id: Uuid, x: f64, y: f64 }, } -#[derive(Serialize)] +#[derive(Serialize, Debug)] #[serde(tag = "type")] pub enum MessageResponse { Room { name: String }, diff --git a/quadratic-multiplayer/src/server.rs b/quadratic-multiplayer/src/server.rs index 6af86a1490..c629d29d5e 100644 --- a/quadratic-multiplayer/src/server.rs +++ b/quadratic-multiplayer/src/server.rs @@ -24,11 +24,14 @@ use futures_util::SinkExt; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use crate::{ + config::{config, Config}, message::{handle_message, MessageRequest, MessageResponse}, - state::{self, State}, + state::State, }; pub async fn serve() -> Result<()> { + let Config { host, port } = config()?; + tracing_subscriber::registry() .with( tracing_subscriber::EnvFilter::try_from_default_env() @@ -48,7 +51,7 @@ pub async fn serve() -> Result<()> { .make_span_with(DefaultMakeSpan::default().include_headers(true)), ); - let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?; + let listener = tokio::net::TcpListener::bind(format!("{host}:{port}")).await?; tracing::info!("listening on {}", listener.local_addr()?); @@ -86,8 +89,12 @@ async fn handle_socket(socket: WebSocket, addr: SocketAddr, state: Arc) { while let Some(Ok(msg)) = receiver.next().await { let response = process_message(msg, Arc::clone(&sender), Arc::clone(&state)).await; - if response.map_or(false, |response| response.is_break()) { - break; + match response { + Ok(ControlFlow::Continue(_)) => {} + Ok(ControlFlow::Break(_)) => break, + Err(e) => { + tracing::error!("Error processing message: {:?}", e); + } } } @@ -109,13 +116,13 @@ async fn process_message( (*sender.lock().await).send(response).await?; } Message::Binary(d) => { - tracing::info!(">>> {} bytes: {:?}", d.len(), d); + tracing::info!("{} bytes: {:?}", d.len(), d); } Message::Close(c) => { if let Some(cf) = c { - tracing::info!(">>> close with code {} and reason `{}`", cf.code, cf.reason); + tracing::info!("Close with code {} and reason `{}`", cf.code, cf.reason); } else { - tracing::info!(">>> omehow sent close message without CloseFrame"); + tracing::info!("Somehow sent close message without CloseFrame"); } return Ok(ControlFlow::Break(None)); } From b97633dac22eff117d7cc4328f924a1f2c81804e Mon Sep 17 00:00:00 2001 From: David DiMaria Date: Thu, 30 Nov 2023 12:49:46 -0700 Subject: [PATCH 06/32] Store users in a room and return room upon enter --- quadratic-multiplayer/src/message.rs | 61 +++++++++++++++++++++++----- quadratic-multiplayer/src/server.rs | 2 +- quadratic-multiplayer/src/state.rs | 38 +++++++++++++---- 3 files changed, 81 insertions(+), 20 deletions(-) diff --git a/quadratic-multiplayer/src/message.rs b/quadratic-multiplayer/src/message.rs index baadba82ce..03b8d577b9 100644 --- a/quadratic-multiplayer/src/message.rs +++ b/quadratic-multiplayer/src/message.rs @@ -1,31 +1,70 @@ use std::sync::Arc; +use anyhow::{anyhow, Result}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::state::State; +use crate::state::{Room, State, User}; #[derive(Deserialize, Debug)] #[serde(tag = "type")] -pub enum MessageRequest { - NewRoom { user_id: Uuid, file_id: Uuid }, - MouseMove { user_id: Uuid, x: f64, y: f64 }, +pub(crate) enum MessageRequest { + EnterRoom { + user_id: Uuid, + file_id: Uuid, + first_name: String, + last_name: String, + image: String, + }, + MouseMove { + user_id: Uuid, + x: f64, + y: f64, + }, } #[derive(Serialize, Debug)] #[serde(tag = "type")] -pub enum MessageResponse { - Room { name: String }, +pub(crate) enum MessageResponse { + Room { room: Room }, MouseMove { user_id: Uuid, x: f64, y: f64 }, } -pub fn handle_message(request: MessageRequest, state: Arc) -> MessageResponse { +pub(crate) async fn handle_message( + request: MessageRequest, + state: Arc, +) -> Result { tracing::trace!("Handling message {:?}", request); match request { - MessageRequest::NewRoom { user_id, file_id } => MessageResponse::Room { - name: format!("room-{}", user_id), - }, - MessageRequest::MouseMove { user_id, x, y } => MessageResponse::MouseMove { user_id, x, y }, + MessageRequest::EnterRoom { + user_id, + file_id, + first_name, + last_name, + image, + } => { + let user = User { + id: user_id, + first_name, + last_name, + image, + }; + + state.enter_room(file_id, user).await; + + Ok(MessageResponse::Room { + room: state + .rooms + .lock() + .await + .get(&file_id) + .ok_or(anyhow!("Room {file_id} not found"))? + .clone(), + }) + } + MessageRequest::MouseMove { user_id, x, y } => { + Ok(MessageResponse::MouseMove { user_id, x, y }) + } } } diff --git a/quadratic-multiplayer/src/server.rs b/quadratic-multiplayer/src/server.rs index c629d29d5e..a82a50bca8 100644 --- a/quadratic-multiplayer/src/server.rs +++ b/quadratic-multiplayer/src/server.rs @@ -110,7 +110,7 @@ async fn process_message( match msg { Message::Text(text) => { let messsage_request = serde_json::from_str::(&text)?; - let message_response = handle_message(messsage_request, state); + let message_response = handle_message(messsage_request, state).await?; let response = Message::Text(serde_json::to_string(&message_response)?); (*sender.lock().await).send(response).await?; diff --git a/quadratic-multiplayer/src/state.rs b/quadratic-multiplayer/src/state.rs index fef047fac7..eb4910a45d 100644 --- a/quadratic-multiplayer/src/state.rs +++ b/quadratic-multiplayer/src/state.rs @@ -1,21 +1,43 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; use uuid::Uuid; -struct Room { - file_id: Uuid, - users: Vec, +#[derive(Serialize, Deserialize, Debug, Clone)] +pub(crate) struct User { + pub(crate) id: Uuid, + pub(crate) first_name: String, + pub(crate) last_name: String, + pub(crate) image: String, } -struct Rooms(Vec); +#[derive(Serialize, Deserialize, Debug, Clone)] +pub(crate) struct Room { + pub(crate) file_id: Uuid, + pub(crate) users: HashMap, +} -pub struct State { - rooms: Mutex, +#[derive(Debug)] +pub(crate) struct State { + pub(crate) rooms: Mutex>, } impl State { - pub fn new() -> Self { + pub(crate) fn new() -> Self { State { - rooms: Mutex::new(Rooms(vec![])), + rooms: Mutex::new(HashMap::new()), } } + + pub(crate) async fn enter_room(&self, file_id: Uuid, user: User) { + let mut rooms = self.rooms.lock().await; + let room = rooms.entry(file_id).or_insert_with(|| Room { + file_id, + users: HashMap::new(), + }); + + // tracing::info!("User {:?} entered room {:?}", user, room); + room.users.insert(user.id, user); + } } From afbe889e98411d0f1671dd5587437d5da0483914 Mon Sep 17 00:00:00 2001 From: David DiMaria Date: Thu, 30 Nov 2023 12:58:34 -0700 Subject: [PATCH 07/32] Store a socket connection for each user in a room --- quadratic-multiplayer/src/message.rs | 5 +++++ quadratic-multiplayer/src/server.rs | 3 ++- quadratic-multiplayer/src/state.rs | 10 +++++++--- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/quadratic-multiplayer/src/message.rs b/quadratic-multiplayer/src/message.rs index 03b8d577b9..b965331726 100644 --- a/quadratic-multiplayer/src/message.rs +++ b/quadratic-multiplayer/src/message.rs @@ -1,7 +1,10 @@ use std::sync::Arc; use anyhow::{anyhow, Result}; +use axum::extract::ws::{Message, WebSocket}; +use futures_util::stream::SplitSink; use serde::{Deserialize, Serialize}; +use tokio::sync::Mutex; use uuid::Uuid; use crate::state::{Room, State, User}; @@ -33,6 +36,7 @@ pub(crate) enum MessageResponse { pub(crate) async fn handle_message( request: MessageRequest, state: Arc, + sender: Arc>>, ) -> Result { tracing::trace!("Handling message {:?}", request); @@ -49,6 +53,7 @@ pub(crate) async fn handle_message( first_name, last_name, image, + socket: sender, }; state.enter_room(file_id, user).await; diff --git a/quadratic-multiplayer/src/server.rs b/quadratic-multiplayer/src/server.rs index a82a50bca8..251949bfd8 100644 --- a/quadratic-multiplayer/src/server.rs +++ b/quadratic-multiplayer/src/server.rs @@ -110,7 +110,8 @@ async fn process_message( match msg { Message::Text(text) => { let messsage_request = serde_json::from_str::(&text)?; - let message_response = handle_message(messsage_request, state).await?; + let message_response = + handle_message(messsage_request, state, Arc::clone(&sender)).await?; let response = Message::Text(serde_json::to_string(&message_response)?); (*sender.lock().await).send(response).await?; diff --git a/quadratic-multiplayer/src/state.rs b/quadratic-multiplayer/src/state.rs index eb4910a45d..85e8d18008 100644 --- a/quadratic-multiplayer/src/state.rs +++ b/quadratic-multiplayer/src/state.rs @@ -1,18 +1,22 @@ -use std::collections::HashMap; +use std::{collections::HashMap, sync::Arc}; +use axum::extract::ws::{Message, WebSocket}; +use futures_util::stream::SplitSink; use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; use uuid::Uuid; -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Debug, Clone)] pub(crate) struct User { pub(crate) id: Uuid, pub(crate) first_name: String, pub(crate) last_name: String, pub(crate) image: String, + #[serde(skip_serializing)] + pub(crate) socket: Arc>>, } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Debug, Clone)] pub(crate) struct Room { pub(crate) file_id: Uuid, pub(crate) users: HashMap, From 1c4ba81c2d2fbd003b4459e7f63f08ac5034231c Mon Sep 17 00:00:00 2001 From: David DiMaria Date: Thu, 30 Nov 2023 13:20:34 -0700 Subject: [PATCH 08/32] Broadcast Room message to all users in a room --- quadratic-multiplayer/.env.example | 2 ++ quadratic-multiplayer/src/message.rs | 44 +++++++++++++++++++++------- quadratic-multiplayer/src/state.rs | 1 + 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/quadratic-multiplayer/.env.example b/quadratic-multiplayer/.env.example index e69de29bb2..b3e300da42 100644 --- a/quadratic-multiplayer/.env.example +++ b/quadratic-multiplayer/.env.example @@ -0,0 +1,2 @@ +HOST=127.0.0.1 +PORT=3001 \ No newline at end of file diff --git a/quadratic-multiplayer/src/message.rs b/quadratic-multiplayer/src/message.rs index b965331726..76d9295990 100644 --- a/quadratic-multiplayer/src/message.rs +++ b/quadratic-multiplayer/src/message.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use anyhow::{anyhow, Result}; use axum::extract::ws::{Message, WebSocket}; use futures_util::stream::SplitSink; +use futures_util::SinkExt; use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; use uuid::Uuid; @@ -29,6 +30,7 @@ pub(crate) enum MessageRequest { #[derive(Serialize, Debug)] #[serde(tag = "type")] pub(crate) enum MessageResponse { + Empty, Room { room: Room }, MouseMove { user_id: Uuid, x: f64, y: f64 }, } @@ -53,23 +55,45 @@ pub(crate) async fn handle_message( first_name, last_name, image, - socket: sender, + socket: Arc::clone(&sender), }; state.enter_room(file_id, user).await; + broadcast(file_id, Arc::clone(&state)).await?; - Ok(MessageResponse::Room { - room: state - .rooms - .lock() - .await - .get(&file_id) - .ok_or(anyhow!("Room {file_id} not found"))? - .clone(), - }) + Ok(MessageResponse::Empty) + // Ok(MessageResponse::Room { + // room: state + // .rooms + // .lock() + // .await + // .get(&file_id) + // .ok_or(anyhow!("Room {file_id} not found"))? + // .clone(), + // }) } MessageRequest::MouseMove { user_id, x, y } => { Ok(MessageResponse::MouseMove { user_id, x, y }) } } } + +pub(crate) async fn broadcast(file_id: Uuid, state: Arc) -> Result<()> { + let room = state + .rooms + .lock() + .await + .get(&file_id) + .ok_or(anyhow!("Room {file_id} not found"))? + .clone(); + + let response = MessageResponse::Room { room: room.clone() }; + + for (_, user) in room.users.iter() { + (*user.socket.lock().await) + .send(Message::Text(serde_json::to_string(&response)?)) + .await?; + } + + Ok(()) +} diff --git a/quadratic-multiplayer/src/state.rs b/quadratic-multiplayer/src/state.rs index 85e8d18008..7d51632067 100644 --- a/quadratic-multiplayer/src/state.rs +++ b/quadratic-multiplayer/src/state.rs @@ -8,6 +8,7 @@ use uuid::Uuid; #[derive(Serialize, Debug, Clone)] pub(crate) struct User { + #[serde(skip_serializing)] pub(crate) id: Uuid, pub(crate) first_name: String, pub(crate) last_name: String, From bc90f03594d9cfe72f64f1063871dfc77d2b447c Mon Sep 17 00:00:00 2001 From: David DiMaria Date: Thu, 30 Nov 2023 14:44:21 -0700 Subject: [PATCH 09/32] Genericize broadcasting + add README --- quadratic-multiplayer/README.md | 153 +++++++++++++++++++++++++++ quadratic-multiplayer/src/message.rs | 74 +++++++++---- quadratic-multiplayer/src/state.rs | 9 +- 3 files changed, 209 insertions(+), 27 deletions(-) create mode 100644 quadratic-multiplayer/README.md diff --git a/quadratic-multiplayer/README.md b/quadratic-multiplayer/README.md new file mode 100644 index 0000000000..77b43af387 --- /dev/null +++ b/quadratic-multiplayer/README.md @@ -0,0 +1,153 @@ +# Quadratic Multiplayer + +An Axum Websocket Server for handling presence and file syncing. + +## Running + +First, copy over the environment variables (customize if applicable): + +```shell +cp .env.example .env +``` + +To run the server: + +```shell +RUST_LOG=info cargo run +``` + +## Development + +To develop with the watcher enabled: + +```shell +RUST_LOG=info cargo watch -x 'run' +``` + +## API + +### Enter Room + +Signals that a user has entered the room + +#### Request + +```rust +EnterRoom { + r#type: String, + user_id: Uuid, + file_id: Uuid, + first_name: String, + last_name: String, + image: String, +}, +``` + +JSON: + +```json +{ + "type": "EnterRoom", + "first_name": "David", + "last_name": "DiMaria", + "image": "https://lh3.googleusercontent.com/a/ACg8ocLcJuKVkU7-Zr67hinRLyzgO_o3VOeMlOA17HcOlKe1fQ=s96-c", + "user_id": "00000000-0000-0000-0000-000000000000", + "file_id": "00000000-0000-0000-0000-000000000001" +} +``` + +#### Response + +```rust +User { + id: Uuid, + first_name: String, + last_name: String, + image: String, +} + +Room { + r#type: String, + room: { + file_id: Uuid, + users: HashMap, + }, +}, +``` + +JSON: + +```json +{ + "type":"Room", + "room":{ + "file_id":"00000000-0000-0000-0000-000000000001", + "users":{ + "00000000-0000-0000-0000-000000000000":{ + "first_name":"David", + "last_name":"DiMaria", + "image":"https://lh3.googleusercontent.com/a/ACg8ocLcJuKVkU7-Zr67hinRLyzgO_o3VOeMlOA17HcOlKe1fQ=s96-c" + }, + "00000000-0000-0000-0000-000000000002":{ + "first_name":"David", + "last_name":"Figatner", + "image":"https://lh3.googleusercontent.com/a/ACg8ocLcJuKVkU7-Zr67hinRLyzgO_o3VOeMlOA17HcOlKe1fQ=s96-c" + } + } + } +} +``` + +### MouseMove + +Signals that a user in a room moved their mouse. + +#### Request + +Rust: + +```rust +MouseMove { + user_id: Uuid, + file_id: Uuid, + x: f64, + y: f64, +}, +``` + +JSON: + +```json +{ + "type": "MouseMove", + "user_id": "00000000-0000-0000-0000-000000000000", + "file_id": "00000000-0000-0000-0000-000000000001", + "x": 10, + "y": 10 +} +``` + +#### Response + +Rust: + +```rust +MouseMove { + user_id: Uuid, + file_id: Uuid, + x: f64, + y: f64, +}, +``` + +JSON: + +```json +{ + "type": "MouseMove", + "user_id": "00000000-0000-0000-0000-000000000000", + "file_id": "00000000-0000-0000-0000-000000000001", + "x": 10, + "y": 10 +} +`` \ No newline at end of file diff --git a/quadratic-multiplayer/src/message.rs b/quadratic-multiplayer/src/message.rs index 76d9295990..a36060ce5c 100644 --- a/quadratic-multiplayer/src/message.rs +++ b/quadratic-multiplayer/src/message.rs @@ -22,6 +22,7 @@ pub(crate) enum MessageRequest { }, MouseMove { user_id: Uuid, + file_id: Uuid, x: f64, y: f64, }, @@ -30,9 +31,15 @@ pub(crate) enum MessageRequest { #[derive(Serialize, Debug)] #[serde(tag = "type")] pub(crate) enum MessageResponse { - Empty, - Room { room: Room }, - MouseMove { user_id: Uuid, x: f64, y: f64 }, + Room { + room: Room, + }, + MouseMove { + user_id: Uuid, + file_id: Uuid, + x: f64, + y: f64, + }, } pub(crate) async fn handle_message( @@ -43,6 +50,8 @@ pub(crate) async fn handle_message( tracing::trace!("Handling message {:?}", request); match request { + // User enters a room. If the room doesn't exist, it is created. + // Users can only be added to a room once MessageRequest::EnterRoom { user_id, file_id, @@ -58,27 +67,48 @@ pub(crate) async fn handle_message( socket: Arc::clone(&sender), }; - state.enter_room(file_id, user).await; - broadcast(file_id, Arc::clone(&state)).await?; - - Ok(MessageResponse::Empty) - // Ok(MessageResponse::Room { - // room: state - // .rooms - // .lock() - // .await - // .get(&file_id) - // .ok_or(anyhow!("Room {file_id} not found"))? - // .clone(), - // }) + let is_new = state.enter_room(file_id, user).await; + + let room = state + .rooms + .lock() + .await + .get(&file_id) + .ok_or(anyhow!("Room {file_id} not found"))? + .clone(); + + let response = MessageResponse::Room { room }; + + // only broadcast if the user is new to the room + if is_new { + broadcast(user_id, file_id, Arc::clone(&state), &response).await?; + } + + Ok(response) } - MessageRequest::MouseMove { user_id, x, y } => { - Ok(MessageResponse::MouseMove { user_id, x, y }) + + // User moves their mouse + MessageRequest::MouseMove { + user_id, + file_id, + x, + y, + } => { + let response = MessageResponse::MouseMove { user_id, x, y }; + + broadcast(user_id, Uuid::nil(), Arc::clone(&state), &response).await?; + + Ok(response) } } } -pub(crate) async fn broadcast(file_id: Uuid, state: Arc) -> Result<()> { +pub(crate) async fn broadcast( + user_id: Uuid, + file_id: Uuid, + state: Arc, + message: &MessageResponse, +) -> Result<()> { let room = state .rooms .lock() @@ -87,11 +117,9 @@ pub(crate) async fn broadcast(file_id: Uuid, state: Arc) -> Result<()> { .ok_or(anyhow!("Room {file_id} not found"))? .clone(); - let response = MessageResponse::Room { room: room.clone() }; - - for (_, user) in room.users.iter() { + for (_, user) in room.users.iter().filter(|user| user.0 != &user_id) { (*user.socket.lock().await) - .send(Message::Text(serde_json::to_string(&response)?)) + .send(Message::Text(serde_json::to_string(&message)?)) .await?; } diff --git a/quadratic-multiplayer/src/state.rs b/quadratic-multiplayer/src/state.rs index 7d51632067..703f90aa7e 100644 --- a/quadratic-multiplayer/src/state.rs +++ b/quadratic-multiplayer/src/state.rs @@ -2,7 +2,7 @@ use std::{collections::HashMap, sync::Arc}; use axum::extract::ws::{Message, WebSocket}; use futures_util::stream::SplitSink; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use tokio::sync::Mutex; use uuid::Uuid; @@ -35,14 +35,15 @@ impl State { } } - pub(crate) async fn enter_room(&self, file_id: Uuid, user: User) { + pub(crate) async fn enter_room(&self, file_id: Uuid, user: User) -> bool { let mut rooms = self.rooms.lock().await; let room = rooms.entry(file_id).or_insert_with(|| Room { file_id, users: HashMap::new(), }); - // tracing::info!("User {:?} entered room {:?}", user, room); - room.users.insert(user.id, user); + tracing::trace!("User {:?} entered room {:?}", user, room); + + room.users.insert(user.id, user).is_none() } } From 03e9346578039e3dfd964a42be1ead41b8cf21e5 Mon Sep 17 00:00:00 2001 From: David DiMaria Date: Thu, 30 Nov 2023 14:47:22 -0700 Subject: [PATCH 10/32] Fix bug in MouseMove --- quadratic-multiplayer/README.md | 2 +- quadratic-multiplayer/src/message.rs | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/quadratic-multiplayer/README.md b/quadratic-multiplayer/README.md index 77b43af387..7717a5d31b 100644 --- a/quadratic-multiplayer/README.md +++ b/quadratic-multiplayer/README.md @@ -150,4 +150,4 @@ JSON: "x": 10, "y": 10 } -`` \ No newline at end of file +``` diff --git a/quadratic-multiplayer/src/message.rs b/quadratic-multiplayer/src/message.rs index a36060ce5c..028a5b0e67 100644 --- a/quadratic-multiplayer/src/message.rs +++ b/quadratic-multiplayer/src/message.rs @@ -94,9 +94,14 @@ pub(crate) async fn handle_message( x, y, } => { - let response = MessageResponse::MouseMove { user_id, x, y }; + let response = MessageResponse::MouseMove { + user_id, + file_id, + x, + y, + }; - broadcast(user_id, Uuid::nil(), Arc::clone(&state), &response).await?; + broadcast(user_id, file_id, Arc::clone(&state), &response).await?; Ok(response) } From de36c921b70916b3c5ab615ae6f2a17a171bccc6 Mon Sep 17 00:00:00 2001 From: David DiMaria Date: Thu, 30 Nov 2023 17:37:52 -0700 Subject: [PATCH 11/32] Add integration test harness, test user enter a room --- quadratic-multiplayer/src/message.rs | 11 +-- quadratic-multiplayer/src/server.rs | 117 +++++++++++++++++++++++---- 2 files changed, 109 insertions(+), 19 deletions(-) diff --git a/quadratic-multiplayer/src/message.rs b/quadratic-multiplayer/src/message.rs index 028a5b0e67..3a7af4bfa6 100644 --- a/quadratic-multiplayer/src/message.rs +++ b/quadratic-multiplayer/src/message.rs @@ -10,7 +10,7 @@ use uuid::Uuid; use crate::state::{Room, State, User}; -#[derive(Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug)] #[serde(tag = "type")] pub(crate) enum MessageRequest { EnterRoom { @@ -114,15 +114,16 @@ pub(crate) async fn broadcast( state: Arc, message: &MessageResponse, ) -> Result<()> { - let room = state + for (_, user) in state .rooms .lock() .await .get(&file_id) .ok_or(anyhow!("Room {file_id} not found"))? - .clone(); - - for (_, user) in room.users.iter().filter(|user| user.0 != &user_id) { + .users + .iter() + .filter(|user| user.0 != &user_id) + { (*user.socket.lock().await) .send(Message::Text(serde_json::to_string(&message)?)) .await?; diff --git a/quadratic-multiplayer/src/server.rs b/quadratic-multiplayer/src/server.rs index 251949bfd8..00ff9dc8fa 100644 --- a/quadratic-multiplayer/src/server.rs +++ b/quadratic-multiplayer/src/server.rs @@ -29,6 +29,19 @@ use crate::{ state::State, }; +pub fn app() -> Router { + Router::new() + // handle websockets + .route("/ws", get(ws_handler)) + // state + .layer(Extension(Arc::new(State::new()))) + // logger + .layer( + TraceLayer::new_for_http() + .make_span_with(DefaultMakeSpan::default().include_headers(true)), + ) +} + pub async fn serve() -> Result<()> { let Config { host, port } = config()?; @@ -40,17 +53,7 @@ pub async fn serve() -> Result<()> { .with(tracing_subscriber::fmt::layer()) .init(); - let app = Router::new() - // handle websockets - .route("/ws", get(ws_handler)) - // state - .layer(Extension(Arc::new(State::new()))) - // logger - .layer( - TraceLayer::new_for_http() - .make_span_with(DefaultMakeSpan::default().include_headers(true)), - ); - + let app = app(); let listener = tokio::net::TcpListener::bind(format!("{host}:{port}")).await?; tracing::info!("listening on {}", listener.local_addr()?); @@ -68,21 +71,22 @@ pub async fn serve() -> Result<()> { async fn ws_handler( ws: WebSocketUpgrade, user_agent: Option>, - ConnectInfo(addr): ConnectInfo, + addr: Option>, Extension(state): Extension>, ) -> impl IntoResponse { let user_agent = user_agent.map_or("Unknown user agent".into(), |user_agent| { user_agent.to_string() }); + let addr = addr.map_or("Unknown address".into(), |addr| addr.to_string()); tracing::info!("`{user_agent}` at {addr} connected."); // upgrade the connection - ws.on_upgrade(move |socket| handle_socket(socket, addr, state)) + ws.on_upgrade(move |socket| handle_socket(socket, state, addr)) } // after websocket is established, handle incoming messages -async fn handle_socket(socket: WebSocket, addr: SocketAddr, state: Arc) { +async fn handle_socket(socket: WebSocket, state: Arc, addr: String) { let (sender, mut receiver) = socket.split(); let sender = Arc::new(Mutex::new(sender)); @@ -134,3 +138,88 @@ async fn process_message( Ok(ControlFlow::Continue(())) } + +#[cfg(test)] +pub(crate) mod tests { + + use super::*; + use std::{ + future::IntoFuture, + net::{Ipv4Addr, SocketAddr}, + }; + use tokio_tungstenite::tungstenite; + use uuid::Uuid; + + pub(crate) async fn integration_test(request: MessageRequest) -> String { + let listener = tokio::net::TcpListener::bind(SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0))) + .await + .unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(axum::serve(listener, app()).into_future()); + + let (mut socket, _response) = tokio_tungstenite::connect_async(format!("ws://{addr}/ws")) + .await + .unwrap(); + + // send the message + socket + .send(tungstenite::Message::text( + serde_json::to_string(&request).unwrap(), + )) + .await + .unwrap(); + + let msg = match socket.next().await.unwrap().unwrap() { + tungstenite::Message::Text(msg) => msg, + other => panic!("expected a text message but got {other:?}"), + }; + + msg + } + + #[tokio::test] + async fn user_enters_a_room() { + let user_id = Uuid::new_v4(); + let file_id = Uuid::new_v4(); + let first_name = "a".to_string(); + let last_name = "b".to_string(); + let image = "c".to_string(); + + let request = MessageRequest::EnterRoom { + user_id, + file_id, + first_name: first_name.clone(), + last_name: last_name.clone(), + image: image.clone(), + }; + let expected_response = format!( + r#"{{"type":"Room","room":{{"file_id":"{file_id}","users":{{"{user_id}":{{"first_name":"{first_name}","last_name":"{last_name}","image":"{image}"}}}}}}}}"# + ); + + let response = integration_test(request).await; + + assert_eq!(response, expected_response); + } + + // #[tokio::test] + // async fn user_moves_a_mouse() { + // let user_id = Uuid::new_v4(); + // let file_id = Uuid::new_v4(); + // let x = 0 as f64; + // let y = 0 as f64; + + // let request = MessageRequest::MouseMove { + // user_id, + // file_id, + // x, + // y, + // }; + + // let expected_response = + // format!(r#"{{"type":"MouseMove", "file_id":"{file_id}","user_id":"{user_id}"}}"#); + + // let response = integration_test(request).await; + + // assert_eq!(response, expected_response); + // } +} From ba2688984e4668b01ecdee659cc9d3084445addc Mon Sep 17 00:00:00 2001 From: David DiMaria Date: Fri, 1 Dec 2023 08:09:27 -0700 Subject: [PATCH 12/32] Add package.json --- quadratic-multiplayer/package.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 quadratic-multiplayer/package.json diff --git a/quadratic-multiplayer/package.json b/quadratic-multiplayer/package.json new file mode 100644 index 0000000000..b0e3173587 --- /dev/null +++ b/quadratic-multiplayer/package.json @@ -0,0 +1,13 @@ +{ + "name": "quadratic-multiplayer", + "description": "Quadratic Multiplayer server", + "version": "0.1.0", + "dependencies": {}, + "devDependencies": {}, + "scripts": { + "start": "RUST_LOG=info cargo run", + "build": "cargo build", + "dev": "RUST_LOG=info cargo watch -x 'run'", + "lint": "cargo clippy --all-targets --all-features -- -D warnings" + } +} From a70aebfdc58f0c587fae15dcdc6fb73020c36180 Mon Sep 17 00:00:00 2001 From: David DiMaria Date: Fri, 1 Dec 2023 08:29:36 -0700 Subject: [PATCH 13/32] Document all the things --- quadratic-multiplayer/src/config.rs | 8 ++++++++ quadratic-multiplayer/src/main.rs | 2 ++ quadratic-multiplayer/src/message.rs | 12 ++++++++++-- quadratic-multiplayer/src/server.rs | 26 +++++++++++++++++--------- quadratic-multiplayer/src/state.rs | 7 +++++++ 5 files changed, 44 insertions(+), 11 deletions(-) diff --git a/quadratic-multiplayer/src/config.rs b/quadratic-multiplayer/src/config.rs index 7a921fb5e1..809c33d63c 100644 --- a/quadratic-multiplayer/src/config.rs +++ b/quadratic-multiplayer/src/config.rs @@ -1,3 +1,10 @@ +//! Global Configuration +//! +//! Leveraging the `dotenv` crate, this module provides a global configuration +//! struct. This struct is populated by the `.env` file in the root of the +//! sub-repo. If ANY of the environment variables are missing, the program will +//! panic at startup. + use anyhow::Result; use dotenv::dotenv; use serde::Deserialize; @@ -8,6 +15,7 @@ pub(crate) struct Config { pub(crate) port: String, } +/// Load the global configuration from the environment into Config. pub(crate) fn config() -> Result { dotenv().ok(); Ok(envy::from_env::()?) diff --git a/quadratic-multiplayer/src/main.rs b/quadratic-multiplayer/src/main.rs index ebd277807d..4fb951c9ff 100644 --- a/quadratic-multiplayer/src/main.rs +++ b/quadratic-multiplayer/src/main.rs @@ -1,5 +1,7 @@ //! Quadratic Multiplayer //! +//! A multiplayer server for Quadratic. Supports user presence and mouse +//! tracking for a shared file. mod config; mod message; diff --git a/quadratic-multiplayer/src/message.rs b/quadratic-multiplayer/src/message.rs index 3a7af4bfa6..10b0832491 100644 --- a/quadratic-multiplayer/src/message.rs +++ b/quadratic-multiplayer/src/message.rs @@ -1,3 +1,10 @@ +//! Websocket Message Handler +//! +//! A central place for handling websocket messages. This module is +//! responsible for incoming requests and outgoing responses. Since +//! socket information is stored in the global state, we can broadcast +//! to all users in a room. + use std::sync::Arc; use anyhow::{anyhow, Result}; @@ -42,6 +49,7 @@ pub(crate) enum MessageResponse { }, } +/// Handle incoming messages. All requests and responses are strictly typed. pub(crate) async fn handle_message( request: MessageRequest, state: Arc, @@ -50,8 +58,7 @@ pub(crate) async fn handle_message( tracing::trace!("Handling message {:?}", request); match request { - // User enters a room. If the room doesn't exist, it is created. - // Users can only be added to a room once + // User enters a room. MessageRequest::EnterRoom { user_id, file_id, @@ -108,6 +115,7 @@ pub(crate) async fn handle_message( } } +/// Broadcast a message to all users in a room except the sender. pub(crate) async fn broadcast( user_id: Uuid, file_id: Uuid, diff --git a/quadratic-multiplayer/src/server.rs b/quadratic-multiplayer/src/server.rs index 00ff9dc8fa..39fec95906 100644 --- a/quadratic-multiplayer/src/server.rs +++ b/quadratic-multiplayer/src/server.rs @@ -1,5 +1,7 @@ //! Websocket Server //! +//! Handle bootstrapping and starting the websocket server. Adds global state +//! to be shared across all requests and threads. Adds tracing/logging. use anyhow::Result; use axum::{ @@ -29,7 +31,9 @@ use crate::{ state::State, }; -pub fn app() -> Router { +/// Construct the application router. This is separated out so that it can be +/// integration tested. +pub(crate) fn app() -> Router { Router::new() // handle websockets .route("/ws", get(ws_handler)) @@ -42,7 +46,8 @@ pub fn app() -> Router { ) } -pub async fn serve() -> Result<()> { +/// Start the websocket server. This is the entrypoint for the application. +pub(crate) async fn serve() -> Result<()> { let Config { host, port } = config()?; tracing_subscriber::registry() @@ -67,7 +72,7 @@ pub async fn serve() -> Result<()> { Ok(()) } -// handle the websocket upgrade from http +// Handle the websocket upgrade from http. async fn ws_handler( ws: WebSocketUpgrade, user_agent: Option>, @@ -85,7 +90,7 @@ async fn ws_handler( ws.on_upgrade(move |socket| handle_socket(socket, state, addr)) } -// after websocket is established, handle incoming messages +// After websocket is established, delegate incoming messages as they arrive. async fn handle_socket(socket: WebSocket, state: Arc, addr: String) { let (sender, mut receiver) = socket.split(); let sender = Arc::new(Mutex::new(sender)); @@ -106,6 +111,7 @@ async fn handle_socket(socket: WebSocket, state: Arc, addr: String) { tracing::info!("Websocket context {addr} destroyed"); } +/// Based on the incoming message type, perform some action and return a response. async fn process_message( msg: Message, sender: Arc>>, @@ -121,7 +127,11 @@ async fn process_message( (*sender.lock().await).send(response).await?; } Message::Binary(d) => { - tracing::info!("{} bytes: {:?}", d.len(), d); + tracing::info!( + "Binary messages are not yet supported. {} bytes: {:?}", + d.len(), + d + ); } Message::Close(c) => { if let Some(cf) = c { @@ -169,12 +179,10 @@ pub(crate) mod tests { .await .unwrap(); - let msg = match socket.next().await.unwrap().unwrap() { + match socket.next().await.unwrap().unwrap() { tungstenite::Message::Text(msg) => msg, other => panic!("expected a text message but got {other:?}"), - }; - - msg + } } #[tokio::test] diff --git a/quadratic-multiplayer/src/state.rs b/quadratic-multiplayer/src/state.rs index 703f90aa7e..76630926b2 100644 --- a/quadratic-multiplayer/src/state.rs +++ b/quadratic-multiplayer/src/state.rs @@ -1,3 +1,8 @@ +//! Shared State +//! +//! Store information about the state of the application in a send + sync +//! struct. All access and mutations to state should be performed here. + use std::{collections::HashMap, sync::Arc}; use axum::extract::ws::{Message, WebSocket}; @@ -35,6 +40,8 @@ impl State { } } + /// Add a user to a room. If the room doesn't exist, it is created. Users + /// are only added to a room once (HashMap). pub(crate) async fn enter_room(&self, file_id: Uuid, user: User) -> bool { let mut rooms = self.rooms.lock().await; let room = rooms.entry(file_id).or_insert_with(|| Room { From 5c8d1cf5c090dbf33f74af54b0c00735e4a71ec2 Mon Sep 17 00:00:00 2001 From: David DiMaria Date: Fri, 1 Dec 2023 10:10:16 -0700 Subject: [PATCH 14/32] Refactor and add tests --- quadratic-multiplayer/Cargo.lock | 77 ++++++++++++++++ quadratic-multiplayer/Cargo.toml | 3 + quadratic-multiplayer/src/config.rs | 13 +++ quadratic-multiplayer/src/main.rs | 2 + quadratic-multiplayer/src/message.rs | 36 +++----- quadratic-multiplayer/src/server.rs | 117 ++++++++++--------------- quadratic-multiplayer/src/state.rs | 48 +++++++++- quadratic-multiplayer/src/test_util.rs | 50 +++++++++++ 8 files changed, 253 insertions(+), 93 deletions(-) create mode 100644 quadratic-multiplayer/src/test_util.rs diff --git a/quadratic-multiplayer/Cargo.lock b/quadratic-multiplayer/Cargo.lock index dde0e876e9..fd84287f69 100644 --- a/quadratic-multiplayer/Cargo.lock +++ b/quadratic-multiplayer/Cargo.lock @@ -215,12 +215,53 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0209d94da627ab5605dcccf08bb18afa5009cfbef48d8a8b7d7bdbc79be25c5e" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "177e3443818124b357d8e76f53be906d60937f0d3a90773a664fa63fa253e621" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836a9bbc7ad63342d6d6e7b815ccab164bc77a2d95d84bc3117a8c0d5c98e2d5" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "data-encoding" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5" +[[package]] +name = "deunicode" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1abaf4d861455be59f64fd2b55606cb151fce304ede7165f410243ce96bde6" + [[package]] name = "digest" version = "0.10.7" @@ -237,6 +278,18 @@ version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" +[[package]] +name = "dummy" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e57e12b69e57fad516e01e2b3960f122696fdb13420e1a88ed8e210316f2876" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "envy" version = "0.4.2" @@ -252,6 +305,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[package]] +name = "fake" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26221445034074d46b276e13eb97a265ebdb8ed8da705c4dddd3dd20b66b45d2" +dependencies = [ + "deunicode", + "dummy", + "rand", +] + [[package]] name = "fnv" version = "1.0.7" @@ -540,6 +604,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "0.5.0" @@ -777,6 +847,7 @@ dependencies = [ "axum-extra", "dotenv", "envy", + "fake", "futures", "futures-util", "headers", @@ -1014,6 +1085,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "syn" version = "2.0.39" diff --git a/quadratic-multiplayer/Cargo.toml b/quadratic-multiplayer/Cargo.toml index e2c5278275..35660f7b5c 100644 --- a/quadratic-multiplayer/Cargo.toml +++ b/quadratic-multiplayer/Cargo.toml @@ -22,3 +22,6 @@ tower-http = { version = "0.5.0", features = ["fs", "trace"] } tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } uuid = { version = "1.6.1", features = ["serde", "v4"] } + +[dev-dependencies] +fake = { version = "2.9.1", features = ["derive"] } diff --git a/quadratic-multiplayer/src/config.rs b/quadratic-multiplayer/src/config.rs index 809c33d63c..3341c9c820 100644 --- a/quadratic-multiplayer/src/config.rs +++ b/quadratic-multiplayer/src/config.rs @@ -20,3 +20,16 @@ pub(crate) fn config() -> Result { dotenv().ok(); Ok(envy::from_env::()?) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn gets_a_config() { + let host = "127.0.0.1"; + std::env::set_var("HOST", host); + let config = config().unwrap(); + assert_eq!(config.host, host.to_string()); + } +} diff --git a/quadratic-multiplayer/src/main.rs b/quadratic-multiplayer/src/main.rs index 4fb951c9ff..381bc51d65 100644 --- a/quadratic-multiplayer/src/main.rs +++ b/quadratic-multiplayer/src/main.rs @@ -7,6 +7,8 @@ mod config; mod message; mod server; mod state; +#[cfg(test)] +mod test_util; use anyhow::Result; diff --git a/quadratic-multiplayer/src/message.rs b/quadratic-multiplayer/src/message.rs index 10b0832491..afbcf70b78 100644 --- a/quadratic-multiplayer/src/message.rs +++ b/quadratic-multiplayer/src/message.rs @@ -7,7 +7,7 @@ use std::sync::Arc; -use anyhow::{anyhow, Result}; +use anyhow::Result; use axum::extract::ws::{Message, WebSocket}; use futures_util::stream::SplitSink; use futures_util::SinkExt; @@ -17,7 +17,7 @@ use uuid::Uuid; use crate::state::{Room, State, User}; -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, PartialEq)] #[serde(tag = "type")] pub(crate) enum MessageRequest { EnterRoom { @@ -35,7 +35,7 @@ pub(crate) enum MessageRequest { }, } -#[derive(Serialize, Debug)] +#[derive(Serialize, Debug, PartialEq)] #[serde(tag = "type")] pub(crate) enum MessageResponse { Room { @@ -71,19 +71,10 @@ pub(crate) async fn handle_message( first_name, last_name, image, - socket: Arc::clone(&sender), + socket: Some(Arc::clone(&sender)), }; - let is_new = state.enter_room(file_id, user).await; - - let room = state - .rooms - .lock() - .await - .get(&file_id) - .ok_or(anyhow!("Room {file_id} not found"))? - .clone(); - + let room = state.get_room(&file_id).await?; let response = MessageResponse::Room { room }; // only broadcast if the user is new to the room @@ -123,18 +114,19 @@ pub(crate) async fn broadcast( message: &MessageResponse, ) -> Result<()> { for (_, user) in state - .rooms - .lock() - .await - .get(&file_id) - .ok_or(anyhow!("Room {file_id} not found"))? + .get_room(&file_id) + .await? .users .iter() .filter(|user| user.0 != &user_id) { - (*user.socket.lock().await) - .send(Message::Text(serde_json::to_string(&message)?)) - .await?; + if let Some(sender) = &user.socket { + sender + .lock() + .await + .send(Message::Text(serde_json::to_string(&message)?)) + .await?; + } } Ok(()) diff --git a/quadratic-multiplayer/src/server.rs b/quadratic-multiplayer/src/server.rs index 39fec95906..923f556fa7 100644 --- a/quadratic-multiplayer/src/server.rs +++ b/quadratic-multiplayer/src/server.rs @@ -14,15 +14,13 @@ use axum::{ Extension, Router, }; use axum_extra::TypedHeader; +use futures::stream::StreamExt; use futures_util::stream::SplitSink; -use tokio::sync::Mutex; - +use futures_util::SinkExt; use std::ops::ControlFlow; use std::{net::SocketAddr, sync::Arc}; +use tokio::sync::Mutex; use tower_http::trace::{DefaultMakeSpan, TraceLayer}; - -use futures::stream::StreamExt; -use futures_util::SinkExt; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use crate::{ @@ -33,12 +31,12 @@ use crate::{ /// Construct the application router. This is separated out so that it can be /// integration tested. -pub(crate) fn app() -> Router { +pub(crate) fn app(state: Arc) -> Router { Router::new() // handle websockets .route("/ws", get(ws_handler)) // state - .layer(Extension(Arc::new(State::new()))) + .layer(Extension(state)) // logger .layer( TraceLayer::new_for_http() @@ -58,7 +56,8 @@ pub(crate) async fn serve() -> Result<()> { .with(tracing_subscriber::fmt::layer()) .init(); - let app = app(); + let state = Arc::new(State::new()); + let app = app(state); let listener = tokio::net::TcpListener::bind(format!("{host}:{port}")).await?; tracing::info!("listening on {}", listener.local_addr()?); @@ -153,81 +152,61 @@ async fn process_message( pub(crate) mod tests { use super::*; - use std::{ - future::IntoFuture, - net::{Ipv4Addr, SocketAddr}, + use crate::{ + state::Room, + test_util::{integration_test, new_user}, }; - use tokio_tungstenite::tungstenite; use uuid::Uuid; - pub(crate) async fn integration_test(request: MessageRequest) -> String { - let listener = tokio::net::TcpListener::bind(SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0))) - .await - .unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(axum::serve(listener, app()).into_future()); - - let (mut socket, _response) = tokio_tungstenite::connect_async(format!("ws://{addr}/ws")) - .await - .unwrap(); - - // send the message - socket - .send(tungstenite::Message::text( - serde_json::to_string(&request).unwrap(), - )) - .await - .unwrap(); - - match socket.next().await.unwrap().unwrap() { - tungstenite::Message::Text(msg) => msg, - other => panic!("expected a text message but got {other:?}"), - } - } - #[tokio::test] async fn user_enters_a_room() { - let user_id = Uuid::new_v4(); + let state = Arc::new(State::new()); let file_id = Uuid::new_v4(); - let first_name = "a".to_string(); - let last_name = "b".to_string(); - let image = "c".to_string(); - + let user = new_user(); + let user_id = user.id; let request = MessageRequest::EnterRoom { user_id, file_id, - first_name: first_name.clone(), - last_name: last_name.clone(), - image: image.clone(), + first_name: user.first_name.clone(), + last_name: user.last_name.clone(), + image: user.image.clone(), }; - let expected_response = format!( - r#"{{"type":"Room","room":{{"file_id":"{file_id}","users":{{"{user_id}":{{"first_name":"{first_name}","last_name":"{last_name}","image":"{image}"}}}}}}}}"# - ); - - let response = integration_test(request).await; + let expected = MessageResponse::Room { + room: Room { + file_id, + users: vec![(user_id, user)].into_iter().collect(), + }, + }; + let response = integration_test(state, request).await; - assert_eq!(response, expected_response); + assert_eq!(response, serde_json::to_string(&expected).unwrap()); } - // #[tokio::test] - // async fn user_moves_a_mouse() { - // let user_id = Uuid::new_v4(); - // let file_id = Uuid::new_v4(); - // let x = 0 as f64; - // let y = 0 as f64; - - // let request = MessageRequest::MouseMove { - // user_id, - // file_id, - // x, - // y, - // }; + #[tokio::test] + async fn user_moves_a_mouse() { + let state = Arc::new(State::new()); + let user = new_user(); + let user_id = user.id; + let file_id = Uuid::new_v4(); + let x = 0 as f64; + let y = 0 as f64; + let request = MessageRequest::MouseMove { + user_id, + file_id, + x, + y, + }; + let expected = MessageResponse::MouseMove { + user_id, + file_id, + x, + y, + }; - // let expected_response = - // format!(r#"{{"type":"MouseMove", "file_id":"{file_id}","user_id":"{user_id}"}}"#); + state.enter_room(file_id, user).await; - // let response = integration_test(request).await; + let response = integration_test(state.clone(), request).await; - // assert_eq!(response, expected_response); - // } + assert_eq!(response, serde_json::to_string(&expected).unwrap()); + } } diff --git a/quadratic-multiplayer/src/state.rs b/quadratic-multiplayer/src/state.rs index 76630926b2..334ddb353d 100644 --- a/quadratic-multiplayer/src/state.rs +++ b/quadratic-multiplayer/src/state.rs @@ -5,6 +5,7 @@ use std::{collections::HashMap, sync::Arc}; +use anyhow::{anyhow, Result}; use axum::extract::ws::{Message, WebSocket}; use futures_util::stream::SplitSink; use serde::Serialize; @@ -19,10 +20,19 @@ pub(crate) struct User { pub(crate) last_name: String, pub(crate) image: String, #[serde(skip_serializing)] - pub(crate) socket: Arc>>, + pub(crate) socket: Option>>>, } -#[derive(Serialize, Debug, Clone)] +impl PartialEq for User { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + && self.first_name == other.first_name + && self.last_name == other.last_name + && self.image == other.image + } +} + +#[derive(Serialize, Debug, Clone, PartialEq)] pub(crate) struct Room { pub(crate) file_id: Uuid, pub(crate) users: HashMap, @@ -53,4 +63,38 @@ impl State { room.users.insert(user.id, user).is_none() } + + /// Retrieves a copy of a room. + pub(crate) async fn get_room(&self, file_id: &Uuid) -> Result { + let rooms = self.rooms.lock().await; + let room = rooms + .get(file_id) + .ok_or(anyhow!("Room {file_id} not found"))? + .to_owned(); + + Ok(room) + } +} + +#[cfg(test)] +mod tests { + use crate::test_util::new_user; + + use super::*; + + #[tokio::test] + async fn enters_and_retrieves_a_room() { + let state = State::new(); + let file_id = Uuid::new_v4(); + let user = new_user(); + + let is_new = state.enter_room(file_id, user.clone()).await; + let room = state.get_room(&file_id).await.unwrap(); + let user = room.users.get(&user.id).unwrap(); + + assert!(is_new); + assert_eq!(state.rooms.lock().await.len(), 1); + assert_eq!(room.users.len(), 1); + assert_eq!(room.users.get(&user.id), Some(user)); + } } diff --git a/quadratic-multiplayer/src/test_util.rs b/quadratic-multiplayer/src/test_util.rs new file mode 100644 index 0000000000..5112d3649f --- /dev/null +++ b/quadratic-multiplayer/src/test_util.rs @@ -0,0 +1,50 @@ +use fake::faker::filesystem::en::FilePath; +use fake::faker::name::en::{FirstName, LastName}; +use fake::Fake; +use futures::stream::StreamExt; +use futures_util::SinkExt; +use std::sync::Arc; +use std::{ + future::IntoFuture, + net::{Ipv4Addr, SocketAddr}, +}; +use tokio_tungstenite::tungstenite; +use uuid::Uuid; + +use crate::message::MessageRequest; +use crate::state::{State, User}; + +pub(crate) fn new_user() -> User { + User { + id: Uuid::new_v4(), + first_name: FirstName().fake(), + last_name: LastName().fake(), + image: FilePath().fake(), + socket: None, + } +} + +pub(crate) async fn integration_test(state: Arc, request: MessageRequest) -> String { + let listener = tokio::net::TcpListener::bind(SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0))) + .await + .unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(axum::serve(listener, crate::server::app(state)).into_future()); + + let (mut socket, _response) = tokio_tungstenite::connect_async(format!("ws://{addr}/ws")) + .await + .unwrap(); + + // send the message + socket + .send(tungstenite::Message::text( + serde_json::to_string(&request).unwrap(), + )) + .await + .unwrap(); + + match socket.next().await.unwrap().unwrap() { + tungstenite::Message::Text(msg) => msg, + other => panic!("expected a text message but got {other:?}"), + } +} From 648b1f2c23bfca2a390315c207f8957e551423d0 Mon Sep 17 00:00:00 2001 From: David DiMaria Date: Fri, 1 Dec 2023 15:16:05 -0700 Subject: [PATCH 15/32] Enhance the README --- quadratic-multiplayer/README.md | 36 ++++++++++++++++++++++++++++++ quadratic-multiplayer/package.json | 2 ++ 2 files changed, 38 insertions(+) diff --git a/quadratic-multiplayer/README.md b/quadratic-multiplayer/README.md index 7717a5d31b..1819b81f49 100644 --- a/quadratic-multiplayer/README.md +++ b/quadratic-multiplayer/README.md @@ -14,14 +14,50 @@ To run the server: ```shell RUST_LOG=info cargo run + +// npm alternative +npm start ``` +Assuming the `HOST` is set to `127.0.0.1` and the `PORT` is set to `3001`, the websocket endpoint is available at `http://127.0.0.1:3001/ws` or `ws://127.0.0.1:3001/ws`. + ## Development To develop with the watcher enabled: ```shell RUST_LOG=info cargo watch -x 'run' + +// npm alternative +npm run dev +``` + +### Testing + +To develop with the watcher enabled: + +```shell +cargo test + +// npm alternative +npm run test + +// watcher +RUST_LOG=info cargo watch -x 'test' + +// npm alternative +npm run test::watch +``` + +### Linting + +To develop with the watcher enabled: + +```shell +cargo clippy --all-targets --all-features -- -D warnings + +// npm alternative +npm run lint ``` ## API diff --git a/quadratic-multiplayer/package.json b/quadratic-multiplayer/package.json index b0e3173587..d50ba62b0a 100644 --- a/quadratic-multiplayer/package.json +++ b/quadratic-multiplayer/package.json @@ -8,6 +8,8 @@ "start": "RUST_LOG=info cargo run", "build": "cargo build", "dev": "RUST_LOG=info cargo watch -x 'run'", + "test": "cargo test", + "test::watch": "RUST_LOG=info cargo watch -x 'test'", "lint": "cargo clippy --all-targets --all-features -- -D warnings" } } From 4136a345b37390c309569f8e851f8152ad693f7e Mon Sep 17 00:00:00 2001 From: David DiMaria Date: Fri, 1 Dec 2023 15:47:10 -0700 Subject: [PATCH 16/32] More tests, refactoring, add local coverage --- quadratic-multiplayer/README.md | 2 +- quadratic-multiplayer/package.json | 8 ++++-- quadratic-multiplayer/src/message.rs | 27 ++++++++++++++++++++ quadratic-multiplayer/src/state.rs | 34 +++++++++++++++----------- quadratic-multiplayer/src/test_util.rs | 11 +++++++++ 5 files changed, 65 insertions(+), 17 deletions(-) diff --git a/quadratic-multiplayer/README.md b/quadratic-multiplayer/README.md index 1819b81f49..6f6139180a 100644 --- a/quadratic-multiplayer/README.md +++ b/quadratic-multiplayer/README.md @@ -46,7 +46,7 @@ npm run test RUST_LOG=info cargo watch -x 'test' // npm alternative -npm run test::watch +npm run test:watch ``` ### Linting diff --git a/quadratic-multiplayer/package.json b/quadratic-multiplayer/package.json index d50ba62b0a..96dec4bd8c 100644 --- a/quadratic-multiplayer/package.json +++ b/quadratic-multiplayer/package.json @@ -9,7 +9,11 @@ "build": "cargo build", "dev": "RUST_LOG=info cargo watch -x 'run'", "test": "cargo test", - "test::watch": "RUST_LOG=info cargo watch -x 'test'", - "lint": "cargo clippy --all-targets --all-features -- -D warnings" + "test:watch": "RUST_LOG=info cargo watch -x 'test'", + "lint": "cargo clippy --all-targets --all-features -- -D warnings", + "coverage:gen": "CARGO_INCREMENTAL=0 RUSTFLAGS='-Cinstrument-coverage' LLVM_PROFILE_FILE='coverage/cargo-test-%p-%m.profraw' cargo test", + "coverage:html": "grcov . --binary-path ./target/debug/deps/ -s . -t html --branch --ignore-not-existing --ignore 'src/wasm_bindings/*' --ignore 'src/bin/*' --ignore '../*' --ignore '/*' -o coverage/html", + "coverage:view": "open coverage/html/index.html" + } } diff --git a/quadratic-multiplayer/src/message.rs b/quadratic-multiplayer/src/message.rs index afbcf70b78..fc014ac205 100644 --- a/quadratic-multiplayer/src/message.rs +++ b/quadratic-multiplayer/src/message.rs @@ -131,3 +131,30 @@ pub(crate) async fn broadcast( Ok(()) } + +#[cfg(test)] +pub(crate) mod tests { + + use crate::test_util::add_new_user_to_room; + + use super::*; + + #[tokio::test] + async fn broadcasting() { + let state = Arc::new(State::new()); + let file_id = Uuid::new_v4(); + let user_1 = add_new_user_to_room(file_id, state.clone()).await; + let _user_2 = add_new_user_to_room(file_id, state.clone()).await; + let message = MessageResponse::MouseMove { + user_id: user_1.id, + file_id, + x: 10 as f64, + y: 10 as f64, + }; + broadcast(user_1.id, file_id, state, &message) + .await + .unwrap(); + + // TODO(ddimaria): mock the splitsink sender to test the actual sending + } +} diff --git a/quadratic-multiplayer/src/state.rs b/quadratic-multiplayer/src/state.rs index 334ddb353d..dc379b90ba 100644 --- a/quadratic-multiplayer/src/state.rs +++ b/quadratic-multiplayer/src/state.rs @@ -38,6 +38,15 @@ pub(crate) struct Room { pub(crate) users: HashMap, } +impl Room { + pub(crate) fn new(file_id: Uuid) -> Self { + Room { + file_id, + users: HashMap::new(), + } + } +} + #[derive(Debug)] pub(crate) struct State { pub(crate) rooms: Mutex>, @@ -50,20 +59,6 @@ impl State { } } - /// Add a user to a room. If the room doesn't exist, it is created. Users - /// are only added to a room once (HashMap). - pub(crate) async fn enter_room(&self, file_id: Uuid, user: User) -> bool { - let mut rooms = self.rooms.lock().await; - let room = rooms.entry(file_id).or_insert_with(|| Room { - file_id, - users: HashMap::new(), - }); - - tracing::trace!("User {:?} entered room {:?}", user, room); - - room.users.insert(user.id, user).is_none() - } - /// Retrieves a copy of a room. pub(crate) async fn get_room(&self, file_id: &Uuid) -> Result { let rooms = self.rooms.lock().await; @@ -74,6 +69,17 @@ impl State { Ok(room) } + + /// Add a user to a room. If the room doesn't exist, it is created. Users + /// are only added to a room once (HashMap). + pub(crate) async fn enter_room(&self, file_id: Uuid, user: User) -> bool { + let mut rooms = self.rooms.lock().await; + let room = rooms.entry(file_id).or_insert_with(|| Room::new(file_id)); + + tracing::trace!("User {:?} entered room {:?}", user, room); + + room.users.insert(user.id, user).is_none() + } } #[cfg(test)] diff --git a/quadratic-multiplayer/src/test_util.rs b/quadratic-multiplayer/src/test_util.rs index 5112d3649f..2ab21712ec 100644 --- a/quadratic-multiplayer/src/test_util.rs +++ b/quadratic-multiplayer/src/test_util.rs @@ -24,11 +24,22 @@ pub(crate) fn new_user() -> User { } } +pub(crate) async fn add_user_to_room(file_id: Uuid, user: User, state: Arc) -> User { + state.enter_room(file_id, user.clone()).await; + user +} + +pub(crate) async fn add_new_user_to_room(file_id: Uuid, state: Arc) -> User { + add_user_to_room(file_id, new_user(), state).await +} + pub(crate) async fn integration_test(state: Arc, request: MessageRequest) -> String { let listener = tokio::net::TcpListener::bind(SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0))) .await .unwrap(); let addr = listener.local_addr().unwrap(); + + // run the server in a separate thread tokio::spawn(axum::serve(listener, crate::server::app(state)).into_future()); let (mut socket, _response) = tokio_tungstenite::connect_async(format!("ws://{addr}/ws")) From 1fda2da7963d37f2fe4dfe6c46cda075328b91fc Mon Sep 17 00:00:00 2001 From: David DiMaria Date: Fri, 1 Dec 2023 16:04:09 -0700 Subject: [PATCH 17/32] Broadcast messages in a separate thread --- quadratic-multiplayer/src/message.rs | 50 ++++++++++++++++------------ 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/quadratic-multiplayer/src/message.rs b/quadratic-multiplayer/src/message.rs index fc014ac205..180d202351 100644 --- a/quadratic-multiplayer/src/message.rs +++ b/quadratic-multiplayer/src/message.rs @@ -35,7 +35,7 @@ pub(crate) enum MessageRequest { }, } -#[derive(Serialize, Debug, PartialEq)] +#[derive(Serialize, Debug, Clone, PartialEq)] #[serde(tag = "type")] pub(crate) enum MessageResponse { Room { @@ -79,7 +79,7 @@ pub(crate) async fn handle_message( // only broadcast if the user is new to the room if is_new { - broadcast(user_id, file_id, Arc::clone(&state), &response).await?; + broadcast(user_id, file_id, Arc::clone(&state), response.clone()).await?; } Ok(response) @@ -99,7 +99,7 @@ pub(crate) async fn handle_message( y, }; - broadcast(user_id, file_id, Arc::clone(&state), &response).await?; + broadcast(user_id, file_id, Arc::clone(&state), response.clone()).await?; Ok(response) } @@ -111,23 +111,33 @@ pub(crate) async fn broadcast( user_id: Uuid, file_id: Uuid, state: Arc, - message: &MessageResponse, + message: MessageResponse, ) -> Result<()> { - for (_, user) in state - .get_room(&file_id) - .await? - .users - .iter() - .filter(|user| user.0 != &user_id) - { - if let Some(sender) = &user.socket { - sender - .lock() - .await - .send(Message::Text(serde_json::to_string(&message)?)) - .await?; + tokio::spawn(async move { + let result = async { + for (_, user) in state + .get_room(&file_id) + .await? + .users + .iter() + .filter(|user| user.0 != &user_id) + { + if let Some(sender) = &user.socket { + sender + .lock() + .await + .send(Message::Text(serde_json::to_string(&message)?)) + .await?; + } + } + + Ok::<_, anyhow::Error>(()) + }; + + if let Err(e) = result.await { + tracing::error!("Error broadcasting message: {:?}", e); } - } + }); Ok(()) } @@ -151,9 +161,7 @@ pub(crate) mod tests { x: 10 as f64, y: 10 as f64, }; - broadcast(user_1.id, file_id, state, &message) - .await - .unwrap(); + broadcast(user_1.id, file_id, state, message).await.unwrap(); // TODO(ddimaria): mock the splitsink sender to test the actual sending } From 5a01270416896eb9abf81268961bde60f19067bf Mon Sep 17 00:00:00 2001 From: David DiMaria Date: Fri, 1 Dec 2023 16:16:01 -0700 Subject: [PATCH 18/32] Don't await braodbast messages --- quadratic-multiplayer/src/message.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/quadratic-multiplayer/src/message.rs b/quadratic-multiplayer/src/message.rs index 180d202351..6405790114 100644 --- a/quadratic-multiplayer/src/message.rs +++ b/quadratic-multiplayer/src/message.rs @@ -79,7 +79,7 @@ pub(crate) async fn handle_message( // only broadcast if the user is new to the room if is_new { - broadcast(user_id, file_id, Arc::clone(&state), response.clone()).await?; + broadcast(user_id, file_id, Arc::clone(&state), response.clone())?; } Ok(response) @@ -99,7 +99,7 @@ pub(crate) async fn handle_message( y, }; - broadcast(user_id, file_id, Arc::clone(&state), response.clone()).await?; + broadcast(user_id, file_id, Arc::clone(&state), response.clone())?; Ok(response) } @@ -107,7 +107,8 @@ pub(crate) async fn handle_message( } /// Broadcast a message to all users in a room except the sender. -pub(crate) async fn broadcast( +/// All messages are sent in a separate thread. +pub(crate) fn broadcast( user_id: Uuid, file_id: Uuid, state: Arc, @@ -161,7 +162,7 @@ pub(crate) mod tests { x: 10 as f64, y: 10 as f64, }; - broadcast(user_1.id, file_id, state, message).await.unwrap(); + broadcast(user_1.id, file_id, state, message).unwrap(); // TODO(ddimaria): mock the splitsink sender to test the actual sending } From 1fab67fa7769ac165b7e4c7c8f71e5b185ebec00 Mon Sep 17 00:00:00 2001 From: David DiMaria Date: Fri, 1 Dec 2023 16:45:26 -0700 Subject: [PATCH 19/32] Add ws server to CI --- .github/workflows/coverage-rust.yml | 42 +++++++++++++++++++++++++--- quadratic-api/src/middleware/team.ts | 5 ++++ quadratic-multiplayer/package.json | 2 +- quadratic-multiplayer/src/message.rs | 3 +- 4 files changed, 45 insertions(+), 7 deletions(-) diff --git a/.github/workflows/coverage-rust.yml b/.github/workflows/coverage-rust.yml index 469c459241..c6583aed4f 100644 --- a/.github/workflows/coverage-rust.yml +++ b/.github/workflows/coverage-rust.yml @@ -32,14 +32,14 @@ jobs: rustup override set nightly if ! which grcov; then cargo install grcov; fi - - name: Build + - name: Build quadratic-core env: RUSTFLAGS: -Cinstrument-coverage run: | cd quadratic-core cargo build - - name: Test + - name: Test quadratic-core env: LLVM_PROFILE_FILE: grcov-%p-%m.profraw RUSTFLAGS: -Cinstrument-coverage @@ -47,7 +47,7 @@ jobs: cd quadratic-core cargo test - - name: Generate coverage + - name: Generate coverage quadratic-core run: | grcov $(find . -name "grcov-*.profraw" -print) \ --branch \ @@ -60,7 +60,41 @@ jobs: --ignore "./quadratic-core/src/bin/*" \ -o lcov.info - - name: Upload coverage reports to Codecov + - name: Upload coverage reports to Codecov quadratic-core uses: codecov/codecov-action@v3 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + - name: Build quadratic-multiplayer + env: + RUSTFLAGS: -Cinstrument-coverage + run: | + cd quadratic-multiplayer + cargo build + + - name: Test quadratic-multiplayer + env: + LLVM_PROFILE_FILE: grcov-%p-%m.profraw + RUSTFLAGS: -Cinstrument-coverage + run: | + cd quadratic-multiplayer + cargo test + + - name: Generate coverage quadratic-multiplayer + run: | + grcov $(find . -name "grcov-*.profraw" -print) \ + --branch \ + --ignore-not-existing \ + --binary-path ./quadratic-multiplayer/target/debug/ \ + -s . \ + -t lcov \ + --ignore "/*" \ + --ignore "./quadratic-multiplayer/src/wasm_bindings/*" \ + --ignore "./quadratic-multiplayer/src/bin/*" \ + -o lcov.info + + - name: Upload coverage reports to Codecov quadratic-multiplayer + uses: codecov/codecov-action@v3 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + diff --git a/quadratic-api/src/middleware/team.ts b/quadratic-api/src/middleware/team.ts index bd818d133d..2ddabc4a0f 100644 --- a/quadratic-api/src/middleware/team.ts +++ b/quadratic-api/src/middleware/team.ts @@ -4,6 +4,8 @@ import dbClient from '../dbClient'; import { Request, RequestWithTeam, RequestWithUser } from '../types/Request'; import { ResponseError } from '../types/Response'; import { getTeamAccess } from '../utils'; +import { userMiddleware } from './user'; +import { validateAccessToken } from './validateAccessToken'; const teamUuidSchema = z.string().uuid(); @@ -19,6 +21,9 @@ export const teamMiddleware = async ( res: Response, next: NextFunction ) => { + await validateAccessToken(req, res, () => {}); + await userMiddleware(req, res, () => {}); + // Validate the team UUID const teamUuid = req.params.uuid; try { diff --git a/quadratic-multiplayer/package.json b/quadratic-multiplayer/package.json index 96dec4bd8c..27306dc8f3 100644 --- a/quadratic-multiplayer/package.json +++ b/quadratic-multiplayer/package.json @@ -12,7 +12,7 @@ "test:watch": "RUST_LOG=info cargo watch -x 'test'", "lint": "cargo clippy --all-targets --all-features -- -D warnings", "coverage:gen": "CARGO_INCREMENTAL=0 RUSTFLAGS='-Cinstrument-coverage' LLVM_PROFILE_FILE='coverage/cargo-test-%p-%m.profraw' cargo test", - "coverage:html": "grcov . --binary-path ./target/debug/deps/ -s . -t html --branch --ignore-not-existing --ignore 'src/wasm_bindings/*' --ignore 'src/bin/*' --ignore '../*' --ignore '/*' -o coverage/html", + "coverage:html": "grcov . --binary-path ./target/debug/deps/ -s . -t html --branch --ignore-not-existing --ignore 'src/main.rs' -o coverage/html", "coverage:view": "open coverage/html/index.html" } diff --git a/quadratic-multiplayer/src/message.rs b/quadratic-multiplayer/src/message.rs index 6405790114..15092f2df3 100644 --- a/quadratic-multiplayer/src/message.rs +++ b/quadratic-multiplayer/src/message.rs @@ -146,9 +146,8 @@ pub(crate) fn broadcast( #[cfg(test)] pub(crate) mod tests { - use crate::test_util::add_new_user_to_room; - use super::*; + use crate::test_util::add_new_user_to_room; #[tokio::test] async fn broadcasting() { From 06c2334d041fd238de3768e4fc71309c5b9d64ac Mon Sep 17 00:00:00 2001 From: David DiMaria Date: Fri, 1 Dec 2023 16:53:35 -0700 Subject: [PATCH 20/32] Add .env.test for CI --- quadratic-multiplayer/.env.test | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 quadratic-multiplayer/.env.test diff --git a/quadratic-multiplayer/.env.test b/quadratic-multiplayer/.env.test new file mode 100644 index 0000000000..b3e300da42 --- /dev/null +++ b/quadratic-multiplayer/.env.test @@ -0,0 +1,2 @@ +HOST=127.0.0.1 +PORT=3001 \ No newline at end of file From 10022580dfe8a6190260ee0d05a302c3af4778e1 Mon Sep 17 00:00:00 2001 From: David DiMaria Date: Fri, 1 Dec 2023 17:00:57 -0700 Subject: [PATCH 21/32] Conditionally load .env.test when testing --- quadratic-multiplayer/src/config.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/quadratic-multiplayer/src/config.rs b/quadratic-multiplayer/src/config.rs index 3341c9c820..7255229972 100644 --- a/quadratic-multiplayer/src/config.rs +++ b/quadratic-multiplayer/src/config.rs @@ -17,6 +17,8 @@ pub(crate) struct Config { /// Load the global configuration from the environment into Config. pub(crate) fn config() -> Result { + let filename = if cfg!(test) { ".env.test" } else { ".env" }; + dotenv::from_filename(filename).ok(); dotenv().ok(); Ok(envy::from_env::()?) } From 5088c6e321b04aadf4769b863a1640fd4a1405e1 Mon Sep 17 00:00:00 2001 From: David Figatner Date: Sun, 3 Dec 2023 07:32:13 -0800 Subject: [PATCH 22/32] basic multiplayer cursor --- .vscode/settings.json | 2 +- package-lock.json | 11 +- package.json | 4 +- quadratic-client/src/dashboard/FileRoute.tsx | 9 +- quadratic-client/src/gridGL/QuadraticGrid.tsx | 2 + .../src/gridGL/interaction/pointer/Pointer.ts | 2 +- .../interaction/pointer/pointerCursor.ts | 5 +- .../src/multiplayer/multiplayer.ts | 133 ++++++++++++++++++ .../multiplayerCursor/MulitplayerCursors.tsx | 61 ++++++++ .../multiplayerCursor/MultiplayerCursor.tsx | 61 ++++++++ .../multiplayerCursor/MultiplayerCursors.css | 30 ++++ .../multiplayerCursor/multiplayerColors.ts | 11 ++ quadratic-multiplayer/package.json | 2 +- quadratic-multiplayer/src/message.rs | 21 +-- quadratic-multiplayer/src/server.rs | 12 +- quadratic-multiplayer/src/state.rs | 12 +- quadratic-multiplayer/src/test_util.rs | 4 +- 17 files changed, 353 insertions(+), 29 deletions(-) create mode 100644 quadratic-client/src/multiplayer/multiplayer.ts create mode 100644 quadratic-client/src/multiplayer/multiplayerCursor/MulitplayerCursors.tsx create mode 100644 quadratic-client/src/multiplayer/multiplayerCursor/MultiplayerCursor.tsx create mode 100644 quadratic-client/src/multiplayer/multiplayerCursor/MultiplayerCursors.css create mode 100644 quadratic-client/src/multiplayer/multiplayerCursor/multiplayerColors.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index a87a25f81f..95498db3b8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -36,7 +36,7 @@ "[rust]": { "editor.defaultFormatter": "rust-lang.rust-analyzer" }, - "rust-analyzer.linkedProjects": ["./quadratic-core/Cargo.toml"], + "rust-analyzer.linkedProjects": ["./quadratic-core/Cargo.toml", "./quadratic-multiplayer/Cargo.toml"], "rust-analyzer.checkOnSave": true, // "rust-analyzer.checkOnSave.command": "clippy", "files.associations": { diff --git a/package-lock.json b/package-lock.json index 2cbce0cd7c..7821b9f3da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "workspaces": [ "quadratic-api", "quadratic-shared", - "quadratic-client" + "quadratic-client", + "quadratic-multiplayer" ], "dependencies": { "zod": "^3.22.4" @@ -17896,6 +17897,10 @@ "resolved": "quadratic-client", "link": true }, + "node_modules/quadratic-multiplayer": { + "resolved": "quadratic-multiplayer", + "link": true + }, "node_modules/quadratic-shared": { "resolved": "quadratic-shared", "link": true @@ -22031,6 +22036,10 @@ } } }, + "quadratic-multiplayer": { + "version": "0.1.0", + "devDependencies": {} + }, "quadratic-shared": { "version": "1.0.0", "license": "ISC" diff --git a/package.json b/package.json index 136ebdf9ba..1a96b50692 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "workspaces": [ "quadratic-api", "quadratic-shared", - "quadratic-client" + "quadratic-client", + "quadratic-multiplayer" ], "scripts": { "start": "npm run watch:front-end", @@ -22,6 +23,7 @@ "watch:front-end": "npm run build:wasm:types && concurrently --hide=2 -n=react,rust,rust-types \"npm:watch:javascript\" \"npm:watch:wasm:javascript\" \"npm run start:api\"", "watch:front-end-back-end": "npm run build:wasm:types && concurrently -n=react,rust,api \"npm:watch:javascript\" \"npm:watch:wasm:javascript\" \"npm run start:api\"", "watch:app": "npm run build:wasm:types && concurrently -n=react,api 'npm:watch:javascript' 'npm run start:api'", + "watch:multiplayer": "npm run dev --workspace=quadratic-multiplayer", "lint:client": "cd quadratic-client && npm run lint:ts && npm run lint:eslint && lint:prettier && lint:clippy", "build:wasm:types": "cd quadratic-core && cargo run --bin export_types", "watch:wasm:javascript": "cd quadratic-core && cargo watch -s 'wasm-pack build --dev --target web --out-dir ../quadratic-client/src/quadratic-core --weak-refs'", diff --git a/quadratic-client/src/dashboard/FileRoute.tsx b/quadratic-client/src/dashboard/FileRoute.tsx index 25c8da435a..e5c5ae322d 100644 --- a/quadratic-client/src/dashboard/FileRoute.tsx +++ b/quadratic-client/src/dashboard/FileRoute.tsx @@ -1,3 +1,5 @@ +import { multiplayer } from '@/multiplayer/multiplayer'; +import { useRootRouteLoaderData } from '@/router'; import { Button } from '@/shadcn/ui/button'; import { ApiSchemas, ApiTypes } from '@quadratic-shared/typesAndSchemas'; import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; @@ -23,6 +25,7 @@ import QuadraticApp from '../ui/QuadraticApp'; export type FileData = { name: string; + uuid: string; sharing: ApiTypes['/v0/files/:uuid/sharing.GET.response']; permission: ApiTypes['/v0/files/:uuid.GET.response']['permission']; }; @@ -84,20 +87,24 @@ export const loader = async ({ request, params }: LoaderFunctionArgs): Promise { + const { user } = useRootRouteLoaderData(); + // Initialize recoil with the file's permission we get from the server - const { permission } = useLoaderData() as FileData; + const { permission, uuid } = useLoaderData() as FileData; const initializeState = ({ set }: MutableSnapshot) => { set(editorInteractionStateAtom, (prevState) => ({ ...prevState, permission, })); }; + multiplayer.enterFileRoom(uuid, user); return ( diff --git a/quadratic-client/src/gridGL/QuadraticGrid.tsx b/quadratic-client/src/gridGL/QuadraticGrid.tsx index 8fdf62862d..5527268d9d 100644 --- a/quadratic-client/src/gridGL/QuadraticGrid.tsx +++ b/quadratic-client/src/gridGL/QuadraticGrid.tsx @@ -1,3 +1,4 @@ +import { MultiplayerCursors } from '@/multiplayer/multiplayerCursor/MulitplayerCursors'; import { useCallback, useEffect, useState } from 'react'; import { useRecoilState } from 'recoil'; import { editorInteractionStateAtom } from '../atoms/editorInteractionStateAtom'; @@ -117,6 +118,7 @@ export default function QuadraticGrid() { > {showInput && } + ); } diff --git a/quadratic-client/src/gridGL/interaction/pointer/Pointer.ts b/quadratic-client/src/gridGL/interaction/pointer/Pointer.ts index 782833e49d..31f748cea7 100644 --- a/quadratic-client/src/gridGL/interaction/pointer/Pointer.ts +++ b/quadratic-client/src/gridGL/interaction/pointer/Pointer.ts @@ -54,7 +54,7 @@ export class Pointer { this.pointerHeading.pointerMove(world) || this.pointerAutoComplete.pointerMove(world) || this.pointerDown.pointerMove(world); - this.pointerCursor.pointerMove(); + this.pointerCursor.pointerMove(world); }; private pointerUp = (e: InteractionEvent): void => { diff --git a/quadratic-client/src/gridGL/interaction/pointer/pointerCursor.ts b/quadratic-client/src/gridGL/interaction/pointer/pointerCursor.ts index e1da232223..7121b78018 100644 --- a/quadratic-client/src/gridGL/interaction/pointer/pointerCursor.ts +++ b/quadratic-client/src/gridGL/interaction/pointer/pointerCursor.ts @@ -1,8 +1,11 @@ +import { multiplayer } from '@/multiplayer/multiplayer'; +import { Point } from 'pixi.js'; import { pixiApp } from '../../pixiApp/PixiApp'; export class PointerCursor { - pointerMove(): void { + pointerMove(world: Point): void { const cursor = pixiApp.pointer.pointerHeading.cursor ?? pixiApp.pointer.pointerAutoComplete.cursor; pixiApp.canvas.style.cursor = cursor ?? 'unset'; + multiplayer.mouseMove(world.x, world.y); } } diff --git a/quadratic-client/src/multiplayer/multiplayer.ts b/quadratic-client/src/multiplayer/multiplayer.ts new file mode 100644 index 0000000000..a544db9664 --- /dev/null +++ b/quadratic-client/src/multiplayer/multiplayer.ts @@ -0,0 +1,133 @@ +import { User } from '@auth0/auth0-spa-js'; + +// todo: create types for messages + +export interface Player { + sheetId: string; + x: number; + y: number; + name: string; + picture: string; + visible: boolean; +} + +class Multiplayer { + private websocket?: WebSocket; + private ready = false; + private room?: string; + private uuid?: string; + + players: Map = new Map(); + + private async init() { + return new Promise((resolve) => { + this.websocket = new WebSocket(import.meta.env.VITE_QUADRATIC_MULTIPLAYER_URL); + this.websocket.addEventListener('message', this.handleMessage); + this.websocket.addEventListener('close', async () => { + console.log('[Multiplayer] websocket closed. Reconnecting...'); + this.ready = false; + await this.init(); + if (this.room) await this.enterFileRoom(this.room, { sub: this.uuid }); + }); + this.websocket.addEventListener('open', () => { + console.log('[Multiplayer] websocket initialized.'); + this.ready = true; + resolve(0); + }); + }); + } + + async enterFileRoom(uuid: string, user?: User) { + // todo: hack to get around sharing bugged in ateam branch + uuid = 'ae910c17-5988-41c7-a915-af90f56d6e69'; + + if (!user) throw new Error('Expected User to be defined'); + if (this.room === uuid) return; + this.room = uuid; + this.uuid = user.sub; + if (!this.ready) await this.init(); + if (!this.websocket) { + throw new Error('[Multiplayer] Websocket not initialized.'); + } + this.websocket.send( + JSON.stringify({ + type: 'EnterRoom', + + // todo: not sure this is the correct user id + user_id: user.sub, + + file_id: uuid, + first_name: user.given_name, + last_name: user.family_name, + image: user.picture, + }) + ); + console.log(`[Multiplayer] Entered room.`); + } + + mouseMove(x: number, y: number) { + if (!this.ready || !this.room || !this.uuid) return; + if (!this.websocket) { + throw new Error('[Multiplayer] Websocket not initialized.'); + } + this.websocket.send( + JSON.stringify({ + type: 'MouseMove', + + user_id: this.uuid, + file_id: this.room, + x, + y, + }) + ); + } + + handleMessage = (e: any) => { + const data = JSON.parse(e.data); + const { type } = data; + if (type === 'Room') { + this.players.clear(); + const users = data.room.users; + for (const userId in users) { + // todo: this check should not be needed (eventually) + if (userId !== this.uuid) { + const user = users[userId]; + const { first_name, last_name, image } = user; + this.players.set(userId, { + name: `${first_name} ${last_name}`, + picture: image, + sheetId: '', + x: 0, + y: 0, + visible: false, + }); + console.log(`[Multiplayer] Player ${userId} entered room.`); + } + } + } + if (type === 'MouseMove') { + // todo: this check should not be needed (eventually) + if (data.user_id !== this.uuid) { + const player = this.players.get(data.user_id); + if (!player) { + throw new Error("Expected Player to be defined before receiving a message of type 'MouseMove'"); + } + this.players.set(data.user_id, { + ...player, + x: data.x, + y: data.y, + visible: true, + // sheetId: data.sheet_id, + }); + window.dispatchEvent(new CustomEvent('multiplayer-cursor')); + } + } + }; + + // todo: maybe players should just be an array to instead of a map to speed up this operation? + getPlayers(): Player[] { + return Array.from(this.players.values()); + } +} + +export const multiplayer = new Multiplayer(); diff --git a/quadratic-client/src/multiplayer/multiplayerCursor/MulitplayerCursors.tsx b/quadratic-client/src/multiplayer/multiplayerCursor/MulitplayerCursors.tsx new file mode 100644 index 0000000000..29083a6c97 --- /dev/null +++ b/quadratic-client/src/multiplayer/multiplayerCursor/MulitplayerCursors.tsx @@ -0,0 +1,61 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { sheets } from '@/grid/controller/Sheets'; +import { pixiApp } from '@/gridGL/pixiApp/PixiApp'; +import { useEffect, useState } from 'react'; +import { multiplayer } from '../multiplayer'; +import { MultiplayerCursor } from './MultiplayerCursor'; +import './MultiplayerCursors.css'; +import { MULTIPLAYER_COLORS } from './multiplayerColors'; + +const OFFSCREEN_SIZE = 5; + +export const MultiplayerCursors = () => { + // triggers a render + const [_, setPlayersTrigger] = useState(0); + useEffect(() => { + const updatePlayersTrigger = () => setPlayersTrigger((x) => x + 1); + window.addEventListener('multiplayer-cursor', updatePlayersTrigger); + return () => window.removeEventListener('multiplayer-cursor', updatePlayersTrigger); + }, []); + + const [currentSheetId, setCurrentSheetId] = useState(sheets.sheet.id); + useEffect(() => { + const changeSheet = () => setCurrentSheetId(sheets.sheet.id); + window.addEventListener('change-sheet', changeSheet); + return () => window.removeEventListener('change-sheet', changeSheet); + }, []); + + return ( +
+ {multiplayer.getPlayers().flatMap((player, index) => { + const color = MULTIPLAYER_COLORS[index % MULTIPLAYER_COLORS.length]; + const bounds = pixiApp.viewport.getVisibleBounds(); + const rect = pixiApp.canvas.getBoundingClientRect(); + const offsetTop = rect.top; + const { x, y, sheetId, name, visible } = player; + if (visible /*&& sheetId === currentSheetId*/) { + const translated = pixiApp.viewport.toScreen(x, y); + let offscreen = false; + if (x > bounds.right - OFFSCREEN_SIZE) { + offscreen = true; + translated.x = rect.right - OFFSCREEN_SIZE * 2; + } else if (x < bounds.left) { + offscreen = true; + translated.x = rect.left; + } + if (y > bounds.bottom) { + offscreen = true; + translated.y = rect.bottom; + } else if (y < bounds.top - offsetTop + OFFSCREEN_SIZE) { + offscreen = true; + translated.y = rect.top - offsetTop; + } + return [ + , + ]; + } + return []; + })} +
+ ); +}; diff --git a/quadratic-client/src/multiplayer/multiplayerCursor/MultiplayerCursor.tsx b/quadratic-client/src/multiplayer/multiplayerCursor/MultiplayerCursor.tsx new file mode 100644 index 0000000000..78e7b8a953 --- /dev/null +++ b/quadratic-client/src/multiplayer/multiplayerCursor/MultiplayerCursor.tsx @@ -0,0 +1,61 @@ +interface Props { + x: number; + y: number; + name: string; + color: string; + offscreen: boolean; +} + +export const MultiplayerCursor = (props: Props) => { + const { x, y, name, color, offscreen } = props; + + if (offscreen) { + return ( +
+ ); + } + + return ( +
+ + + + +
+ + {name} + +
+
+ ); +}; diff --git a/quadratic-client/src/multiplayer/multiplayerCursor/MultiplayerCursors.css b/quadratic-client/src/multiplayer/multiplayerCursor/MultiplayerCursors.css new file mode 100644 index 0000000000..1df459e517 --- /dev/null +++ b/quadratic-client/src/multiplayer/multiplayerCursor/MultiplayerCursors.css @@ -0,0 +1,30 @@ +.multiplayer-cursors { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; +} + +.multiplayer-cursor, +.multiplayer-cursor-offscreen { + position: absolute; + pointer-events: none; + user-select: none; + left: 0; + top: 0; + transition: transform 0.25s cubic-bezier(0.17, 0.93, 0.38, 1); + z-index: 10000; +} + +.multiplayer-cursor-offscreen { + width: 10px; + height: 10px; +} + +.multiplayer-cursor svg { + position: absolute; + top: 0; + left: 0; +} diff --git a/quadratic-client/src/multiplayer/multiplayerCursor/multiplayerColors.ts b/quadratic-client/src/multiplayer/multiplayerCursor/multiplayerColors.ts new file mode 100644 index 0000000000..7fb90b57dc --- /dev/null +++ b/quadratic-client/src/multiplayer/multiplayerCursor/multiplayerColors.ts @@ -0,0 +1,11 @@ +export const MULTIPLAYER_COLORS = [ + // '#1a1c2c', + '#E57373', + '#9575CD', + '#4FC3F7', + '#81C784', + '#144cb5', + '#FF8A65', + '#F06292', + '#7986CB', +]; diff --git a/quadratic-multiplayer/package.json b/quadratic-multiplayer/package.json index 27306dc8f3..be8a0ae141 100644 --- a/quadratic-multiplayer/package.json +++ b/quadratic-multiplayer/package.json @@ -14,6 +14,6 @@ "coverage:gen": "CARGO_INCREMENTAL=0 RUSTFLAGS='-Cinstrument-coverage' LLVM_PROFILE_FILE='coverage/cargo-test-%p-%m.profraw' cargo test", "coverage:html": "grcov . --binary-path ./target/debug/deps/ -s . -t html --branch --ignore-not-existing --ignore 'src/main.rs' -o coverage/html", "coverage:view": "open coverage/html/index.html" - + } } diff --git a/quadratic-multiplayer/src/message.rs b/quadratic-multiplayer/src/message.rs index 15092f2df3..50c94b9569 100644 --- a/quadratic-multiplayer/src/message.rs +++ b/quadratic-multiplayer/src/message.rs @@ -21,14 +21,14 @@ use crate::state::{Room, State, User}; #[serde(tag = "type")] pub(crate) enum MessageRequest { EnterRoom { - user_id: Uuid, + user_id: String, file_id: Uuid, first_name: String, last_name: String, image: String, }, MouseMove { - user_id: Uuid, + user_id: String, file_id: Uuid, x: f64, y: f64, @@ -42,7 +42,7 @@ pub(crate) enum MessageResponse { room: Room, }, MouseMove { - user_id: Uuid, + user_id: String, file_id: Uuid, x: f64, y: f64, @@ -73,9 +73,11 @@ pub(crate) async fn handle_message( image, socket: Some(Arc::clone(&sender)), }; - let is_new = state.enter_room(file_id, user).await; + let user_id = user.id.clone(); + let is_new = state.enter_room(file_id, &user).await; let room = state.get_room(&file_id).await?; let response = MessageResponse::Room { room }; + tracing::info!("user {} entered room", user.id); // only broadcast if the user is new to the room if is_new { @@ -93,7 +95,7 @@ pub(crate) async fn handle_message( y, } => { let response = MessageResponse::MouseMove { - user_id, + user_id: user_id.clone(), file_id, x, y, @@ -109,7 +111,7 @@ pub(crate) async fn handle_message( /// Broadcast a message to all users in a room except the sender. /// All messages are sent in a separate thread. pub(crate) fn broadcast( - user_id: Uuid, + user_id: String, file_id: Uuid, state: Arc, message: MessageResponse, @@ -121,7 +123,8 @@ pub(crate) fn broadcast( .await? .users .iter() - .filter(|user| user.0 != &user_id) + // todo: this is not working :( + .filter(|(_, user)| user_id != user.id) { if let Some(sender) = &user.socket { sender @@ -156,12 +159,12 @@ pub(crate) mod tests { let user_1 = add_new_user_to_room(file_id, state.clone()).await; let _user_2 = add_new_user_to_room(file_id, state.clone()).await; let message = MessageResponse::MouseMove { - user_id: user_1.id, + user_id: user_1.id.clone(), file_id, x: 10 as f64, y: 10 as f64, }; - broadcast(user_1.id, file_id, state, message).unwrap(); + broadcast(user_1.id.clone(), file_id, state, message).unwrap(); // TODO(ddimaria): mock the splitsink sender to test the actual sending } diff --git a/quadratic-multiplayer/src/server.rs b/quadratic-multiplayer/src/server.rs index 923f556fa7..6eaa90f3f0 100644 --- a/quadratic-multiplayer/src/server.rs +++ b/quadratic-multiplayer/src/server.rs @@ -163,9 +163,9 @@ pub(crate) mod tests { let state = Arc::new(State::new()); let file_id = Uuid::new_v4(); let user = new_user(); - let user_id = user.id; + let user_id = user.id.clone(); let request = MessageRequest::EnterRoom { - user_id, + user_id: user_id.clone(), file_id, first_name: user.first_name.clone(), last_name: user.last_name.clone(), @@ -186,24 +186,24 @@ pub(crate) mod tests { async fn user_moves_a_mouse() { let state = Arc::new(State::new()); let user = new_user(); - let user_id = user.id; + let user_id = user.id.clone(); let file_id = Uuid::new_v4(); let x = 0 as f64; let y = 0 as f64; let request = MessageRequest::MouseMove { - user_id, + user_id: user_id.clone(), file_id, x, y, }; let expected = MessageResponse::MouseMove { - user_id, + user_id: user_id.clone(), file_id, x, y, }; - state.enter_room(file_id, user).await; + state.enter_room(file_id, &user).await; let response = integration_test(state.clone(), request).await; diff --git a/quadratic-multiplayer/src/state.rs b/quadratic-multiplayer/src/state.rs index dc379b90ba..6c5c7914cb 100644 --- a/quadratic-multiplayer/src/state.rs +++ b/quadratic-multiplayer/src/state.rs @@ -15,7 +15,7 @@ use uuid::Uuid; #[derive(Serialize, Debug, Clone)] pub(crate) struct User { #[serde(skip_serializing)] - pub(crate) id: Uuid, + pub(crate) id: String, pub(crate) first_name: String, pub(crate) last_name: String, pub(crate) image: String, @@ -35,7 +35,7 @@ impl PartialEq for User { #[derive(Serialize, Debug, Clone, PartialEq)] pub(crate) struct Room { pub(crate) file_id: Uuid, - pub(crate) users: HashMap, + pub(crate) users: HashMap, } impl Room { @@ -72,13 +72,15 @@ impl State { /// Add a user to a room. If the room doesn't exist, it is created. Users /// are only added to a room once (HashMap). - pub(crate) async fn enter_room(&self, file_id: Uuid, user: User) -> bool { + pub(crate) async fn enter_room(&self, file_id: Uuid, user: &User) -> bool { let mut rooms = self.rooms.lock().await; let room = rooms.entry(file_id).or_insert_with(|| Room::new(file_id)); + let user_id = user.id.clone(); + tracing::trace!("User {:?} entered room {:?}", user, room); - room.users.insert(user.id, user).is_none() + room.users.insert(user_id, user.clone()).is_none() } } @@ -94,7 +96,7 @@ mod tests { let file_id = Uuid::new_v4(); let user = new_user(); - let is_new = state.enter_room(file_id, user.clone()).await; + let is_new = state.enter_room(file_id, &user).await; let room = state.get_room(&file_id).await.unwrap(); let user = room.users.get(&user.id).unwrap(); diff --git a/quadratic-multiplayer/src/test_util.rs b/quadratic-multiplayer/src/test_util.rs index 2ab21712ec..010072c6ea 100644 --- a/quadratic-multiplayer/src/test_util.rs +++ b/quadratic-multiplayer/src/test_util.rs @@ -16,7 +16,7 @@ use crate::state::{State, User}; pub(crate) fn new_user() -> User { User { - id: Uuid::new_v4(), + id: "user".to_string(), first_name: FirstName().fake(), last_name: LastName().fake(), image: FilePath().fake(), @@ -25,7 +25,7 @@ pub(crate) fn new_user() -> User { } pub(crate) async fn add_user_to_room(file_id: Uuid, user: User, state: Arc) -> User { - state.enter_room(file_id, user.clone()).await; + state.enter_room(file_id, &user).await; user } From b3e8c5cbd2532dc54521ec9891276a7a36a7dbc5 Mon Sep 17 00:00:00 2001 From: David Figatner Date: Sun, 3 Dec 2023 08:36:57 -0800 Subject: [PATCH 23/32] mulitplayer selection --- .../src/grid/sheet/SheetCursor.ts | 12 +++ .../src/gridGL/UI/UIMultiplayerCursor.ts | 73 +++++++++++++++++++ .../interaction/pointer/pointerCursor.ts | 2 +- .../src/gridGL/pixiApp/PixiApp.ts | 7 ++ quadratic-client/src/gridGL/pixiApp/Update.ts | 4 +- .../src/multiplayer/multiplayer.ts | 54 ++++++++++---- .../multiplayerCursor/MulitplayerCursors.tsx | 14 +++- .../multiplayerCursor/multiplayerColors.ts | 6 ++ quadratic-multiplayer/src/message.rs | 27 +++++++ 9 files changed, 181 insertions(+), 18 deletions(-) create mode 100644 quadratic-client/src/gridGL/UI/UIMultiplayerCursor.ts diff --git a/quadratic-client/src/grid/sheet/SheetCursor.ts b/quadratic-client/src/grid/sheet/SheetCursor.ts index 3d9ff39f72..bf241a8dd5 100644 --- a/quadratic-client/src/grid/sheet/SheetCursor.ts +++ b/quadratic-client/src/grid/sheet/SheetCursor.ts @@ -1,3 +1,4 @@ +import { multiplayer } from '@/multiplayer/multiplayer'; import { IViewportTransformState } from 'pixi-viewport'; import { Rectangle } from 'pixi.js'; import { pixiApp } from '../../gridGL/pixiApp/PixiApp'; @@ -77,6 +78,17 @@ export class SheetCursor { this.keyboardMovePosition = options.keyboardMovePosition; } pixiApp.updateCursorPosition({ ensureVisible: options.ensureVisible ?? true }); + multiplayer.sendSelection( + this.cursorPosition, + this.multiCursor + ? new Rectangle( + this.multiCursor.originPosition.x, + this.multiCursor.originPosition.y, + this.multiCursor.terminalPosition.x - this.multiCursor.originPosition.x, + this.multiCursor.terminalPosition.y - this.multiCursor.originPosition.y + ) + : undefined + ); } changeBoxCells(boxCells: boolean) { diff --git a/quadratic-client/src/gridGL/UI/UIMultiplayerCursor.ts b/quadratic-client/src/gridGL/UI/UIMultiplayerCursor.ts new file mode 100644 index 0000000000..9e47628274 --- /dev/null +++ b/quadratic-client/src/gridGL/UI/UIMultiplayerCursor.ts @@ -0,0 +1,73 @@ +import { multiplayer } from '@/multiplayer/multiplayer'; +import { MULTIPLAYER_COLORS_TINT } from '@/multiplayer/multiplayerCursor/multiplayerColors'; +import { Graphics, Rectangle } from 'pixi.js'; +import { sheets } from '../../grid/controller/Sheets'; +import { pixiApp } from '../pixiApp/PixiApp'; +import { Coordinate } from '../types/size'; + +export const CURSOR_THICKNESS = 1; +const ALPHA = 0.5; +const FILL_ALPHA = 0.01 / ALPHA; + +export class UIMultiPlayerCursor extends Graphics { + dirty = false; + + constructor() { + super(); + this.alpha = ALPHA; + } + + // todo: handle multiple people in the same cell + private drawCursor(color: number, cursor: Coordinate) { + const sheet = sheets.sheet; + + let { x, y, width, height } = sheet.getCellOffsets(cursor.x, cursor.y); + + // draw cursor + this.lineStyle({ + width: CURSOR_THICKNESS, + color, + alignment: 0, + }); + this.moveTo(x, y); + this.lineTo(x + width, y); + this.lineTo(x + width, y + height); + this.moveTo(x + width, y + height); + this.lineTo(x, y + height); + this.lineTo(x, y); + } + + private drawMultiCursor(color: number, rectangle: Rectangle): void { + const sheet = sheets.sheet; + this.lineStyle(1, color, 1, 0, true); + this.beginFill(color, FILL_ALPHA); + const startCell = sheet.getCellOffsets(rectangle.x, rectangle.y); + const endCell = sheet.getCellOffsets(rectangle.x + rectangle.width, rectangle.y + rectangle.height); + this.drawRect( + startCell.x, + startCell.y, + endCell.x + endCell.width - startCell.x, + endCell.y + endCell.height - startCell.y + ); + } + + update() { + if (this.dirty) { + this.dirty = false; + this.clear(); + // const sheetId = sheets.sheet.id; + multiplayer.players.forEach((player) => { + const color = MULTIPLAYER_COLORS_TINT[player.color]; + if (player.selection /* && player.sheetId === sheetId */) { + this.drawCursor(color, player.selection.cursor); + + // note: the rectangle is not really a PIXI.Rectangle, but a (x, y, width, height) type (b/c we JSON stringified) + if (player.selection.rectangle) { + this.drawMultiCursor(color, player.selection.rectangle); + } + } + }); + pixiApp.setViewportDirty(); + } + } +} diff --git a/quadratic-client/src/gridGL/interaction/pointer/pointerCursor.ts b/quadratic-client/src/gridGL/interaction/pointer/pointerCursor.ts index 7121b78018..1d80a9f547 100644 --- a/quadratic-client/src/gridGL/interaction/pointer/pointerCursor.ts +++ b/quadratic-client/src/gridGL/interaction/pointer/pointerCursor.ts @@ -6,6 +6,6 @@ export class PointerCursor { pointerMove(world: Point): void { const cursor = pixiApp.pointer.pointerHeading.cursor ?? pixiApp.pointer.pointerAutoComplete.cursor; pixiApp.canvas.style.cursor = cursor ?? 'unset'; - multiplayer.mouseMove(world.x, world.y); + multiplayer.sendMouseMove(world.x, world.y); } } diff --git a/quadratic-client/src/gridGL/pixiApp/PixiApp.ts b/quadratic-client/src/gridGL/pixiApp/PixiApp.ts index 35403bb598..ae3b6e3d7f 100644 --- a/quadratic-client/src/gridGL/pixiApp/PixiApp.ts +++ b/quadratic-client/src/gridGL/pixiApp/PixiApp.ts @@ -13,6 +13,7 @@ import { sheets } from '../../grid/controller/Sheets'; import { AxesLines } from '../UI/AxesLines'; import { Cursor } from '../UI/Cursor'; import { GridLines } from '../UI/GridLines'; +import { UIMultiPlayerCursor } from '../UI/UIMultiplayerCursor'; import { BoxCells } from '../UI/boxCells'; import { GridHeadings } from '../UI/gridHeadings/GridHeadings'; import { CellsSheets } from '../cells/CellsSheets'; @@ -37,6 +38,7 @@ export class PixiApp { gridLines!: GridLines; axesLines!: AxesLines; cursor!: Cursor; + multiplayerCursor!: UIMultiPlayerCursor; headings!: GridHeadings; boxCells!: BoxCells; cellsSheets!: CellsSheets; @@ -120,6 +122,7 @@ export class PixiApp { this.cellsSheets = this.viewportContents.addChild(new CellsSheets()); this.boxCells = this.viewportContents.addChild(new BoxCells()); + this.multiplayerCursor = this.viewportContents.addChild(new UIMultiPlayerCursor()); this.cursor = this.viewportContents.addChild(new Cursor()); this.headings = this.viewportContents.addChild(new GridHeadings()); @@ -229,6 +232,7 @@ export class PixiApp { this.gridLines.visible = options?.gridLines ?? false; this.axesLines.visible = false; this.cursor.visible = false; + this.multiplayerCursor.visible = false; this.headings.visible = false; this.quadrants.visible = false; this.boxCells.visible = false; @@ -242,6 +246,7 @@ export class PixiApp { this.gridLines.visible = true; this.axesLines.visible = true; this.cursor.visible = true; + this.multiplayerCursor.visible = true; this.headings.visible = true; this.boxCells.visible = true; this.quadrants.visible = this.cacheIsVisible; @@ -295,6 +300,7 @@ export class PixiApp { this.axesLines.dirty = true; this.headings.dirty = true; this.cursor.dirty = true; + this.multiplayerCursor.dirty = true; this.boxCells.reset(); this.viewport.dirty = true; @@ -340,6 +346,7 @@ export class PixiApp { this.headings.dirty = true; this.gridLines.dirty = true; this.cursor.dirty = true; + this.multiplayerCursor.dirty = true; } } diff --git a/quadratic-client/src/gridGL/pixiApp/Update.ts b/quadratic-client/src/gridGL/pixiApp/Update.ts index 6b07d2dc56..1f3e0a4cd1 100644 --- a/quadratic-client/src/gridGL/pixiApp/Update.ts +++ b/quadratic-client/src/gridGL/pixiApp/Update.ts @@ -78,6 +78,7 @@ export class Update { pixiApp.axesLines.dirty || pixiApp.headings.dirty || pixiApp.boxCells.dirty || + pixiApp.multiplayerCursor.dirty || pixiApp.cursor.dirty; if (rendererDirty && debugShowWhyRendering) { @@ -99,7 +100,8 @@ export class Update { debugTimeCheck('[Update] boxCells'); pixiApp.cursor.update(); debugTimeCheck('[Update] cursor'); - debugTimeReset(); + pixiApp.multiplayerCursor.update(); + debugTimeCheck('[Update] multiplayerCursor'); pixiApp.cellsSheets.update(); debugTimeCheck('[Update] cellsSheets'); diff --git a/quadratic-client/src/multiplayer/multiplayer.ts b/quadratic-client/src/multiplayer/multiplayer.ts index a544db9664..a4e1dda395 100644 --- a/quadratic-client/src/multiplayer/multiplayer.ts +++ b/quadratic-client/src/multiplayer/multiplayer.ts @@ -1,4 +1,8 @@ +import { pixiApp } from '@/gridGL/pixiApp/PixiApp'; +import { Coordinate } from '@/gridGL/types/size'; import { User } from '@auth0/auth0-spa-js'; +import { Rectangle } from 'pixi.js'; +import { MULTIPLAYER_COLORS } from './multiplayerCursor/multiplayerColors'; // todo: create types for messages @@ -8,7 +12,9 @@ export interface Player { y: number; name: string; picture: string; + color: number; visible: boolean; + selection?: { cursor: Coordinate; rectangle?: Rectangle }; } class Multiplayer { @@ -17,6 +23,9 @@ class Multiplayer { private room?: string; private uuid?: string; + // keep track of the next player's color index + private nextColor = 0; + players: Map = new Map(); private async init() { @@ -65,7 +74,7 @@ class Multiplayer { console.log(`[Multiplayer] Entered room.`); } - mouseMove(x: number, y: number) { + sendMouseMove(x: number, y: number) { if (!this.ready || !this.room || !this.uuid) return; if (!this.websocket) { throw new Error('[Multiplayer] Websocket not initialized.'); @@ -82,6 +91,22 @@ class Multiplayer { ); } + sendSelection(cursor: Coordinate, rectangle?: Rectangle) { + if (!this.ready || !this.room || !this.uuid) return; + if (!this.websocket) { + throw new Error('[Multiplayer] Websocket not initialized.'); + } + this.websocket.send( + JSON.stringify({ + type: 'ChangeSelection', + + user_id: this.uuid, + file_id: this.room, + selection: JSON.stringify({ cursor, rectangle }), + }) + ); + } + handleMessage = (e: any) => { const data = JSON.parse(e.data); const { type } = data; @@ -99,8 +124,10 @@ class Multiplayer { sheetId: '', x: 0, y: 0, + color: this.nextColor, visible: false, }); + this.nextColor = (this.nextColor + 1) % MULTIPLAYER_COLORS.length; console.log(`[Multiplayer] Player ${userId} entered room.`); } } @@ -112,22 +139,23 @@ class Multiplayer { if (!player) { throw new Error("Expected Player to be defined before receiving a message of type 'MouseMove'"); } - this.players.set(data.user_id, { - ...player, - x: data.x, - y: data.y, - visible: true, - // sheetId: data.sheet_id, - }); + player.x = data.x; + player.y = data.y; + player.visible = true; window.dispatchEvent(new CustomEvent('multiplayer-cursor')); } + } else if (type === 'ChangeSelection') { + // todo: this check should not be needed (eventually) + if (data.user_id !== this.uuid) { + const player = this.players.get(data.user_id); + if (!player) { + throw new Error("Expected Player to be defined before receiving a message of type 'ChangeSelection'"); + } + player.selection = JSON.parse(data.selection); + pixiApp.multiplayerCursor.dirty = true; + } } }; - - // todo: maybe players should just be an array to instead of a map to speed up this operation? - getPlayers(): Player[] { - return Array.from(this.players.values()); - } } export const multiplayer = new Multiplayer(); diff --git a/quadratic-client/src/multiplayer/multiplayerCursor/MulitplayerCursors.tsx b/quadratic-client/src/multiplayer/multiplayerCursor/MulitplayerCursors.tsx index 29083a6c97..b53f07be89 100644 --- a/quadratic-client/src/multiplayer/multiplayerCursor/MulitplayerCursors.tsx +++ b/quadratic-client/src/multiplayer/multiplayerCursor/MulitplayerCursors.tsx @@ -27,8 +27,9 @@ export const MultiplayerCursors = () => { return (
- {multiplayer.getPlayers().flatMap((player, index) => { - const color = MULTIPLAYER_COLORS[index % MULTIPLAYER_COLORS.length]; + const players = []; + {[...multiplayer.players].flatMap(([id, player]) => { + const color = MULTIPLAYER_COLORS[player.color]; const bounds = pixiApp.viewport.getVisibleBounds(); const rect = pixiApp.canvas.getBoundingClientRect(); const offsetTop = rect.top; @@ -51,7 +52,14 @@ export const MultiplayerCursors = () => { translated.y = rect.top - offsetTop; } return [ - , + , ]; } return []; diff --git a/quadratic-client/src/multiplayer/multiplayerCursor/multiplayerColors.ts b/quadratic-client/src/multiplayer/multiplayerCursor/multiplayerColors.ts index 7fb90b57dc..3a391cfc1a 100644 --- a/quadratic-client/src/multiplayer/multiplayerCursor/multiplayerColors.ts +++ b/quadratic-client/src/multiplayer/multiplayerCursor/multiplayerColors.ts @@ -1,3 +1,4 @@ +// needs to be kept in sync with MULTIPLAYER_COLORS_TINT export const MULTIPLAYER_COLORS = [ // '#1a1c2c', '#E57373', @@ -9,3 +10,8 @@ export const MULTIPLAYER_COLORS = [ '#F06292', '#7986CB', ]; + +export const MULTIPLAYER_COLORS_TINT = [ + // 0x1a1c2c, + 0xe57373, 0x9575cd, 0x4fc3f7, 0x81c784, 0x144cb5, 0xff8a65, 0xf06292, 0x7986cb, +]; diff --git a/quadratic-multiplayer/src/message.rs b/quadratic-multiplayer/src/message.rs index 50c94b9569..e8b32b8268 100644 --- a/quadratic-multiplayer/src/message.rs +++ b/quadratic-multiplayer/src/message.rs @@ -33,6 +33,11 @@ pub(crate) enum MessageRequest { x: f64, y: f64, }, + ChangeSelection { + user_id: String, + file_id: Uuid, + selection: String, + }, } #[derive(Serialize, Debug, Clone, PartialEq)] @@ -47,6 +52,11 @@ pub(crate) enum MessageResponse { x: f64, y: f64, }, + ChangeSelection { + user_id: String, + file_id: Uuid, + selection: String, + }, } /// Handle incoming messages. All requests and responses are strictly typed. @@ -105,6 +115,23 @@ pub(crate) async fn handle_message( Ok(response) } + + // User changes their selection + MessageRequest::ChangeSelection { + user_id, + file_id, + selection, + } => { + let response = MessageResponse::ChangeSelection { + user_id: user_id.clone(), + file_id, + selection, + }; + + broadcast(user_id, file_id, Arc::clone(&state), response.clone())?; + + Ok(response) + } } } From ddc58bccc9e4ba380d842f099081c262304788d1 Mon Sep 17 00:00:00 2001 From: David Figatner Date: Mon, 4 Dec 2023 06:18:42 -0800 Subject: [PATCH 24/32] cursor disappears when other player is off the canvas --- .../src/gridGL/interaction/pointer/Pointer.ts | 7 ++++ .../src/multiplayer/multiplayer.ts | 42 ++++++++++++------- .../src/multiplayer/multiplayerTypes.ts | 3 ++ quadratic-multiplayer/src/message.rs | 12 +++--- 4 files changed, 43 insertions(+), 21 deletions(-) create mode 100644 quadratic-client/src/multiplayer/multiplayerTypes.ts diff --git a/quadratic-client/src/gridGL/interaction/pointer/Pointer.ts b/quadratic-client/src/gridGL/interaction/pointer/Pointer.ts index 31f748cea7..e339ef3e31 100644 --- a/quadratic-client/src/gridGL/interaction/pointer/Pointer.ts +++ b/quadratic-client/src/gridGL/interaction/pointer/Pointer.ts @@ -1,3 +1,4 @@ +import { multiplayer } from '@/multiplayer/multiplayer'; import { Viewport } from 'pixi-viewport'; import { InteractionEvent } from 'pixi.js'; import { pixiApp } from '../../pixiApp/PixiApp'; @@ -23,14 +24,20 @@ export class Pointer { viewport.on('pointermove', this.pointerMove); viewport.on('pointerup', this.pointerUp); viewport.on('pointerupoutside', this.pointerUp); + pixiApp.canvas.addEventListener('pointerleave', this.pointerLeave); } + private pointerLeave = () => { + multiplayer.sendMouseMove(); + }; + destroy() { const viewport = pixiApp.viewport; viewport.off('pointerdown', this.handlePointerDown); viewport.off('pointermove', this.pointerMove); viewport.off('pointerup', this.pointerUp); viewport.off('pointerupoutside', this.pointerUp); + pixiApp.canvas.removeEventListener('pointerleave', this.pointerLeave); this.pointerDown.destroy(); } diff --git a/quadratic-client/src/multiplayer/multiplayer.ts b/quadratic-client/src/multiplayer/multiplayer.ts index a4e1dda395..f797d5997d 100644 --- a/quadratic-client/src/multiplayer/multiplayer.ts +++ b/quadratic-client/src/multiplayer/multiplayer.ts @@ -74,21 +74,30 @@ class Multiplayer { console.log(`[Multiplayer] Entered room.`); } - sendMouseMove(x: number, y: number) { + sendMouseMove(x?: number, y?: number) { if (!this.ready || !this.room || !this.uuid) return; if (!this.websocket) { throw new Error('[Multiplayer] Websocket not initialized.'); } - this.websocket.send( - JSON.stringify({ - type: 'MouseMove', - - user_id: this.uuid, - file_id: this.room, - x, - y, - }) - ); + if (x === undefined || y === undefined) { + this.websocket.send( + JSON.stringify({ + type: 'MouseMove', + user_id: this.uuid, + file_id: this.room, + }) + ); + } else { + this.websocket.send( + JSON.stringify({ + type: 'MouseMove', + user_id: this.uuid, + file_id: this.room, + x, + y, + }) + ); + } } sendSelection(cursor: Coordinate, rectangle?: Rectangle) { @@ -99,7 +108,6 @@ class Multiplayer { this.websocket.send( JSON.stringify({ type: 'ChangeSelection', - user_id: this.uuid, file_id: this.room, selection: JSON.stringify({ cursor, rectangle }), @@ -139,9 +147,13 @@ class Multiplayer { if (!player) { throw new Error("Expected Player to be defined before receiving a message of type 'MouseMove'"); } - player.x = data.x; - player.y = data.y; - player.visible = true; + if (data.x !== null && data.y !== null) { + player.x = data.x; + player.y = data.y; + player.visible = true; + } else { + player.visible = false; + } window.dispatchEvent(new CustomEvent('multiplayer-cursor')); } } else if (type === 'ChangeSelection') { diff --git a/quadratic-client/src/multiplayer/multiplayerTypes.ts b/quadratic-client/src/multiplayer/multiplayerTypes.ts new file mode 100644 index 0000000000..834927fd5b --- /dev/null +++ b/quadratic-client/src/multiplayer/multiplayerTypes.ts @@ -0,0 +1,3 @@ +export interface MessageMouseMove { + +} \ No newline at end of file diff --git a/quadratic-multiplayer/src/message.rs b/quadratic-multiplayer/src/message.rs index e8b32b8268..d9a7191c97 100644 --- a/quadratic-multiplayer/src/message.rs +++ b/quadratic-multiplayer/src/message.rs @@ -30,8 +30,8 @@ pub(crate) enum MessageRequest { MouseMove { user_id: String, file_id: Uuid, - x: f64, - y: f64, + x: Option, + y: Option, }, ChangeSelection { user_id: String, @@ -49,8 +49,8 @@ pub(crate) enum MessageResponse { MouseMove { user_id: String, file_id: Uuid, - x: f64, - y: f64, + x: Option, + y: Option, }, ChangeSelection { user_id: String, @@ -188,8 +188,8 @@ pub(crate) mod tests { let message = MessageResponse::MouseMove { user_id: user_1.id.clone(), file_id, - x: 10 as f64, - y: 10 as f64, + x: Some(10f64), + y: Some(10f64), }; broadcast(user_1.id.clone(), file_id, state, message).unwrap(); From a60f57882036dd259e703ce6d99ed4553a681f54 Mon Sep 17 00:00:00 2001 From: David Figatner Date: Mon, 4 Dec 2023 07:05:29 -0800 Subject: [PATCH 25/32] added types to TS --- quadratic-client/src/gridGL/pixiApp/Update.ts | 3 + .../src/multiplayer/multiplayer.ts | 104 ++++++++++-------- .../multiplayerCursor/MulitplayerCursors.tsx | 2 +- .../src/multiplayer/multiplayerTypes.ts | 39 ++++++- .../src/ui/menus/CodeEditor/CodeEditor.tsx | 9 ++ quadratic-multiplayer/src/message.rs | 2 + 6 files changed, 110 insertions(+), 49 deletions(-) diff --git a/quadratic-client/src/gridGL/pixiApp/Update.ts b/quadratic-client/src/gridGL/pixiApp/Update.ts index 1f3e0a4cd1..cf8bdc9167 100644 --- a/quadratic-client/src/gridGL/pixiApp/Update.ts +++ b/quadratic-client/src/gridGL/pixiApp/Update.ts @@ -1,3 +1,4 @@ +import { multiplayer } from '@/multiplayer/multiplayer'; import { Point } from 'pixi.js'; import { debugShowFPS, debugShowWhyRendering } from '../../debugFlags'; import { FPS } from '../helpers/Fps'; @@ -120,6 +121,8 @@ export class Update { thumbnail.check(); } + multiplayer.update(); + this.raf = requestAnimationFrame(this.update); this.fps?.update(); }; diff --git a/quadratic-client/src/multiplayer/multiplayer.ts b/quadratic-client/src/multiplayer/multiplayer.ts index f797d5997d..a4bedd1d28 100644 --- a/quadratic-client/src/multiplayer/multiplayer.ts +++ b/quadratic-client/src/multiplayer/multiplayer.ts @@ -3,13 +3,16 @@ import { Coordinate } from '@/gridGL/types/size'; import { User } from '@auth0/auth0-spa-js'; import { Rectangle } from 'pixi.js'; import { MULTIPLAYER_COLORS } from './multiplayerCursor/multiplayerColors'; +import { MessageChangeSelection, MessageMouseMove, ReceiveMessages, SendEnterRoom } from './multiplayerTypes'; + +const UPDATE_TIME = 1000 / 30; // todo: create types for messages export interface Player { sheetId: string; - x: number; - y: number; + x?: number; + y?: number; name: string; picture: string; color: number; @@ -17,12 +20,16 @@ export interface Player { selection?: { cursor: Coordinate; rectangle?: Rectangle }; } -class Multiplayer { +export class Multiplayer { private websocket?: WebSocket; private ready = false; private room?: string; private uuid?: string; + // queue of items waiting to be sent to the server on the next tick + private queue: { move?: MessageMouseMove; selection?: MessageChangeSelection } = {}; + private lastTime = 0; + // keep track of the next player's color index private nextColor = 0; @@ -50,7 +57,7 @@ class Multiplayer { // todo: hack to get around sharing bugged in ateam branch uuid = 'ae910c17-5988-41c7-a915-af90f56d6e69'; - if (!user) throw new Error('Expected User to be defined'); + if (!user?.sub) throw new Error('Expected User to be defined'); if (this.room === uuid) return; this.room = uuid; this.uuid = user.sub; @@ -58,65 +65,68 @@ class Multiplayer { if (!this.websocket) { throw new Error('[Multiplayer] Websocket not initialized.'); } - this.websocket.send( - JSON.stringify({ - type: 'EnterRoom', - - // todo: not sure this is the correct user id - user_id: user.sub, - - file_id: uuid, - first_name: user.given_name, - last_name: user.family_name, - image: user.picture, - }) - ); + const enterRoom: SendEnterRoom = { + type: 'EnterRoom', + + // todo: not sure this is the correct user id + user_id: user.sub, + + file_id: uuid, + first_name: user.given_name ?? '', + last_name: user.family_name ?? '', + image: user.picture ?? '', + }; + this.websocket.send(JSON.stringify(enterRoom)); console.log(`[Multiplayer] Entered room.`); } sendMouseMove(x?: number, y?: number) { - if (!this.ready || !this.room || !this.uuid) return; - if (!this.websocket) { - throw new Error('[Multiplayer] Websocket not initialized.'); - } if (x === undefined || y === undefined) { - this.websocket.send( - JSON.stringify({ - type: 'MouseMove', - user_id: this.uuid, - file_id: this.room, - }) - ); + this.queue.move = { + type: 'MouseMove', + user_id: this.uuid!, + file_id: this.room!, + }; } else { - this.websocket.send( - JSON.stringify({ - type: 'MouseMove', - user_id: this.uuid, - file_id: this.room, - x, - y, - }) - ); + this.queue.move = { + type: 'MouseMove', + user_id: this.uuid!, + file_id: this.room!, + x, + y, + }; } } sendSelection(cursor: Coordinate, rectangle?: Rectangle) { + this.queue.selection = { + type: 'ChangeSelection', + user_id: this.uuid!, + file_id: this.room!, + selection: JSON.stringify({ cursor, rectangle }), + }; + } + + update() { if (!this.ready || !this.room || !this.uuid) return; if (!this.websocket) { throw new Error('[Multiplayer] Websocket not initialized.'); } - this.websocket.send( - JSON.stringify({ - type: 'ChangeSelection', - user_id: this.uuid, - file_id: this.room, - selection: JSON.stringify({ cursor, rectangle }), - }) - ); + const now = performance.now(); + if (now - this.lastTime < UPDATE_TIME) return; + if (this.queue.move) { + this.websocket.send(JSON.stringify(this.queue.move)); + this.queue.move = undefined; + } + if (this.queue.selection) { + this.websocket.send(JSON.stringify(this.queue.selection)); + this.queue.selection = undefined; + } + this.lastTime = now; } - handleMessage = (e: any) => { - const data = JSON.parse(e.data); + handleMessage = (e: { data: string }) => { + const data = JSON.parse(e.data) as ReceiveMessages; const { type } = data; if (type === 'Room') { this.players.clear(); diff --git a/quadratic-client/src/multiplayer/multiplayerCursor/MulitplayerCursors.tsx b/quadratic-client/src/multiplayer/multiplayerCursor/MulitplayerCursors.tsx index b53f07be89..1ace79324e 100644 --- a/quadratic-client/src/multiplayer/multiplayerCursor/MulitplayerCursors.tsx +++ b/quadratic-client/src/multiplayer/multiplayerCursor/MulitplayerCursors.tsx @@ -34,7 +34,7 @@ export const MultiplayerCursors = () => { const rect = pixiApp.canvas.getBoundingClientRect(); const offsetTop = rect.top; const { x, y, sheetId, name, visible } = player; - if (visible /*&& sheetId === currentSheetId*/) { + if (visible && x !== undefined && y !== undefined /*&& sheetId === currentSheetId*/) { const translated = pixiApp.viewport.toScreen(x, y); let offscreen = false; if (x > bounds.right - OFFSCREEN_SIZE) { diff --git a/quadratic-client/src/multiplayer/multiplayerTypes.ts b/quadratic-client/src/multiplayer/multiplayerTypes.ts index 834927fd5b..c1c0bcbbdd 100644 --- a/quadratic-client/src/multiplayer/multiplayerTypes.ts +++ b/quadratic-client/src/multiplayer/multiplayerTypes.ts @@ -1,3 +1,40 @@ export interface MessageMouseMove { + type: 'MouseMove'; + user_id: string; + file_id: string; + x?: number | null; + y?: number | null; +} -} \ No newline at end of file +export interface ReceiveEnterRoom { + type: 'Room'; + room: { + users: Record< + string, + { + user_id: string; + first_name: string; + last_name: string; + image: string; + } + >; + }; +} + +export interface MessageChangeSelection { + type: 'ChangeSelection'; + user_id: string; + file_id: string; + selection: string; +} + +export interface SendEnterRoom { + type: 'EnterRoom'; + user_id: string; + file_id: string; + first_name: string; + last_name: string; + image: string; +} + +export type ReceiveMessages = MessageMouseMove | ReceiveEnterRoom | MessageChangeSelection; diff --git a/quadratic-client/src/ui/menus/CodeEditor/CodeEditor.tsx b/quadratic-client/src/ui/menus/CodeEditor/CodeEditor.tsx index ed93899014..be0bd0be35 100644 --- a/quadratic-client/src/ui/menus/CodeEditor/CodeEditor.tsx +++ b/quadratic-client/src/ui/menus/CodeEditor/CodeEditor.tsx @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { pythonStateAtom } from '@/atoms/pythonStateAtom'; +import { multiplayer } from '@/multiplayer/multiplayer'; import mixpanel from 'mixpanel-browser'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useRecoilState, useRecoilValue } from 'recoil'; @@ -191,6 +192,14 @@ export const CodeEditor = () => { backgroundColor: '#ffffff', }} onKeyDownCapture={onKeyDownEditor} + onPointerEnter={() => { + // todo: handle multiplayer code editor here + multiplayer.sendMouseMove(); + }} + onPointerMove={(e) => { + console.log('hi'); + e.stopPropagation(); + }} > {showSaveChangesAlert && ( Date: Mon, 4 Dec 2023 07:44:59 -0800 Subject: [PATCH 26/32] added tests --- quadratic-multiplayer/src/message.rs | 18 +++++++++++++++- quadratic-multiplayer/src/server.rs | 32 ++++++++++++++++++++++++---- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/quadratic-multiplayer/src/message.rs b/quadratic-multiplayer/src/message.rs index dada50b32b..dee4bc8b6c 100644 --- a/quadratic-multiplayer/src/message.rs +++ b/quadratic-multiplayer/src/message.rs @@ -182,7 +182,7 @@ pub(crate) mod tests { use crate::test_util::add_new_user_to_room; #[tokio::test] - async fn broadcasting() { + async fn test_mouse_move() { let state = Arc::new(State::new()); let file_id = Uuid::new_v4(); let user_1 = add_new_user_to_room(file_id, state.clone()).await; @@ -197,4 +197,20 @@ pub(crate) mod tests { // TODO(ddimaria): mock the splitsink sender to test the actual sending } + + #[tokio::test] + async fn test_change_selection() { + let state = Arc::new(State::new()); + let file_id = Uuid::new_v4(); + let user_1 = add_new_user_to_room(file_id, state.clone()).await; + let _user_2 = add_new_user_to_room(file_id, state.clone()).await; + let message = MessageResponse::ChangeSelection { + user_id: user_1.id.clone(), + file_id: file_id, + selection: "test".to_string(), + }; + broadcast(user_1.id.clone(), file_id, state, message).unwrap(); + + // TODO(ddimaria): mock the splitsink sender to test the actual sending + } } diff --git a/quadratic-multiplayer/src/server.rs b/quadratic-multiplayer/src/server.rs index 6eaa90f3f0..68c0a921a2 100644 --- a/quadratic-multiplayer/src/server.rs +++ b/quadratic-multiplayer/src/server.rs @@ -193,14 +193,38 @@ pub(crate) mod tests { let request = MessageRequest::MouseMove { user_id: user_id.clone(), file_id, - x, - y, + x: Some(x), + y: Some(y), }; let expected = MessageResponse::MouseMove { user_id: user_id.clone(), file_id, - x, - y, + x: Some(x), + y: Some(y), + }; + + state.enter_room(file_id, &user).await; + + let response = integration_test(state.clone(), request).await; + + assert_eq!(response, serde_json::to_string(&expected).unwrap()); + } + + #[tokio::test] + async fn user_changes_selection() { + let state = Arc::new(State::new()); + let user = new_user(); + let user_id = user.id.clone(); + let file_id = Uuid::new_v4(); + let request = MessageRequest::ChangeSelection { + user_id: user_id.clone(), + file_id, + selection: "test".to_string(), + }; + let expected = MessageResponse::ChangeSelection { + user_id: user_id.clone(), + file_id, + selection: "test".to_string(), }; state.enter_room(file_id, &user).await; From 0a001fa33274527008d9521ead518d344bdeaea3 Mon Sep 17 00:00:00 2001 From: David Figatner Date: Mon, 4 Dec 2023 07:47:45 -0800 Subject: [PATCH 27/32] removed a weird artifact --- .../src/multiplayer/multiplayerCursor/MulitplayerCursors.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/quadratic-client/src/multiplayer/multiplayerCursor/MulitplayerCursors.tsx b/quadratic-client/src/multiplayer/multiplayerCursor/MulitplayerCursors.tsx index 1ace79324e..e104c1c86a 100644 --- a/quadratic-client/src/multiplayer/multiplayerCursor/MulitplayerCursors.tsx +++ b/quadratic-client/src/multiplayer/multiplayerCursor/MulitplayerCursors.tsx @@ -27,7 +27,6 @@ export const MultiplayerCursors = () => { return (
- const players = []; {[...multiplayer.players].flatMap(([id, player]) => { const color = MULTIPLAYER_COLORS[player.color]; const bounds = pixiApp.viewport.getVisibleBounds(); From c1a55f20022532e94e5cb84005a0d628b074c117 Mon Sep 17 00:00:00 2001 From: David Figatner Date: Mon, 4 Dec 2023 08:14:03 -0800 Subject: [PATCH 28/32] added multiplayer start to npm start --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 1a96b50692..456b06953b 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,8 @@ "perf": "npm run watch:perf:front-end", "watch:javascript": "cd quadratic-client && npm run watch:javascript", "watch:perf:front-end": "npm run build:wasm:types && concurrently --hide=2 -n=react,rust,rust-types \"npm:watch:javascript\" \"npm:watch:wasm:perf:javascript\" \"npm run start:api\"", - "watch:front-end": "npm run build:wasm:types && concurrently --hide=2 -n=react,rust,rust-types \"npm:watch:javascript\" \"npm:watch:wasm:javascript\" \"npm run start:api\"", - "watch:front-end-back-end": "npm run build:wasm:types && concurrently -n=react,rust,api \"npm:watch:javascript\" \"npm:watch:wasm:javascript\" \"npm run start:api\"", + "watch:front-end": "npm run build:wasm:types && concurrently --hide=2 -n=react,rust,rust-types \"npm:watch:javascript\" \"npm:watch:wasm:javascript\" \"npm:start:api\" \"npm:watch:multiplayer\"", + "watch:front-end-back-end": "npm run build:wasm:types && concurrently -n=react,rust,api \"npm:watch:javascript\" \"npm:watch:wasm:javascript\" \"npm:start:api\" \"npm:watch:multiplayer\"", "watch:app": "npm run build:wasm:types && concurrently -n=react,api 'npm:watch:javascript' 'npm run start:api'", "watch:multiplayer": "npm run dev --workspace=quadratic-multiplayer", "lint:client": "cd quadratic-client && npm run lint:ts && npm run lint:eslint && lint:prettier && lint:clippy", From 69a665f56f3254f535caedc8b79d67b95967d74b Mon Sep 17 00:00:00 2001 From: David Figatner Date: Mon, 4 Dec 2023 08:46:34 -0800 Subject: [PATCH 29/32] file_id is real now; started work on transactions --- package.json | 3 +- .../src/multiplayer/multiplayer.ts | 3 -- .../src/multiplayer/multiplayerTypes.ts | 9 +++- quadratic-multiplayer/src/message.rs | 47 +++++++++++++++++++ quadratic-multiplayer/src/server.rs | 24 ++++++++++ 5 files changed, 81 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 456b06953b..1400aca8c8 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "coverage:wasm:gen": "cd quadratic-core && cd quadratic-core && CARGO_INCREMENTAL=0 RUSTFLAGS='-Cinstrument-coverage' LLVM_PROFILE_FILE='coverage/cargo-test-%p-%m.profraw' cargo test", "coverage:wasm:html": "cd quadratic-core && cd quadratic-core && grcov . --binary-path ./target/debug/deps/ -s . -t html --branch --ignore-not-existing --ignore 'src/wasm_bindings/*' --ignore 'src/bin/*' --ignore '../*' --ignore '/*' -o coverage/html", "coverage:wasm:view": "open quadratic-core/coverage/html/index.html", - "test:wasm": "cd quadratic-core && cargo test", + "test:wasm": "cd run test --workspace=quadratic-core", + "test:multiplayer": "npm run test --workspace=quadratic-multiplayer", "watch:test:wasm": "cd quadratic-core && cargo watch -x test", "benchmark:rust": "cd quadratic-core && cargo bench", "lint:clippy": "cd quadratic-core && cargo clippy --all-targets --all-features -- -D warnings" diff --git a/quadratic-client/src/multiplayer/multiplayer.ts b/quadratic-client/src/multiplayer/multiplayer.ts index a4bedd1d28..f210aade71 100644 --- a/quadratic-client/src/multiplayer/multiplayer.ts +++ b/quadratic-client/src/multiplayer/multiplayer.ts @@ -54,9 +54,6 @@ export class Multiplayer { } async enterFileRoom(uuid: string, user?: User) { - // todo: hack to get around sharing bugged in ateam branch - uuid = 'ae910c17-5988-41c7-a915-af90f56d6e69'; - if (!user?.sub) throw new Error('Expected User to be defined'); if (this.room === uuid) return; this.room = uuid; diff --git a/quadratic-client/src/multiplayer/multiplayerTypes.ts b/quadratic-client/src/multiplayer/multiplayerTypes.ts index c1c0bcbbdd..54686f8653 100644 --- a/quadratic-client/src/multiplayer/multiplayerTypes.ts +++ b/quadratic-client/src/multiplayer/multiplayerTypes.ts @@ -37,4 +37,11 @@ export interface SendEnterRoom { image: string; } -export type ReceiveMessages = MessageMouseMove | ReceiveEnterRoom | MessageChangeSelection; +export interface MessageTransaction { + type: 'Transaction'; + user_id: string; + file_id: string; + operations: string; +} + +export type ReceiveMessages = MessageMouseMove | ReceiveEnterRoom | MessageChangeSelection | MessageTransaction; diff --git a/quadratic-multiplayer/src/message.rs b/quadratic-multiplayer/src/message.rs index dee4bc8b6c..e13f94f317 100644 --- a/quadratic-multiplayer/src/message.rs +++ b/quadratic-multiplayer/src/message.rs @@ -39,6 +39,13 @@ pub(crate) enum MessageRequest { file_id: Uuid, selection: String, }, + Transaction { + user_id: String, + file_id: Uuid, + + // todo: this is a stringified Vec. Eventually, Operation should be a shared type. + operations: String, + }, } // NOTE: needs to be kept in sync with multiplayerTypes.ts @@ -59,6 +66,13 @@ pub(crate) enum MessageResponse { file_id: Uuid, selection: String, }, + Transaction { + user_id: String, + file_id: Uuid, + + // todo: this is a stringified Vec. Eventually, Operation should be a shared type. + operations: String, + }, } /// Handle incoming messages. All requests and responses are strictly typed. @@ -134,6 +148,23 @@ pub(crate) async fn handle_message( Ok(response) } + + // User sends transactions + MessageRequest::Transaction { + user_id, + file_id, + operations, + } => { + let response = MessageResponse::Transaction { + user_id: user_id.clone(), + file_id, + operations, + }; + + broadcast(user_id, file_id, Arc::clone(&state), response.clone())?; + + Ok(response) + } } } @@ -213,4 +244,20 @@ pub(crate) mod tests { // TODO(ddimaria): mock the splitsink sender to test the actual sending } + + #[tokio::test] + async fn test_transaction() { + let state = Arc::new(State::new()); + let file_id = Uuid::new_v4(); + let user_1 = add_new_user_to_room(file_id, state.clone()).await; + let _user_2 = add_new_user_to_room(file_id, state.clone()).await; + let message = MessageResponse::Transaction { + user_id: user_1.id.clone(), + file_id: file_id, + operations: "test".to_string(), + }; + broadcast(user_1.id.clone(), file_id, state, message).unwrap(); + + // TODO(ddimaria): mock the splitsink sender to test the actual sending + } } diff --git a/quadratic-multiplayer/src/server.rs b/quadratic-multiplayer/src/server.rs index 68c0a921a2..28cb1aac03 100644 --- a/quadratic-multiplayer/src/server.rs +++ b/quadratic-multiplayer/src/server.rs @@ -233,4 +233,28 @@ pub(crate) mod tests { assert_eq!(response, serde_json::to_string(&expected).unwrap()); } + + #[tokio::test] + async fn user_shares_operations() { + let state = Arc::new(State::new()); + let user = new_user(); + let user_id = user.id.clone(); + let file_id = Uuid::new_v4(); + let request = MessageRequest::Transaction { + user_id: user_id.clone(), + file_id, + operations: "test".to_string(), + }; + let expected = MessageResponse::Transaction { + user_id: user_id.clone(), + file_id, + operations: "test".to_string(), + }; + + state.enter_room(file_id, &user).await; + + let response = integration_test(state.clone(), request).await; + + assert_eq!(response, serde_json::to_string(&expected).unwrap()); + } } From 20d23f277c28d205e6e51996cc36744ae8d9677d Mon Sep 17 00:00:00 2001 From: David Kircos Date: Mon, 4 Dec 2023 13:26:23 -0700 Subject: [PATCH 30/32] add zod to api-deps (needed for heroku) --- package-lock.json | 3 ++- quadratic-api/package.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index eaad4601d8..165d68cf1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21768,7 +21768,8 @@ "multer-s3": "^3.0.1", "newrelic": "^10.6.2", "openai": "^3.2.1", - "supertest": "^6.3.3" + "supertest": "^6.3.3", + "zod": "^3.22.4" }, "devDependencies": { "@aws-sdk/types": "^3.449.0", diff --git a/quadratic-api/package.json b/quadratic-api/package.json index d0f90eaab8..e9c794907f 100644 --- a/quadratic-api/package.json +++ b/quadratic-api/package.json @@ -46,7 +46,8 @@ "multer-s3": "^3.0.1", "newrelic": "^10.6.2", "openai": "^3.2.1", - "supertest": "^6.3.3" + "supertest": "^6.3.3", + "zod": "^3.22.4" }, "devDependencies": { "@aws-sdk/types": "^3.449.0", From d5288ec36dbaffde477f6e8a95e00bdfccdab70e Mon Sep 17 00:00:00 2001 From: David Kircos Date: Mon, 4 Dec 2023 14:24:15 -0700 Subject: [PATCH 31/32] try api --- Procfile | 3 +++ app.json | 3 --- package.json | 3 ++- quadratic-api/Procfile | 3 --- 4 files changed, 5 insertions(+), 7 deletions(-) create mode 100644 Procfile delete mode 100644 quadratic-api/Procfile diff --git a/Procfile b/Procfile new file mode 100644 index 0000000000..38365ab016 --- /dev/null +++ b/Procfile @@ -0,0 +1,3 @@ +web: node -r newrelic quadratic-api/dist/server.js + +release: npx prisma migrate deploy diff --git a/app.json b/app.json index 93c53f1397..708d1cd58e 100644 --- a/app.json +++ b/app.json @@ -8,9 +8,6 @@ } ], "buildpacks": [ - { - "url": "https://github.com/timanovsky/subdir-heroku-buildpack" - }, { "url": "heroku/nodejs" } diff --git a/package.json b/package.json index 1400aca8c8..910938997a 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ "test:multiplayer": "npm run test --workspace=quadratic-multiplayer", "watch:test:wasm": "cd quadratic-core && cargo watch -x test", "benchmark:rust": "cd quadratic-core && cargo bench", - "lint:clippy": "cd quadratic-core && cargo clippy --all-targets --all-features -- -D warnings" + "lint:clippy": "cd quadratic-core && cargo clippy --all-targets --all-features -- -D warnings", + "build": "npm run build --workspace=quadratic-api" }, "dependencies": { "zod": "^3.22.4" diff --git a/quadratic-api/Procfile b/quadratic-api/Procfile deleted file mode 100644 index 92d1b3236b..0000000000 --- a/quadratic-api/Procfile +++ /dev/null @@ -1,3 +0,0 @@ -web: node -r newrelic dist/server.js - -release: npx prisma migrate deploy From 1d2b49bb0a9b965962d290997d7116a70c3a4352 Mon Sep 17 00:00:00 2001 From: David Kircos Date: Mon, 4 Dec 2023 14:31:44 -0700 Subject: [PATCH 32/32] ignore --- .slugignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 .slugignore diff --git a/.slugignore b/.slugignore new file mode 100644 index 0000000000..9d8565a436 --- /dev/null +++ b/.slugignore @@ -0,0 +1 @@ +quadratic-client/*