diff --git a/Cargo.lock b/Cargo.lock index aef23266..e9e5e140 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -60,9 +60,9 @@ checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "approx" @@ -84,9 +84,9 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.9.1" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" dependencies = [ "rustversion", ] @@ -136,15 +136,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "axum" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "bytes", @@ -200,15 +200,15 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bitvec" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", @@ -227,9 +227,9 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", ] @@ -249,7 +249,7 @@ dependencies = [ "indexmap", "js-sys", "once_cell", - "rand 0.9.4", + "rand 0.9.5", "serde", "serde_bytes", "serde_json", @@ -270,7 +270,7 @@ dependencies = [ "hex", "indexmap", "js-sys", - "rand 0.9.4", + "rand 0.9.5", "serde", "serde_bytes", "simdutf8", @@ -281,9 +281,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "byteorder" @@ -293,9 +293,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] @@ -318,9 +318,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.60" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" dependencies = [ "find-msvc-tools", "shlex", @@ -334,19 +334,19 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core 0.10.0", + "rand_core 0.10.1", ] [[package]] @@ -378,18 +378,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.0" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" dependencies = [ "anstyle", "clap_lex", @@ -409,9 +409,9 @@ checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" [[package]] name = "codspeed" -version = "4.4.1" +version = "4.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b684e94583e85a5ca7e1a6454a89d76a5121240f2fb67eb564129d9bafdb9db0" +checksum = "57af92d1db7f6871b7e82c79cd87f2501db66f36b0eab924be6ea83dd6b2f3f3" dependencies = [ "anyhow", "cc", @@ -427,9 +427,9 @@ dependencies = [ [[package]] name = "codspeed-criterion-compat" -version = "4.4.1" +version = "4.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e65444156eb73ad7f57618188f8d4a281726d133ef55b96d1dcff89528609ab" +checksum = "1d31ae2e9ab23c29fa13bdfa06d012524176f5c0f4e25ec262cd829d947ebc5e" dependencies = [ "clap", "codspeed", @@ -440,9 +440,9 @@ dependencies = [ [[package]] name = "codspeed-criterion-compat-walltime" -version = "4.4.1" +version = "4.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96389aaa4bbb872ea4924dc0335b2bb181bcf28d6eedbe8fea29afcc5bde36a6" +checksum = "cc8605e40bab5114dcb0f76268e18880082b5798dec10757b5b58d2c3bbc7a1c" dependencies = [ "anes", "cast", @@ -568,9 +568,9 @@ dependencies = [ [[package]] name = "crc-catalog" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crc16" @@ -590,9 +590,9 @@ dependencies = [ [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -600,27 +600,27 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-queue" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crunchy" @@ -640,9 +640,9 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ "hybrid-array", ] @@ -692,18 +692,15 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] [[package]] name = "derive-syn-parse" @@ -769,26 +766,25 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common 0.1.7", - "subtle", ] [[package]] name = "digest" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "const-oid", - "crypto-common 0.2.1", + "crypto-common 0.2.2", "ctutils", ] [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -803,9 +799,9 @@ checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" dependencies = [ "serde", ] @@ -840,6 +836,33 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "engineioxide-client" +version = "0.1.0" +dependencies = [ + "bytes", + "engineioxide", + "engineioxide-core", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "serde", + "serde_json", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tokio-tungstenite", + "tokio-util", + "tracing", + "tracing-subscriber", +] + [[package]] name = "engineioxide-core" version = "0.2.2" @@ -868,6 +891,8 @@ version = "0.1.0" dependencies = [ "bytes", "engineioxide", + "engineioxide-client", + "futures-util", "hyper", "hyper-util", "serde_json", @@ -945,10 +970,10 @@ dependencies = [ ] [[package]] -name = "foldhash" -version = "0.1.5" +name = "fnv" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "foldhash" @@ -980,7 +1005,7 @@ dependencies = [ "futures", "log", "parking_lot", - "rand 0.8.5", + "rand 0.8.7", "redis-protocol", "semver", "socket2 0.5.10", @@ -1010,9 +1035,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" dependencies = [ "futures-channel", "futures-core", @@ -1025,9 +1050,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", "futures-sink", @@ -1035,15 +1060,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" dependencies = [ "futures-core", "futures-task", @@ -1063,15 +1088,15 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", @@ -1080,21 +1105,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-channel", "futures-core", @@ -1159,16 +1184,14 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "rand_core 0.10.0", - "wasip2", - "wasip3", + "rand_core 0.10.1", ] [[package]] @@ -1177,6 +1200,25 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -1188,15 +1230,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash 0.1.5", -] - [[package]] name = "hashbrown" version = "0.16.1" @@ -1205,20 +1238,20 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash 0.2.0", + "foldhash", ] [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" dependencies = [ "hashbrown 0.16.1", ] @@ -1260,16 +1293,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ - "hmac 0.13.0", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest 0.10.7", + "hmac", ] [[package]] @@ -1278,14 +1302,14 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest 0.11.2", + "digest 0.11.3", ] [[package]] name = "http" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -1293,9 +1317,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -1303,9 +1327,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", @@ -1328,23 +1352,24 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hybrid-array" -version = "0.4.10" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ "typenum", ] [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1353,6 +1378,7 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", ] [[package]] @@ -1362,12 +1388,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "bytes", + "futures-channel", + "futures-util", "http", "http-body", "hyper", + "libc", "pin-project-lite", + "socket2 0.6.5", "tokio", + "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -1452,12 +1484,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -1477,9 +1503,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -1492,9 +1518,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", - "serde", - "serde_core", + "hashbrown 0.17.1", ] [[package]] @@ -1525,11 +1549,12 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ - "once_cell", + "cfg-if", + "futures-util", "wasm-bindgen", ] @@ -1539,23 +1564,17 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" -version = "0.2.184" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ "libc", ] @@ -1577,9 +1596,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "loom" @@ -1665,16 +1684,6 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608" -[[package]] -name = "md-5" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" -dependencies = [ - "cfg-if", - "digest 0.10.7", -] - [[package]] name = "md-5" version = "0.11.0" @@ -1682,14 +1691,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", - "digest 0.11.2", + "digest 0.11.3", ] [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "mime" @@ -1705,9 +1714,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "mio" -version = "1.2.0" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", @@ -1716,9 +1725,9 @@ dependencies = [ [[package]] name = "mongocrypt" -version = "0.3.2" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8da0cd419a51a5fb44819e290fbdb0665a54f21dead8923446a799c7f4d26ad9" +checksum = "8426a875ded61430d4a811dbfda7633b6b8af0225c547fc6c28b8b0aa7d79a13" dependencies = [ "bson 2.15.0", "mongocrypt-sys", @@ -1728,15 +1737,15 @@ dependencies = [ [[package]] name = "mongocrypt-sys" -version = "0.1.5+1.15.1" +version = "0.1.6+1.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224484c5d09285a7b8cb0a0c117e847ebd14cb6e4470ecf68cdb89c503b0edb9" +checksum = "851fac73f7fe22f6a3ab87f720ce509cae7c9fd08e7dd27866cc232dee07ccf4" [[package]] name = "mongodb" -version = "3.5.2" +version = "3.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c5941683db2ab2697f71e58dc0319024e808d3b28e7cf20f4bfb445fe54a30b" +checksum = "b814038f367d212f55de0a630cb35102a9b8ca23785a86955d62c0087c93846d" dependencies = [ "base64", "bitflags", @@ -1747,23 +1756,22 @@ dependencies = [ "futures-io", "futures-util", "hex", - "hmac 0.12.1", + "hmac", "macro_magic", - "md-5 0.10.6", + "md-5", "mongocrypt", "mongodb-internal-macros", "pbkdf2", "percent-encoding", - "rand 0.9.4", + "rand 0.9.5", "rustc_version_runtime", "rustls", - "rustversion", "serde", "serde_bytes", "serde_with", - "sha1 0.10.6", - "sha2 0.10.9", - "socket2 0.6.3", + "sha1", + "sha2 0.11.0", + "socket2 0.6.5", "stringprep", "strsim", "take_mut", @@ -1778,9 +1786,9 @@ dependencies = [ [[package]] name = "mongodb-internal-macros" -version = "3.5.2" +version = "3.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47021a12bbf0dffde9c890fa2d36ff6ae342c532016226b04a42301b2b912660" +checksum = "f736d2fbc56e0011a341fbb9172bd822fda75c5f93b82fae1c7aab1e2613c810" dependencies = [ "macro_magic", "proc-macro2", @@ -1790,9 +1798,9 @@ dependencies = [ [[package]] name = "nix" -version = "0.31.2" +version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ "bitflags", "cfg-if", @@ -1821,9 +1829,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-traits" @@ -1895,11 +1903,11 @@ dependencies = [ [[package]] name = "pbkdf2" -version = "0.12.2" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" dependencies = [ - "digest 0.10.7", + "digest 0.11.3", ] [[package]] @@ -1963,16 +1971,16 @@ dependencies = [ [[package]] name = "postgres-protocol" -version = "0.6.11" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56201207dac53e2f38e848e31b4b91616a6bb6e0c7205b77718994a7f49e70fc" +checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" dependencies = [ "base64", "byteorder", "bytes", "fallible-iterator", - "hmac 0.13.0", - "md-5 0.11.0", + "hmac", + "md-5", "memchr", "rand 0.10.2", "sha2 0.11.0", @@ -1981,9 +1989,9 @@ dependencies = [ [[package]] name = "postgres-types" -version = "0.2.13" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8dc729a129e682e8d24170cd30ae1aa01b336b096cbb56df6d534ffec133d186" +checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" dependencies = [ "bytes", "fallible-iterator", @@ -2014,16 +2022,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -2035,9 +2033,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -2062,9 +2060,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -2073,9 +2071,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -2088,8 +2086,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", - "getrandom 0.4.2", - "rand_core 0.10.0", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -2132,15 +2130,15 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rayon" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -2158,9 +2156,9 @@ dependencies = [ [[package]] name = "redis" -version = "1.2.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f44e94c96d8870a387d88ce3de3fdd608cbfc0705f03cb343cdde91509d3e49a" +checksum = "b0b9503711b03773e43b31668c7b5bd279ee7cd9b7d18cff7c23a42cc1d08e5a" dependencies = [ "arcstr", "async-lock", @@ -2173,9 +2171,9 @@ dependencies = [ "log", "percent-encoding", "pin-project-lite", - "rand 0.9.4", + "rand 0.10.2", "ryu", - "socket2 0.6.3", + "socket2 0.6.5", "tokio", "tokio-util", "url", @@ -2207,9 +2205,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -2219,9 +2217,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -2230,9 +2228,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "ring" @@ -2299,9 +2297,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ "log", "once_cell", @@ -2314,9 +2312,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "zeroize", ] @@ -2334,9 +2332,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -2413,9 +2411,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "indexmap", "itoa", @@ -2470,17 +2468,6 @@ dependencies = [ "syn", ] -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - [[package]] name = "sha1" version = "0.11.0" @@ -2489,7 +2476,7 @@ checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "digest 0.11.2", + "digest 0.11.3", ] [[package]] @@ -2511,7 +2498,7 @@ checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "digest 0.11.2", + "digest 0.11.3", ] [[package]] @@ -2525,9 +2512,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -2547,9 +2534,9 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "siphasher" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" @@ -2559,9 +2546,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" dependencies = [ "serde", ] @@ -2578,9 +2565,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -2843,10 +2830,10 @@ dependencies = [ "futures-util", "hex", "hkdf", - "hmac 0.13.0", + "hmac", "itoa", "log", - "md-5 0.11.0", + "md-5", "memchr", "rand 0.10.2", "serde", @@ -2910,9 +2897,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -2970,21 +2957,20 @@ dependencies = [ [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] [[package]] name = "time" -version = "0.3.47" +version = "0.3.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -2994,15 +2980,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" dependencies = [ "num-conv", "time-core", @@ -3039,9 +3025,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -3054,9 +3040,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.51.1" +version = "1.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" dependencies = [ "bytes", "libc", @@ -3064,16 +3050,16 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.3", + "socket2 0.6.5", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" dependencies = [ "proc-macro2", "quote", @@ -3082,9 +3068,9 @@ dependencies = [ [[package]] name = "tokio-postgres" -version = "0.7.17" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dd8df5ef180f6364759a6f00f7aadda4fbbac86cdee37480826a6ff9f3574ce" +checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" dependencies = [ "async-trait", "byteorder", @@ -3100,7 +3086,7 @@ dependencies = [ "postgres-protocol", "postgres-types", "rand 0.10.2", - "socket2 0.6.3", + "socket2 0.6.5", "tokio", "tokio-util", "whoami", @@ -3149,6 +3135,7 @@ dependencies = [ "futures-core", "futures-io", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] @@ -3243,6 +3230,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "tungstenite" version = "0.30.0" @@ -3255,7 +3248,7 @@ dependencies = [ "httparse", "log", "rand 0.10.2", - "sha1 0.11.0", + "sha1", "thiserror", ] @@ -3281,9 +3274,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-bidi" @@ -3314,9 +3307,9 @@ checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-xid" @@ -3356,11 +3349,11 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.23.0" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -3388,6 +3381,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -3405,18 +3407,9 @@ dependencies = [ [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen", ] @@ -3432,9 +3425,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -3445,9 +3438,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3455,9 +3448,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", @@ -3468,52 +3461,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "web-sys" -version = "0.3.95" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -3521,18 +3480,18 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.6" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] [[package]] name = "whoami" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6a5b12f9df4f978d2cfdb1bd3bac52433f44393342d7ee9c25f5a1c14c0f45d" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" dependencies = [ "libc", "libredox", @@ -3715,91 +3674,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "wit-bindgen" -version = "0.51.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" @@ -3818,15 +3695,15 @@ dependencies = [ [[package]] name = "xxhash-rust" -version = "0.8.15" +version = "0.8.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" +checksum = "985eec839aaf2a1270af8f4ebcf63cf9401cfd90f0902f97c28d9f104ffbde72" [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -3847,18 +3724,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", @@ -3867,9 +3744,9 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] @@ -3888,9 +3765,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" @@ -3927,6 +3804,6 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index e8b48ca6..04df3efa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,7 +36,7 @@ http-body-util = "0.1" thiserror = "2.0" tracing = "0.1" itoa = "1.0" -hyper-util.version = "0.1" +hyper-util.version = "0.1.20" hyper = "1.9" pin-project-lite = "0.2" matchit = "0.9" diff --git a/crates/engineioxide-client/Cargo.toml b/crates/engineioxide-client/Cargo.toml new file mode 100644 index 00000000..43727d12 --- /dev/null +++ b/crates/engineioxide-client/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "engineioxide-client" +description = "Engine IO client implementation in rust" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true +license.workspace = true +readme = "README.md" + +[dependencies] +engineioxide-core = { path = "../engineioxide-core", version = "0.2" } +bytes.workspace = true +futures-core.workspace = true +futures-util.workspace = true +http.workspace = true +http-body.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio = { workspace = true, features = ["rt", "time", "macros", "parking_lot"] } +hyper = { workspace = true, features = ["client", "http1"] } +tokio-tungstenite.workspace = true +http-body-util.workspace = true +pin-project-lite.workspace = true +smallvec.workspace = true +hyper-util = { workspace = true, features = ["tokio", "client", "client-pool", "client-legacy", "http1", "http2"] } +tokio-stream.workspace = true +tokio-util.workspace = true + +# Tracing +tracing = { workspace = true } # TODO: make optional + +[dev-dependencies] +tracing-subscriber = { workspace = true, features = ["env-filter"] } +# Testing +engineioxide = { path = "../engineioxide", features = ["tracing"] } + + +[features] +v3 = ["engineioxide-core/v3"] +# tracing = ["dep:tracing", "engineioxide-core/tracing"] +__test_harness = [] diff --git a/crates/engineioxide-client/README.md b/crates/engineioxide-client/README.md new file mode 100644 index 00000000..e69de29b diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs new file mode 100644 index 00000000..121f4196 --- /dev/null +++ b/crates/engineioxide-client/src/client.rs @@ -0,0 +1,345 @@ +use std::{ + fmt, + pin::Pin, + task::{Context, Poll, Waker, ready}, + time::Instant, +}; + +use engineioxide_core::{OpenPacket, Packet, Sid, TransportType}; +use futures_core::Stream; +use futures_util::Sink; +use tracing::Level; + +use crate::{ + EngineIoClientConfig, + config::IntoEngineIoClientConfig, + errors::{ClientError, ConnectError}, + event::EioEvent, + flavors, + transport::{Transport, TransportSvc, WsTransport, polling::PollingTransport}, +}; + +pin_project_lite::pin_project! { + pub struct Client { + #[pin] + transport: Transport, + sink_waker: Option, + config: EngineIoClientConfig, + + open_packet: OpenPacket, + last_ping: Instant, + state: ClientState, + pending_pong: bool, + } +} + +#[derive(Debug)] +enum ClientState { + Open, // connected; owe the caller a Connect event + Upgrading, // driving the ws upgrade handshake + Running, // steady state + Closing, // draining the transport toward close + Closed, +} + +impl Client { + pub async fn connect_with_hyper_ws( + config: impl IntoEngineIoClientConfig, + ) -> Result> { + let svc = flavors::hyper_tungstenite::HyperTungsteniteFlavor::new(); + Self::connect(svc, config).await + } +} + +impl Client> { + pub async fn connect_with_testbed( + svc: Svc, + config: impl IntoEngineIoClientConfig, + ) -> Result>> { + let svc = flavors::testing::TestingFlavor::new(svc); + Self::connect(svc, config).await + } +} + +impl Client { + pub async fn connect( + svc: S, + config: impl IntoEngineIoClientConfig, + ) -> Result> { + let config = config.into_config()?; + let (transport, open_packet) = Self::connect_inner(svc, &config).await?; + + let client = Client { + transport, + open_packet, + config, + sink_waker: None, + last_ping: Instant::now(), + state: ClientState::Open, + pending_pong: false, + }; + + Ok(client) + } + + async fn connect_inner( + svc: S, + config: &EngineIoClientConfig, + ) -> Result<(Transport, OpenPacket), ClientError> { + let (transport, packet) = match config.initial_transport() { + TransportType::Polling => { + let (transport, open_packet) = PollingTransport::connect(svc, config).await?; + (transport.into(), open_packet) + } + TransportType::Websocket => { + let (transport, open_packet) = WsTransport::connect(svc, config).await?; + (transport.into(), open_packet) + } + }; + + Ok((transport, packet)) + } + + pub fn transport(&self) -> TransportType { + self.transport.transport_type() + } +} + +impl Client { + pub fn sid(&self) -> Sid { + self.open_packet.sid + } + + fn should_upgrade(&self) -> bool { + self.transport() != TransportType::Websocket + && self + .open_packet + .upgrades + .contains(&TransportType::Websocket) + && self.config.transports.contains(&TransportType::Websocket) + } +} + +impl Stream for Client { + type Item = Result>; + + #[tracing::instrument(skip(cx))] + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match ready!(self.as_mut().poll_next_inner(cx)) { + Some(Ok(item)) => Poll::Ready(Some(Ok(item))), + Some(Err(err)) if err.should_close() => { + // hard closing on errors + *self.project().state = ClientState::Closed; + Poll::Ready(Some(Err(err))) + } + Some(Err(err)) => Poll::Ready(Some(Err(err))), + None => Poll::Ready(None), + } + } +} + +impl Client { + fn poll_next_inner( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll>>> { + // The heartbeat drives the transport sink: it must not run once the + // session is closing or closed, and its errors must surface. + if matches!( + self.state, + ClientState::Open | ClientState::Upgrading | ClientState::Running + ) && let Poll::Ready(Err(err)) = self.as_mut().poll_heartbeat(cx) + { + return Poll::Ready(Some(Err(err))); + } + + match self.state { + ClientState::Open => { + *self.as_mut().project().state = if self.should_upgrade() { + ClientState::Upgrading + } else { + ClientState::Running + }; + Poll::Ready(Some(Ok(EioEvent::Connect(self.open_packet.sid)))) + } + ClientState::Upgrading => self.poll_upgrade(cx), + ClientState::Running => self.poll_transport(cx), + ClientState::Closed | ClientState::Closing => Poll::Ready(None), + } + } + + fn poll_transport( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll>>> { + let proj = self.as_mut().project(); + match ready!(proj.transport.poll_next(cx)) { + Some(Ok(Packet::Ping)) => { + *proj.pending_pong = true; + *proj.last_ping = Instant::now(); + cx.waker().wake_by_ref(); + Poll::Pending + } + Some(Ok(Packet::Close)) => { + *proj.state = ClientState::Closing; + cx.waker().wake_by_ref(); // wake up to close the transport + Poll::Ready(Some(Ok(EioEvent::Disconnect))) + } + Some(Ok(Packet::Message(v))) => Poll::Ready(Some(Ok(EioEvent::Message(v)))), + Some(Ok(Packet::Binary(v) | Packet::BinaryV3(v))) => { + Poll::Ready(Some(Ok(EioEvent::Binary(v)))) + } + Some(Ok(Packet::Noop)) => { + // ignore noop packets + cx.waker().wake_by_ref(); + Poll::Pending + } + Some(Ok(p)) => Poll::Ready(Some(Err(ClientError::InvalidPacket(p)))), + Some(Err(e)) => Poll::Ready(Some(Err(e))), + None => Poll::Ready(None), + } + } + + fn poll_upgrade( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll>>> { + let sid = self.sid(); + let proj = self.as_mut().project(); + match ready!(proj.transport.upgrade(cx, proj.config, sid)) { + Some(Ok(())) => { + tracing::debug!(%sid, "websocket transport upgraded"); + *proj.state = ClientState::Running; + if let Some(waker) = proj.sink_waker.take() { + tracing::debug!("waking up sink after end of upgrade"); + waker.wake(); + } + + cx.waker().wake_by_ref(); + Poll::Ready(Some(Ok(EioEvent::Upgrade(self.transport())))) + } + //TODO: handle upgrade failures gracefully + Some(Err(e)) => { + *proj.state = ClientState::Closed; + Poll::Ready(Some(Err(e))) + } + None => { + *proj.state = ClientState::Closed; + Poll::Ready(None) + } // TODO: fallback to polling if upgrade fails, + } + } + + /// A fatal error surfaced by the transport sink means the session is + /// over: mark the client closed so the stream terminates instead of + /// driving a dead transport. + fn close_on_fatal(self: Pin<&mut Self>, err: &ClientError) { + if err.should_close() { + *self.project().state = ClientState::Closed; + } + } + + fn poll_heartbeat( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll>> { + if self.last_ping.elapsed() + >= self.open_packet.ping_interval + self.open_packet.ping_timeout + { + return Poll::Ready(Err(ClientError::HeartbeatTimeout)); + } + + let mut proj = self.project(); + if *proj.pending_pong { + ready!(proj.transport.as_mut().poll_ready(cx))?; + *proj.pending_pong = false; + proj.transport.as_mut().start_send(Packet::Pong)?; + } + + // idempotent: continues an in-flight flush, or Ready immediately if clean + // TODO: check this assertion + proj.transport.poll_flush(cx) + } +} + +impl Sink for Client { + type Error = ClientError; + + #[tracing::instrument(level = Level::TRACE, skip(cx), ret)] + fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.state { + ClientState::Upgrading => { + // save waker to wake the task when transport is migrated. + self.project().sink_waker.replace(cx.waker().clone()); + Poll::Pending + } + ClientState::Open | ClientState::Running => { + let res = ready!(self.as_mut().project().transport.poll_ready(cx)) + .inspect_err(|err| self.close_on_fatal(err)); + Poll::Ready(res) + } + ClientState::Closing => Poll::Ready(Err(ClientError::TransportClosed)), + ClientState::Closed => Poll::Ready(Err(ClientError::TransportClosed)), + } + } + + #[tracing::instrument(level = Level::TRACE, ret)] + fn start_send(mut self: Pin<&mut Self>, event: EioEvent) -> Result<(), Self::Error> { + if let Some(packet) = event.into() { + self.as_mut() + .project() + .transport + .start_send(packet) + .inspect_err(|err| self.close_on_fatal(err))?; + } + Ok(()) + } + + #[tracing::instrument(level = Level::TRACE, skip(cx), ret)] + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let res = ready!(self.as_mut().project().transport.poll_flush(cx)); + if let Err(err) = &res { + self.close_on_fatal(err); + } + Poll::Ready(res) + } + + #[tracing::instrument(level = Level::TRACE, skip(cx), ret)] + fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let proj = self.project(); + match *proj.state { + ClientState::Open | ClientState::Upgrading | ClientState::Running => { + *proj.state = ClientState::Closing; + cx.waker().wake_by_ref(); + Poll::Pending + } + ClientState::Closing => match ready!(proj.transport.poll_close(cx)) { + Ok(()) => { + *proj.state = ClientState::Closed; + Poll::Ready(Ok(())) + } + Err(err) => { + if err.should_close() { + *proj.state = ClientState::Closed; + } + Poll::Ready(Err(err)) + } + }, + ClientState::Closed => Poll::Ready(Ok(())), + } + } +} + +impl fmt::Debug for Client { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Client") + .field("transport", &self.transport) + .field("sink_waker", &self.sink_waker) + .field("config", &self.config) + .field("open_packet", &self.open_packet) + .field("last_ping", &self.last_ping) + .field("state", &self.state) + .field("pending_pong", &self.pending_pong) + .finish() + } +} diff --git a/crates/engineioxide-client/src/config.rs b/crates/engineioxide-client/src/config.rs new file mode 100644 index 00000000..2680b62d --- /dev/null +++ b/crates/engineioxide-client/src/config.rs @@ -0,0 +1,99 @@ +use std::str::FromStr; + +use engineioxide_core::TransportType; +use http::{Uri, uri}; + +#[derive(Debug)] +pub struct EngineIoClientConfig { + /// A list of transports to try (in order). Engine.io always attempts to + /// connect directly with the first one, provided the feature detection test + /// for it passes. + /// + /// Defaults to `[Polling, Websocket]`. + pub transports: Vec, + + /// The uri to use to connect to the server. + /// + /// Defaults to `http://localhost/engine.io`. + pub uri: Uri, +} + +impl Default for EngineIoClientConfig { + fn default() -> Self { + Self { + uri: Uri::from_static("http://localhost/engine.io"), + transports: vec![TransportType::Polling, TransportType::Websocket], + } + } +} + +impl EngineIoClientConfig { + pub fn builder() -> EngineIoClientConfigBuilder { + EngineIoClientConfigBuilder::new() + } + + pub(crate) fn initial_transport(&self) -> TransportType { + *self + .transports + .first() + .expect("transport list should never be empty") + } +} + +pub trait IntoEngineIoClientConfig { + fn into_config(self) -> Result; +} +impl IntoEngineIoClientConfig for EngineIoClientConfig { + fn into_config(self) -> Result { + Ok(self) + } +} +impl IntoEngineIoClientConfig for &str { + fn into_config(self) -> Result { + EngineIoClientConfigBuilder::new().uri(self).build() + } +} +impl IntoEngineIoClientConfig for Result { + fn into_config(self) -> Result { + self + } +} +impl IntoEngineIoClientConfig for [TransportType; N] { + fn into_config(self) -> Result { + EngineIoClientConfigBuilder::new().transports(self).build() + } +} +impl FromStr for EngineIoClientConfig { + type Err = uri::InvalidUri; + + fn from_str(s: &str) -> Result { + s.parse() + } +} + +#[derive(Default)] +pub struct EngineIoClientConfigBuilder { + config: EngineIoClientConfig, + uri: Option, +} +impl EngineIoClientConfigBuilder { + pub fn new() -> Self { + Self::default() + } + pub fn uri(mut self, uri: &str) -> Self { + self.uri = Some(uri.to_string()); + self + } + pub fn transports(mut self, transports: [TransportType; N]) -> Self { + const { assert!(N > 0, "transports list should be non-empty") }; + + self.config.transports = transports.to_vec(); + self + } + pub fn build(mut self) -> Result { + if let Some(uri) = self.uri { + self.config.uri = uri.parse()?; //TODO: err + } + Ok(self.config) + } +} diff --git a/crates/engineioxide-client/src/errors.rs b/crates/engineioxide-client/src/errors.rs new file mode 100644 index 00000000..a45960ea --- /dev/null +++ b/crates/engineioxide-client/src/errors.rs @@ -0,0 +1,80 @@ +use core::fmt; + +use engineioxide_core::Packet; +use http::uri; +use thiserror::Error; + +use crate::transport::{TransportSvc, polling::PollingError, ws::WsError}; + +#[derive(Error)] +pub enum ConnectError { + #[error(transparent)] + Client(ClientError), + #[error("failed to build client, invalid uri: {0}")] + Config(#[from] uri::InvalidUri), +} + +#[derive(Error)] +pub enum ClientError { + #[error("polling transport error: {0}")] + Polling(PollingError), + #[error("websocket transport error: {0}")] + Websocket(WsError), + + #[error("heartbeat timeout, closing connection")] + HeartbeatTimeout, + + #[error("transport closed, it is not possible to send or receive data")] + TransportClosed, + #[error("invalid packet received from server: {0:?}")] + InvalidPacket(Packet), +} + +impl ClientError { + pub(crate) fn should_close(&self) -> bool { + match self { + ClientError::Polling(e) => e.should_close(), + ClientError::Websocket(e) => e.should_close(), + ClientError::TransportClosed => false, // we are already closed, no need to close again + ClientError::HeartbeatTimeout | ClientError::InvalidPacket(_) => true, + } + } +} + +impl fmt::Debug for ClientError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ClientError::Polling(e) => f.debug_tuple("Polling").field(e).finish(), + ClientError::Websocket(e) => f.debug_tuple("Websocket").field(e).finish(), + ClientError::TransportClosed => f.write_str("TransportClosed"), + ClientError::InvalidPacket(p) => f.debug_tuple("InvalidPacket").field(p).finish(), + ClientError::HeartbeatTimeout => f.write_str("HeartbeatTimeout"), + } + } +} + +impl fmt::Debug for ConnectError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ConnectError::Client(e) => f.debug_tuple("Client").field(e).finish(), + ConnectError::Config(e) => f.debug_tuple("Config").field(e).finish(), + } + } +} + +impl From> for ClientError { + fn from(e: PollingError) -> Self { + ClientError::Polling(e) + } +} +impl From> for ClientError { + fn from(e: WsError) -> Self { + ClientError::Websocket(e) + } +} + +impl From> for ConnectError { + fn from(value: ClientError) -> Self { + Self::Client(value) + } +} diff --git a/crates/engineioxide-client/src/event.rs b/crates/engineioxide-client/src/event.rs new file mode 100644 index 00000000..1676aa58 --- /dev/null +++ b/crates/engineioxide-client/src/event.rs @@ -0,0 +1,21 @@ +use bytes::Bytes; +use engineioxide_core::{Packet, Sid, Str, TransportType}; + +#[derive(Debug, PartialEq)] +pub enum EioEvent { + Connect(Sid), + Disconnect, + Message(Str), + Binary(Bytes), + Upgrade(TransportType), +} + +impl From for Option { + fn from(value: EioEvent) -> Option { + match value { + EioEvent::Message(msg) => Some(Packet::Message(msg)), + EioEvent::Binary(bin) => Some(Packet::Binary(bin)), + _ => None, + } + } +} diff --git a/crates/engineioxide-client/src/flavors/hyper.rs b/crates/engineioxide-client/src/flavors/hyper.rs new file mode 100644 index 00000000..6d255fa6 --- /dev/null +++ b/crates/engineioxide-client/src/flavors/hyper.rs @@ -0,0 +1,52 @@ +use std::{convert::Infallible, future::Ready}; + +use bytes::Bytes; +use http::Response; +use http_body_util::combinators::BoxBody; +use hyper::body::Incoming; +use hyper_util::client::legacy::{ + Client, ResponseFuture, + connect::{HttpConnector, dns::GaiResolver}, +}; + +use crate::flavors::noop_impl::NoopWebSocket; + +#[derive(Debug, Clone)] +pub struct HyperFlavor { + client: Client, BoxBody>, +} + +impl HyperFlavor { + pub fn new() -> Self { + Self { + client: Client::builder(hyper_util::rt::TokioExecutor::new()).build_http(), + } + } +} +impl Default for HyperFlavor { + fn default() -> Self { + Self::new() + } +} + +/// HTTP Service implementation +impl hyper::service::Service>> for HyperFlavor { + type Response = Response; + type Error = hyper_util::client::legacy::Error; + type Future = ResponseFuture; + + fn call(&self, req: http::Request>) -> Self::Future { + self.client.request(req) + } +} + +/// WS Service Implementation +impl hyper::service::Service> for HyperFlavor { + type Response = NoopWebSocket; + type Error = Infallible; + type Future = Ready>; + + fn call(&self, _: http::Request<()>) -> Self::Future { + std::future::ready(Ok(NoopWebSocket)) + } +} diff --git a/crates/engineioxide-client/src/flavors/hyper_tungstenite.rs b/crates/engineioxide-client/src/flavors/hyper_tungstenite.rs new file mode 100644 index 00000000..42e5d84a --- /dev/null +++ b/crates/engineioxide-client/src/flavors/hyper_tungstenite.rs @@ -0,0 +1,146 @@ +use std::{ + convert::Infallible, + pin::Pin, + task::{Context, Poll, ready}, +}; + +use bytes::Bytes; +use engineioxide_core::Str; +use futures_core::{Stream, future::BoxFuture}; +use futures_util::{FutureExt, Sink}; +use http::Response; +use http_body_util::combinators::BoxBody; +use hyper::body::Incoming; +use hyper_util::client::legacy::ResponseFuture; +use pin_project_lite::pin_project; +use tokio::net::TcpStream; +use tokio_tungstenite::{ + MaybeTlsStream, + tungstenite::{self, Message, Utf8Bytes}, +}; + +use crate::{ + flavors::hyper::HyperFlavor, + transport::ws::{WebSocket, WsMessage}, +}; + +impl From for tungstenite::Message { + fn from(value: WsMessage) -> Self { + match value { + WsMessage::Text(v) => { + tungstenite::Message::Text(unsafe { Utf8Bytes::from_bytes_unchecked(v.into()) }) + } + WsMessage::Binary(bytes) => tungstenite::Message::Binary(bytes), + WsMessage::Close => tungstenite::Message::Close(None), + } + } +} + +#[derive(Debug, Clone)] +pub struct HyperTungsteniteFlavor { + hyper_svc: HyperFlavor, +} + +impl HyperTungsteniteFlavor { + pub fn new() -> Self { + Self { + hyper_svc: HyperFlavor::new(), + } + } +} +impl Default for HyperTungsteniteFlavor { + fn default() -> Self { + Self::new() + } +} + +/// HTTP Service implementation +impl hyper::service::Service>> for HyperTungsteniteFlavor { + type Response = Response; + type Error = hyper_util::client::legacy::Error; + type Future = ResponseFuture; + + fn call(&self, req: http::Request>) -> Self::Future { + self.hyper_svc.call(req) + } +} + +/// WS Service Implementation +impl hyper::service::Service> for HyperTungsteniteFlavor { + type Response = TokioTungsteniteWS>; + type Error = tungstenite::Error; + type Future = BoxFuture<'static, Result>; + + fn call(&self, req: http::Request<()>) -> Self::Future { + async move { + let (ws, _) = tokio_tungstenite::connect_async(req).await?; + Ok(ws.into()) + } + .boxed() + } +} + +pin_project! { + pub struct TokioTungsteniteWS { + #[pin] + inner: tokio_tungstenite::WebSocketStream, + } +} + +impl From> for TokioTungsteniteWS +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ + fn from(inner: tokio_tungstenite::WebSocketStream) -> Self { + Self { inner } + } +} + +impl WebSocket for TokioTungsteniteWS +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ + type Error = tungstenite::Error; +} + +impl Sink + for TokioTungsteniteWS +{ + type Error = tungstenite::Error; + + fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.project().inner.poll_ready(cx) + } + + fn start_send(self: Pin<&mut Self>, item: WsMessage) -> Result<(), Self::Error> { + self.project().inner.start_send(item.into()) + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.project().inner.poll_flush(cx) + } + + fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.project().inner.poll_close(cx) + } +} + +impl Stream for TokioTungsteniteWS { + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match ready!(self.project().inner.poll_next(cx)) { + Some(Ok(Message::Text(v))) => Poll::Ready(Some(Ok(WsMessage::Text(unsafe { + Str::from_bytes_unchecked(v.into()) + })))), + Some(Ok(Message::Binary(v))) => Poll::Ready(Some(Ok(WsMessage::Binary(v)))), + Some(Ok(Message::Close(_))) => Poll::Ready(Some(Ok(WsMessage::Close))), + Some(Ok(_)) => { + cx.waker().wake_by_ref(); + Poll::Pending + } + Some(Err(e)) => Poll::Ready(Some(Err(e))), + None => Poll::Pending, + } + } +} diff --git a/crates/engineioxide-client/src/flavors/mod.rs b/crates/engineioxide-client/src/flavors/mod.rs new file mode 100644 index 00000000..b385ebce --- /dev/null +++ b/crates/engineioxide-client/src/flavors/mod.rs @@ -0,0 +1,60 @@ +pub mod hyper; +pub mod hyper_tungstenite; +pub mod testing; +pub mod wasm; + +mod noop_impl { + use std::{ + convert::Infallible, + pin::Pin, + task::{Context, Poll}, + }; + + use futures_core::Stream; + use futures_util::Sink; + + use crate::transport::ws::{WebSocket, WsMessage}; + + #[derive(Debug, Default, Clone)] + pub struct NoopWebSocket; + impl WebSocket for NoopWebSocket { + type Error = Infallible; + } + + impl Stream for NoopWebSocket { + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(None) + } + } + + impl Sink for NoopWebSocket { + type Error = Infallible; + + fn poll_ready( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + + fn start_send(self: Pin<&mut Self>, _item: WsMessage) -> Result<(), Self::Error> { + Ok(()) + } + + fn poll_flush( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_close( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + } +} diff --git a/crates/engineioxide-client/src/flavors/testing.rs b/crates/engineioxide-client/src/flavors/testing.rs new file mode 100644 index 00000000..8b2cce08 --- /dev/null +++ b/crates/engineioxide-client/src/flavors/testing.rs @@ -0,0 +1,162 @@ +use std::{ + convert::Infallible, + pin::Pin, + task::{Context, Poll}, +}; + +use bytes::Bytes; +use futures_core::{future::BoxFuture, ready}; +use futures_util::FutureExt; +use http_body_util::combinators::BoxBody; +use hyper::service::Service as HyperSvc; +use pin_project_lite::pin_project; +use tokio::io; +use tokio_tungstenite::tungstenite::protocol::Role; + +use crate::{flavors::hyper_tungstenite::TokioTungsteniteWS, transport::PollingSvc}; + +/// Trait alias for [`TestingFlavor`] inner service. +/// +/// Typically this wil be satisfied by the engineioxide service. +pub trait EngineSvc: + PollingSvc> + + HyperSvc< + (DuplexStream, http::Request<()>), + Response = (), + Error: std::error::Error + Send + 'static, + Future: Send, + > + Send + + Clone + + 'static +{ +} + +impl EngineSvc for Svc where + Svc: PollingSvc> + + HyperSvc< + (DuplexStream, http::Request<()>), + Response = (), + Error: std::error::Error + Send + 'static, + Future: Send, + > + Send + + Clone + + 'static +{ +} + +#[derive(Debug, Clone)] +pub struct TestingFlavor { + inner: Svc, +} +impl TestingFlavor { + pub fn new(inner: Svc) -> Self { + Self { inner } + } +} +impl From for TestingFlavor { + fn from(inner: Svc) -> Self { + Self { inner } + } +} + +/// HTTP Service implementation +impl HyperSvc>> for TestingFlavor +where + Svc: HyperSvc>>, + Svc: Clone, +{ + type Response = Svc::Response; + type Error = Svc::Error; + type Future = Svc::Future; + + fn call(&self, req: http::Request>) -> Self::Future { + self.inner.clone().call(req) + } +} + +/// Websocket service implementation +impl HyperSvc> for TestingFlavor +where + Svc: + HyperSvc<(DuplexStream, http::Request<()>), Response = (), Error = E, Future = Fut> + Clone, + Svc: Clone + Send + 'static, + E: std::error::Error + Send + 'static, + Fut: Future> + Send, +{ + type Response = TokioTungsteniteWS; + type Error = tokio_tungstenite::tungstenite::Error; + type Future = BoxFuture<'static, Result>; + + fn call(&self, req: http::Request<()>) -> Self::Future { + let svc = self.inner.clone(); + let (client, server) = DuplexStream::new(); + tracing::debug!("initializing duplex stream"); + + async move { + svc.call((server, req)) + .await + .map_err(|e| io::Error::other(e.to_string()))?; + + tracing::debug!("server connected, wiring websocket client"); + + let ws = tokio_tungstenite::WebSocketStream::from_raw_socket( + client, + Role::Client, + Default::default(), + ) + .await; + + tracing::debug!("stub ws client wired up"); + + Ok(TokioTungsteniteWS::from(ws)) + } + .boxed() + } +} +pin_project! { + pub struct DuplexStream { + #[pin] + inner: io::DuplexStream, + } +} +impl DuplexStream { + fn new() -> (DuplexStream, DuplexStream) { + let (st1, st2) = io::duplex(usize::MAX); + let st1 = DuplexStream { inner: st1 }; + let st2 = DuplexStream { inner: st2 }; + (st1, st2) + } +} + +impl io::AsyncRead for DuplexStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut io::ReadBuf<'_>, + ) -> Poll> { + self.project().inner.poll_read(cx, buf) + } +} + +impl io::AsyncWrite for DuplexStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + let len = buf.len(); + + // Drop the error to match a real TCP socket which won't + // immediately error on close. + let _ = ready!(self.project().inner.poll_write(cx, buf)); + Poll::Ready(Ok(len)) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} diff --git a/crates/engineioxide-client/src/flavors/wasm.rs b/crates/engineioxide-client/src/flavors/wasm.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/crates/engineioxide-client/src/flavors/wasm.rs @@ -0,0 +1 @@ + diff --git a/crates/engineioxide-client/src/lib.rs b/crates/engineioxide-client/src/lib.rs new file mode 100644 index 00000000..63c4ebc2 --- /dev/null +++ b/crates/engineioxide-client/src/lib.rs @@ -0,0 +1,22 @@ +#![allow(missing_docs)] +#![cfg_attr(docsrs, feature(doc_cfg))] +#![doc( + html_logo_url = "https://raw.githubusercontent.com/Totodore/socketioxide/refs/heads/main/.github/logo_dark.svg" +)] +#![doc( + html_favicon_url = "https://raw.githubusercontent.com/Totodore/socketioxide/refs/heads/main/.github/logo_dark.ico" +)] +//! Engine.IO client library for Rust. + +mod client; +mod config; +mod errors; +mod event; + +pub mod flavors; +pub mod transport; + +pub use crate::client::Client; +pub use crate::event::EioEvent; +pub use config::{EngineIoClientConfig, EngineIoClientConfigBuilder}; +pub use errors::{ClientError, ConnectError}; diff --git a/crates/engineioxide-client/src/transport/mod.rs b/crates/engineioxide-client/src/transport/mod.rs new file mode 100644 index 00000000..9fc8c6f9 --- /dev/null +++ b/crates/engineioxide-client/src/transport/mod.rs @@ -0,0 +1,223 @@ +use std::{ + convert::Infallible, + fmt, + pin::Pin, + task::{Context, Poll, ready}, +}; + +use bytes::Bytes; +use engineioxide_core::{Packet, ProtocolVersion, Sid, TransportType}; +use futures_core::Stream; +use futures_util::Sink; +use http::{ + Request, Uri, + uri::{PathAndQuery, Scheme}, +}; +use http_body_util::{Empty, combinators::BoxBody}; +use tracing::Level; + +use crate::{EngineIoClientConfig, errors::ClientError}; + +pub use polling::{PollingSvc, PollingTransport}; +pub use ws::{WebSocket, WsSvc, WsTransport}; + +pub mod polling; +pub mod ws; + +pub trait TransportSvc: PollingSvc + WsSvc {} +impl TransportSvc for S {} + +pin_project_lite::pin_project! { + #[project = TransportProj] + pub enum Transport { + Polling { + #[pin] + inner: PollingTransport + }, + Websocket { + #[pin] + inner: WsTransport + } + } +} + +impl Transport { + pub fn transport_type(&self) -> TransportType { + match self { + Transport::Polling { .. } => TransportType::Polling, + Transport::Websocket { .. } => TransportType::Websocket, + } + } +} +impl Transport { + /// Upgrade the current transport to [`WsTransport`]. + /// + /// Starts by flushing the polling transport, once done switch to websocket + /// and drive the websocket protocol upgrade. + #[tracing::instrument(level = Level::TRACE, skip_all, ret)] + pub fn upgrade( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + config: &EngineIoClientConfig, + sid: Sid, + ) -> Poll>>> { + match self.as_mut().project() { + TransportProj::Polling { mut inner } => { + // start by flushing polling transport to ensure there is no pending data + ready!(inner.as_mut().poll_flush(cx))?; + let svc = inner.svc.clone(); + + self.set(Transport::Websocket { + inner: WsTransport::connect_with_upgrade(svc, config, sid), + }); + cx.waker().wake_by_ref(); + Poll::Pending + } + + TransportProj::Websocket { inner } => match ready!(inner.poll_next(cx)) { + Some(Ok(Packet::Upgrade)) => Poll::Ready(Some(Ok(()))), + Some(Ok(p)) => todo!("handle err: {p:?}"), + Some(Err(err)) => Poll::Ready(Some(Err(err.into()))), + None => Poll::Ready(None), + }, + } + } +} + +impl Stream for Transport { + type Item = Result>; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.as_mut().project() { + TransportProj::Polling { inner } => inner.poll_next(cx).map_err(ClientError::Polling), + TransportProj::Websocket { inner } => { + inner.poll_next(cx).map_err(ClientError::Websocket) + } + } + } +} +impl Sink for Transport { + type Error = ClientError; + + fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.project() { + TransportProj::Polling { inner } => inner.poll_ready(cx).map_err(ClientError::Polling), + TransportProj::Websocket { inner } => { + inner.poll_ready(cx).map_err(ClientError::Websocket) + } + } + } + + fn start_send(self: Pin<&mut Self>, item: Packet) -> Result<(), Self::Error> { + match self.project() { + TransportProj::Polling { inner } => { + inner.start_send(item).map_err(ClientError::Polling) + } + TransportProj::Websocket { inner } => { + inner.start_send(item).map_err(ClientError::Websocket) + } + } + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.project() { + TransportProj::Polling { inner } => inner.poll_flush(cx).map_err(ClientError::Polling), + TransportProj::Websocket { inner } => { + inner.poll_flush(cx).map_err(ClientError::Websocket) + } + } + } + + fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.project() { + TransportProj::Polling { inner } => inner.poll_close(cx).map_err(ClientError::Polling), + TransportProj::Websocket { inner } => { + inner.poll_close(cx).map_err(ClientError::Websocket) + } + } + } +} + +impl From> for Transport { + fn from(inner: PollingTransport) -> Self { + Self::Polling { inner } + } +} +impl From> for Transport { + fn from(inner: WsTransport) -> Self { + Self::Websocket { inner } + } +} +impl fmt::Debug for Transport { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Polling { inner } => f.debug_struct("Polling").field("inner", inner).finish(), + Self::Websocket { inner } => f.debug_struct("Websocket").field("inner", inner).finish(), + } + } +} + +fn build_connect_req( + base_uri: &Uri, + transport: TransportType, +) -> Request> { + let uri = with_mandatory_query(base_uri, transport, None); + + Request::builder() + .method(http::Method::GET) + .uri(uri) + .body(BoxBody::new(Empty::new())) + .unwrap() +} + +/// Merges the user-provided `base_uri` (scheme + authority + path, and any +/// pre-existing query) with the query parameters engine.io mandates on every +/// request: the protocol version (`EIO`) and the `transport` in use. +fn with_mandatory_query(base_uri: &Uri, transport: TransportType, sid: Option) -> Uri { + let secure = is_uri_secure(base_uri); + let mut parts = base_uri.clone().into_parts(); + + parts.scheme = match (transport, secure) { + (TransportType::Polling, Some(true)) => Some(Scheme::HTTPS), + (TransportType::Websocket, Some(true)) => Some("wss".parse().unwrap()), + (TransportType::Polling, Some(false)) => Some(Scheme::HTTP), + (TransportType::Websocket, Some(false)) => Some("ws".parse().unwrap()), + (_, None) => None, + }; + + let path = parts + .path_and_query + .as_ref() + .map(|pq| pq.path()) + .unwrap_or("/") + .to_owned(); + + let existing_query = parts + .path_and_query + .as_ref() + .and_then(|pq| pq.query()) + .filter(|q| !q.is_empty()); + + let protocol = format_args!("EIO={}&transport={transport}", ProtocolVersion::V4); + + let path = match (existing_query, sid) { + (Some(existing), Some(sid)) => format!("{path}?sid={sid}&{protocol}&{existing}"), + (Some(existing), None) => format!("{path}?{protocol}&{existing}"), + (None, Some(sid)) => format!("{path}?sid={sid}&{protocol}"), + (None, None) => format!("{path}?{protocol}"), + }; + + parts.path_and_query = + Some(PathAndQuery::try_from(path).expect("base uri path should be valid")); + + Uri::from_parts(parts).expect("base uri should produce a valid uri") +} + +//TODO: invalid scheme err +fn is_uri_secure(uri: &Uri) -> Option { + match uri.scheme_str()? { + "http" | "ws" => Some(false), + "https" | "wss" => Some(true), + _ => None, + } +} diff --git a/crates/engineioxide-client/src/transport/polling.rs b/crates/engineioxide-client/src/transport/polling.rs new file mode 100644 index 00000000..1cd4eee1 --- /dev/null +++ b/crates/engineioxide-client/src/transport/polling.rs @@ -0,0 +1,518 @@ +use std::{ + convert::Infallible, + fmt, + pin::Pin, + task::{Context, Poll, ready}, +}; + +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use engineioxide_core::{ + OpenPacket, Packet, PacketParseError, ProtocolVersion, Sid, TransportType, payload, +}; +use futures_core::Stream; +use futures_util::{FutureExt, Sink, StreamExt}; +use http::{Request, Response, StatusCode, Uri, response}; +use http_body_util::{BodyExt, Empty, Full, combinators::BoxBody}; +use hyper::service::Service as HyperSvc; +use pin_project_lite::pin_project; +use serde::Deserialize; + +use crate::EngineIoClientConfig; + +pub trait PollingSvc: + HyperSvc< + Request>, + Response = Response, + Error = ::Error, + > +{ + type Body: http_body::Body + 'static; + type Error: fmt::Debug + std::error::Error; + type ResBodyError: fmt::Debug + std::error::Error + 'static; +} + +impl PollingSvc for S +where + S: HyperSvc>, Response = Response>, + >>>::Error: fmt::Debug + std::error::Error, + B: http_body::Body + 'static, + ::Error: fmt::Debug + std::error::Error + 'static, + ::Data: Send + fmt::Debug + 'static, +{ + type Body = B; + type Error = >>>::Error; + type ResBodyError = ::Error; +} + +pin_project! { + #[project = PollStateProj] + enum PollState { + Pending { + #[pin] + fut: F + }, + Decoding { + #[pin] + stream: Pin>>> + }, + // Terminal state: the previous request future is dropped so it can + // never be polled again after it completed with an error. + Closed, + } +} + +pin_project! { + #[project = PostStateProj] + enum PostState { + Queuing { + bytes: BytesMut, + }, + Pending { + #[pin] + fut: F, + // TODO: BytesList + bytes: BytesMut, + }, + // Terminal state: in-flight request and queued bytes are discarded. + Closed, + } +} + +#[derive(Debug, Default, PartialEq, Eq)] +enum ClosingState { + #[default] + Open, + Closing, + Closed, +} +impl Default for PostState { + fn default() -> Self { + PostState::Queuing { + bytes: BytesMut::new(), + } + } +} +impl PostState { + fn queuing(bytes: BytesMut) -> Self { + Self::Queuing { bytes } + } +} + +impl PollState { + fn new_request>(svc: &S, base_uri: &Uri, sid: Sid) -> Self { + let uri = super::with_mandatory_query(base_uri, TransportType::Polling, Some(sid)); + + let req = Request::builder() + .method(http::Method::GET) + .uri(uri) + .body(BoxBody::new(Empty::new())) + .unwrap(); + + let fut = svc.call(req); + PollState::Pending { fut } + } +} +impl PostState { + fn new_request>( + svc: &S, + uri: &Uri, + sid: Sid, + body: BytesMut, + ) -> Self { + let uri = super::with_mandatory_query(uri, TransportType::Polling, Some(sid)); + + let req = Request::builder() + .method(http::Method::POST) + .uri(uri) + .body(BoxBody::new(Full::new(body.freeze()))) + .unwrap(); + + let fut = svc.call(req); + PostState::Pending { + fut, + bytes: BytesMut::new(), + } + } +} + +#[derive(thiserror::Error)] +pub enum PollingError { + #[error("http error: {0}")] + Http(::Error), + #[error("polling http body error: {0}")] + HttpBody(::ResBodyError), + #[error("packet error: {0}")] + Packet(#[from] PacketParseError), + #[error("server response error: {0}")] + Protocol(#[from] ProtocolError), + #[error("transport closed, it is not possible to send or receive data")] + Closed, +} + +impl fmt::Debug for PollingError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + PollingError::Http(err) => f.debug_tuple("Http").field(err).finish(), + PollingError::HttpBody(err) => f.debug_tuple("HttpBody").field(err).finish(), + PollingError::Packet(err) => f.debug_tuple("Packet").field(err).finish(), + PollingError::Protocol(err) => f.debug_tuple("Protocol").field(err).finish(), + PollingError::Closed => f.write_str("Closed"), + } + } +} + +impl PollingError { + pub(crate) fn should_close(&self) -> bool { + true + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ProtocolError { + #[error("internal error: {status}")] + ServerError { status: StatusCode }, + + #[error("invalid request: {status}")] + InvalidRequest { status: StatusCode }, + + #[error("unknown transport")] + UnknownTransport, + #[error("unknown session id")] + UnknownSessionID, + #[error("bad handshake method")] + BadHandshakeMethod, + #[error("transport mismatch")] + TransportMismatch, + #[error("unsupported protocol version")] + UnsupportedProtocolVersion, +} +impl ProtocolError { + /// Tries to parse a response body and generate a [`ProtocolError`] + /// from it. + fn from_parts(parts: response::Parts, body: impl Buf) -> Self { + #[derive(Deserialize)] + struct ErrorBody { + code: u8, + } + + serde_json::from_reader(body.reader()) + .map(|ErrorBody { code }| Self::new(parts.status, Some(code))) + .unwrap_or_else(|_| Self::new(parts.status, None)) + } + + fn new(status: StatusCode, code: Option) -> Self { + match code { + Some(0) => ProtocolError::UnknownTransport, + Some(1) => ProtocolError::UnknownSessionID, + Some(2) => ProtocolError::BadHandshakeMethod, + Some(3) => ProtocolError::TransportMismatch, + Some(5) => ProtocolError::UnsupportedProtocolVersion, + _ if status.is_client_error() => ProtocolError::InvalidRequest { status }, + _ => ProtocolError::ServerError { status }, + } + } +} + +pin_project! { + pub struct PollingTransport + { + pub(crate) svc: S, + + #[pin] + poll_state: PollState, + + #[pin] + post_state: PostState, + + close_state: ClosingState, + + base_uri: Uri, + max_payload: u64, + sid: Sid, + } +} + +impl PollingTransport { + pub async fn connect( + svc: S, + config: &EngineIoClientConfig, + ) -> Result<(Self, OpenPacket), PollingError> { + let req = super::build_connect_req(&config.uri, TransportType::Polling); + tracing::trace!(?req, "handshake request"); + + let res = svc.call(req).await.map_err(PollingError::Http)?; + let body = res.collect().await.map_err(PollingError::HttpBody)?; + + let packet = Packet::parse( + ProtocolVersion::V4, + String::from_utf8(body.to_bytes().to_vec()).unwrap(), + )?; + + match packet { + Packet::Open(open) => { + let poll_state = PollState::new_request(&svc, &config.uri, open.sid); + let transport = PollingTransport { + svc, + poll_state, + post_state: PostState::default(), + close_state: ClosingState::default(), + sid: open.sid, + max_payload: open.max_payload, + base_uri: config.uri.clone(), + }; + + tracing::debug!(?transport, ?open, "polling transport intialized"); + Ok((transport, open)) + } + _ => Err(PollingError::Packet(PacketParseError::InvalidPacketType( + None, + ))), + } + } + + /// Tear the transport down: drop any in-flight request future (it must + /// never be polled again once it completed), discard queued writes and + /// refuse any further use. + fn terminate(self: Pin<&mut Self>) { + let mut proj = self.project(); + proj.poll_state.set(PollState::Closed); + proj.post_state.set(PostState::Closed); + *proj.close_state = ClosingState::Closed; + } +} + +impl Stream for PollingTransport { + type Item = Result>; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + // the session is over (error or graceful close): the stream is fused. + if self.close_state != ClosingState::Open { + return Poll::Ready(None); + } + + match ready!(self.as_mut().poll_next_inner(cx)) { + Some(Err(err)) if err.should_close() => { + self.terminate(); + Poll::Ready(Some(Err(err))) + } + res => Poll::Ready(res), + } + } +} + +impl PollingTransport { + fn poll_next_inner( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll>>> { + tracing::trace!(poll_state = ?self.poll_state, "polling"); + + let mut proj = self.as_mut().project().poll_state.project(); + match proj { + PollStateProj::Pending { ref mut fut } => { + match ready!(fut.as_mut().poll(cx)) { + Ok(res) => { + let (parts, body) = res.into_parts(); + let body = Box::pin(body); + + if !parts.status.is_success() { + let body = body + .collect() + .now_or_never() + .unwrap() + .map_err(PollingError::HttpBody)?; //TODO: body collect state machine + let error = ProtocolError::from_parts(parts, body.aggregate()); + return Poll::Ready(Some(Err(PollingError::Protocol(error)))); + } + + let stream = + payload::decoder(body, None, ProtocolVersion::V4, self.max_payload) + .boxed_local(); + + self.project() + .poll_state + .set(PollState::Decoding { stream }); + + cx.waker().wake_by_ref(); + Poll::Pending + } + Err(err) => Poll::Ready(Some(Err(PollingError::Http(err)))), + } + } + PollStateProj::Decoding { stream } => { + if let Some(packet) = ready!(stream.poll_next(cx)) { + Poll::Ready(Some(packet.map_err(PollingError::from))) + } else { + tracing::debug!( + sid = %self.sid, + "decoding stream ended, new polling req" + ); + let request = PollState::new_request(&self.svc, &self.base_uri, self.sid); + self.project().poll_state.set(request); + //check if wake is needed + cx.waker().wake_by_ref(); + Poll::Pending + } + } + PollStateProj::Closed => Poll::Ready(None), + } + } + + fn poll_flush_inner( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll>> { + tracing::trace!(post_state = ?self.post_state, "flushing"); + let proj = self.as_mut().project().post_state.project(); + + match proj { + PostStateProj::Queuing { bytes } if bytes.is_empty() => Poll::Ready(Ok(())), + PostStateProj::Queuing { bytes } => { + let body = std::mem::take(bytes); + //TODO: handle max body size from open packet + let post_state = PostState::new_request(&self.svc, &self.base_uri, self.sid, body); + self.project().post_state.set(post_state); + cx.waker().wake_by_ref(); + Poll::Pending + } + PostStateProj::Pending { fut, bytes } => { + match ready!(fut.poll(cx)) { + Ok(res) => { + let (parts, res_body) = res.into_parts(); + let res_body = res_body + .collect() + .now_or_never() + .unwrap() + .map_err(PollingError::HttpBody)?; //TODO error body collect + if !parts.status.is_success() { + let err = ProtocolError::from_parts(parts, res_body.aggregate()); + return Poll::Ready(Err(PollingError::Protocol(err))); + } + + let body = std::mem::take(bytes); + if body.is_empty() { + self.project().post_state.set(PostState::queuing(body)); + Poll::Ready(Ok(())) + } else { + // resend another request immediately, the buffer was filled + // while the previous one was sent + let post_state = + PostState::new_request(&self.svc, &self.base_uri, self.sid, body); + self.project().post_state.set(post_state); + cx.waker().wake_by_ref(); + Poll::Pending + } + } + Err(err) => Poll::Ready(Err(PollingError::Http(err))), + } + } + PostStateProj::Closed => Poll::Ready(Ok(())), + } + } +} + +impl Sink for PollingTransport { + type Error = PollingError; + + fn poll_ready(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + if self.close_state != ClosingState::Open { + Poll::Ready(Err(PollingError::Closed)) + } else { + Poll::Ready(Ok(())) + } + } + + fn start_send(self: Pin<&mut Self>, item: Packet) -> Result<(), Self::Error> { + tracing::trace!(post_state = ?self.post_state, "sending packet"); + if self.close_state != ClosingState::Open { + return Err(PollingError::Closed); + } + self.project().post_state.encode(item); + Ok(()) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match ready!(self.as_mut().poll_flush_inner(cx)) { + Err(err) => { + // any polling error is fatal: tear the transport down before + // surfacing it so the completed request future can never be + // polled again. + self.terminate(); + Poll::Ready(Err(err)) + } + ok => Poll::Ready(ok), + } + } + + fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.close_state { + ClosingState::Open => { + // we dont need to call poll_ready on ourselve + self.as_mut().start_send(Packet::Close)?; + *self.project().close_state = ClosingState::Closing; + cx.waker().wake_by_ref(); + Poll::Pending + } + ClosingState::Closing => { + ready!(self.as_mut().poll_flush(cx))?; + // the close packet is flushed: abort the held poll request + // and refuse any further use. + self.terminate(); + Poll::Ready(Ok(())) + } + ClosingState::Closed => Poll::Ready(Ok(())), + } + } +} + +impl PostState { + pub fn encode(self: Pin<&mut Self>, item: Packet) { + const PACKET_SEPARATOR_V4: u8 = b'\x1e'; + let packet: Bytes = item.into(); + let bytes = match self.project() { + PostStateProj::Queuing { bytes } => bytes, + PostStateProj::Pending { bytes, .. } => bytes, + // unreachable from `start_send` (gated on `close_state`), writes + // on a closed transport are discarded. + PostStateProj::Closed => return, + }; + + if !bytes.is_empty() { + bytes.put_u8(PACKET_SEPARATOR_V4); + } + bytes.extend_from_slice(&packet); + } +} + +impl fmt::Debug for PollState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Pending { .. } => f.debug_struct("Pending").finish_non_exhaustive(), + Self::Decoding { .. } => f.debug_struct("Decoding").finish_non_exhaustive(), + Self::Closed => f.write_str("Closed"), + } + } +} +impl fmt::Debug for PostState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Queuing { bytes } => f.debug_struct("Queuing").field("bytes", bytes).finish(), + Self::Pending { bytes, .. } => f + .debug_struct("Pending") + .field("bytes", bytes) + .finish_non_exhaustive(), + Self::Closed => f.write_str("Closed"), + } + } +} +impl fmt::Debug for PollingTransport { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PollingTransport") + .field("poll_state", &self.poll_state) + .field("post_state", &self.post_state) + .field("close_state", &self.close_state) + .field("base_uri", &self.base_uri) + .field("sid", &self.sid) + .finish_non_exhaustive() + } +} diff --git a/crates/engineioxide-client/src/transport/ws.rs b/crates/engineioxide-client/src/transport/ws.rs new file mode 100644 index 00000000..60e8640d --- /dev/null +++ b/crates/engineioxide-client/src/transport/ws.rs @@ -0,0 +1,438 @@ +use std::{ + fmt, + pin::Pin, + task::{Context, Poll, Waker, ready}, +}; + +use bytes::Bytes; +use engineioxide_core::{ + OpenPacket, Packet, PacketParseError, ProtocolVersion, Sid, Str, TransportType, +}; +use futures_core::Stream; +use futures_util::{Sink, StreamExt}; +use http::Request; +use hyper::service::Service as HyperSvc; +use pin_project_lite::pin_project; +use tokio_tungstenite::tungstenite::handshake::client::generate_key; +use tracing::Level; + +use crate::EngineIoClientConfig; + +pin_project! { + pub struct WsTransport { + svc: S, + sink_waker: Option, + + #[pin] + state: WsTransportState, + } +} + +pin_project! { + #[project = WsTransportStateProj] + enum WsTransportState { + Connecting { + #[pin] + fut: S::Future, + }, + Stream { + #[pin] + stream: S::WebSocket, + upgrade: UpgradeHandshakeState, + }, + // Terminal state: the connect future / websocket stream is dropped so + // it can never be polled again after an error or a close. + Closed, + } +} +#[derive(Debug)] +enum UpgradeHandshakeState { + ShouldSendPingUpgrade, + ShouldFlushPingUpgrade, + WaitingPong, + ShouldSendUpgrade, + ShouldFlushUpgrade, + Done, +} + +pub enum WsError { + Websocket(::Error), + Packet(PacketParseError), + InvalidPacket { + expected: Box, + got: Box, + }, + Closed, +} + +impl WsError { + pub(crate) fn should_close(&self) -> bool { + matches!( + self, + WsError::Closed | WsError::Websocket(_) | WsError::Packet(_) + ) + } + fn invalid_packet(expected: Packet, got: Packet) -> Self { + Self::InvalidPacket { + expected: Box::new(expected), + got: Box::new(got), + } + } +} +impl From for WsError { + fn from(e: PacketParseError) -> Self { + WsError::Packet(e) + } +} +impl fmt::Debug for WsError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + WsError::Websocket(e) => f.debug_tuple("Websocket").field(e).finish(), + WsError::Packet(e) => f.debug_tuple("Packet").field(e).finish(), + WsError::InvalidPacket { expected, got } => f + .debug_struct("InvalidPacket") + .field("expected", expected) + .field("got", got) + .finish(), + WsError::Closed => f.write_str("Closed"), + } + } +} +impl fmt::Display for WsError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + WsError::Websocket(e) => write!(f, "websocket error: {}", e), + WsError::Packet(e) => write!(f, "packet error: {}", e), + WsError::InvalidPacket { expected, got } => write!( + f, + "invalid packet received, expected {expected:?}, got {got:?}" + ), + WsError::Closed => write!(f, "websocket closed"), + } + } +} +impl std::error::Error for WsError {} + +impl WsTransport { + #[tracing::instrument(skip(svc))] + pub fn connect_with_upgrade(svc: S, config: &EngineIoClientConfig, sid: Sid) -> Self { + tracing::trace!("websocket connection with upgrade"); + let uri = super::with_mandatory_query(&config.uri, TransportType::Websocket, Some(sid)); + let req = Request::get(uri) + .header("Host", "127.0.0.1") + .header("Connection", "Upgrade") + .header("Upgrade", "websocket") + .header("Sec-WebSocket-Version", "13") + .header("Sec-WebSocket-Key", generate_key()) + .body(()) + .unwrap(); + + let fut = svc.call(req); + Self { + svc, + sink_waker: None, + state: WsTransportState::Connecting { fut }, + } + } + + #[tracing::instrument(skip(svc))] + pub async fn connect( + svc: S, + config: &EngineIoClientConfig, + ) -> Result<(Self, OpenPacket), WsError> { + tracing::trace!("websocket connection without upgrade"); + let uri = super::with_mandatory_query(&config.uri, TransportType::Websocket, None); + + let req = Request::get(uri) + .header("Connection", "Upgrade") + .header("Upgrade", "websocket") + .header("Sec-WebSocket-Version", "13") + .header("Sec-WebSocket-Key", generate_key()) + .body(()) + .unwrap(); + + let mut stream = svc.call(req).await.map_err(WsError::Websocket)?; + + tracing::debug!("handshake response received, waiting for open packet"); + let packet = match stream.next().await.ok_or(WsError::Closed)? { + Ok(msg) => parse_packet(msg)?, + Err(e) => return Err(WsError::Websocket(e)), + }; + tracing::debug!("open packet received, switching to streaming"); + + let ws = Self { + svc, + sink_waker: None, + state: WsTransportState::Stream { + stream, + upgrade: UpgradeHandshakeState::Done, + }, + }; + + match packet { + Packet::Open(open_packet) => Ok((ws, open_packet)), + _ => Err(WsError::Packet(PacketParseError::InvalidPacketType(None))), + } + } +} + +pub trait WsSvc: + HyperSvc, Response = Self::WebSocket, Error = ::Error> + Clone +{ + type Error: fmt::Debug + std::error::Error; + type WebSocket: WebSocket::Error>; +} + +impl WsSvc for S +where + S: HyperSvc, Response = WS> + Clone, + WS: WebSocket>>::Error>, + >>::Error: fmt::Debug + std::error::Error, +{ + type Error = >>::Error; + type WebSocket = WS; +} + +pub trait WebSocket: + Stream::Error>> + + Sink::Error> + + Sized + + Unpin +{ + type Error: fmt::Debug + std::error::Error; +} + +pub enum WsMessage { + Text(Str), + Binary(Bytes), + Close, +} + +fn parse_packet(msg: WsMessage) -> Result> { + match msg { + WsMessage::Text(msg) => { + let msg_str = unsafe { Str::from_bytes_unchecked(msg.into()) }; + let packet = Packet::parse(ProtocolVersion::V4, msg_str)?; + Ok(packet) + } + WsMessage::Binary(data) => Ok(Packet::Binary(data)), + WsMessage::Close => { + todo!("impl ws close"); + } + } +} + +#[tracing::instrument(level = Level::TRACE, skip(cx, stream), ret)] +fn poll_upgrade( + cx: &mut Context<'_>, + mut stream: Pin<&mut S::WebSocket>, + curr: &mut UpgradeHandshakeState, +) -> Poll>> { + match curr { + UpgradeHandshakeState::ShouldSendPingUpgrade => { + ready!(stream.as_mut().poll_ready(cx)).map_err(WsError::Websocket)?; + stream + .start_send(WsMessage::Text(Packet::PingUpgrade.into())) + .map_err(WsError::Websocket)?; + Poll::Ready(Ok(UpgradeHandshakeState::ShouldFlushPingUpgrade)) + } + UpgradeHandshakeState::ShouldFlushPingUpgrade => { + ready!(stream.as_mut().poll_flush(cx)).map_err(WsError::Websocket)?; + Poll::Ready(Ok(UpgradeHandshakeState::WaitingPong)) + } + UpgradeHandshakeState::WaitingPong => { + match ready!(stream.as_mut().poll_next(cx)).map(|v| v.map(parse_packet::)) { + Some(Ok(Ok(Packet::PongUpgrade))) => { + Poll::Ready(Ok(UpgradeHandshakeState::ShouldSendUpgrade)) + } + Some(Ok(Ok(p))) => { + Poll::Ready(Err(WsError::invalid_packet(Packet::PongUpgrade, p))) + } + Some(Ok(Err(parsing_err))) => Poll::Ready(Err(parsing_err)), + Some(Err(err)) => Poll::Ready(Err(WsError::Websocket(err))), + None => Poll::Ready(Err(WsError::Closed)), + } + } + UpgradeHandshakeState::ShouldSendUpgrade => { + ready!(stream.as_mut().poll_ready(cx)).map_err(WsError::Websocket)?; + stream + .start_send(WsMessage::Text(Packet::Upgrade.into())) + .map_err(WsError::Websocket)?; + Poll::Ready(Ok(UpgradeHandshakeState::ShouldFlushUpgrade)) + } + UpgradeHandshakeState::ShouldFlushUpgrade => { + ready!(stream.as_mut().poll_flush(cx)).map_err(WsError::Websocket)?; + Poll::Ready(Ok(UpgradeHandshakeState::Done)) + } + UpgradeHandshakeState::Done => { + unreachable!("poll_upgrade should never be called once upgrade as been performed") + } + } +} + +impl Stream for WsTransport { + type Item = Result>; + + #[tracing::instrument(level = Level::TRACE, skip_all, ret)] + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match ready!(self.as_mut().poll_next_inner(cx)) { + // an error or the end of the stream is terminal: drop the + // websocket (or the completed connect future) so it can never + // be polled again. + Some(Err(err)) if err.should_close() => { + self.project().state.set(WsTransportState::Closed); + Poll::Ready(Some(Err(err))) + } + None => { + self.project().state.set(WsTransportState::Closed); + Poll::Ready(None) + } + packet => Poll::Ready(packet), + } + } +} + +impl WsTransport { + fn poll_next_inner( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll>>> { + match self.as_mut().project().state.project() { + // if we were connecting it means that's an upgrade. + // TODO: this assertion might be brittle, add a test to prove that. + WsTransportStateProj::Connecting { fut } => match ready!(fut.poll(cx)) { + Ok(stream) => { + self.project().state.set(WsTransportState::Stream { + stream, + upgrade: UpgradeHandshakeState::ShouldSendPingUpgrade, + }); + cx.waker().wake_by_ref(); + Poll::Pending + } + Err(e) => Poll::Ready(Some(Err(WsError::Websocket(e)))), + }, + WsTransportStateProj::Stream { + stream, + upgrade: UpgradeHandshakeState::Done, + .. + } => match ready!(stream.poll_next(cx)) { + Some(Ok(msg)) => match parse_packet(msg) { + Ok(packet) => Poll::Ready(Some(Ok(packet))), + Err(e) => Poll::Ready(Some(Err(e))), + }, + Some(Err(e)) => Poll::Ready(Some(Err(WsError::Websocket(e)))), + None => Poll::Ready(None), + }, + WsTransportStateProj::Stream { stream, upgrade } => { + match ready!(poll_upgrade(cx, stream, upgrade)) { + Ok(UpgradeHandshakeState::Done) => { + *upgrade = UpgradeHandshakeState::Done; + tracing::debug!("upgrade done, switching in nominal state"); + if let Some(waker) = self.project().sink_waker.take() { + waker.wake(); + } + cx.waker().wake_by_ref(); + Poll::Ready(Some(Ok(Packet::Upgrade))) + } + Ok(next) => { + // switch to next upgrade state + *upgrade = next; + cx.waker().wake_by_ref(); + Poll::Pending + } + Err(err) => Poll::Ready(Some(Err(err))), + } + } + WsTransportStateProj::Closed => Poll::Ready(None), + } + } +} + +impl Sink for WsTransport { + type Error = WsError; + + #[tracing::instrument(level = Level::TRACE, skip_all, ret)] + fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let proj = self.as_mut().project(); + match proj.state.project() { + WsTransportStateProj::Stream { + stream, + upgrade: UpgradeHandshakeState::Done, + .. + } => stream.poll_ready(cx).map_err(WsError::Websocket), + WsTransportStateProj::Closed => Poll::Ready(Err(WsError::Closed)), + _ => { + proj.sink_waker.replace(cx.waker().clone()); + Poll::Pending + } + } + } + + fn start_send(self: Pin<&mut Self>, item: Packet) -> Result<(), Self::Error> { + match self.project().state.project() { + WsTransportStateProj::Stream { + stream, + upgrade: UpgradeHandshakeState::Done, + } => { + let msg = match item { + Packet::Binary(bin) => WsMessage::Binary(bin), + Packet::Noop => return Ok(()), + p => WsMessage::Text(String::from(p).into()), + }; + stream.start_send(msg).map_err(WsError::Websocket) + } + WsTransportStateProj::Closed => Err(WsError::Closed), + _ => { + panic!("Sink is not ready") + } + } + } + + #[tracing::instrument(level = Level::TRACE, skip_all, ret)] + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.project().state.project() { + WsTransportStateProj::Stream { + stream, + upgrade: UpgradeHandshakeState::Done, + } => stream.poll_flush(cx).map_err(WsError::Websocket), + _ => Poll::Ready(Ok(())), + } + } + + #[tracing::instrument(level = Level::TRACE, skip_all, ret)] + fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.as_mut().project().state.project() { + WsTransportStateProj::Connecting { .. } => { + // abort the in-flight connection attempt + self.project().state.set(WsTransportState::Closed); + Poll::Ready(Ok(())) + } + WsTransportStateProj::Stream { stream, .. } => { + stream.poll_close(cx).map_err(WsError::Websocket) + } + WsTransportStateProj::Closed => Poll::Ready(Ok(())), + } + } +} + +impl fmt::Debug for WsTransport { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("WsTransport") + .field("sink_waker", &self.sink_waker) + .field("state", &self.state) + .finish_non_exhaustive() + } +} + +impl fmt::Debug for WsTransportState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Connecting { .. } => f.debug_struct("Connecting").finish_non_exhaustive(), + Self::Stream { upgrade, .. } => f + .debug_struct("Stream") + .field("upgrade", upgrade) + .finish_non_exhaustive(), + Self::Closed => f.write_str("Closed"), + } + } +} diff --git a/crates/engineioxide-client/tests/it/close.rs b/crates/engineioxide-client/tests/it/close.rs new file mode 100644 index 00000000..132652cf --- /dev/null +++ b/crates/engineioxide-client/tests/it/close.rs @@ -0,0 +1,346 @@ +//! Closing behavior tests. +//! +//! Reference behavior (engine.io-client, protocol v4): +//! * Server-initiated close: a `1` (close) packet ends the session — the +//! reference client fires `close` with reason "transport close". Mapped to +//! this API: the stream yields [`EioEvent::Disconnect`], then terminates. +//! * Client-initiated close: buffered packets are flushed first, then the +//! polling transport POSTs a `1` (close) packet, while the websocket +//! transport simply closes the connection (no close packet). The reference +//! reason is "forced close". +//! * Packets submitted after a close are never delivered (the reference +//! client silently discards them). +//! * An abruptly closed websocket is a *clean* close ("transport close"), +//! not an error. + +use std::{assert_matches, time::Duration}; + +use engineioxide::{DisconnectReason, TransportType}; +use engineioxide_client::{Client, ClientError, EioEvent, transport::ws::WsError}; +use engineioxide_core::Packet; +use futures_util::{SinkExt, StreamExt}; +use tokio::time; + +use crate::mock::{ + self, + fixture::{Event, service, service_with_registry}, + helpers::{ClientTestExt, FutureTestExt}, +}; + +/// A `1` (close) packet received on a poll ends the session: `Disconnect` +/// then end-of-stream, and the client stops issuing requests. +#[tokio::test] +async fn server_close_packet_polling() { + let open = mock::open_packet_no_upgrade(); + let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; + assert_eq!(client.next_ok().await, EioEvent::Connect(open.sid)); + + let (disconnect, _) = tokio::join!(client.next_ok(), async { + server.next_http().await.respond_packets([Packet::Close]) + }); + assert_eq!(disconnect, EioEvent::Disconnect); + client.next_close().await; +} + +/// A `1` (close) packet received on the websocket ends the session the same +/// way. +#[tokio::test] +async fn server_close_packet_ws() { + let open = mock::open_packet_no_upgrade(); + let (mut client, _server, ws) = mock::connect_ws(&open).await; + assert_eq!(client.next_ok().await, EioEvent::Connect(open.sid)); + + ws.send_packet(Packet::Close); + assert_eq!(client.next_ok().await, EioEvent::Disconnect); + client.next_close().await; +} + +/// Server-initiated close against the real server, polling transport. +#[tokio::test] +async fn server_close_real_server_polling() { + let (svc, mut rx, registry) = service_with_registry(Default::default()); + let mut client = Client::connect(svc, [TransportType::Polling]) + .timeout() + .await + .unwrap(); + let sid = client.sid(); + assert_eq!(rx.next_ok().await, Event::Connect(sid)); + assert_eq!(client.next_ok().await, EioEvent::Connect(sid)); + + // start a polling req before closing the socket server side. Otherwise + // The disconnect packet is never sent because the client didn't initiated a req immediately + let fut = client.next_ok(); + tokio::pin!(fut); + let event = loop { + tokio::select! { + res = &mut fut => break res, + _ = time::sleep(Duration::from_millis(10)) => { + registry.lock().unwrap()[&sid].close(DisconnectReason::TransportClose); + } + } + }; + + assert_eq!(event, EioEvent::Disconnect); + client.next_close().await; +} + +/// Server-initiated close against the real server, websocket transport. +#[tokio::test] +async fn server_close_real_server_ws() { + let (svc, mut rx, registry) = service_with_registry(Default::default()); + let mut client = Client::connect(svc, [TransportType::Websocket]) + .await + .unwrap(); + let sid = client.sid(); + assert_eq!(rx.next_ok().await, Event::Connect(sid)); + assert_eq!(client.next_ok().await, EioEvent::Connect(sid)); + + registry.lock().unwrap()[&sid].close(DisconnectReason::TransportClose); + + assert_eq!(client.next_ok().await, EioEvent::Disconnect); + client.next_close().await; +} + +/// Closing a polling client must send a `1` (close) packet so the server +/// learns about the disconnection immediately (reference `Polling.doClose` +/// writes a close packet). +#[tokio::test] +async fn client_close_polling_sends_close_packet() { + let open = mock::open_packet_no_upgrade(); + let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; + assert_eq!(client.next_ok().await, EioEvent::Connect(open.sid)); + + tokio::join!( + async { client.close().timeout().await.expect("close must succeed") }, + async { + let post = server.next_post_parking_get().await; + assert_eq!( + post.packets(), + vec![Packet::Close], + "closing a polling client must POST a close packet" + ); + post.respond_ok(); + }, + ); +} + +/// Closing a websocket client closes the connection without sending a `1` +/// packet (reference `WS.doClose` just closes the socket). +#[tokio::test] +async fn client_close_ws_closes_the_connection() { + let open = mock::open_packet_no_upgrade(); + let (mut client, _server, mut ws) = mock::connect_ws(&open).await; + assert_eq!(client.next_ok().await, EioEvent::Connect(open.sid)); + + client.close().timeout().await.unwrap(); + + // The server observes the websocket closing, with no engine.io close + // packet beforehand. + match ws.recv().timeout().await { + Some(engineioxide_client::transport::ws::WsMessage::Close) | None => (), + Some(engineioxide_client::transport::ws::WsMessage::Text(t)) => { + assert_ne!(&*t, "1", "no close packet is sent over websocket"); + panic!("unexpected frame while closing: {t:?}"); + } + Some(engineioxide_client::transport::ws::WsMessage::Binary(_)) => { + panic!("unexpected binary frame while closing") + } + } +} + +/// After a client-side close the real server must observe the disconnection +/// promptly (i.e. via the close packet, not a heartbeat timeout — the +/// default ping budget is 45s while the test deadline is 5s), polling +/// transport. +#[tokio::test] +async fn client_close_notifies_server_polling() { + let (svc, mut rx) = service(); + let mut client = Client::connect(svc, [TransportType::Polling]) + .await + .unwrap(); + let sid = client.sid(); + assert_eq!(rx.next_ok().await, Event::Connect(sid)); + assert_eq!(client.next_ok().await, EioEvent::Connect(sid)); + + client.close().timeout().await.unwrap(); + + assert_eq!( + rx.next_ok().await, + Event::Disconnect(sid, DisconnectReason::TransportClose), + "the server must observe a graceful close" + ); +} + +/// Same requirement over websocket: closing the connection is enough for +/// the server to observe a graceful disconnection. +#[tokio::test] +async fn client_close_notifies_server_ws() { + let (svc, mut rx) = service(); + let mut client = Client::connect(svc, [TransportType::Websocket]) + .await + .unwrap(); + let sid = client.sid(); + assert_eq!(rx.next_ok().await, Event::Connect(sid)); + assert_eq!(client.next_ok().await, EioEvent::Connect(sid)); + + client.close().timeout().await.unwrap(); + + assert_eq!( + rx.next_ok().await, + Event::Disconnect(sid, DisconnectReason::TransportClose), + "the server must observe a graceful close" + ); +} + +/// Packets buffered before a close must be flushed before the transport +/// closes (reference client waits for `drain` before closing), polling +/// transport. +#[tokio::test] +async fn close_flushes_buffered_packets_polling() { + let (svc, mut rx) = service(); + let mut client = Client::connect(svc, [TransportType::Polling]) + .await + .unwrap(); + let sid = client.sid(); + assert_eq!(rx.next_ok().await, Event::Connect(sid)); + assert_eq!(client.next_ok().await, EioEvent::Connect(sid)); + + // feed() queues without flushing; close() must flush then close. + client.feed(EioEvent::Message("one".into())).await.unwrap(); + client.feed(EioEvent::Message("two".into())).await.unwrap(); + client.close().timeout().await.expect("close must succeed"); + + assert_eq!(rx.next_ok().await, Event::Message(sid, "one".into())); + assert_eq!(rx.next_ok().await, Event::Message(sid, "two".into())); + assert_eq!( + rx.next_ok().await, + Event::Disconnect(sid, DisconnectReason::TransportClose), + ); + //TODO: assert client / server closing +} + +/// Same flush-before-close requirement over websocket. +#[tokio::test] +async fn close_flushes_buffered_packets_ws() { + let (svc, mut rx) = service(); + let mut client = Client::connect(svc, [TransportType::Websocket]) + .await + .unwrap(); + let sid = client.sid(); + assert_eq!(rx.next_ok().await, Event::Connect(sid)); + assert_eq!(client.next_ok().await, EioEvent::Connect(sid)); + + client.feed(EioEvent::Message("one".into())).await.unwrap(); + client.feed(EioEvent::Message("two".into())).await.unwrap(); + client.close().timeout().await.unwrap(); + + assert_eq!(rx.next_ok().await, Event::Message(sid, "one".into())); + assert_eq!(rx.next_ok().await, Event::Message(sid, "two".into())); + assert_eq!( + rx.next_ok().await, + Event::Disconnect(sid, DisconnectReason::TransportClose), + ); + //TODO: assert client / server closing +} + +/// Packets submitted after a local close must never reach the server (the +/// reference client silently discards them; a sink error is also +/// acceptable — but panicking or delivering is not). +#[tokio::test] +async fn send_after_close_is_not_delivered() { + let (svc, mut rx) = service(); + let mut client = Client::connect(svc, [TransportType::Polling]) + .await + .unwrap(); + let sid = client.sid(); + assert_eq!(rx.next_ok().await, Event::Connect(sid)); + assert_eq!(client.next_ok().await, EioEvent::Connect(sid)); + + client.close().timeout().await.unwrap(); + + assert_matches!( + client + .send(EioEvent::Message("late".into())) + .timeout() + .await, + Err(ClientError::TransportClosed) + ); + + if let Ok(Some(Event::Message(_, msg))) = + tokio::time::timeout(Duration::from_millis(300), rx.recv()).await + { + panic!("a message sent after close must not be delivered: {msg:?}") + } +} + +/// Sending after the *server* closed the session must surface a sink error +/// (the session is gone), not panic. +#[tokio::test] +async fn send_after_server_close_is_an_error() { + let open = mock::open_packet_no_upgrade(); + let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; + assert_eq!(client.next_ok().await, EioEvent::Connect(open.sid)); + + let (disconnect, _) = tokio::join!(client.next_ok(), async { + server.next_http().await.respond_packets([Packet::Close]) + },); + assert_eq!(disconnect, EioEvent::Disconnect); + client.next_close().await; + + let res = client + .send(EioEvent::Message("late".into())) + .timeout() + .await; + assert!( + res.is_err(), + "sending on a closed session must surface an error" + ); +} + +/// An abrupt websocket termination is a clean close for the reference +/// client ("transport close"), not an error: the stream must terminate +/// without yielding one. +#[tokio::test] +async fn abrupt_ws_termination_ends_the_stream() { + let open = mock::open_packet_no_upgrade(); + let (mut client, _server, ws) = mock::connect_ws(&open).await; + assert_eq!(client.next_ok().await, EioEvent::Connect(open.sid)); + + ws.close(); + + async { + loop { + match client.next().await { + // Surfacing the disconnection as an event first is fine. + Some(Ok(EioEvent::Disconnect)) => continue, + Some(Ok(ev)) => panic!("unexpected event on abrupt close: {ev:?}"), + Some(Err(e)) => panic!("an abrupt close is not an error: {e}"), + None => break, + } + } + } + .timeout() + .await; +} + +/// A websocket-level error must surface as a stream error, then the stream +/// must terminate (reference client: `error` event, then close with reason +/// "transport error"). +#[tokio::test] +async fn ws_error_surfaces_then_stream_terminates() { + let open = mock::open_packet_no_upgrade(); + let (mut client, _server, ws) = mock::connect_ws(&open).await; + assert_eq!(client.next_ok().await, EioEvent::Connect(open.sid)); + + ws.send_error("connection reset by peer"); + + assert_matches!( + client.next_err().await, + ClientError::Websocket(WsError::Websocket(_)) + ); + + // Keep `ws` alive: termination must come from the client closing itself + // after the error, not from the mock dropping the connection. + client.next_close().await; + drop(ws); +} diff --git a/crates/engineioxide-client/tests/it/errors.rs b/crates/engineioxide-client/tests/it/errors.rs new file mode 100644 index 00000000..74d9a129 --- /dev/null +++ b/crates/engineioxide-client/tests/it/errors.rs @@ -0,0 +1,201 @@ +//! Transport error handling tests. +//! +//! Reference behavior (engine.io-client, protocol v4): +//! * A non-2xx HTTP status on a polling GET/POST is a transport error: the +//! client emits `error` then closes with reason "transport error". Mapped +//! to this API: the stream (or the sink call) yields an `Err`, then the +//! stream terminates. It must never panic, and never keep polling. +//! * A payload that cannot be decoded produces `Error("server error")` and +//! the same error-then-close sequence. +//! * `noop` packets are ignored (their only purpose is releasing a held +//! poll during upgrades). +//! * The client never retries or reconnects on its own. + +use std::assert_matches; + +use engineioxide_client::{ + ClientError, EioEvent, + transport::{ + polling::{PollingError, ProtocolError}, + ws::WsError, + }, +}; +use engineioxide_core::{Packet, TransportType}; +use futures_util::SinkExt; +use http::StatusCode; + +use crate::mock::{ + self, + helpers::{ClientTestExt, FutureTestExt}, +}; + +/// A 500 on a mid-session poll must surface an error and close the session. +#[tokio::test] +async fn polling_get_http_error_surfaces_and_closes() { + let open = mock::open_packet_no_upgrade(); + let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; + assert_eq!( + client.next_ok().timeout().await, + EioEvent::Connect(open.sid) + ); + + tokio::join!(async { server.next_http().await.respond(500, "") }, async { + let err = client.next_err().timeout().await; + assert_matches!( + err, + ClientError::Polling(PollingError::Protocol(ProtocolError::ServerError { + status: StatusCode::INTERNAL_SERVER_ERROR + })) + ); + client.next_close().timeout().await; + }); +} + +/// A 400 `{"code":1,"message":"Session ID unknown"}` (e.g. the server +/// restarted and lost the session) must surface an error and close: this is +/// what a higher layer observes to decide to reconnect. +#[tokio::test] +async fn polling_get_session_unknown_surfaces_and_closes() { + let open = mock::open_packet_no_upgrade(); + let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; + assert_eq!( + client.next_ok().timeout().await, + EioEvent::Connect(open.sid) + ); + + tokio::join!( + async { + server + .next_http() + .await + .respond(400, "{\"code\":1,\"message\":\"Session ID unknown\"}") + }, + async { + let err = client.next_err().timeout().await; + assert_matches!( + err, + ClientError::Polling(PollingError::Protocol(ProtocolError::UnknownSessionID)) + ); + client.next_close().timeout().await; + } + ); +} + +/// A network-level failure on a mid-session poll must surface an error and +/// close the session. +#[tokio::test] +async fn polling_get_network_error_surfaces_and_closes() { + let open = mock::open_packet_no_upgrade(); + let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; + assert_eq!( + client.next_ok().timeout().await, + EioEvent::Connect(open.sid) + ); + + tokio::join!( + async { server.next_http().await.fail("connection reset") }, + async { + let err = client.next_err().timeout().await; + assert_matches!(err, ClientError::Polling(PollingError::Http(_))); + client.next_close().timeout().await; + } + ); +} + +/// An HTTP error on a POST write must surface as a sink error (reference: +/// "xhr post error" → close "transport error") — not a panic. +#[tokio::test] +async fn polling_post_http_error_surfaces() { + let open = mock::open_packet_no_upgrade(); + let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; + assert_eq!( + client.next_ok().timeout().await, + EioEvent::Connect(open.sid) + ); + + let (_, res) = tokio::join!( + async { server.next_post_parking_get().await.respond(413, "") }, + async { + client + .send(EioEvent::Message("hello".into())) + .timeout() + .await + }, + ); + assert_matches!( + res, + Err(ClientError::Polling(PollingError::Protocol( + ProtocolError::InvalidRequest { + status: StatusCode::PAYLOAD_TOO_LARGE + } + ))) + ); + + client.next_close().timeout().await; +} + +/// An undecodable poll payload must surface an error and close the session +/// (reference: `Error("server error")`, close "transport error"). +#[tokio::test] +async fn polling_parse_error_surfaces_and_closes() { + let open = mock::open_packet_no_upgrade(); + let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; + assert_eq!( + client.next_ok().timeout().await, + EioEvent::Connect(open.sid) + ); + + tokio::join!( + async { server.next_http().await.respond(200, "garbage!") }, + async { + let err = client.next_err().timeout().await; + assert_matches!(err, ClientError::Polling(PollingError::Packet(_))); + client.next_close().timeout().await; + } + ); +} + +/// An invalid packet received over websocket must surface an error and +/// close the session. +#[tokio::test] +async fn ws_parse_error_surfaces_and_closes() { + let open = mock::open_packet_no_upgrade(); + let (mut client, _server, ws) = mock::connect_ws(&open).await; + assert_eq!( + client.next_ok().timeout().await, + EioEvent::Connect(open.sid) + ); + + ws.send_text("garbage!"); + + let err = client.next_err().timeout().await; + assert_matches!(err, ClientError::Websocket(WsError::Packet(_))); + + client.next_close().timeout().await; +} + +/// `noop` packets must be ignored: no user-visible event, no error, and the +/// poll loop keeps running. +#[tokio::test] +async fn noop_packet_is_ignored_polling() { + let open = mock::open_packet_no_upgrade(); + let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; + assert_eq!( + client.next_ok().timeout().await, + EioEvent::Connect(open.sid) + ); + + let (event, _) = tokio::join!(client.next_ok().timeout(), async { + server.next_http().await.respond_packets([Packet::Noop]); + // The client must keep polling after the noop... + let poll = server.next_http().await; + // ...and the noop must not have surfaced anything. + poll.respond_packets([Packet::Message("after-noop".into())]); + },); + + assert_eq!( + event, + EioEvent::Message("after-noop".into()), + "the noop must be skipped silently" + ); +} diff --git a/crates/engineioxide-client/tests/it/handshake.rs b/crates/engineioxide-client/tests/it/handshake.rs new file mode 100644 index 00000000..3ffa14e9 --- /dev/null +++ b/crates/engineioxide-client/tests/it/handshake.rs @@ -0,0 +1,198 @@ +//! Handshake tests. +//! +//! Reference behavior (engine.io-client, protocol v4): +//! * The handshake request is a `GET` carrying `EIO=4` and `transport=...` +//! query parameters, and no `sid` (the session does not exist yet). +//! User-provided query parameters must be preserved. +//! * The server answers with an `open` packet: +//! `0{"sid":...,"upgrades":[...],"pingInterval":...,"pingTimeout":...,"maxPayload":...}`. +//! * The `sid` is then attached to every subsequent request. +//! * Anything else — a non-open packet, malformed JSON, an HTTP error status, +//! a network failure, a closed websocket — must surface as a connection +//! error. + +use std::assert_matches; + +use engineioxide_client::{Client, ConnectError, EioEvent, EngineIoClientConfig}; +use engineioxide_core::{Packet, TransportType}; +use http::Method; + +use crate::mock::{ + self, + helpers::{ClientTestExt, FutureTestExt}, +}; + +/// The polling handshake must be a `GET` on the configured path with +/// `EIO=4&transport=polling` and no `sid`. +#[tokio::test] +async fn polling_handshake_request_format() { + let (svc, mut server) = mock::mock(); + let config = EngineIoClientConfig::builder() + .uri("http://example.com/engine.io") + .transports([TransportType::Polling]) + .build(); + + let (client, _) = tokio::join!(Client::connect(svc, config).timeout(), async { + let call = server.next_http().await; + assert_eq!(call.method, Method::GET); + assert_eq!(call.uri.path(), "/engine.io"); + assert_eq!(call.query("EIO"), Some("4")); + assert_eq!(call.query("transport"), Some("polling")); + assert_eq!(call.query("sid"), None, "no sid before the session exists"); + assert!(call.body.is_empty(), "the handshake GET has no body"); + call.respond_open(&mock::open_packet()); + },); + let client = client.expect("handshake should succeed"); + assert_eq!(client.transport(), TransportType::Polling); +} + +/// The websocket handshake must carry `EIO=4&transport=websocket` and no +/// `sid` when connecting directly (no polling session to upgrade). +#[tokio::test] +async fn ws_handshake_request_format() { + let (svc, mut server) = mock::mock(); + let config = EngineIoClientConfig::builder() + .uri("http://example.com/engine.io") + .transports([TransportType::Websocket]) + .build(); + + let (client, _ws) = tokio::join!(Client::connect(svc, config).timeout(), async { + let call = server.next_ws().await; + assert_eq!(call.req.uri().path(), "/engine.io"); + assert_eq!(call.query("EIO"), Some("4")); + assert_eq!(call.query("transport"), Some("websocket")); + assert_eq!(call.query("sid"), None, "no sid before the session exists"); + let ws = call.accept(); + ws.send_packet(Packet::Open(mock::open_packet())); + ws + },); + let client = client.expect("handshake should succeed"); + assert_eq!(client.transport(), TransportType::Websocket); +} + +/// Once the open packet is received, its `sid` must be exposed and attached +/// to every subsequent request. +#[tokio::test] +async fn handshake_sid_is_attached_to_subsequent_requests() { + let open = mock::open_packet(); + let sid = open.sid.to_string(); + let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; + + assert_eq!(client.sid(), open.sid); + assert_eq!(client.next_ok().await, EioEvent::Connect(open.sid)); + + let (event, _) = tokio::join!(client.next_ok().timeout(), async { + let call = server.next_http().await; + assert_eq!(call.method, Method::GET); + assert_eq!(call.query("sid").map(str::to_owned), Some(sid)); + assert_eq!(call.query("EIO"), Some("4")); + assert_eq!(call.query("transport"), Some("polling")); + call.respond_packets([Packet::Message("hi".into())]); + },); + assert_eq!(event, EioEvent::Message("hi".into())); +} + +/// Query parameters provided in the configured uri must be preserved +/// alongside the mandatory engine.io parameters. +#[tokio::test] +async fn handshake_preserves_custom_query_params() { + let (svc, mut server) = mock::mock(); + let config = EngineIoClientConfig::builder() + .uri("http://example.com/engine.io?token=s3cret") + .transports([TransportType::Polling]) + .build(); + + let (client, _) = tokio::join!(Client::connect(svc, config).timeout(), async { + let call = server.next_http().await; + assert_eq!(call.query("token"), Some("s3cret")); + assert_eq!(call.query("EIO"), Some("4")); + assert_eq!(call.query("transport"), Some("polling")); + call.respond_open(&mock::open_packet()); + },); + client.expect("handshake should succeed"); + //TODO: not finished +} + +/// Run a polling connect against a scripted handshake answer and require it +/// to fail. +async fn polling_connect_must_fail(answer: impl FnOnce(mock::HttpCall)) { + let (svc, mut server) = mock::mock(); + + let (res, _) = tokio::join!( + Client::connect(svc, [TransportType::Polling]).timeout(), + async { answer(server.next_http().await) }, + ); + assert_matches!(res, Err(ConnectError::Client(_))); +} + +/// Run a websocket connect against a scripted handshake answer and require +/// it to fail. +async fn ws_connect_must_fail(answer: impl FnOnce(mock::WsCall)) { + let (svc, mut server) = mock::mock(); + let (res, _) = tokio::join!( + Client::connect(svc, [TransportType::Websocket]).timeout(), + async { answer(server.next_ws().await) }, + ); + assert_matches!(res, Err(ConnectError::Client(_))); +} + +/// A first packet that is not an `open` packet is a protocol violation. +#[tokio::test] +async fn handshake_rejects_non_open_packet() { + polling_connect_must_fail(|call| call.respond(200, "4hello")).await; +} + +/// An open packet with a malformed JSON payload must be rejected. +#[tokio::test] +async fn handshake_rejects_malformed_open_packet() { + polling_connect_must_fail(|call| call.respond(200, "0{\"sid\":")).await; +} + +/// An HTTP error status on the handshake (e.g. the server refuses the +/// transport) must be surfaced as a connection error. +#[tokio::test] +async fn handshake_rejects_http_error_status() { + polling_connect_must_fail(|call| { + call.respond(400, "{\"code\":0,\"message\":\"Transport unknown\"}") + }) + .await; +} + +/// An empty 500 response must be surfaced as a connection error. +#[tokio::test] +async fn handshake_rejects_server_error_status() { + polling_connect_must_fail(|call| call.respond(500, "")).await; +} + +/// A network-level failure during the handshake must be surfaced. +#[tokio::test] +async fn handshake_network_error() { + polling_connect_must_fail(|call| call.fail("connection refused")).await; +} + +/// Over websocket, a first packet that is not an `open` packet is a protocol +/// violation. +#[tokio::test] +async fn ws_handshake_rejects_non_open_packet() { + ws_connect_must_fail(|call| { + let ws = call.accept(); + ws.send_text("4hello"); + }) + .await; +} + +/// A websocket closed before sending the open packet must surface an error. +#[tokio::test] +async fn ws_handshake_rejects_immediate_close() { + ws_connect_must_fail(|call| { + let ws = call.accept(); + ws.close(); + }) + .await; +} + +/// A refused websocket connection must surface an error. +#[tokio::test] +async fn ws_handshake_rejects_refused_connection() { + ws_connect_must_fail(|call| call.reject("connection refused")).await; +} diff --git a/crates/engineioxide-client/tests/it/heartbeat.rs b/crates/engineioxide-client/tests/it/heartbeat.rs new file mode 100644 index 00000000..8b47ce87 --- /dev/null +++ b/crates/engineioxide-client/tests/it/heartbeat.rs @@ -0,0 +1,311 @@ +//! Heartbeat mechanism tests. +//! +//! In the engine.io v4 protocol the *server* drives the heartbeat: every +//! `ping_interval` it sends a `Packet::Ping` and expects a `Packet::Pong` back +//! within `ping_timeout`, otherwise it closes the socket with +//! [`DisconnectReason::HeartbeatTimeout`]. +//! +//! The pong is emitted transparently by `Client::poll_next`: a `Ping` is +//! intercepted, a `Pong` is sent and the `Ping` is never surfaced to the user. + +use std::time::{Duration, Instant}; + +use engineioxide::{ + TransportType, + config::{EngineIoConfig, EngineIoConfigBuilder}, +}; +use engineioxide_client::{Client, EioEvent, EngineIoClientConfig}; +use engineioxide_core::{OpenPacket, Packet}; +use futures_util::{SinkExt, StreamExt}; + +use crate::mock::{ + self, + fixture::{Event, service_with_config}, + helpers::{ClientTestExt, FutureTestExt}, +}; + +const PING_INTERVAL: Duration = Duration::from_millis(150); +const PING_TIMEOUT: Duration = Duration::from_millis(100); + +fn config() -> EngineIoConfig { + EngineIoConfigBuilder::new() + .ping_interval(PING_INTERVAL) + .ping_timeout(PING_TIMEOUT) + .build() +} + +/// A [`Client`] that is continuously polled must auto-respond to the server's +/// `Ping`s with `Pong`s, keeping the connection alive across several ping +/// cycles. The connection must also still be usable afterwards. +#[tokio::test] +async fn heartbeat_keeps_connection_alive_polling() { + let (svc, mut rx) = service_with_config(config()); + let config = EngineIoClientConfig::builder() + .transports([TransportType::Polling]) + .build(); + let mut client = Client::connect(svc, config).await.unwrap(); + let sid = client.sid(); + assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); + assert_eq!( + client.next().await.unwrap().unwrap(), + EioEvent::Connect(sid) + ); + + // Drive the client for several ping cycles. Any server event during this + // window can only be a disconnect (we send nothing), which would mean the + // heartbeat failed. + let window = PING_INTERVAL * 5; + let deadline = tokio::time::sleep(window); + tokio::pin!(deadline); + tokio::select! { + _ = &mut deadline => (), + ev = rx.recv() => panic!("unexpected server event during heartbeat: {ev:?}"), + // Polling the stream is what lets the client receive `Ping`s and + // emit `Pong`s. `Ping` is consumed internally and never yielded, + // so this branch effectively never resolves. + packet = client.next() => match packet { + Some(Ok(p)) => panic!("unexpected packet during heartbeat: {p:?}"), + Some(Err(e)) => panic!("client stream error during heartbeat: {e:?}"), + None => panic!("client stream ended unexpectedly"), + }, + } + + // The connection survived several ping cycles: prove it is still healthy + // by round-tripping a message (the handler echoes it back). + client.send(EioEvent::Message("hb".into())).await.unwrap(); + assert_eq!( + rx.recv().await.unwrap(), + Event::Message(sid, "hb".into()), + "server should still receive messages after the heartbeat window", + ); + match client.next().await { + Some(Ok(EioEvent::Message(msg))) => { + assert_eq!(msg, "hb") + } + // Ignore any other packet (should not happen, `Ping` is internal). + Some(Ok(p)) => panic!("unexpected packet: {p:?}"), + Some(Err(e)) => panic!("client stream error: {e:?}"), + None => panic!("client stream ended before echo"), + } +} + +/// Wire-level pong check over websocket: a server `2` (ping) frame must be +/// answered with a `3` (pong) frame, and the ping must never surface as a +/// user-visible event. +#[tokio::test] +async fn ping_answered_with_pong_ws() { + let open = mock::open_packet_no_upgrade(); + let (mut client, _server, mut ws) = mock::connect_ws(&open).await; + assert_eq!( + client.next_ok().timeout().await, + EioEvent::Connect(open.sid) + ); + + tokio::select! { + // Polling the stream is what drives the ping/pong exchange; the ping + // is internal so this branch must never resolve. + ev = client.next() => panic!("the ping must not surface: {ev:?}"), + _ = async { + for _ in 0..3 { + ws.send_packet(Packet::Ping); + assert_eq!(ws.recv_packet().await, Packet::Pong); + } + } => (), + } +} + +/// Wire-level pong check over polling: a `2` (ping) packet received on a GET +/// must be answered with a `3` (pong) POST, and never surface as an event. +#[tokio::test] +async fn ping_answered_with_pong_polling() { + let open = mock::open_packet_no_upgrade(); + let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; + assert_eq!( + client.next_ok().timeout().await, + EioEvent::Connect(open.sid) + ); + + tokio::select! { + ev = client.next() => panic!("the ping must not surface: {ev:?}"), + _ = async { + let poll = server.next_http().await; + poll.respond_packets([Packet::Ping]); + let post = server.next_post_parking_get().await; + assert_eq!(post.packets(), vec![Packet::Pong], "the ping must be answered with a pong"); + post.respond_ok(); + } => (), + } +} + +/// Reference client behavior: after the handshake the client arms a liveness +/// timer of `pingInterval + pingTimeout`; if no ping arrives in that window +/// it closes itself (official close reason: "ping timeout"). +/// +/// Here the server never pings: the client must terminate its stream within +/// the budget instead of waiting forever. +#[tokio::test] +async fn ping_timeout_closes_the_socket_polling() { + let open = OpenPacket { + ping_interval: PING_INTERVAL, + ping_timeout: PING_TIMEOUT, + ..mock::open_packet_no_upgrade() + }; + let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; + let started = Instant::now(); + assert_eq!( + client.next_ok().timeout().await, + EioEvent::Connect(open.sid) + ); + + let drive = async { + client.next_err().timeout().await; //TODO: equal + client.next_close().timeout().await; + }; + // The server stays silent: polls are held forever, no ping is ever sent. + let park = async { + loop { + match server.next_call().await { + mock::ServerCall::Http(c) => c.park(), + mock::ServerCall::Ws(c) => c.park(), + } + } + }; + async { + tokio::select! { _ = drive => (), _ = park => unreachable!() } + } + .timeout_with(PING_INTERVAL + PING_TIMEOUT) + .await; + + let elapsed = started.elapsed(); + assert!( + elapsed >= Duration::from_millis(150), + "client closed before the pingInterval + pingTimeout budget: {elapsed:?}" + ); +} + +/// Same liveness requirement over websocket. +#[tokio::test] +async fn ping_timeout_closes_the_socket_ws() { + let open = OpenPacket { + ping_interval: PING_INTERVAL, + ping_timeout: PING_TIMEOUT, + ..mock::open_packet_no_upgrade() + }; + // `ws` must stay alive: dropping it would close the stream and make the + // test pass without exercising the client-side timer. + let (mut client, _server, ws) = mock::connect_ws(&open).await; + let started = Instant::now(); + assert_eq!( + client.next_ok().timeout().await, + EioEvent::Connect(open.sid) + ); + + async { + client.next_err().timeout().await; //TODO: equal + client.next_close().timeout().await; + } + .timeout_with(PING_INTERVAL + PING_TIMEOUT) + .await; + + let elapsed = started.elapsed(); + assert!( + elapsed >= Duration::from_millis(150), + "client closed before the pingInterval + pingTimeout budget: {elapsed:?}" + ); + drop(ws); +} + +/// The liveness timer must be re-armed by every received ping: as long as +/// the server keeps pinging (even past the initial budget), the client stays +/// alive; once pings stop, it must close within the budget. +#[tokio::test] +async fn ping_timeout_is_reset_by_each_ping() { + let open = OpenPacket { + ping_interval: Duration::from_millis(150), + ping_timeout: PING_TIMEOUT, + ..mock::open_packet_no_upgrade() + }; + let (mut client, _server, mut ws) = mock::connect_ws(&open).await; + assert_eq!( + client.next_ok().timeout().await, + EioEvent::Connect(open.sid) + ); + + // 4 pings every 120ms: t=480ms, well past the 250ms budget. The client + // must still be alive because each ping re-arms the timer. + tokio::select! { + ev = client.next() => panic!("client must stay alive while pings keep coming: {ev:?}"), + _ = async { + for _ in 0..4 { + tokio::time::sleep(Duration::from_millis(120)).await; + ws.send_packet(Packet::Ping); + assert_eq!(ws.recv_packet().await, Packet::Pong); + } + } => (), + } + + // Now the server goes silent: the client must close within the budget. + async { + client.next_err().timeout().await; //TODO: equal + client.next_close().timeout().await; + } + .timeout_with(PING_INTERVAL + PING_TIMEOUT) + .await; +} + +/// A [`Client`] that is continuously polled must auto-respond to the server's +/// `Ping`s with `Pong`s, keeping the connection alive across several ping +/// cycles. The connection must also still be usable afterwards. +#[tokio::test] +async fn heartbeat_keeps_connection_alive_websocket() { + let (svc, mut rx) = service_with_config(config()); + + let config = EngineIoClientConfig::builder() + .transports([TransportType::Websocket]) + .build(); + + let mut client = Client::connect(svc, config).await.unwrap(); + let sid = client.sid(); + assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); + assert_eq!( + client.next().await.unwrap().unwrap(), + EioEvent::Connect(sid) + ); + + // Drive the client for several ping cycles. Any server event during this + // window can only be a disconnect (we send nothing), which would mean the + // heartbeat failed. + let window = PING_INTERVAL * 5; + let deadline = tokio::time::sleep(window); + tokio::pin!(deadline); + tokio::select! { + _ = &mut deadline => (), + ev = rx.recv() => panic!("unexpected server event during heartbeat: {ev:?}"), + // Polling the stream is what lets the client receive `Ping`s and + // emit `Pong`s. `Ping` is consumed internally and never yielded, + // so this branch effectively never resolves. + packet = client.next() => match packet { + Some(Ok(p)) => panic!("unexpected packet during heartbeat: {p:?}"), + Some(Err(e)) => panic!("client stream error during heartbeat: {e:?}"), + None => panic!("client stream ended unexpectedly"), + }, + } + + // The connection survived several ping cycles: prove it is still healthy + // by round-tripping a message (the handler echoes it back). + client.send(EioEvent::Message("hb".into())).await.unwrap(); + assert_eq!( + rx.recv().await.unwrap(), + Event::Message(sid, "hb".into()), + "server should still receive messages after the heartbeat window", + ); + match client.next().await { + Some(Ok(EioEvent::Message(msg))) => { + assert_eq!(msg, "hb") + } + // Ignore any other packet (should not happen, `Ping` is internal). + Some(Ok(p)) => panic!("unexpected packet: {p:?}"), + Some(Err(e)) => panic!("client stream error: {e:?}"), + None => panic!("client stream ended before echo"), + } +} diff --git a/crates/engineioxide-client/tests/it/main.rs b/crates/engineioxide-client/tests/it/main.rs new file mode 100644 index 00000000..6dbee178 --- /dev/null +++ b/crates/engineioxide-client/tests/it/main.rs @@ -0,0 +1,10 @@ +mod mock; + +mod close; +mod errors; +mod handshake; +mod heartbeat; +mod payload; +mod upgrade; + +const fn main() {} diff --git a/crates/engineioxide-client/tests/it/mock/fixture.rs b/crates/engineioxide-client/tests/it/mock/fixture.rs new file mode 100644 index 00000000..04aad6b3 --- /dev/null +++ b/crates/engineioxide-client/tests/it/mock/fixture.rs @@ -0,0 +1,113 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use bytes::Bytes; +use engineioxide::config::EngineIoConfig; +use engineioxide::handler::EngineIoHandler; +use engineioxide::service::EngineIoService; +use engineioxide::{DisconnectReason, Socket}; +use engineioxide_client::flavors::testing::TestingFlavor; +use engineioxide_core::{Sid, Str}; +use tokio::sync::mpsc; +use tracing_subscriber::EnvFilter; + +/// Handle over the sockets currently connected to the test server, letting +/// tests drive server-side actions (e.g. closing a session). +pub type SocketRegistry = Arc>>>>; + +#[derive(Debug, PartialEq, Eq)] +pub enum Event { + Connect(Sid), + Disconnect(Sid, DisconnectReason), + Message(Sid, Str), + Binary(Sid, Bytes), +} + +#[derive(Debug)] +pub struct EchoHandler { + tx: mpsc::UnboundedSender, + sockets: SocketRegistry, +} + +impl EchoHandler { + fn new() -> (Self, mpsc::UnboundedReceiver, SocketRegistry) { + let (tx, rx) = mpsc::unbounded_channel(); + let sockets = SocketRegistry::default(); + ( + Self { + tx, + sockets: sockets.clone(), + }, + rx, + sockets, + ) + } +} + +fn init_tracing() { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .try_init() + .ok(); +} + +pub fn service_with_config( + config: EngineIoConfig, +) -> ( + TestingFlavor>, + mpsc::UnboundedReceiver, +) { + let (svc, rx, _) = service_with_registry(config); + (svc, rx) +} + +/// Same as [`service_with_config`] but also returns the [`SocketRegistry`] +/// so tests can act on server-side sockets (e.g. close them). +pub fn service_with_registry( + config: EngineIoConfig, +) -> ( + TestingFlavor>, + mpsc::UnboundedReceiver, + SocketRegistry, +) { + init_tracing(); + let (handler, rx, sockets) = EchoHandler::new(); + let svc = EngineIoService::with_config(Arc::new(handler), config); + (svc.into(), rx, sockets) +} + +pub fn service() -> ( + TestingFlavor>, + mpsc::UnboundedReceiver, +) { + service_with_config(EngineIoConfig::default()) +} + +impl EngineIoHandler for EchoHandler { + type Data = (); + fn on_connect(self: Arc, socket: Arc>) { + self.sockets + .lock() + .unwrap() + .insert(socket.id, socket.clone()); + self.tx.send(Event::Connect(socket.id)).unwrap(); + } + + fn on_disconnect(&self, socket: Arc>, reason: DisconnectReason) { + self.tx.send(Event::Disconnect(socket.id, reason)).unwrap(); + } + + fn on_message(self: &Arc, msg: Str, socket: Arc>) { + self.tx + .send(Event::Message(socket.id, msg.clone())) + .unwrap(); + socket.emit(msg).unwrap(); + } + + fn on_binary(self: &Arc, data: Bytes, socket: Arc>) { + self.tx + .send(Event::Binary(socket.id, data.clone())) + .unwrap(); + socket.emit_binary(data).unwrap(); + } +} diff --git a/crates/engineioxide-client/tests/it/mock/helpers.rs b/crates/engineioxide-client/tests/it/mock/helpers.rs new file mode 100644 index 00000000..0458d80d --- /dev/null +++ b/crates/engineioxide-client/tests/it/mock/helpers.rs @@ -0,0 +1,194 @@ +//! Async helpers for the mock engine.io server. + +use std::{ + convert::Infallible, + fmt, + marker::PhantomData, + panic::Location, + pin::Pin, + task::{Context, Poll, ready}, + time::Duration, +}; + +use futures_core::{Stream, future::BoxFuture}; +use futures_util::{FutureExt, StreamExt}; +use pin_project_lite::pin_project; +use tokio::{sync::mpsc, time}; + +/// Default deadline for every await in the tests: an enforcement test must +/// fail fast instead of hanging the whole suite. +pub const DEADLINE: Duration = Duration::from_secs(5); + +pub trait FutureTestExt: Future + Sized { + fn timeout(self) -> TimeoutFut; + fn timeout_with(self, duration: Duration) -> TimeoutFut; +} + +pin_project! { + pub struct TimeoutFut { + #[pin] + inner: time::Timeout, + caller: &'static Location<'static>, + deadline: Duration, + } +} + +impl TimeoutFut { + fn new(fut: F, deadline: Duration, caller: &'static Location<'static>) -> Self { + Self { + caller, + deadline, + inner: time::timeout(deadline, fut), + } + } +} +impl Future for TimeoutFut { + type Output = F::Output; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let proj = self.project(); + match ready!(proj.inner.poll(cx)) { + Ok(v) => Poll::Ready(v), + Err(_) => panic!("timed out after {:?} from {}", proj.deadline, proj.caller), + } + } +} +impl FutureTestExt for F { + #[track_caller] + fn timeout(self) -> TimeoutFut { + TimeoutFut::new(self, DEADLINE, Location::caller()) + } + + #[track_caller] + fn timeout_with(self, deadline: Duration) -> TimeoutFut { + TimeoutFut::new(self, deadline, Location::caller()) + } +} +mod via { + pub enum Stream {} + pub enum Concrete {} +} +pin_project! { + pub struct NextFut { + #[pin] + inner: TimeoutFut, + _phantom: PhantomData + } +} +impl NextFut { + #[track_caller] + fn new(fut: F) -> Self { + Self { + inner: fut.timeout(), + _phantom: PhantomData, + } + } +} + +mod private { + pub enum NextOk {} + pub enum NextErr {} + pub enum NextClose {} +} +impl>>, T: fmt::Debug, E: fmt::Debug> Future + for NextFut +{ + type Output = T; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let proj = self.project(); + let caller = proj.inner.caller; + match ready!(proj.inner.poll(cx)) { + Some(Ok(v)) => Poll::Ready(v), + Some(Err(v)) => { + panic!("expected a value from stream, got an error: {v:?} from {caller}") + } + None => panic!("called next_ok on a closed stream from: {caller}",), + } + } +} +impl>>, T: fmt::Debug, E: fmt::Debug> Future + for NextFut +{ + type Output = E; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let proj = self.project(); + let caller = proj.inner.caller; + match ready!(proj.inner.poll(cx)) { + Some(Err(v)) => Poll::Ready(v), + Some(Ok(v)) => { + panic!("expected an error from stream, got a value: {v:?} from {caller}") + } + None => panic!("called next_err on a closed stream from: {caller}",), + } + } +} +impl>>, T: fmt::Debug, E: fmt::Debug> Future + for NextFut +{ + type Output = (); + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let proj = self.project(); + let caller = proj.inner.caller; + match ready!(proj.inner.poll(cx)) { + Some(v) => panic!("expected a closed stream, got an item: {v:?} from: {caller}"), + None => Poll::Ready(()), + } + } +} + +pub trait ClientTestExt { + type Fut<'a>: Future + 'a + where + Self: 'a; + fn next_ok(&mut self) -> NextFut, private::NextOk>; + fn next_err(&mut self) -> NextFut, private::NextErr>; + fn next_close(&mut self) -> NextFut, private::NextClose>; + + fn _phantom() -> PhantomData { + PhantomData + } +} + +impl> + Unpin + 'static, T: fmt::Debug, E: fmt::Debug> + ClientTestExt for S +{ + type Fut<'a> = futures_util::stream::Next<'a, S>; + + #[track_caller] + fn next_ok(&mut self) -> NextFut, private::NextOk> { + NextFut::new(self.next()) + } + + #[track_caller] + fn next_err(&mut self) -> NextFut, private::NextErr> { + NextFut::new(self.next()) + } + + #[track_caller] + fn next_close(&mut self) -> NextFut, private::NextClose> { + NextFut::new(self.next()) + } +} +impl ClientTestExt + for mpsc::UnboundedReceiver +{ + type Fut<'a> = BoxFuture<'a, Option>>; + + #[track_caller] + fn next_ok(&mut self) -> NextFut, private::NextOk> { + NextFut::new(async { self.recv().await.map(Ok) }.boxed()) + } + + #[track_caller] + fn next_err(&mut self) -> NextFut, private::NextErr> { + NextFut::new(async { self.recv().await.map(Ok) }.boxed()) + } + + #[track_caller] + fn next_close(&mut self) -> NextFut, private::NextClose> { + NextFut::new(async { self.recv().await.map(Ok) }.boxed()) + } +} diff --git a/crates/engineioxide-client/tests/it/mock/mod.rs b/crates/engineioxide-client/tests/it/mock/mod.rs new file mode 100644 index 00000000..ca5fd992 --- /dev/null +++ b/crates/engineioxide-client/tests/it/mock/mod.rs @@ -0,0 +1,470 @@ +//! A scripted mock engine.io server. + +use std::{ + convert::Infallible, + fmt, + pin::Pin, + task::{Context, Poll}, + time::Duration, +}; + +use bytes::Bytes; +use engineioxide_client::{ + Client, + transport::{WebSocket, ws::WsMessage}, +}; +use engineioxide_core::{OpenPacket, Packet, ProtocolVersion, Sid, TransportType}; +use futures_core::{Stream, future::BoxFuture}; +use futures_util::{FutureExt, Sink}; +use http::{Method, Request, Response, Uri}; +use http_body_util::{BodyExt, Full, combinators::BoxBody}; +use hyper::service::Service as HyperSvc; +use tokio::sync::{mpsc, oneshot}; +use tracing_subscriber::EnvFilter; + +use crate::mock::helpers::FutureTestExt; + +pub mod fixture; +pub mod helpers; + +/// The engine.io v4 payload record separator. +pub const SEP: char = '\x1e'; + +#[derive(Debug, Clone)] +pub struct MockError(pub String); +impl fmt::Display for MockError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "mock error: {}", self.0) + } +} +impl std::error::Error for MockError {} + +/// Create a connected `(client service, scripted server)` pair. +pub fn mock() -> (MockSvc, MockServer) { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .try_init() + .ok(); + let (tx, rx) = mpsc::unbounded_channel(); + (MockSvc { tx }, MockServer { rx }) +} + +/// The transport service handed to [`Client`]. Every request it receives is +/// forwarded to the paired [`MockServer`] for the test to answer. +#[derive(Debug, Clone)] +pub struct MockSvc { + tx: mpsc::UnboundedSender, +} + +/// HTTP (polling) side: satisfies `PollingSvc`. +impl HyperSvc>> for MockSvc { + type Response = Response>; + type Error = MockError; + type Future = BoxFuture<'static, Result>; + + fn call(&self, req: Request>) -> Self::Future { + let calls = self.tx.clone(); + // The request is surfaced to the test only once the client actually + // polls the future, mirroring "the request was sent on the wire". + async move { + let (parts, body) = req.into_parts(); + let body = body.collect().await.unwrap().to_bytes(); + let (respond, rx) = oneshot::channel(); + let call = HttpCall { + method: parts.method, + uri: parts.uri, + body, + respond, + }; + calls + .send(ServerCall::Http(call)) + .map_err(|_| MockError("mock server dropped".into()))?; + rx.await + .map_err(|_| MockError("mock server dropped the response".into()))? + } + .boxed() + } +} + +/// Websocket side: satisfies `WsSvc`. +impl HyperSvc> for MockSvc { + type Response = MockWs; + type Error = MockError; + type Future = BoxFuture<'static, Result>; + + fn call(&self, req: Request<()>) -> Self::Future { + let calls = self.tx.clone(); + async move { + let (respond, rx) = oneshot::channel(); + calls + .send(ServerCall::Ws(WsCall { req, respond })) + .map_err(|_| MockError("mock server dropped".into()))?; + rx.await + .map_err(|_| MockError("mock server dropped the connection".into()))? + } + .boxed() + } +} + +/// A channel-backed [`WebSocket`] implementation handed to the client when +/// the test accepts a websocket connection. +pub struct MockWs { + to_server: mpsc::UnboundedSender, + from_server: mpsc::UnboundedReceiver>, + closed: bool, +} + +impl WebSocket for MockWs { + type Error = MockError; +} + +impl Stream for MockWs { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.from_server.poll_recv(cx) + } +} + +impl Sink for MockWs { + type Error = MockError; + + fn poll_ready(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + if self.closed { + Poll::Ready(Err(MockError("websocket already closed".into()))) + } else { + Poll::Ready(Ok(())) + } + } + + fn start_send(self: Pin<&mut Self>, item: WsMessage) -> Result<(), Self::Error> { + if self.closed { + return Err(MockError("websocket already closed".into())); + } + self.to_server + .send(item) + .map_err(|_| MockError("server closed the connection".into())) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_close( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + if !self.closed { + // Emulate the websocket close handshake: the peer observes a + // close frame. + let _ = self.to_server.send(WsMessage::Close); + self.closed = true; + } + Poll::Ready(Ok(())) + } +} + +/// A request the client issued, waiting for the test to decide its fate. +pub enum ServerCall { + Http(HttpCall), + Ws(WsCall), +} + +impl ServerCall { + pub fn describe(&self) -> String { + match self { + ServerCall::Http(c) => format!("http {} {}", c.method, c.uri), + ServerCall::Ws(c) => format!("ws connect {}", c.req.uri()), + } + } +} + +/// An in-flight HTTP request (polling GET or POST) with its collected body. +pub struct HttpCall { + pub method: Method, + pub uri: Uri, + pub body: Bytes, + respond: oneshot::Sender>, MockError>>, +} + +impl HttpCall { + /// Value of a query parameter, if present. + pub fn query(&self, key: &str) -> Option<&str> { + query_param(&self.uri, key) + } + + /// Decode the request body as engine.io packets (separated by `\x1e`). + pub fn packets(&self) -> Vec { + decode_payload(&self.body) + } + + pub fn respond(self, status: u16, body: impl Into) { + let res = Response::builder() + .status(status) + .body(Full::new(body.into())) + .unwrap(); + // The client may legitimately have dropped the request (e.g. a poll + // abandoned on upgrade), so a send failure is not an error. + let _ = self.respond.send(Ok(res)); + } + + /// Respond 200 with the given packets joined by the record separator. + pub fn respond_packets(self, packets: impl IntoIterator) { + let body = encode_payload(packets); + self.respond(200, body); + } + + /// Respond 200 with an open packet (handshake). + pub fn respond_open(self, open: &OpenPacket) { + self.respond_packets([Packet::Open(open.clone())]); + } + + /// Respond 200 "ok", as the reference server does for POST writes. + pub fn respond_ok(self) { + self.respond(200, "ok"); + } + + /// Make the request fail with a transport-level (network) error. + pub fn fail(self, msg: &str) { + let _ = self.respond.send(Err(MockError(msg.into()))); + } + + /// Never answer: the request stays in flight forever (e.g. a held poll). + pub fn park(self) { + std::mem::forget(self.respond); + } +} + +/// An in-flight websocket connection request. +pub struct WsCall { + pub req: Request<()>, + respond: oneshot::Sender>, +} + +impl WsCall { + pub fn query(&self, key: &str) -> Option<&str> { + query_param(self.req.uri(), key) + } + + /// Accept the connection and return the server-side handle. + pub fn accept(self) -> ServerWs { + let (to_client, from_server) = mpsc::unbounded_channel(); + let (to_server, from_client) = mpsc::unbounded_channel(); + let ws = MockWs { + to_server, + from_server, + closed: false, + }; + let _ = self.respond.send(Ok(ws)); + ServerWs { + tx: to_client, + rx: from_client, + } + } + + /// Refuse the connection with an error. + pub fn reject(self, msg: &str) { + let _ = self.respond.send(Err(MockError(msg.into()))); + } + + /// Never answer the connection request. + pub fn park(self) { + std::mem::forget(self.respond); + } +} + +/// Server-side handle over an accepted mock websocket. +pub struct ServerWs { + tx: mpsc::UnboundedSender>, + rx: mpsc::UnboundedReceiver, +} + +impl ServerWs { + pub fn send_packet(&self, packet: Packet) { + match packet { + Packet::Binary(data) => self.send_message(WsMessage::Binary(data)), + p => self.send_text(String::from(p)), + } + } + + pub fn send_text(&self, text: impl Into) { + self.send_message(WsMessage::Text(text.into().into())); + } + + pub fn send_message(&self, msg: WsMessage) { + self.tx.send(Ok(msg)).expect("client dropped the websocket"); + } + + /// Surface a websocket-level error to the client. + pub fn send_error(&self, msg: &str) { + self.tx + .send(Err(MockError(msg.into()))) + .expect("client dropped the websocket"); + } + + /// Next raw message sent by the client, `None` once the client closed. + pub async fn recv(&mut self) -> Option { + self.rx.recv().timeout().await + } + + /// Next client message decoded as a packet (binary frames are messages). + pub async fn recv_packet(&mut self) -> Packet { + match self.recv().await { + Some(WsMessage::Text(text)) => { + Packet::parse(ProtocolVersion::V4, text).expect("client sent an invalid packet") + } + Some(WsMessage::Binary(data)) => Packet::Binary(data), + Some(WsMessage::Close) => panic!("expected a packet, got a websocket close frame"), + None => panic!("expected a packet, but the client closed the websocket"), + } + } + + /// Close the connection from the server side (client stream terminates). + pub fn close(self) {} +} + +/// Test-side handle receiving every request made by the client. +pub struct MockServer { + rx: mpsc::UnboundedReceiver, +} + +impl MockServer { + pub async fn next_call(&mut self) -> ServerCall { + self.rx + .recv() + .timeout() + .await + .expect("client service dropped, no more requests will come") + } + + pub async fn next_http(&mut self) -> HttpCall { + match self.next_call().await { + ServerCall::Http(call) => call, + ServerCall::Ws(call) => { + panic!("expected an http request, got a ws connect: {:?}", call.req) + } + } + } + + pub async fn next_ws(&mut self) -> WsCall { + match self.next_call().await { + ServerCall::Ws(call) => call, + ServerCall::Http(call) => panic!( + "expected a ws connect, got an http request: {} {}", + call.method, call.uri + ), + } + } + + /// Wait for a ws connect, leaving any interleaved polling request parked + /// (the reference client keeps a poll in flight while probing). + pub async fn next_ws_parking_http(&mut self) -> WsCall { + loop { + match self.next_call().await { + ServerCall::Ws(call) => return call, + ServerCall::Http(call) => call.park(), + } + } + } + + /// Wait for a POST write; polling GETs received meanwhile are parked. + pub async fn next_post_parking_get(&mut self) -> HttpCall { + loop { + let call = self.next_http().await; + if call.method == Method::POST { + return call; + } + call.park() + } + } + + /// Assert the client stays silent (no http request, no ws connect) for + /// the whole window. + pub async fn assert_no_call(&mut self, window: Duration, what: &str) { + match tokio::time::timeout(window, self.rx.recv()).await { + Err(_) => (), // silence: all good + Ok(None) => (), // client service dropped: silent forever + Ok(Some(call)) => panic!( + "unexpected client request while {what}: {}", + call.describe() + ), + } + } +} + +/// An open packet advertising a websocket upgrade. +pub fn open_packet() -> OpenPacket { + OpenPacket { + sid: Sid::new(), + upgrades: [TransportType::Websocket].into_iter().collect(), + ping_interval: Duration::from_millis(25000), + ping_timeout: Duration::from_millis(20000), + max_payload: 100_000, + } +} + +/// An open packet advertising no upgrade at all. +pub fn open_packet_no_upgrade() -> OpenPacket { + OpenPacket { + upgrades: std::iter::empty().collect(), + ..open_packet() + } +} + +/// Connect a client over polling against the mock, answering the handshake +/// with `open`. Returns the connected client and the scripted server. +pub async fn connect_polling( + open: &OpenPacket, + transports: [TransportType; N], +) -> (Client, MockServer) { + let (svc, mut server) = mock(); + let (client, _) = tokio::join!(Client::connect(svc, transports).timeout(), async { + let call = server.next_http().await; + assert_eq!(call.method, Method::GET, "handshake must be a GET"); + call.respond_open(open); + }); + (client.expect("handshake should succeed"), server) +} + +/// Connect a client directly over websocket against the mock. +pub async fn connect_ws(open: &OpenPacket) -> (Client, MockServer, ServerWs) { + let (svc, mut server) = mock(); + let client = Client::connect(svc, [TransportType::Websocket]).timeout(); + let (client, ws) = tokio::join!(client, async { + let ws = server.next_ws().await.accept(); + ws.send_packet(Packet::Open(open.clone())); + ws + }); + (client.expect("handshake should succeed"), server, ws) +} + +/// Split a `\x1e`-separated payload into packets. +pub fn decode_payload(body: &[u8]) -> Vec { + let body = std::str::from_utf8(body).expect("payload should be valid utf8"); + if body.is_empty() { + return Vec::new(); + } + body.split(SEP) + .map(|part| { + Packet::parse(ProtocolVersion::V4, part.to_owned()) + .expect("payload part should be a valid packet") + }) + .collect() +} + +/// Join packets with the `\x1e` record separator. +pub fn encode_payload(packets: impl IntoIterator) -> String { + packets + .into_iter() + .map(String::from) + .collect::>() + .join("\x1e") +} + +fn query_param<'a>(uri: &'a Uri, key: &str) -> Option<&'a str> { + uri.query()? + .split('&') + .filter_map(|pair| pair.split_once('=')) + .find(|(k, _)| *k == key) + .map(|(_, v)| v) +} diff --git a/crates/engineioxide-client/tests/it/payload.rs b/crates/engineioxide-client/tests/it/payload.rs new file mode 100644 index 00000000..c8f31a00 --- /dev/null +++ b/crates/engineioxide-client/tests/it/payload.rs @@ -0,0 +1,269 @@ +//! Payload encoding and size-limit tests. +//! +//! Reference behavior (engine.io-client, protocol v4): +//! * Polling payloads are packets joined with the `\x1e` record separator; +//! binary packets are base64 with a `b` prefix. Websocket sends binary +//! data as raw binary frames. +//! * `maxPayload` (from the handshake) bounds the size of a multi-packet +//! polling POST: the write buffer is split so each POST stays under the +//! limit. A single packet bigger than the limit is sent anyway and the +//! server rejects it (HTTP 413) — which is a transport error. + +use bytes::Bytes; +use engineioxide::{TransportType, config::EngineIoConfig}; +use engineioxide_client::{Client, EioEvent}; +use engineioxide_core::Packet; +use futures_util::{SinkExt, StreamExt}; + +use crate::mock::{ + self, + fixture::{Event, service, service_with_config}, + helpers::{ClientTestExt, FutureTestExt}, +}; + +/// Several packets received in a single poll response (separated by `\x1e`) +/// must be surfaced in order. +#[tokio::test] +async fn multiple_packets_in_a_single_poll_response() { + let open = mock::open_packet_no_upgrade(); + let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; + assert_eq!( + client.next_ok().timeout().await, + EioEvent::Connect(open.sid) + ); + + let (events, _) = tokio::join!( + async { + let mut events = Vec::new(); + for _ in 0..3 { + events.push(client.next().await.unwrap().unwrap()); + } + events + } + .timeout(), + async { + server.next_http().await.respond_packets([ + Packet::Message("first".into()), + Packet::Message("second".into()), + Packet::Binary(Bytes::from_static(&[1, 2, 3])), + ]); + }, + ); + assert_eq!( + events, + vec![ + EioEvent::Message("first".into()), + EioEvent::Message("second".into()), + EioEvent::Binary(Bytes::from_static(&[1, 2, 3])), + ] + ); +} + +/// Packets queued before a flush must be batched in a single POST, joined +/// with the record separator. +#[tokio::test] +async fn flush_batches_packets_with_record_separator() { + let open = mock::open_packet_no_upgrade(); + let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; + assert_eq!( + client.next_ok().timeout().await, + EioEvent::Connect(open.sid) + ); + + client.feed(EioEvent::Message("a".into())).await.unwrap(); + client.feed(EioEvent::Message("b".into())).await.unwrap(); + client.feed(EioEvent::Message("c".into())).await.unwrap(); + + let (res, _) = tokio::join!(client.flush().timeout(), async { + let post = server.next_post_parking_get().await; + assert_eq!(&post.body[..], b"4a\x1e4b\x1e4c"); + post.respond_ok(); + },); + assert!(res.is_ok()); +} + +/// Binary packets on the polling transport are base64-encoded with a `b` +/// prefix, in both directions. +#[tokio::test] +async fn binary_is_base64_on_polling() { + let open = mock::open_packet_no_upgrade(); + let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; + assert_eq!( + client.next_ok().timeout().await, + EioEvent::Connect(open.sid) + ); + + // Inbound: `bAQID` is [1, 2, 3]. + let (event, _) = tokio::join!(client.next_ok().timeout(), async { + server.next_http().await.respond(200, "bAQID") + },); + assert_eq!(event, EioEvent::Binary(Bytes::from_static(&[1, 2, 3]))); + + // Outbound: [4, 5, 6] must be POSTed as `bBAUG`. + let (res, _) = tokio::join!( + client + .send(EioEvent::Binary(Bytes::from_static(&[4, 5, 6]))) + .timeout(), + async { + let post = server.next_post_parking_get().await; + assert_eq!(&post.body[..], b"bBAUG"); + post.respond_ok(); + }, + ); + assert!(res.is_ok()); +} + +/// A multi-packet flush must be split so that each polling POST stays under +/// the handshake `maxPayload` (reference `_getWritablePackets`). The server +/// enforces the limit with HTTP 413, so all four messages arriving proves +/// the client split the batch. +#[tokio::test] +async fn flush_splits_batches_at_max_payload() { + let config = EngineIoConfig::builder().max_payload(100).build(); + let (svc, mut rx) = service_with_config(config); + + let mut client = Client::connect(svc, [TransportType::Polling]) + .await + .unwrap(); + let sid = client.sid(); + assert_eq!(rx.next_ok().timeout().await, Event::Connect(sid)); + assert_eq!(client.next_ok().timeout().await, EioEvent::Connect(sid)); + + // 4 messages of 30 bytes each: 127 wire bytes in a single payload, + // which must be split into (at least) two POSTs of <= 100 bytes. + let msg = "a".repeat(30); + for _ in 0..4 { + client + .feed(EioEvent::Message(msg.clone().into())) + .await + .unwrap(); + } + client.flush().timeout().await.unwrap(); + + for i in 0..4 { + assert_eq!( + rx.next_ok().timeout().await, + Event::Message(sid, msg.clone().into()), + "message {i} must arrive: batches must be split under maxPayload" + ); + } +} + +/// A single packet over `maxPayload` cannot be split: the reference client +/// sends it anyway, the server rejects it (413) and the failure surfaces as +/// a transport error — never a panic. +#[tokio::test] +async fn oversized_packet_surfaces_a_transport_error() { + let config = EngineIoConfig::builder().max_payload(100).build(); + let (svc, mut rx) = service_with_config(config); + + let mut client = Client::connect(svc, [TransportType::Polling]) + .await + .unwrap(); + let sid = client.sid(); + assert_eq!(rx.next_ok().timeout().await, Event::Connect(sid)); + assert_eq!(client.next_ok().timeout().await, EioEvent::Connect(sid)); + + let res = client + .send(EioEvent::Message("a".repeat(300).into())) + .timeout() + .await; //TODO: correct equality + assert!( + res.is_err(), + "an oversized write rejected by the server must surface an error" + ); +} + +/// An empty message must round-trip unchanged. +#[tokio::test] +async fn empty_message_round_trip() { + let (svc, mut rx) = service(); + let mut client = Client::connect(svc, [TransportType::Polling]) + .await + .unwrap(); + let sid = client.sid(); + assert_eq!(rx.next_ok().timeout().await, Event::Connect(sid)); + assert_eq!(client.next_ok().timeout().await, EioEvent::Connect(sid)); + + client.send(EioEvent::Message("".into())).await.unwrap(); + assert_eq!(rx.next_ok().timeout().await, Event::Message(sid, "".into())); + assert_eq!( + client.next_ok().timeout().await, + EioEvent::Message("".into()) + ); +} + +/// A multibyte utf-8 message must round-trip unchanged on both transports. +#[tokio::test] +async fn utf8_message_round_trip_ws() { + let (svc, mut rx) = service(); + let mut client = Client::connect(svc, [TransportType::Websocket]) + .timeout() + .await + .unwrap(); + let sid = client.sid(); + assert_eq!(rx.next_ok().timeout().await, Event::Connect(sid)); + assert_eq!(client.next_ok().timeout().await, EioEvent::Connect(sid)); + + let text = "héllo 🌍 世界"; + client.send(EioEvent::Message(text.into())).await.unwrap(); + assert_eq!( + rx.next_ok().timeout().await, + Event::Message(sid, text.into()), + ); + assert_eq!( + client.next_ok().timeout().await, + EioEvent::Message(text.into()), + ); +} +/// A multibyte utf-8 message must round-trip unchanged on both transports. +#[tokio::test] +async fn utf8_message_round_trip_polling() { + let (svc, mut rx) = service(); + let mut client = Client::connect(svc, [TransportType::Polling]) + .timeout() + .await + .unwrap(); + let sid = client.sid(); + assert_eq!(rx.next_ok().timeout().await, Event::Connect(sid)); + assert_eq!(client.next_ok().timeout().await, EioEvent::Connect(sid)); + + let text = "héllo 🌍 世界"; + client.send(EioEvent::Message(text.into())).await.unwrap(); + assert_eq!( + rx.next_ok().timeout().await, + Event::Message(sid, text.into()), + ); + assert_eq!( + client.next_ok().timeout().await, + EioEvent::Message(text.into()), + ); +} + +/// A large binary payload must round-trip unchanged over websocket. +#[tokio::test] +async fn large_binary_round_trip_ws() { + let (svc, mut rx) = service(); + let mut client = Client::connect(svc, [TransportType::Websocket]) + .await + .unwrap(); + let sid = client.sid(); + assert_eq!(rx.next_ok().timeout().await, Event::Connect(sid)); + assert_eq!(client.next_ok().timeout().await, EioEvent::Connect(sid)); + + // 8KiB crosses the server's default 4KiB websocket read buffer. + let data: Bytes = (0..8192u32) + .map(|i| (i % 251) as u8) + .collect::>() + .into(); + client + .send(EioEvent::Binary(data.clone())) + .timeout() + .await + .unwrap(); + assert_eq!( + rx.next_ok().timeout().await, + Event::Binary(sid, data.clone()) + ); + assert_eq!(client.next_ok().timeout().await, EioEvent::Binary(data)); +} diff --git a/crates/engineioxide-client/tests/it/upgrade.rs b/crates/engineioxide-client/tests/it/upgrade.rs new file mode 100644 index 00000000..38ca9b35 --- /dev/null +++ b/crates/engineioxide-client/tests/it/upgrade.rs @@ -0,0 +1,393 @@ +//! Transport upgrade tests. +//! +//! Reference behavior (engine.io-client, protocol v4): +//! * After the handshake, if the server offers `websocket` in `upgrades` and +//! the client is configured for it, the client probes: it connects a +//! websocket with `EIO=4&transport=websocket&sid=...`, sends `2probe`, +//! expects `3probe` back, pauses polling, then confirms with `5`. +//! * Polling stays active while probing: packets keep flowing until the +//! probe succeeds, and nothing may be lost across the switch. +//! * **A failed probe never kills the session**: the client emits an +//! `upgradeError` and keeps running on polling. There is no retry. +//! * No probe is attempted when the server offers no upgrade or when the +//! client is not configured for websocket. + +use std::assert_matches; + +use engineioxide_client::{Client, EioEvent, EngineIoClientConfig}; +use engineioxide_core::{Packet, TransportType}; +use futures_util::{SinkExt, StreamExt}; +use http::Method; + +use crate::mock::{ + self, + fixture::{Event, service, service_with_registry}, + helpers::{ClientTestExt, FutureTestExt}, +}; + +/// Wire-level probe sequence: ws connect with the session `sid`, `2probe` +/// out, `3probe` in, `5` out. After the upgrade all traffic flows over the +/// websocket. +#[tokio::test] +async fn upgrade_probe_wire_sequence() { + let open = mock::open_packet(); + let sid = open.sid.to_string(); + let (mut client, mut server) = + mock::connect_polling(&open, [TransportType::Polling, TransportType::Websocket]).await; + assert_eq!( + client.next_ok().timeout().await, + EioEvent::Connect(open.sid) + ); + + let script = async { + let call = server.next_ws_parking_http().await; + assert_eq!(call.query("EIO"), Some("4")); + assert_eq!(call.query("transport"), Some("websocket")); + assert_eq!( + call.query("sid").map(str::to_owned), + Some(sid), + "the probe must join the existing session" + ); + let mut ws = call.accept(); + assert_eq!( + ws.recv_packet().await, + Packet::PingUpgrade, + "probe ping expected" + ); + ws.send_packet(Packet::PongUpgrade); + assert_eq!( + ws.recv_packet().await, + Packet::Upgrade, + "upgrade confirmation expected" + ); + ws + }; + let (event, mut ws) = tokio::join!(client.next_ok().timeout(), script); + assert_eq!(event, EioEvent::Upgrade(TransportType::Websocket)); + assert_eq!(client.transport(), TransportType::Websocket); + + // Traffic now flows over the websocket, in both directions. + + client + .send(EioEvent::Message("hello".into())) + .timeout() + .await + .unwrap(); + assert_eq!(ws.recv_packet().await, Packet::Message("hello".into())); + ws.send_packet(Packet::Message("world".into())); + assert_eq!( + client.next_ok().timeout().await, + EioEvent::Message("world".into()) + ); +} + +/// Polling must remain active while the probe is in flight: a message +/// arriving on the held poll during the probe must be delivered. +#[tokio::test] +async fn polling_stays_active_during_probe() { + let open = mock::open_packet(); + let (mut client, mut server) = + mock::connect_polling(&open, [TransportType::Polling, TransportType::Websocket]).await; + assert_eq!( + client.next_ok().timeout().await, + EioEvent::Connect(open.sid) + ); + + let script = async { + // Wait for the probe connect, keeping track of the in-flight poll. + let mut held_poll = None; + let ws = loop { + match server.next_call().await { + mock::ServerCall::Ws(c) => break c, + mock::ServerCall::Http(c) => held_poll = Some(c), + } + }; + let mut ws = ws.accept(); + assert_eq!(ws.recv_packet().await, Packet::PingUpgrade); + + // Mid-probe, the server delivers a message on the polling transport: + // the reference client processes it (polling is only paused *after* + // the probe succeeds). + let poll = match held_poll { + Some(c) => c, + None => server.next_http().await, + }; + poll.respond_packets([Packet::Message("during-probe".into())]); + ws + }; + + // The client must yield the polled message even though it is probing. + let (event, ws) = tokio::join!(client.next_ok().timeout(), script,); + assert_eq!(event, EioEvent::Message("during-probe".into())); + + // And the upgrade must still complete afterwards. + let mut ws = ws; + let finish = async { + ws.send_packet(Packet::PongUpgrade); + // A pausing client waits for its in-flight poll to complete: the + // server releases it with a noop (reference server behavior). + loop { + tokio::select! { + p = ws.recv_packet() => { + assert_eq!(p, Packet::Upgrade); + break; + } + call = server.next_call() => match call { + mock::ServerCall::Http(c) => c.respond_packets([Packet::Noop]), + mock::ServerCall::Ws(c) => { + panic!("unexpected second ws connect: {:?}", c.req) + } + }, + } + } + }; + let (event, _) = tokio::join!(client.next_ok().timeout(), finish); + assert_eq!(event, EioEvent::Upgrade(TransportType::Websocket)); +} + +/// A probe that cannot even connect must not kill the session: the client +/// keeps running on polling (official behavior: `upgradeError` event, no +/// close, no retry). +#[tokio::test] +async fn failed_ws_connect_falls_back_to_polling() { + let open = mock::open_packet(); + let (mut client, mut server) = + mock::connect_polling(&open, [TransportType::Polling, TransportType::Websocket]).await; + assert_eq!( + client.next_ok().timeout().await, + EioEvent::Connect(open.sid) + ); + + let script = async { + let mut held_poll = None; + let ws = loop { + match server.next_call().await { + mock::ServerCall::Ws(c) => break c, + mock::ServerCall::Http(c) => held_poll = Some(c), + } + }; + ws.reject("connection refused"); + // The session must keep working over polling. + let poll = match held_poll { + Some(c) => c, + None => server.next_http().await, + }; + poll.respond_packets([Packet::Message("still-alive".into())]); + }; + + let (event, _) = tokio::join!(client.next_ok().timeout(), script,); + assert_eq!(event, EioEvent::Message("still-alive".into())); + assert_eq!(client.transport(), TransportType::Polling); +} + +/// A probe answered with the wrong packet (a plain pong instead of +/// `3probe`) must abort the upgrade and keep the session on polling. +#[tokio::test] +async fn wrong_probe_reply_falls_back_to_polling() { + let open = mock::open_packet(); + let (mut client, mut server) = + mock::connect_polling(&open, [TransportType::Polling, TransportType::Websocket]).await; + assert_eq!( + client.next_ok().timeout().await, + EioEvent::Connect(open.sid) + ); + + let script = async { + let mut held_poll = None; + let ws = loop { + match server.next_call().await { + mock::ServerCall::Ws(c) => break c, + mock::ServerCall::Http(c) => held_poll = Some(c), + } + }; + let mut ws = ws.accept(); + assert_eq!(ws.recv_packet().await, Packet::PingUpgrade); + ws.send_packet(Packet::Pong); // wrong reply: plain pong, not "3probe" + let poll = match held_poll { + Some(c) => c, + None => server.next_http().await, + }; + poll.respond_packets([Packet::Message("still-alive".into())]); + ws + }; + + let (event, _) = tokio::join!(client.next_ok().timeout(), script,); + assert_eq!(event, EioEvent::Message("still-alive".into())); + assert_eq!(client.transport(), TransportType::Polling); +} + +/// A websocket closed mid-probe must abort the upgrade and keep the session +/// on polling. +#[tokio::test] +async fn ws_closed_during_probe_falls_back_to_polling() { + let open = mock::open_packet(); + let (mut client, mut server) = + mock::connect_polling(&open, [TransportType::Polling, TransportType::Websocket]).await; + assert_eq!( + client.next_ok().timeout().await, + EioEvent::Connect(open.sid) + ); + + let script = async { + let mut held_poll = None; + let ws = loop { + match server.next_call().await { + mock::ServerCall::Ws(c) => break c, + mock::ServerCall::Http(c) => held_poll = Some(c), + } + }; + let mut ws = ws.accept(); + assert_eq!(ws.recv_packet().await, Packet::PingUpgrade); + ws.close(); // the probe transport dies before answering + let poll = match held_poll { + Some(c) => c, + None => server.next_http().await, + }; + poll.respond_packets([Packet::Message("still-alive".into())]); + }; + + let (event, _) = tokio::join!(client.next_ok().timeout(), script,); + assert_eq!(event, EioEvent::Message("still-alive".into())); + assert_eq!(client.transport(), TransportType::Polling); +} + +/// No probe may be attempted when the server offers no upgrade. +#[tokio::test] +async fn no_probe_when_server_offers_no_upgrade() { + let open = mock::open_packet_no_upgrade(); + let (mut client, mut server) = + mock::connect_polling(&open, [TransportType::Polling, TransportType::Websocket]).await; + assert_eq!( + client.next_ok().timeout().await, + EioEvent::Connect(open.sid) + ); + + let script = async { + let poll = match server.next_call().await { + mock::ServerCall::Http(c) => c, + mock::ServerCall::Ws(c) => { + panic!( + "no ws connect expected without server upgrades: {:?}", + c.req + ) + } + }; + assert_eq!(poll.method, Method::GET); + server + .assert_no_call( + std::time::Duration::from_millis(300), + "no upgrade is offered", + ) + .await; + poll.respond_packets([Packet::Message("plain-polling".into())]); + }; + + let (event, _) = tokio::join!(client.next_ok().timeout(), script); + assert_eq!(event, EioEvent::Message("plain-polling".into())); + assert_eq!(client.transport(), TransportType::Polling); +} + +/// No probe may be attempted when the client is configured for polling only, +/// even if the server offers the websocket upgrade. +#[tokio::test] +async fn no_probe_when_client_is_polling_only() { + let open = mock::open_packet(); // server offers websocket + let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; + assert_eq!( + client.next_ok().timeout().await, + EioEvent::Connect(open.sid) + ); + + let script = async { + let poll = match server.next_call().await { + mock::ServerCall::Http(c) => c, + mock::ServerCall::Ws(c) => { + panic!( + "no ws connect expected for a polling-only client: {:?}", + c.req + ) + } + }; + server + .assert_no_call( + std::time::Duration::from_millis(300), + "client is polling-only", + ) + .await; + poll.respond_packets([Packet::Message("plain-polling".into())]); + }; + + let (event, _) = tokio::join!(client.next_ok().timeout(), script); + assert_eq!(event, EioEvent::Message("plain-polling".into())); + assert_eq!(client.transport(), TransportType::Polling); +} + +/// A message sent while the upgrade is in progress must be buffered and +/// delivered once the new transport is live (the reference client inhibits +/// `flush()` while `upgrading` and flushes right after the upgrade). +#[tokio::test] +async fn send_during_upgrade_is_delivered_after_upgrade() { + let (svc, mut rx) = service(); + let client = Client::connect(svc, EngineIoClientConfig::default()) + .timeout() + .await + .unwrap(); + let sid = client.sid(); + assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); + let (mut ctx, mut crx) = client.split::(); + assert_eq!(crx.next().await.unwrap().unwrap(), EioEvent::Connect(sid)); + + // The send blocks until the upgrade completes, so it must be driven + // concurrently with the stream. + tokio::join!( + async { + ctx.send(EioEvent::Message("buffered".into())) + .timeout() + .await + .unwrap(); + }, + async { + assert_matches!( + crx.next().timeout().await, + Some(Ok(EioEvent::Upgrade(TransportType::Websocket))) + ); + assert_matches!( + crx.next().timeout().await, + Some(Ok(EioEvent::Message(msg))) if msg == "buffered", + "the buffered message must be delivered once upgraded" + ); + }, + ); + + assert_eq!( + rx.recv().timeout().await, + Some(Event::Message(sid, "buffered".into())) + ); +} + +/// A message emitted by the server right at connection time must not be lost +/// even though the client immediately upgrades to websocket. +#[tokio::test] +async fn no_message_loss_across_upgrade() { + let (svc, mut rx, registry) = service_with_registry(Default::default()); + let mut client = Client::connect(svc, EngineIoClientConfig::default()) + .timeout() + .await + .unwrap(); + let sid = client.sid(); + assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); + + // Emit while the client is (most likely) still on polling. + registry.lock().unwrap()[&sid].emit("early").unwrap(); + + let received = async { + loop { + match client.next_ok().await { + EioEvent::Message(msg) => break msg, + _ => continue, // Connect / Upgrade events + } + } + } + .await; + assert_eq!(received, "early"); +} diff --git a/crates/engineioxide-core/src/packet.rs b/crates/engineioxide-core/src/packet.rs index 0d16416d..91631e62 100644 --- a/crates/engineioxide-core/src/packet.rs +++ b/crates/engineioxide-core/src/packet.rs @@ -174,6 +174,11 @@ impl From for Bytes { String::from(value).into() } } +impl From for Str { + fn from(value: Packet) -> Self { + Str::from(String::from(value)) + } +} /// Serialize a [Packet] to a [String] according to the Engine.IO protocol impl From for String { @@ -223,6 +228,7 @@ impl Packet { .ok_or(PacketParseError::InvalidPacketType(None))?; let is_upgrade = value.len() == 6 && &value[1..6] == "probe"; let res = match packet_type { + b'0' => Packet::Open(serde_json::from_slice(&value.as_bytes()[1..])?), b'1' => Packet::Close, b'2' if is_upgrade => Packet::PingUpgrade, b'2' => Packet::Ping, diff --git a/crates/engineioxide-core/src/protocol.rs b/crates/engineioxide-core/src/protocol.rs index acc9714f..8fddfad0 100644 --- a/crates/engineioxide-core/src/protocol.rs +++ b/crates/engineioxide-core/src/protocol.rs @@ -68,6 +68,13 @@ impl<'de> Deserialize<'de> for TransportType { } } +impl std::fmt::Display for TransportType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let v: &'static str = (*self).into(); + write!(f, "{}", v) + } +} + /// Cannot determine the transport type to connect to the client/server. #[derive(Debug, Copy, Clone)] pub struct UnknownTransportError; diff --git a/crates/engineioxide/src/service/mod.rs b/crates/engineioxide/src/service/mod.rs index 6b9d1c13..e5f1adf0 100644 --- a/crates/engineioxide/src/service/mod.rs +++ b/crates/engineioxide/src/service/mod.rs @@ -44,6 +44,7 @@ use tower_service::Service as TowerSvc; use crate::{ body::ResponseBody, config::EngineIoConfig, engine::EngineIo, handler::EngineIoHandler, + service::parser::RequestInfo, }; mod futures; @@ -136,6 +137,24 @@ where } } +impl TowerSvc<(St, http::Request<()>)> for EngineIoService +where + H: EngineIoHandler, + St: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, +{ + type Response = (); + type Error = parser::ParseError; + type Future = std::future::Ready>; + + fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, (conn, req): (St, http::Request<()>)) -> Self::Future { + std::future::ready(ws_conn_inner(self.engine.clone(), conn, req)) + } +} + /// Hyper 1.0 Service implementation. impl HyperSvc> for EngineIoService where @@ -160,6 +179,38 @@ where } } +impl HyperSvc<(St, http::Request<()>)> for EngineIoService +where + H: EngineIoHandler, + St: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, +{ + type Response = (); + type Error = parser::ParseError; + type Future = std::future::Ready>; + + fn call(&self, (conn, req): (St, http::Request<()>)) -> Self::Future { + std::future::ready(ws_conn_inner(self.engine.clone(), conn, req)) + } +} + +fn ws_conn_inner( + engine: Arc>, + conn: St, + req: http::Request<()>, +) -> Result<(), parser::ParseError> +where + H: EngineIoHandler, + St: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, +{ + let RequestInfo { protocol, sid, .. } = RequestInfo::parse(&req, &engine.config)?; + + let (parts, _) = req.into_parts(); + let fut = crate::transport::ws::on_init(engine, conn, protocol, sid, parts); + tokio::spawn(fut); + + Ok(()) +} + #[cfg(feature = "__test_harness")] #[doc(hidden)] impl EngineIoService diff --git a/crates/engineioxide/src/service/parser.rs b/crates/engineioxide/src/service/parser.rs index 2cb5623a..045bc39f 100644 --- a/crates/engineioxide/src/service/parser.rs +++ b/crates/engineioxide/src/service/parser.rs @@ -127,7 +127,7 @@ pub struct RequestInfo { impl RequestInfo { /// Parse the request URI to extract the [`TransportType`](crate::service::TransportType) and the socket id. - fn parse(req: &Request, config: &EngineIoConfig) -> Result { + pub(super) fn parse(req: &Request, config: &EngineIoConfig) -> Result { use ParseError::*; let query = req.uri().query().ok_or(UnknownTransport)?; diff --git a/crates/socketioxide/src/service.rs b/crates/socketioxide/src/service.rs index 55ea5225..0c28c3fe 100644 --- a/crates/socketioxide/src/service.rs +++ b/crates/socketioxide/src/service.rs @@ -56,11 +56,12 @@ where #[inline(always)] fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { - self.engine_svc.poll_ready(cx) + TowerSvc::>::poll_ready(&mut self.engine_svc, cx) } + #[inline(always)] fn call(&mut self, req: Request) -> Self::Future { - self.engine_svc.call(req) + TowerSvc::>::call(&mut self.engine_svc, req) } } @@ -80,7 +81,7 @@ where #[inline(always)] fn call(&self, req: Request) -> Self::Future { - self.engine_svc.call(req) + HyperSvc::>::call(&self.engine_svc, req) } } diff --git a/e2e/engineioxide/Cargo.toml b/e2e/engineioxide/Cargo.toml index d79ec8b2..220314fe 100644 --- a/e2e/engineioxide/Cargo.toml +++ b/e2e/engineioxide/Cargo.toml @@ -16,7 +16,9 @@ bytes.workspace = true engineioxide = { path = "../../crates/engineioxide", default-features = false, features = [ "tracing", ] } +engineioxide-client = { path = "../../crates/engineioxide-client" } hyper-util = { workspace = true, features = ["tokio"] } +futures-util.workspace = true hyper = { workspace = true, features = ["server", "http1"] } tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } tracing-subscriber.workspace = true @@ -27,6 +29,10 @@ serde_json.workspace = true name = "engineioxide-e2e" path = "engineioxide.rs" +[[bin]] +name = "engineioxide-e2e-client" +path = "client.rs" + [features] v3 = ["engineioxide/v3"] v4 = [] diff --git a/e2e/engineioxide/client.rs b/e2e/engineioxide/client.rs new file mode 100644 index 00000000..60761348 --- /dev/null +++ b/e2e/engineioxide/client.rs @@ -0,0 +1,37 @@ +use engineioxide_client::{Client, EioEvent}; +use futures_util::{SinkExt, StreamExt}; +use tracing_subscriber::{EnvFilter, FmtSubscriber}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let subscriber = FmtSubscriber::builder() + .with_env_filter(EnvFilter::from_default_env()) + .finish(); + tracing::subscriber::set_global_default(subscriber)?; + + let client = Client::connect_with_hyper_ws("http://localhost:3000/engine.io").await?; + let (mut tx, mut rx) = client.split(); + + while let Some(Ok(event)) = rx.next().await { + match event { + EioEvent::Connect(sid) => { + println!("socket connect {sid}"); + } + EioEvent::Disconnect => { + println!("socket disconnect"); + } + EioEvent::Message(msg) => { + println!("Ping pong message {:?}", msg); + tx.send(EioEvent::Message(msg)).await?; + } + EioEvent::Binary(data) => { + println!("Ping pong binary message {:?}", data); + tx.send(EioEvent::Binary(data)).await?; + } + EioEvent::Upgrade(transport_type) => { + println!("transport upgraded to {transport_type:?}") + } + } + } + Ok(()) +}